Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+23
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "workflowBlocks.db.server.ts"), "utf8");
|
||||
|
||||
describe("workflow block tenant scope", () => {
|
||||
it("verifies parent workflow tenant before creating blocks", () => {
|
||||
expect(source).toContain("tenantId: string | null");
|
||||
expect(source).toContain("db.workflow.findFirstOrThrow");
|
||||
expect(source).toContain("where: { id: data.workflowId, tenantId: data.tenantId }");
|
||||
});
|
||||
|
||||
it("updates blocks through tenant-scoped parent workflow filters", () => {
|
||||
expect(source).toContain("updateWorkflowBlock(\n scope: { id: string; workflowId: string; tenantId: string | null }");
|
||||
expect(source).toContain("db.workflowBlock.updateMany");
|
||||
expect(source).toContain("workflow: { tenantId: scope.tenantId }");
|
||||
expect(source).toContain("db.workflowBlock.findFirstOrThrow");
|
||||
expect(source).not.toContain("db.workflowBlock.update({");
|
||||
expect(source).not.toContain("where: { id: scope.id, workflowId: scope.workflowId },");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { WorkflowBlock, WorkflowBlockCondition, WorkflowBlockConditionGroup, WorkflowBlockToBlock } from "@toolctrl/db/prisma-browser";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export type WorkflowBlockWithDetails = WorkflowBlock & {
|
||||
toBlocks: (WorkflowBlockToBlock & {
|
||||
toBlock: WorkflowBlock;
|
||||
})[];
|
||||
conditionsGroups: (WorkflowBlockConditionGroup & {
|
||||
conditions: WorkflowBlockCondition[];
|
||||
})[];
|
||||
};
|
||||
|
||||
export async function getWorkflowBlockTypes(workflowId: string) {
|
||||
const workflowBlocks = await db.workflowBlock.findMany({
|
||||
where: {
|
||||
workflowId,
|
||||
},
|
||||
select: { type: true },
|
||||
});
|
||||
return workflowBlocks.map((block) => block.type);
|
||||
}
|
||||
|
||||
export async function createWorkflowBlock(data: {
|
||||
workflowId: string;
|
||||
tenantId: string | null;
|
||||
type: string;
|
||||
input: string;
|
||||
description: string;
|
||||
isTrigger: boolean;
|
||||
isBlock: boolean;
|
||||
// positionX: number;
|
||||
// positionY: number;
|
||||
}) {
|
||||
await db.workflow.findFirstOrThrow({
|
||||
where: { id: data.workflowId, tenantId: data.tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
return await db.workflowBlock.create({
|
||||
data: {
|
||||
workflowId: data.workflowId,
|
||||
type: data.type,
|
||||
description: data.description,
|
||||
isTrigger: data.isTrigger,
|
||||
isBlock: data.isBlock,
|
||||
input: data.input,
|
||||
// positionX: data.positionX,
|
||||
// positionY: data.positionY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflowBlock(
|
||||
scope: { id: string; workflowId: string; tenantId: string | null },
|
||||
data: {
|
||||
type?: string;
|
||||
input?: string;
|
||||
description?: string;
|
||||
isTrigger?: boolean;
|
||||
isBlock?: boolean;
|
||||
}
|
||||
) {
|
||||
const updated = await db.workflowBlock.updateMany({
|
||||
where: {
|
||||
id: scope.id,
|
||||
workflowId: scope.workflowId,
|
||||
workflow: { tenantId: scope.tenantId },
|
||||
},
|
||||
data: {
|
||||
type: data.type,
|
||||
description: data.description,
|
||||
input: data.input,
|
||||
isTrigger: data.isTrigger,
|
||||
isBlock: data.isBlock,
|
||||
// positionX: data.positionX,
|
||||
// positionY: data.positionY,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Workflow block ${scope.id} not found`);
|
||||
}
|
||||
return await db.workflowBlock.findFirstOrThrow({
|
||||
where: {
|
||||
id: scope.id,
|
||||
workflowId: scope.workflowId,
|
||||
workflow: { tenantId: scope.tenantId },
|
||||
},
|
||||
});
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowCredentialDbTestMocks = {
|
||||
findFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getDbMocks(): WorkflowCredentialDbTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowCredentialDbTestMocks__?: WorkflowCredentialDbTestMocks };
|
||||
store.__workflowCredentialDbTestMocks__ ??= {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
return store.__workflowCredentialDbTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflowCredential: {
|
||||
findFirstOrThrow: getDbMocks().findFirstOrThrow,
|
||||
updateMany: getDbMocks().updateMany,
|
||||
deleteMany: getDbMocks().deleteMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { deleteWorkflowCredential, updateWorkflowCredential } from "./workflowCredentials.db.server";
|
||||
|
||||
const dbMocks = getDbMocks();
|
||||
|
||||
describe("workflow credential mutations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "credential_1", tenantId: "tenant_1", name: "token", value: "next" });
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
it("updates credentials with tenant scope", async () => {
|
||||
await expect(updateWorkflowCredential("credential_1", { tenantId: "tenant_1", value: "next" })).resolves.toEqual({
|
||||
id: "credential_1",
|
||||
tenantId: "tenant_1",
|
||||
name: "token",
|
||||
value: "next",
|
||||
});
|
||||
|
||||
expect(dbMocks.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "credential_1", tenantId: "tenant_1" },
|
||||
data: { value: "next" },
|
||||
});
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "credential_1", tenantId: "tenant_1" } });
|
||||
});
|
||||
|
||||
it("deletes credentials with tenant scope", async () => {
|
||||
await deleteWorkflowCredential("credential_1", { tenantId: "tenant_1" });
|
||||
|
||||
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "credential_1", tenantId: "tenant_1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when scoped updates or deletes miss", async () => {
|
||||
dbMocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
dbMocks.deleteMany.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await expect(updateWorkflowCredential("missing", { tenantId: "tenant_1", value: "next" })).rejects.toThrow(
|
||||
"Workflow credential missing not found"
|
||||
);
|
||||
await expect(deleteWorkflowCredential("missing", { tenantId: "tenant_1" })).rejects.toThrow("Workflow credential missing not found");
|
||||
});
|
||||
});
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export async function getAllWorkflowCredentials({ tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowCredential.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowCredentialById(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowCredential
|
||||
.findFirstOrThrow({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowCredentialByName(name: string, { tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowCredential
|
||||
.findFirstOrThrow({
|
||||
where: { name, tenantId },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflowCredential({
|
||||
tenantId,
|
||||
createdByUserId,
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
createdByUserId: string | null;
|
||||
name: string;
|
||||
value: string;
|
||||
}) {
|
||||
return await db.workflowCredential.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdByUserId,
|
||||
name,
|
||||
value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflowCredential(id: string, { tenantId, value }: { tenantId: string | null; value?: string }) {
|
||||
const updated = await db.workflowCredential.updateMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
data: {
|
||||
value,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Workflow credential ${id} not found`);
|
||||
}
|
||||
return await getWorkflowCredentialById(id, { tenantId });
|
||||
}
|
||||
|
||||
export async function deleteWorkflowCredential(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
const deleted = await db.workflowCredential.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(`Workflow credential ${id} not found`);
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowExecutionDbTestMocks = {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
findFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getDbMocks(): WorkflowExecutionDbTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowExecutionDbTestMocks__?: WorkflowExecutionDbTestMocks };
|
||||
store.__workflowExecutionDbTestMocks__ ??= {
|
||||
findMany: vi.fn(),
|
||||
findFirstOrThrow: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
};
|
||||
return store.__workflowExecutionDbTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflowExecution: {
|
||||
findMany: getDbMocks().findMany,
|
||||
findFirstOrThrow: getDbMocks().findFirstOrThrow,
|
||||
updateMany: getDbMocks().updateMany,
|
||||
count: getDbMocks().count,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { getAllWorkflowExecutions, getWorkflowExecutions, updateWorkflowExecution } from "./workflowExecutions.db.server";
|
||||
|
||||
const dbMocks = getDbMocks();
|
||||
|
||||
describe("workflow execution filters", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.findMany.mockResolvedValue([]);
|
||||
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "execution_1", tenantId: "tenant_1" });
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
dbMocks.count.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it("maps type filters to workflow execution type instead of workflow id", async () => {
|
||||
await getAllWorkflowExecutions({
|
||||
tenantId: "tenant_1",
|
||||
pagination: { page: 1, pageSize: 25 },
|
||||
filters: {
|
||||
query: "",
|
||||
properties: [
|
||||
{ name: "workflowId", title: "Workflow", value: "workflow_1" },
|
||||
{ name: "status", title: "Status", value: "running" },
|
||||
{ name: "type", title: "Type", value: "api" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbMocks.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
tenantId: "tenant_1",
|
||||
appliesToAllTenants: false,
|
||||
workflowId: "workflow_1",
|
||||
status: "running",
|
||||
type: "api",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a lightweight list projection for workflow execution indexes", async () => {
|
||||
await getAllWorkflowExecutions({
|
||||
tenantId: "tenant_1",
|
||||
pagination: { page: 2, pageSize: 10 },
|
||||
filters: {
|
||||
query: "",
|
||||
properties: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(dbMocks.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: 10,
|
||||
skip: 10,
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
workflowId: true,
|
||||
workflow: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
_count: { select: { blockRuns: true } },
|
||||
blockRuns: {
|
||||
take: 1,
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: {
|
||||
workflowBlock: {
|
||||
select: { type: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const query = dbMocks.findMany.mock.calls[0][0];
|
||||
expect(query).not.toHaveProperty("include");
|
||||
expect(query.select).not.toHaveProperty("input");
|
||||
expect(query.select).not.toHaveProperty("output");
|
||||
expect(query.select).not.toHaveProperty("waitingBlock");
|
||||
expect(dbMocks.count).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
tenantId: "tenant_1",
|
||||
appliesToAllTenants: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates workflow executions only within the caller tenant scope", async () => {
|
||||
await updateWorkflowExecution(
|
||||
"execution_1",
|
||||
{ tenantId: "tenant_1" },
|
||||
{
|
||||
status: "success",
|
||||
output: "{\"ok\":true}",
|
||||
duration: 12,
|
||||
error: null,
|
||||
waitingBlockId: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(dbMocks.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "execution_1", tenantId: "tenant_1" },
|
||||
data: expect.objectContaining({
|
||||
status: "success",
|
||||
output: "{\"ok\":true}",
|
||||
duration: 12,
|
||||
error: null,
|
||||
waitingBlockId: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "execution_1", tenantId: "tenant_1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps global workflow execution lists scoped to tenantId null", async () => {
|
||||
await getWorkflowExecutions({ workflowId: "workflow_global" }, { tenantId: null });
|
||||
|
||||
expect(dbMocks.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { workflowId: "workflow_global", tenantId: null },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
import { Prisma, WorkflowBlockExecution, WorkflowExecution } from "@toolctrl/db/prisma-browser";
|
||||
import { FiltersDto } from "~/application/dtos/data/FiltersDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { WorkflowBlockWithDetails } from "./workflowBlocks.db.server";
|
||||
import { WorkflowStatus } from "../dtos/WorkflowStatus";
|
||||
|
||||
export type WorkflowExecutionWithDetails = WorkflowExecution & {
|
||||
workflow: { id: string; name: string };
|
||||
tenant: { id: string; name: string; slug: string } | null;
|
||||
waitingBlock: WorkflowBlockWithDetails | null;
|
||||
blockRuns: (WorkflowBlockExecution & {
|
||||
workflowBlock: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
})[];
|
||||
};
|
||||
|
||||
export type WorkflowExecutionListItem = Pick<WorkflowExecution, "id" | "workflowId" | "type" | "status" | "createdAt" | "endedAt" | "error"> & {
|
||||
workflow: { id: string; name: string };
|
||||
tenant: { id: string; name: string; slug: string } | null;
|
||||
_count: { blockRuns: number };
|
||||
blockRuns: {
|
||||
workflowBlock: {
|
||||
type: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
type WorkflowExecutionClient = Pick<typeof db, "workflowExecution">;
|
||||
|
||||
export async function getAllWorkflowExecutions({
|
||||
tenantId,
|
||||
pagination,
|
||||
filters,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
pagination: { page: number; pageSize: number };
|
||||
filters: FiltersDto;
|
||||
}): Promise<{ items: WorkflowExecutionListItem[]; pagination: PaginationDto }> {
|
||||
let where: Prisma.WorkflowExecutionWhereInput = {};
|
||||
if (tenantId === null) {
|
||||
where = {
|
||||
OR: [{ appliesToAllTenants: true }, { tenantId: null }],
|
||||
};
|
||||
} else {
|
||||
where = {
|
||||
tenantId,
|
||||
appliesToAllTenants: false,
|
||||
};
|
||||
}
|
||||
const workflowId = filters?.properties.find((f) => f.name === "workflowId")?.value ?? filters?.query ?? "";
|
||||
const status = filters?.properties.find((f) => f.name === "status")?.value ?? filters?.query ?? "";
|
||||
const type = filters?.properties.find((f) => f.name === "type")?.value ?? filters?.query ?? "";
|
||||
if (workflowId) {
|
||||
where.workflowId = workflowId;
|
||||
}
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
if (type) {
|
||||
where.type = type;
|
||||
}
|
||||
const [items, totalItems] = await Promise.all([
|
||||
db.workflowExecution.findMany({
|
||||
take: pagination.pageSize,
|
||||
skip: pagination.pageSize * (pagination.page - 1),
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
workflowId: true,
|
||||
type: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
endedAt: true,
|
||||
error: true,
|
||||
workflow: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
_count: { select: { blockRuns: true } },
|
||||
blockRuns: {
|
||||
take: 1,
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: {
|
||||
workflowBlock: {
|
||||
select: { type: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
db.workflowExecution.count({
|
||||
where,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / pagination.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWorkflowExecutions(
|
||||
{ workflowId }: { workflowId: string },
|
||||
session: { tenantId: string | null }
|
||||
): Promise<WorkflowExecutionWithDetails[]> {
|
||||
const where: Prisma.WorkflowExecutionWhereInput = {
|
||||
workflowId,
|
||||
tenantId: session.tenantId,
|
||||
};
|
||||
return db.workflowExecution.findMany({
|
||||
where,
|
||||
include: {
|
||||
workflow: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
waitingBlock: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
|
||||
},
|
||||
},
|
||||
blockRuns: {
|
||||
orderBy: { startedAt: "asc" },
|
||||
include: {
|
||||
workflowBlock: {
|
||||
select: { type: true, description: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowExecution(
|
||||
id: string,
|
||||
session: { tenantId: string | null },
|
||||
client: WorkflowExecutionClient = db
|
||||
): Promise<WorkflowExecutionWithDetails | null> {
|
||||
// findFirst (not findFirstOrThrow): the return type is `| null` and every
|
||||
// caller null-checks. A stale/deleted executionId (e.g. a deep link to an
|
||||
// execution removed by a re-seed) must resolve to null → graceful 404, not an
|
||||
// unhandled throw that renders a 500 builder/executions page.
|
||||
return client.workflowExecution
|
||||
.findFirst({
|
||||
where: {
|
||||
id,
|
||||
tenantId: session.tenantId,
|
||||
},
|
||||
include: {
|
||||
workflow: { select: { id: true, name: true } },
|
||||
tenant: { select: { id: true, name: true, slug: true } },
|
||||
waitingBlock: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
|
||||
},
|
||||
},
|
||||
blockRuns: {
|
||||
orderBy: { startedAt: "asc" },
|
||||
include: {
|
||||
workflowBlock: {
|
||||
select: { type: true, description: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
export async function countWorkflowExecutions({ tenantId }: { tenantId: string | null }): Promise<number> {
|
||||
let where: Prisma.WorkflowExecutionWhereInput = {};
|
||||
if (tenantId === null) {
|
||||
where = {
|
||||
OR: [{ appliesToAllTenants: true }, { tenantId: null }],
|
||||
};
|
||||
} else {
|
||||
where = {
|
||||
tenantId,
|
||||
appliesToAllTenants: false,
|
||||
};
|
||||
}
|
||||
return await db.workflowExecution.count({
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflowExecution(
|
||||
id: string,
|
||||
session: { tenantId: string | null },
|
||||
{
|
||||
error,
|
||||
status,
|
||||
output,
|
||||
duration,
|
||||
waitingBlockId,
|
||||
}: {
|
||||
error: string | null;
|
||||
status: WorkflowStatus;
|
||||
output: string;
|
||||
duration: number;
|
||||
waitingBlockId: string | null;
|
||||
},
|
||||
client: WorkflowExecutionClient = db
|
||||
): Promise<WorkflowExecutionWithDetails> {
|
||||
const updated = await client.workflowExecution.updateMany({
|
||||
where: { id, tenantId: session.tenantId },
|
||||
data: {
|
||||
status: error ? "error" : status,
|
||||
output,
|
||||
duration: Math.round(duration),
|
||||
endedAt: new Date(),
|
||||
error,
|
||||
waitingBlockId,
|
||||
},
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
const execution = await getWorkflowExecution(id, session, client);
|
||||
if (!execution) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
return execution;
|
||||
}
|
||||
|
||||
export async function deleteWorkflowExecution(id: string, session: { tenantId: string | null }) {
|
||||
return await db.workflowExecution.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId: session.tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowVariableDbTestMocks = {
|
||||
findFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getDbMocks(): WorkflowVariableDbTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowVariableDbTestMocks__?: WorkflowVariableDbTestMocks };
|
||||
store.__workflowVariableDbTestMocks__ ??= {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
return store.__workflowVariableDbTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflowVariable: {
|
||||
findFirstOrThrow: getDbMocks().findFirstOrThrow,
|
||||
updateMany: getDbMocks().updateMany,
|
||||
deleteMany: getDbMocks().deleteMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { deleteWorkflowVariable, updateWorkflowVariable } from "./workflowVariable.db.server";
|
||||
|
||||
const dbMocks = getDbMocks();
|
||||
|
||||
describe("workflow variable mutations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "variable_1", tenantId: "tenant_1", name: "token", value: "next" });
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
it("updates variables with tenant scope", async () => {
|
||||
await expect(updateWorkflowVariable("variable_1", { tenantId: "tenant_1", value: "next" })).resolves.toEqual({
|
||||
id: "variable_1",
|
||||
tenantId: "tenant_1",
|
||||
name: "token",
|
||||
value: "next",
|
||||
});
|
||||
|
||||
expect(dbMocks.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "variable_1", tenantId: "tenant_1" },
|
||||
data: { name: undefined, value: "next" },
|
||||
});
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "variable_1", tenantId: "tenant_1" } });
|
||||
});
|
||||
|
||||
it("deletes variables with tenant scope", async () => {
|
||||
await deleteWorkflowVariable("variable_1", { tenantId: "tenant_1" });
|
||||
|
||||
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "variable_1", tenantId: "tenant_1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when scoped updates miss", async () => {
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
await expect(updateWorkflowVariable("missing", { tenantId: "tenant_1", value: "next" })).rejects.toThrow("Workflow variable missing not found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
export async function getAllWorkflowVariables({ tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowVariable.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowVariableById(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowVariable
|
||||
.findFirstOrThrow({
|
||||
where: { id, tenantId },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowVariableByName(name: string, { tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflowVariable
|
||||
.findFirstOrThrow({
|
||||
where: { name, tenantId },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflowVariable({
|
||||
tenantId,
|
||||
createdByUserId,
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
createdByUserId: string | null;
|
||||
name: string;
|
||||
value: string;
|
||||
}) {
|
||||
return await db.workflowVariable.create({
|
||||
data: {
|
||||
tenantId,
|
||||
createdByUserId,
|
||||
name,
|
||||
value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflowVariable(id: string, { tenantId, name, value }: { tenantId: string | null; name?: string; value?: string }) {
|
||||
const updated = await db.workflowVariable.updateMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
value,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Workflow variable ${id} not found`);
|
||||
}
|
||||
return await getWorkflowVariableById(id, { tenantId });
|
||||
}
|
||||
|
||||
export async function deleteWorkflowVariable(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
const deleted = await db.workflowVariable.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
tenantId,
|
||||
},
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(`Workflow variable ${id} not found`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowDbTestMocks = {
|
||||
findFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getDbMocks(): WorkflowDbTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowDbTestMocks__?: WorkflowDbTestMocks };
|
||||
store.__workflowDbTestMocks__ ??= {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
};
|
||||
return store.__workflowDbTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflow: {
|
||||
findFirstOrThrow: getDbMocks().findFirstOrThrow,
|
||||
updateMany: getDbMocks().updateMany,
|
||||
deleteMany: getDbMocks().deleteMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { deleteWorkflow, getWorkflowByAccessibleId, updateWorkflow } from "./workflows.db.server";
|
||||
|
||||
const dbMocks = getDbMocks();
|
||||
|
||||
describe("workflow mutations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMocks.findFirstOrThrow.mockResolvedValue({ id: "workflow_1", tenantId: "tenant_1", name: "Updated" });
|
||||
dbMocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
dbMocks.deleteMany.mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
it("updates workflows with tenant scope", async () => {
|
||||
await expect(updateWorkflow({ id: "workflow_1", tenantId: "tenant_1" }, { name: "Updated" })).resolves.toEqual({
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
name: "Updated",
|
||||
});
|
||||
|
||||
expect(dbMocks.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "workflow_1", tenantId: "tenant_1" },
|
||||
data: {
|
||||
name: "Updated",
|
||||
description: undefined,
|
||||
status: undefined,
|
||||
appliesToAllTenants: undefined,
|
||||
},
|
||||
});
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith({ where: { id: "workflow_1", tenantId: "tenant_1" } });
|
||||
});
|
||||
|
||||
it("deletes workflows with tenant scope", async () => {
|
||||
await expect(deleteWorkflow({ id: "workflow_1", tenantId: "tenant_1" })).resolves.toEqual({ count: 1 });
|
||||
|
||||
expect(dbMocks.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "workflow_1", tenantId: "tenant_1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when scoped workflow mutations miss", async () => {
|
||||
dbMocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||
dbMocks.deleteMany.mockResolvedValueOnce({ count: 0 });
|
||||
|
||||
await expect(updateWorkflow({ id: "missing", tenantId: "tenant_1" }, { name: "Updated" })).rejects.toThrow("Workflow missing not found");
|
||||
await expect(deleteWorkflow({ id: "missing", tenantId: "tenant_1" })).rejects.toThrow("Workflow missing not found");
|
||||
});
|
||||
|
||||
it("reads execution workflows only through tenant or public global scope", async () => {
|
||||
await getWorkflowByAccessibleId({ id: "workflow_1", tenantId: "tenant_1" });
|
||||
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: "workflow_1",
|
||||
OR: [
|
||||
{ tenantId: "tenant_1" },
|
||||
{
|
||||
tenantId: null,
|
||||
appliesToAllTenants: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("reads anonymous execution workflows only from public global scope", async () => {
|
||||
await getWorkflowByAccessibleId({ id: "workflow_1", tenantId: null });
|
||||
|
||||
expect(dbMocks.findFirstOrThrow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "workflow_1", tenantId: null, appliesToAllTenants: true },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Prisma, Workflow, WorkflowInputExample } from "@toolctrl/db/prisma-browser";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { WorkflowBlockWithDetails } from "./workflowBlocks.db.server";
|
||||
import { TenantSimple } from "~/utils/db/tenants.db.server";
|
||||
import TenantModelHelper from "~/utils/helpers/models/TenantModelHelper";
|
||||
|
||||
export type WorkflowWithDetails = Workflow & {
|
||||
tenant: TenantSimple | null;
|
||||
blocks: WorkflowBlockWithDetails[];
|
||||
inputExamples: WorkflowInputExample[];
|
||||
_count: {
|
||||
executions: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getAllWorkflows({
|
||||
tenantId,
|
||||
status,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
status?: "draft" | "live" | "archived";
|
||||
}): Promise<WorkflowWithDetails[]> {
|
||||
return await db.workflow.findMany({
|
||||
where: { tenantId, status },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllWorkflowsAppliesToAllTenants({
|
||||
tenantId,
|
||||
status,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
status?: "draft" | "live" | "archived";
|
||||
}): Promise<WorkflowWithDetails[]> {
|
||||
const where: Prisma.WorkflowWhereInput = { status };
|
||||
if (tenantId === null) {
|
||||
where.tenantId = null;
|
||||
} else {
|
||||
where.OR = [{ tenantId }, { appliesToAllTenants: true, tenantId: null }];
|
||||
}
|
||||
return await db.workflow.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: { include: { conditions: { orderBy: { index: "asc" } } }, orderBy: { index: "asc" } },
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowsIdsAndNames({ tenantId }: { tenantId: string | null }): Promise<{ id: string; name: string }[]> {
|
||||
return await db.workflow.findMany({
|
||||
where: { tenantId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: [{ createdAt: "desc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowById({ id, tenantId }: { id: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
|
||||
return await db.workflow
|
||||
.findFirstOrThrow({
|
||||
where: { id, tenantId },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: {
|
||||
include: { conditions: { orderBy: { index: "asc" } } },
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowByAccessibleId({ id, tenantId }: { id: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
|
||||
const where: Prisma.WorkflowWhereInput =
|
||||
tenantId === null
|
||||
? { id, tenantId: null, appliesToAllTenants: true }
|
||||
: {
|
||||
id,
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{
|
||||
tenantId: null,
|
||||
appliesToAllTenants: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return await db.workflow
|
||||
.findFirstOrThrow({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: {
|
||||
include: { conditions: { orderBy: { index: "asc" } } },
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflow(id: string): Promise<WorkflowWithDetails | null> {
|
||||
return await db.workflow
|
||||
.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: {
|
||||
include: { conditions: { orderBy: { index: "asc" } } },
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWorkflowByName({ name, tenantId }: { name: string; tenantId: string | null }): Promise<WorkflowWithDetails | null> {
|
||||
return await db.workflow
|
||||
.findFirstOrThrow({
|
||||
where: { name, tenantId },
|
||||
include: {
|
||||
tenant: { select: TenantModelHelper.selectSimpleTenantProperties },
|
||||
blocks: {
|
||||
include: {
|
||||
toBlocks: { include: { toBlock: true } },
|
||||
conditionsGroups: {
|
||||
include: { conditions: { orderBy: { index: "asc" } } },
|
||||
orderBy: { index: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
inputExamples: true,
|
||||
_count: { select: { executions: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflow(data: { tenantId: string | null; createdByUserId: string | null; name: string; description: string }) {
|
||||
return await db.workflow.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
createdByUserId: data.createdByUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflow(
|
||||
scope: { id: string; tenantId: string | null },
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: "draft" | "live" | "archived";
|
||||
appliesToAllTenants?: boolean;
|
||||
}
|
||||
) {
|
||||
const updated = await db.workflow.updateMany({
|
||||
where: { id: scope.id, tenantId: scope.tenantId },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
status: data.status,
|
||||
appliesToAllTenants: data.appliesToAllTenants,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Workflow ${scope.id} not found`);
|
||||
}
|
||||
return await db.workflow.findFirstOrThrow({
|
||||
where: { id: scope.id, tenantId: scope.tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(scope: { id: string; tenantId: string | null }) {
|
||||
const deleted = await db.workflow.deleteMany({
|
||||
where: { id: scope.id, tenantId: scope.tenantId },
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(`Workflow ${scope.id} not found`);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export async function countWorkflows({ tenantId }: { tenantId: string | null }) {
|
||||
return await db.workflow.count({
|
||||
where: { tenantId },
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user