Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -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 },
},
});
}
@@ -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");
});
});
@@ -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`);
}
}
@@ -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 },
}),
);
});
});
@@ -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,
},
});
}
@@ -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 },
});
}