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
+288
@@ -0,0 +1,288 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
|
||||
type WorkflowPreflightServiceTestMocks = {
|
||||
getAvailableVariableNames: ReturnType<typeof vi.fn>;
|
||||
getAvailableCredentialNames: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getMocks(): WorkflowPreflightServiceTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowPreflightServiceTestMocks__?: WorkflowPreflightServiceTestMocks };
|
||||
store.__workflowPreflightServiceTestMocks__ ??= {
|
||||
getAvailableVariableNames: vi.fn(),
|
||||
getAvailableCredentialNames: vi.fn(),
|
||||
};
|
||||
return store.__workflowPreflightServiceTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("./WorkflowVariablesAndCredentialsService", () => ({
|
||||
default: {
|
||||
getAvailableVariableNames: getMocks().getAvailableVariableNames,
|
||||
getAvailableCredentialNames: getMocks().getAvailableCredentialNames,
|
||||
},
|
||||
}));
|
||||
|
||||
import WorkflowPreflightService from "./WorkflowPreflightService";
|
||||
|
||||
const mocks = getMocks();
|
||||
|
||||
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Workflow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
appliesToAllTenants: false,
|
||||
blocks: [
|
||||
{
|
||||
id: "block_1",
|
||||
index: 0,
|
||||
type: "email",
|
||||
variableName: "email",
|
||||
description: "",
|
||||
input: { to: "{{$vars.recipient}}", subject: "{{$vars.subject}}", body: "Hello", provider: "{{$credentials.mail}}" },
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
},
|
||||
],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowPreflightService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient"]);
|
||||
mocks.getAvailableCredentialNames.mockResolvedValue(["mail"]);
|
||||
});
|
||||
|
||||
it("reports missing workflow variables", async () => {
|
||||
const result = await WorkflowPreflightService.check({ workflow: workflow(), tenantId: "tenant_1" });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingVariables).toEqual(["subject"]);
|
||||
expect(result.missingCredentials).toEqual([]);
|
||||
expect(result.missingRequiredInputs).toEqual([]);
|
||||
expect(result.invalidValidationSchemas).toEqual([]);
|
||||
expect(result.nativeCapabilityErrors).toEqual([]);
|
||||
expect(result.errors).toEqual(["Missing workflow variable: subject"]);
|
||||
});
|
||||
|
||||
it("reports missing workflow credentials without loading credential values", async () => {
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
|
||||
mocks.getAvailableCredentialNames.mockResolvedValue([]);
|
||||
|
||||
const result = await WorkflowPreflightService.check({ workflow: workflow(), tenantId: "tenant_1" });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingCredentials).toEqual(["mail"]);
|
||||
expect(result.missingRequiredInputs).toEqual([]);
|
||||
expect(result.invalidValidationSchemas).toEqual([]);
|
||||
expect(mocks.getAvailableCredentialNames).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
|
||||
});
|
||||
|
||||
it("reports missing workflow references in supported Handlebars lookup and with expressions", async () => {
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
|
||||
mocks.getAvailableCredentialNames.mockResolvedValue(["mail"]);
|
||||
|
||||
const result = await WorkflowPreflightService.check({
|
||||
workflow: workflow({
|
||||
blocks: [
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
input: {
|
||||
to: "{{$vars.recipient}}",
|
||||
subject: "{{$vars.subject}}",
|
||||
body: "{{#with $vars}}{{planTier}}{{/with}}",
|
||||
provider: 'Bearer {{lookup $credentials "apiToken"}} {{#with $credentials}}{{webhookSecret}}{{/with}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
tenantId: "tenant_1",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingVariables).toEqual(["planTier"]);
|
||||
expect(result.missingCredentials).toEqual(["apiToken", "webhookSecret"]);
|
||||
expect(result.missingRequiredInputs).toEqual([]);
|
||||
expect(result.invalidValidationSchemas).toEqual([]);
|
||||
expect(result.errors).toEqual([
|
||||
"Missing workflow variable: planTier",
|
||||
"Missing workflow credential: apiToken",
|
||||
"Missing workflow credential: webhookSecret",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports missing required block inputs", async () => {
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
|
||||
|
||||
const result = await WorkflowPreflightService.check({
|
||||
workflow: workflow({
|
||||
blocks: [
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
input: { to: "{{$vars.recipient}}", subject: "{{$vars.subject}}", provider: "{{$credentials.mail}}" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
tenantId: "tenant_1",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingVariables).toEqual([]);
|
||||
expect(result.missingCredentials).toEqual([]);
|
||||
expect(result.missingRequiredInputs).toEqual([{ blockId: "block_1", blockType: "email", missingInputs: ["body"] }]);
|
||||
expect(result.invalidValidationSchemas).toEqual([]);
|
||||
expect(result.errors).toEqual(["Missing required input on block block_1: body"]);
|
||||
});
|
||||
|
||||
it("reports blank required string inputs as missing", async () => {
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
|
||||
|
||||
const result = await WorkflowPreflightService.check({
|
||||
workflow: workflow({
|
||||
blocks: [
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
input: { to: "{{$vars.recipient}}", subject: "{{$vars.subject}}", provider: "{{$credentials.mail}}", body: " " },
|
||||
},
|
||||
],
|
||||
}),
|
||||
tenantId: "tenant_1",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.missingRequiredInputs).toEqual([{ blockId: "block_1", blockType: "email", missingInputs: ["body"] }]);
|
||||
});
|
||||
|
||||
it("reports invalid manual trigger validation schemas before execution is queued", async () => {
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
|
||||
|
||||
const result = await WorkflowPreflightService.check({
|
||||
workflow: workflow({
|
||||
blocks: [
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
id: "manual_1",
|
||||
type: "manual",
|
||||
isTrigger: true,
|
||||
variableName: "manual",
|
||||
input: { validation: "{not-json" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
tenantId: "tenant_1",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.invalidValidationSchemas).toHaveLength(1);
|
||||
expect(result.invalidValidationSchemas[0]).toMatchObject({ blockId: "manual_1" });
|
||||
expect(result.errors[0]).toContain("Invalid manual trigger validation schema on block manual_1:");
|
||||
});
|
||||
|
||||
it("reports worker-native credential references as capability errors", async () => {
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient", "subject"]);
|
||||
|
||||
const result = await WorkflowPreflightService.check({ workflow: workflow(), tenantId: "tenant_1", requireNativeSupport: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
missingVariables: [],
|
||||
missingCredentials: [],
|
||||
missingRequiredInputs: [],
|
||||
nativeCapabilityErrors: ["missing trigger block", 'unsupported credential reference "mail" on block block_1'],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports worker-native credential references before queueing manual execution", async () => {
|
||||
const result = await WorkflowPreflightService.check({
|
||||
workflow: workflow({
|
||||
blocks: [
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
id: "trigger",
|
||||
type: "manual",
|
||||
isTrigger: true,
|
||||
variableName: "manual",
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
id: "http",
|
||||
type: "httpRequest",
|
||||
variableName: "http",
|
||||
input: { url: "{{$credentials.apiBase}}/users", method: "GET" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
tenantId: "tenant_1",
|
||||
requireNativeSupport: true,
|
||||
});
|
||||
|
||||
expect(result.nativeCapabilityErrors).toEqual(['unsupported credential reference "apiBase" on block http']);
|
||||
expect(result.errors).toContain('Worker-native execution does not support this workflow yet: unsupported credential reference "apiBase" on block http');
|
||||
});
|
||||
|
||||
it("reports worker-native if edge shape errors before queueing manual execution", async () => {
|
||||
const result = await WorkflowPreflightService.check({
|
||||
workflow: workflow({
|
||||
blocks: [
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
id: "trigger",
|
||||
type: "manual",
|
||||
isTrigger: true,
|
||||
variableName: "manual",
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
id: "if_1",
|
||||
type: "if",
|
||||
variableName: "if",
|
||||
input: {},
|
||||
conditionGroups: [],
|
||||
toBlocks: [{ id: "edge_true", toBlockId: "next", condition: "true" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
tenantId: "tenant_1",
|
||||
requireNativeSupport: true,
|
||||
});
|
||||
|
||||
expect(result.nativeCapabilityErrors).toEqual(["if block if_1 must have exactly one condition group", "if block if_1 is missing false edge"]);
|
||||
});
|
||||
|
||||
it("passes when refs and native capabilities are satisfied", async () => {
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["message"]);
|
||||
|
||||
await expect(
|
||||
WorkflowPreflightService.check({
|
||||
workflow: workflow({
|
||||
blocks: [
|
||||
{
|
||||
...workflow().blocks[0],
|
||||
type: "log",
|
||||
variableName: "log",
|
||||
input: { message: "Hello {{$vars.message}}" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
tenantId: "tenant_1",
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
nativeCapabilityErrors: [],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { t as i18nT } from "i18next";
|
||||
import { getNativeWorkflowTriggerCapabilityErrors } from "@toolctrl/foundation";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { preflightWorkflow, type WorkflowRequiredInputIssue } from "../helpers/WorkflowReferenceUtils";
|
||||
import WorkflowVariablesAndCredentialsService from "./WorkflowVariablesAndCredentialsService";
|
||||
import WorkflowsValidationService from "./WorkflowsValidationService";
|
||||
|
||||
export type WorkflowValidationSchemaIssue = {
|
||||
blockId: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type WorkflowPreflightResult = {
|
||||
ok: boolean;
|
||||
missingVariables: string[];
|
||||
missingCredentials: string[];
|
||||
missingRequiredInputs: WorkflowRequiredInputIssue[];
|
||||
invalidValidationSchemas: WorkflowValidationSchemaIssue[];
|
||||
nativeCapabilityErrors: string[];
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
function formatMissingReferences({
|
||||
missingVariables,
|
||||
missingCredentials,
|
||||
missingRequiredInputs,
|
||||
invalidValidationSchemas,
|
||||
nativeCapabilityErrors,
|
||||
}: {
|
||||
missingVariables: string[];
|
||||
missingCredentials: string[];
|
||||
missingRequiredInputs: WorkflowRequiredInputIssue[];
|
||||
invalidValidationSchemas: WorkflowValidationSchemaIssue[];
|
||||
nativeCapabilityErrors: string[];
|
||||
}) {
|
||||
return [
|
||||
...missingVariables.map((name) => i18nT("workflows.errors.missingWorkflowVariable", { name })),
|
||||
...missingCredentials.map((name) => i18nT("workflows.errors.missingWorkflowCredential", { name })),
|
||||
...missingRequiredInputs.flatMap((issue) =>
|
||||
issue.missingInputs.map((name) =>
|
||||
i18nT("workflows.errors.missingRequiredInputOnBlock", {
|
||||
blockId: issue.blockId,
|
||||
name,
|
||||
})
|
||||
)
|
||||
),
|
||||
...invalidValidationSchemas.map((issue) =>
|
||||
i18nT("workflows.errors.invalidManualValidationSchema", {
|
||||
blockId: issue.blockId,
|
||||
error: issue.error,
|
||||
})
|
||||
),
|
||||
...nativeCapabilityErrors.map((error) => i18nT("workflows.errors.nativeCapabilityUnsupported", { error })),
|
||||
];
|
||||
}
|
||||
|
||||
function getNativeCapabilityErrors(workflow: WorkflowDto): string[] {
|
||||
return getNativeWorkflowTriggerCapabilityErrors(workflow.blocks);
|
||||
}
|
||||
|
||||
function getInvalidValidationSchemas(workflow: WorkflowDto): WorkflowValidationSchemaIssue[] {
|
||||
return workflow.blocks.flatMap((block) => {
|
||||
if (block.type !== "manual" || !block.input.validation) {
|
||||
return [];
|
||||
}
|
||||
const { error } = WorkflowsValidationService.parseJsonSchema(block.input.validation);
|
||||
return error ? [{ blockId: block.id, error }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function check({
|
||||
workflow,
|
||||
tenantId,
|
||||
appliesToAllTenants,
|
||||
requireNativeSupport,
|
||||
}: {
|
||||
workflow: WorkflowDto;
|
||||
tenantId: string | null;
|
||||
appliesToAllTenants?: boolean;
|
||||
requireNativeSupport?: boolean;
|
||||
}): Promise<WorkflowPreflightResult> {
|
||||
const effectiveAppliesToAllTenants = appliesToAllTenants === true || workflow.appliesToAllTenants === true;
|
||||
const [variables, credentials] = await Promise.all([
|
||||
WorkflowVariablesAndCredentialsService.getAvailableVariableNames({ tenantId, appliesToAllTenants: effectiveAppliesToAllTenants }),
|
||||
WorkflowVariablesAndCredentialsService.getAvailableCredentialNames({ tenantId, appliesToAllTenants: effectiveAppliesToAllTenants }),
|
||||
]);
|
||||
const { missingVariables, missingCredentials, missingRequiredInputs } = preflightWorkflow({ workflow, variables, credentials });
|
||||
const invalidValidationSchemas = getInvalidValidationSchemas(workflow);
|
||||
const nativeCapabilityErrors = requireNativeSupport === true ? getNativeCapabilityErrors(workflow) : [];
|
||||
const errors = formatMissingReferences({ missingVariables, missingCredentials, missingRequiredInputs, invalidValidationSchemas, nativeCapabilityErrors });
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
missingVariables,
|
||||
missingCredentials,
|
||||
missingRequiredInputs,
|
||||
invalidValidationSchemas,
|
||||
nativeCapabilityErrors,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfFailed(result: WorkflowPreflightResult) {
|
||||
if (!result.ok) {
|
||||
throw Object.assign(
|
||||
new Error(i18nT("workflows.errors.preflightFailed", { errors: result.errors.join("; ") })),
|
||||
{ status: 400, preflight: result }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
check,
|
||||
throwIfFailed,
|
||||
};
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import CryptoApi from "~/utils/api/.server/CryptoApi";
|
||||
import { getAllWorkflowCredentials } from "../db/workflowCredentials.db.server";
|
||||
import { getAllWorkflowVariables } from "../db/workflowVariable.db.server";
|
||||
|
||||
async function getCredentialsContext({
|
||||
tenantId,
|
||||
appliesToAllTenants,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
appliesToAllTenants: boolean | undefined;
|
||||
}): Promise<{ [key: string]: string }> {
|
||||
let credentials: { [key: string]: string } = {};
|
||||
|
||||
if (appliesToAllTenants && tenantId) {
|
||||
const adminItems = await getAllWorkflowCredentials({ tenantId: null });
|
||||
adminItems.forEach((item) => {
|
||||
const value = CryptoApi.decrypt(item.value);
|
||||
credentials[item.name] = value;
|
||||
});
|
||||
}
|
||||
|
||||
const items = await getAllWorkflowCredentials({ tenantId });
|
||||
items.forEach((item) => {
|
||||
const value = CryptoApi.decrypt(item.value);
|
||||
credentials[item.name] = value;
|
||||
});
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function getVariablesContext({
|
||||
tenantId,
|
||||
appliesToAllTenants,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
appliesToAllTenants: boolean | undefined;
|
||||
}): Promise<{ [key: string]: string }> {
|
||||
let variables: { [key: string]: string } = {};
|
||||
|
||||
if (appliesToAllTenants && tenantId) {
|
||||
const adminItems = await getAllWorkflowVariables({ tenantId: null });
|
||||
adminItems.forEach((item) => {
|
||||
variables[item.name] = item.value;
|
||||
});
|
||||
}
|
||||
|
||||
const items = await getAllWorkflowVariables({ tenantId });
|
||||
items.forEach((item) => {
|
||||
variables[item.name] = item.value;
|
||||
});
|
||||
return variables;
|
||||
}
|
||||
|
||||
async function getAvailableCredentialNames({
|
||||
tenantId,
|
||||
appliesToAllTenants,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
appliesToAllTenants: boolean | undefined;
|
||||
}): Promise<string[]> {
|
||||
const names = new Set<string>();
|
||||
|
||||
if (appliesToAllTenants && tenantId) {
|
||||
const adminItems = await getAllWorkflowCredentials({ tenantId: null });
|
||||
adminItems.forEach((item) => names.add(item.name));
|
||||
}
|
||||
|
||||
const items = await getAllWorkflowCredentials({ tenantId });
|
||||
items.forEach((item) => names.add(item.name));
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function getAvailableVariableNames({
|
||||
tenantId,
|
||||
appliesToAllTenants,
|
||||
}: {
|
||||
tenantId: string | null;
|
||||
appliesToAllTenants: boolean | undefined;
|
||||
}): Promise<string[]> {
|
||||
const names = new Set<string>();
|
||||
|
||||
if (appliesToAllTenants && tenantId) {
|
||||
const adminItems = await getAllWorkflowVariables({ tenantId: null });
|
||||
adminItems.forEach((item) => names.add(item.name));
|
||||
}
|
||||
|
||||
const items = await getAllWorkflowVariables({ tenantId });
|
||||
items.forEach((item) => names.add(item.name));
|
||||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export default {
|
||||
getCredentialsContext,
|
||||
getVariablesContext,
|
||||
getAvailableCredentialNames,
|
||||
getAvailableVariableNames,
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import WorkflowsConditionsService from "./WorkflowsConditionsService";
|
||||
import type { WorkflowConditionOperator } from "../dtos/WorkflowConditionDtos";
|
||||
|
||||
function validate(operator: WorkflowConditionOperator, variable: string, value = "match") {
|
||||
return WorkflowsConditionsService.validateGroups({
|
||||
conditionGroup: {
|
||||
index: 0,
|
||||
type: "AND",
|
||||
conditions: [{ index: 0, variable: "{{value}}", operator, value }],
|
||||
},
|
||||
workflowContext: { value: variable },
|
||||
});
|
||||
}
|
||||
|
||||
describe("WorkflowsConditionsService", () => {
|
||||
it.each([
|
||||
["=", "match", "match", true],
|
||||
["!=", "match", "other", true],
|
||||
[">", "b", "a", true],
|
||||
["<", "a", "b", true],
|
||||
[">=", "b", "b", true],
|
||||
["<=", "b", "b", true],
|
||||
["contains", "pre-match-post", "match", true],
|
||||
["doesNotContain", "pre-post", "match", true],
|
||||
["startsWith", "match-post", "match", true],
|
||||
["endsWith", "pre-match", "match", true],
|
||||
] satisfies Array<[WorkflowConditionOperator, string, string, boolean]>)(
|
||||
"evaluates %s conditions",
|
||||
(operator, variable, value, expected) => {
|
||||
expect(validate(operator, variable, value)).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
["contains", "pre-post", "match"],
|
||||
["doesNotContain", "pre-match-post", "match"],
|
||||
["startsWith", "pre-match", "match"],
|
||||
["endsWith", "match-post", "match"],
|
||||
] satisfies Array<[WorkflowConditionOperator, string, string]>)("rejects failed %s conditions", (operator, variable, value) => {
|
||||
expect(validate(operator, variable, value)).toBe(false);
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { WorkflowConditionOperators, WorkflowConditionsGroupDto } from "../dtos/WorkflowConditionDtos";
|
||||
import { parseVariable } from "./WorkflowsVariablesService";
|
||||
|
||||
function validateGroups({ conditionGroup, workflowContext }: { conditionGroup: WorkflowConditionsGroupDto; workflowContext: { [key: string]: any } }) {
|
||||
const results = conditionGroup.conditions.map((condition) => {
|
||||
const operator = WorkflowConditionOperators.find((f) => f.value === condition.operator);
|
||||
if (!operator) {
|
||||
throw new Error("Invalid operator: " + condition.operator);
|
||||
}
|
||||
const variable = parseVariable(condition.variable, workflowContext);
|
||||
if (!operator.requiresValue) {
|
||||
switch (operator.value) {
|
||||
case "isNotEmpty":
|
||||
return variable && variable !== "";
|
||||
case "isEmpty":
|
||||
return !variable || variable.trim() === "";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const value = parseVariable(condition.value, workflowContext);
|
||||
switch (operator.value) {
|
||||
case "=":
|
||||
return (variable?.toString() || "") === (value?.toString() || "");
|
||||
case "!=":
|
||||
return (variable?.toString() || "") !== (value?.toString() || "");
|
||||
case ">":
|
||||
return variable && value && variable > value;
|
||||
case "<":
|
||||
return variable && value && variable < value;
|
||||
case ">=":
|
||||
return variable && value && variable >= value;
|
||||
case "<=":
|
||||
return variable && value && variable <= value;
|
||||
case "startsWith":
|
||||
return variable?.startsWith(value || "");
|
||||
case "endsWith":
|
||||
return variable?.endsWith(value || "");
|
||||
case "contains":
|
||||
return variable?.includes(value || "");
|
||||
case "doesNotContain":
|
||||
return !variable?.includes(value || "");
|
||||
default:
|
||||
throw new Error("Invalid operator: " + condition.operator);
|
||||
}
|
||||
}
|
||||
});
|
||||
const groupResult = conditionGroup.type === "AND" ? results.every((f) => f) : results.some((f) => f);
|
||||
return groupResult;
|
||||
}
|
||||
|
||||
export default {
|
||||
validateGroups,
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readRepoFile } from "~/utils/testing/repoPaths";
|
||||
|
||||
describe("WorkflowsExecutionsService environment boundary", () => {
|
||||
it("loads runtime mode through the web config boundary", () => {
|
||||
const source = readRepoFile("apps/web/app/modules/workflowEngine/services/WorkflowsExecutionsService.ts");
|
||||
|
||||
expect(source).toContain('from "@toolctrl/config"');
|
||||
expect(source).toContain("loadWebConfig().config.nodeEnv");
|
||||
expect(source).not.toMatch(/process\.env\.NODE_ENV\b/);
|
||||
});
|
||||
});
|
||||
+929
@@ -0,0 +1,929 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowExecutionServiceTestMocks = {
|
||||
workflowExecutionCreate: ReturnType<typeof vi.fn>;
|
||||
transaction: ReturnType<typeof vi.fn>;
|
||||
publishOutboxEvent: ReturnType<typeof vi.fn>;
|
||||
tenantFindFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
userFindUnique: ReturnType<typeof vi.fn>;
|
||||
getWorkflowExecution: ReturnType<typeof vi.fn>;
|
||||
updateWorkflowExecution: ReturnType<typeof vi.fn>;
|
||||
getWorkflowById: ReturnType<typeof vi.fn>;
|
||||
executeBlock: ReturnType<typeof vi.fn>;
|
||||
getVariablesContext: ReturnType<typeof vi.fn>;
|
||||
getCredentialsContext: ReturnType<typeof vi.fn>;
|
||||
getAvailableVariableNames: ReturnType<typeof vi.fn>;
|
||||
getAvailableCredentialNames: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getMocks(): WorkflowExecutionServiceTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowExecutionServiceTestMocks__?: WorkflowExecutionServiceTestMocks };
|
||||
store.__workflowExecutionServiceTestMocks__ ??= {
|
||||
workflowExecutionCreate: vi.fn(),
|
||||
transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback({ workflowExecution: { create: getMocks().workflowExecutionCreate } })),
|
||||
publishOutboxEvent: vi.fn(async () => ({ id: "outbox_1" })),
|
||||
tenantFindFirstOrThrow: vi.fn(),
|
||||
userFindUnique: vi.fn(),
|
||||
getWorkflowExecution: vi.fn(),
|
||||
updateWorkflowExecution: vi.fn(),
|
||||
getWorkflowById: vi.fn(),
|
||||
executeBlock: vi.fn(),
|
||||
getVariablesContext: vi.fn(),
|
||||
getCredentialsContext: vi.fn(),
|
||||
getAvailableVariableNames: vi.fn(),
|
||||
getAvailableCredentialNames: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
return store.__workflowExecutionServiceTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
$transaction: getMocks().transaction,
|
||||
workflowExecution: { create: getMocks().workflowExecutionCreate },
|
||||
tenant: { findFirstOrThrow: getMocks().tenantFindFirstOrThrow },
|
||||
user: { findUnique: getMocks().userFindUnique },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@toolctrl/db/outbox", () => ({
|
||||
publishOutboxEvent: getMocks().publishOutboxEvent,
|
||||
}));
|
||||
|
||||
vi.mock("../db/workflowExecutions.db.server", () => ({
|
||||
getWorkflowExecution: getMocks().getWorkflowExecution,
|
||||
updateWorkflowExecution: getMocks().updateWorkflowExecution,
|
||||
}));
|
||||
|
||||
vi.mock("./WorkflowsService", () => ({
|
||||
default: {
|
||||
getById: getMocks().getWorkflowById,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./blocks/WorkflowBlockService", () => ({
|
||||
default: {
|
||||
execute: getMocks().executeBlock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./WorkflowVariablesAndCredentialsService", () => ({
|
||||
default: {
|
||||
getVariablesContext: getMocks().getVariablesContext,
|
||||
getCredentialsContext: getMocks().getCredentialsContext,
|
||||
getAvailableVariableNames: getMocks().getAvailableVariableNames,
|
||||
getAvailableCredentialNames: getMocks().getAvailableCredentialNames,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/stream/emitter.server", () => ({
|
||||
emitter: { emit: getMocks().emit },
|
||||
}));
|
||||
|
||||
import WorkflowsExecutionsService from "./WorkflowsExecutionsService";
|
||||
|
||||
const mocks = getMocks();
|
||||
|
||||
const triggerBlock = {
|
||||
id: "block_trigger",
|
||||
index: 0,
|
||||
type: "trigger",
|
||||
variableName: "",
|
||||
description: "Trigger",
|
||||
input: {},
|
||||
isTrigger: true,
|
||||
isBlock: false,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
};
|
||||
|
||||
const workflow = {
|
||||
id: "workflow_global",
|
||||
tenantId: null,
|
||||
tenant: null,
|
||||
name: "Global workflow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
appliesToAllTenants: true,
|
||||
blocks: [triggerBlock],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
};
|
||||
|
||||
const tenantWorkflow = {
|
||||
...workflow,
|
||||
id: "workflow_tenant",
|
||||
tenantId: "tenant_1",
|
||||
tenant: { id: "tenant_1", name: "Acme", slug: "acme" },
|
||||
name: "Tenant workflow",
|
||||
appliesToAllTenants: false,
|
||||
};
|
||||
|
||||
function executionRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "execution_1",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_tenant",
|
||||
type: "api",
|
||||
input: "{}",
|
||||
output: "{}",
|
||||
status: "success",
|
||||
duration: 1,
|
||||
endedAt: null,
|
||||
error: null,
|
||||
waitingBlockId: null,
|
||||
appliesToAllTenants: false,
|
||||
workflow: { id: "workflow_tenant", name: "Tenant workflow" },
|
||||
tenant: { id: "tenant_1", name: "Acme", slug: "acme" },
|
||||
waitingBlock: null,
|
||||
blockRuns: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowsExecutionsService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.workflowExecutionCreate.mockResolvedValue({ id: "execution_1" });
|
||||
mocks.tenantFindFirstOrThrow.mockResolvedValue({ id: "tenant_1", name: "Acme" });
|
||||
mocks.userFindUnique.mockResolvedValue({ id: "user_1", email: "ada@example.com", firstName: "Ada", lastName: "Lovelace" });
|
||||
mocks.getWorkflowById.mockResolvedValue(workflow);
|
||||
mocks.executeBlock.mockResolvedValue({ status: "success", workflowContext: { ok: true, $credentials: { token: "secret" } }, waitingBlockId: null });
|
||||
mocks.getVariablesContext.mockResolvedValue({ tenantVariable: "value" });
|
||||
mocks.getCredentialsContext.mockResolvedValue({ tenantCredential: "value" });
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient"]);
|
||||
mocks.getAvailableCredentialNames.mockResolvedValue(["tenantCredential"]);
|
||||
mocks.getWorkflowExecution.mockResolvedValue(executionRow({ status: "running", type: "manual", input: '{"ok":true}' }));
|
||||
mocks.updateWorkflowExecution.mockResolvedValue(executionRow({ output: '{"ok":true}' }));
|
||||
});
|
||||
|
||||
it("rejects global workflows for tenant API executions", async () => {
|
||||
await expect(
|
||||
WorkflowsExecutionsService.execute("workflow_global", {
|
||||
type: "api",
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow is not available for tenant API execution.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
|
||||
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows internal trigger executions for global workflows in tenant scope", async () => {
|
||||
await WorkflowsExecutionsService.execute("workflow_global", {
|
||||
type: "trigger",
|
||||
input: { event: "row.created" },
|
||||
session: { tenantId: "tenant_1", userId: null },
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
type: "trigger",
|
||||
appliesToAllTenants: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mocks.getVariablesContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: true });
|
||||
expect(mocks.getCredentialsContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: true });
|
||||
});
|
||||
|
||||
it("rejects draft tenant workflows for API executions", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({ ...tenantWorkflow, status: "draft" });
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "api",
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow is not live.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
|
||||
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects draft tenant workflows for manual executions", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({ ...tenantWorkflow, status: "draft" });
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "manual",
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow is not live.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.executeBlock).not.toHaveBeenCalled();
|
||||
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
|
||||
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues manual executions through the workflow outbox without executing inline", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
mocks.workflowExecutionCreate.mockResolvedValueOnce({ id: "execution_manual_1" });
|
||||
mocks.getWorkflowExecution.mockResolvedValueOnce(executionRow({ id: "execution_manual_1", status: "running", type: "manual", input: '{"name":"Ada"}' }));
|
||||
|
||||
const execution = await WorkflowsExecutionsService.queueManualExecution("workflow_tenant", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(execution.id).toBe("execution_manual_1");
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_tenant",
|
||||
createdByUserId: "user_1",
|
||||
type: "manual",
|
||||
status: "running",
|
||||
input: JSON.stringify({ name: "Ada" }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
type: "workflow.execution.requested",
|
||||
payload: expect.objectContaining({
|
||||
workflowId: "workflow_tenant",
|
||||
executionId: "execution_manual_1",
|
||||
type: "manual",
|
||||
input: { name: "Ada" },
|
||||
userId: "user_1",
|
||||
}),
|
||||
dispatchKey: "workflow-execution:execution_manual_1",
|
||||
})
|
||||
);
|
||||
expect(mocks.executeBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects queued manual executions with invalid manual trigger validation before creating outbox work", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({
|
||||
...tenantWorkflow,
|
||||
blocks: [
|
||||
{
|
||||
...triggerBlock,
|
||||
id: "manual_1",
|
||||
type: "manual",
|
||||
input: { validation: "{not-json" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.queueManualExecution("workflow_tenant", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
status: 400,
|
||||
preflight: expect.objectContaining({
|
||||
invalidValidationSchemas: [expect.objectContaining({ blockId: "manual_1" })],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues manual executions for HTTP-bridge-capable workflows without native block preflight", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({
|
||||
...tenantWorkflow,
|
||||
blocks: [
|
||||
triggerBlock,
|
||||
{
|
||||
...triggerBlock,
|
||||
id: "block_row_create",
|
||||
type: "rowCreate",
|
||||
input: { entity: "contact", row: { name: "Ada" } },
|
||||
isTrigger: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.workflowExecutionCreate.mockResolvedValueOnce({ id: "execution_manual_http_bridge" });
|
||||
mocks.getWorkflowExecution.mockResolvedValueOnce(
|
||||
executionRow({
|
||||
id: "execution_manual_http_bridge",
|
||||
status: "running",
|
||||
type: "manual",
|
||||
input: '{"name":"Ada"}',
|
||||
})
|
||||
);
|
||||
|
||||
const execution = await WorkflowsExecutionsService.queueManualExecution("workflow_tenant", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(execution.id).toBe("execution_manual_http_bridge");
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: "workflow.execution.requested",
|
||||
payload: expect.objectContaining({ executionId: "execution_manual_http_bridge" }),
|
||||
})
|
||||
);
|
||||
expect(mocks.executeBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues API executions through the workflow outbox without executing inline", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
mocks.workflowExecutionCreate.mockResolvedValueOnce({ id: "execution_api_1" });
|
||||
mocks.getWorkflowExecution.mockResolvedValueOnce(executionRow({ id: "execution_api_1", status: "running", type: "api", input: '{"name":"Ada"}' }));
|
||||
|
||||
const execution = await WorkflowsExecutionsService.queueApiExecution("workflow_tenant", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(execution.id).toBe("execution_api_1");
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_tenant",
|
||||
createdByUserId: "user_1",
|
||||
type: "api",
|
||||
status: "running",
|
||||
input: JSON.stringify({ name: "Ada" }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
type: "workflow.execution.requested",
|
||||
payload: expect.objectContaining({
|
||||
workflowId: "workflow_tenant",
|
||||
executionId: "execution_api_1",
|
||||
type: "api",
|
||||
input: { name: "Ada" },
|
||||
userId: "user_1",
|
||||
}),
|
||||
dispatchKey: "workflow-execution:execution_api_1",
|
||||
})
|
||||
);
|
||||
expect(mocks.executeBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues API continuations through the workflow outbox without executing inline", async () => {
|
||||
mocks.getWorkflowExecution
|
||||
.mockResolvedValueOnce(executionRow({ id: "execution_waiting_1", status: "waitingBlock", waitingBlockId: "block_trigger", input: '{"step":1}' }))
|
||||
.mockResolvedValueOnce(executionRow({ id: "execution_waiting_1", status: "waitingBlock", waitingBlockId: "block_trigger", input: '{"step":1}' }));
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
|
||||
const execution = await WorkflowsExecutionsService.queueContinuationExecution("execution_waiting_1", {
|
||||
type: "api",
|
||||
input: { approved: true },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(execution.id).toBe("execution_waiting_1");
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
type: "workflow.execution.continue.requested",
|
||||
payload: expect.objectContaining({
|
||||
workflowId: "workflow_tenant",
|
||||
executionId: "execution_waiting_1",
|
||||
type: "api",
|
||||
input: { approved: true },
|
||||
userId: "user_1",
|
||||
}),
|
||||
dispatchKey: expect.stringMatching(/^workflow-continuation:execution_waiting_1:block_trigger:\d+$/u),
|
||||
})
|
||||
);
|
||||
expect(mocks.executeBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps legacy waiting continuations queueable while waitingBlock rows roll out", async () => {
|
||||
mocks.getWorkflowExecution
|
||||
.mockResolvedValueOnce(executionRow({ id: "execution_legacy_waiting_1", status: "waiting", waitingBlockId: "block_trigger", input: '{"step":1}' }))
|
||||
.mockResolvedValueOnce(executionRow({ id: "execution_legacy_waiting_1", status: "waiting", waitingBlockId: "block_trigger", input: '{"step":1}' }));
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
|
||||
await WorkflowsExecutionsService.queueContinuationExecution("execution_legacy_waiting_1", {
|
||||
type: "manual",
|
||||
input: { approved: true },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: "workflow.execution.continue.requested",
|
||||
dispatchKey: expect.stringMatching(/^workflow-continuation:execution_legacy_waiting_1:block_trigger:\d+$/u),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps continuation redelivery idempotent for the same wait snapshot regardless of input but separates later waits", async () => {
|
||||
const firstWait = executionRow({
|
||||
id: "execution_multi_wait_1",
|
||||
status: "waitingBlock",
|
||||
waitingBlockId: "block_wait_1",
|
||||
updatedAt: new Date("2026-05-08T10:00:01.000Z"),
|
||||
input: '{"step":1}',
|
||||
});
|
||||
const secondWait = executionRow({
|
||||
id: "execution_multi_wait_1",
|
||||
status: "waitingBlock",
|
||||
waitingBlockId: "block_wait_2",
|
||||
updatedAt: new Date("2026-05-08T10:05:01.000Z"),
|
||||
input: '{"step":2}',
|
||||
});
|
||||
mocks.getWorkflowExecution
|
||||
.mockResolvedValueOnce(firstWait)
|
||||
.mockResolvedValueOnce(firstWait)
|
||||
.mockResolvedValueOnce(firstWait)
|
||||
.mockResolvedValueOnce(firstWait)
|
||||
.mockResolvedValueOnce(secondWait)
|
||||
.mockResolvedValueOnce(secondWait);
|
||||
mocks.getWorkflowById.mockResolvedValue(tenantWorkflow);
|
||||
|
||||
await WorkflowsExecutionsService.queueContinuationExecution("execution_multi_wait_1", {
|
||||
type: "manual",
|
||||
input: { approved: true },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
await WorkflowsExecutionsService.queueContinuationExecution("execution_multi_wait_1", {
|
||||
type: "manual",
|
||||
input: { approved: false, reason: "changed" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
await WorkflowsExecutionsService.queueContinuationExecution("execution_multi_wait_1", {
|
||||
type: "manual",
|
||||
input: { approved: false, reason: "changed" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
const dispatchKeys = mocks.publishOutboxEvent.mock.calls.map(([, event]) => event.dispatchKey);
|
||||
expect(dispatchKeys[0]).toBe(dispatchKeys[1]);
|
||||
expect(dispatchKeys[2]).not.toBe(dispatchKeys[0]);
|
||||
expect(dispatchKeys[0]).toMatch(/^workflow-continuation:execution_multi_wait_1:block_wait_1:\d+$/u);
|
||||
expect(dispatchKeys[2]).toMatch(/^workflow-continuation:execution_multi_wait_1:block_wait_2:\d+$/u);
|
||||
});
|
||||
|
||||
it("rejects queueing continuations when the execution is not waiting", async () => {
|
||||
mocks.getWorkflowExecution.mockResolvedValueOnce(executionRow({ id: "execution_done_1", status: "success", waitingBlockId: null }));
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.queueContinuationExecution("execution_done_1", {
|
||||
type: "manual",
|
||||
input: { approved: true },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toThrow("Workflow execution is not waiting for a block");
|
||||
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects global workflows for queued tenant API executions", async () => {
|
||||
await expect(
|
||||
WorkflowsExecutionsService.queueApiExecution("workflow_global", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow is not available for tenant API execution.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores and executes tenant workflows with tenant scoped variables and credentials", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
|
||||
await WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "api",
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ appliesToAllTenants: false }),
|
||||
})
|
||||
);
|
||||
expect(mocks.getVariablesContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
|
||||
expect(mocks.getCredentialsContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
|
||||
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
|
||||
"execution_1",
|
||||
{ tenantId: "tenant_1", userId: "user_1" },
|
||||
expect.objectContaining({ status: "success" }),
|
||||
expect.anything()
|
||||
);
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: "plugin.hook.workflow.executed",
|
||||
dispatchKey: "workflow-execution:execution_1:success",
|
||||
tenantId: "tenant_1",
|
||||
payload: expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_tenant",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: "user_1",
|
||||
type: "api",
|
||||
status: "success",
|
||||
errorCode: null,
|
||||
appliesToAllTenants: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mocks.publishOutboxEvent.mock.calls[0]?.[0]).toBe(mocks.updateWorkflowExecution.mock.calls[0]?.[3]);
|
||||
});
|
||||
|
||||
it("redacts workflow error details before emitting plugin hooks", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
mocks.executeBlock.mockRejectedValueOnce(new Error("Provider failed for https://api.example.test?token=sk_live_secret"));
|
||||
mocks.updateWorkflowExecution.mockResolvedValueOnce(
|
||||
executionRow({
|
||||
status: "error",
|
||||
error: "Provider failed for https://api.example.test?token=sk_live_secret",
|
||||
})
|
||||
);
|
||||
|
||||
await WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "api",
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
|
||||
"execution_1",
|
||||
{ tenantId: "tenant_1", userId: "user_1" },
|
||||
expect.objectContaining({
|
||||
status: "error",
|
||||
error: "Provider failed for https://api.example.test?token=sk_live_secret",
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: "plugin.hook.workflow.executed",
|
||||
dispatchKey: "workflow-execution:execution_1:error",
|
||||
tenantId: "tenant_1",
|
||||
payload: expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_tenant",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: "user_1",
|
||||
type: "api",
|
||||
status: "error",
|
||||
errorCode: "workflow_execution_failed",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mocks.publishOutboxEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
type: "plugin.hook.workflow.failed",
|
||||
dispatchKey: "workflow-execution:execution_1:failed",
|
||||
tenantId: "tenant_1",
|
||||
payload: expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_tenant",
|
||||
workflowExecutionId: "execution_1",
|
||||
userId: "user_1",
|
||||
type: "api",
|
||||
status: "error",
|
||||
errorCode: "workflow_execution_failed",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mocks.publishOutboxEvent.mock.calls[0]?.[1]?.payload).not.toHaveProperty("error");
|
||||
expect(mocks.publishOutboxEvent.mock.calls[1]?.[1]?.payload).not.toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("redacts credential values from persisted execution outputs and errors", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
mocks.getCredentialsContext.mockResolvedValueOnce({ apiToken: "sk_live_secret" });
|
||||
mocks.executeBlock.mockRejectedValueOnce(new Error("Provider echoed sk_live_secret"));
|
||||
|
||||
await WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "api",
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
|
||||
"execution_1",
|
||||
{ tenantId: "tenant_1", userId: "user_1" },
|
||||
expect.objectContaining({
|
||||
output: "{}",
|
||||
error: "Provider echoed [REDACTED_CREDENTIAL]",
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts copied credential values from persisted execution context", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
mocks.getCredentialsContext.mockResolvedValueOnce({ apiToken: "sk_live_secret" });
|
||||
mocks.executeBlock.mockResolvedValueOnce({
|
||||
status: "success",
|
||||
workflowContext: {
|
||||
http: { body: "sk_live_secret" },
|
||||
nested: { values: ["safe", "Bearer sk_live_secret"] },
|
||||
$credentials: { apiToken: "sk_live_secret" },
|
||||
},
|
||||
waitingBlockId: null,
|
||||
});
|
||||
|
||||
await WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "api",
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
|
||||
"execution_1",
|
||||
{ tenantId: "tenant_1", userId: "user_1" },
|
||||
expect.objectContaining({
|
||||
output: JSON.stringify({
|
||||
http: { body: "[REDACTED_CREDENTIAL]" },
|
||||
nested: { values: ["safe", "Bearer [REDACTED_CREDENTIAL]"] },
|
||||
}),
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
expect(JSON.stringify(mocks.updateWorkflowExecution.mock.calls)).not.toContain("sk_live_secret");
|
||||
});
|
||||
|
||||
it("does not emit workflow plugin hooks for waiting or platform-scope executions", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
mocks.executeBlock.mockResolvedValueOnce({ status: "waitingBlock", workflowContext: { ok: false }, waitingBlockId: "block_wait" });
|
||||
mocks.updateWorkflowExecution.mockResolvedValueOnce(executionRow({ status: "waitingBlock", waitingBlockId: "block_wait" }));
|
||||
|
||||
await WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "manual",
|
||||
input: {},
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
|
||||
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({ ...workflow, appliesToAllTenants: true });
|
||||
mocks.updateWorkflowExecution.mockResolvedValueOnce(executionRow({ tenantId: null, appliesToAllTenants: true, status: "success" }));
|
||||
|
||||
await WorkflowsExecutionsService.execute("workflow_global", {
|
||||
type: "manual",
|
||||
input: {},
|
||||
session: { tenantId: null, userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(mocks.publishOutboxEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing workflow references before creating an execution", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({
|
||||
...tenantWorkflow,
|
||||
blocks: [
|
||||
{
|
||||
...triggerBlock,
|
||||
input: { value: "{{$vars.missingVariable}} {{$credentials.missingCredential}}" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "manual",
|
||||
input: {},
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow preflight failed: Missing workflow variable: missingVariable; Missing workflow credential: missingCredential",
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
|
||||
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing condition references before creating an execution", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({
|
||||
...tenantWorkflow,
|
||||
blocks: [
|
||||
{
|
||||
...triggerBlock,
|
||||
id: "block_condition",
|
||||
type: "if",
|
||||
conditionGroups: [
|
||||
{
|
||||
index: 0,
|
||||
type: "AND",
|
||||
conditions: [
|
||||
{
|
||||
index: 0,
|
||||
variable: "{{$vars.expectedPlan}}",
|
||||
operator: "=",
|
||||
value: "{{$credentials.planSecret}}",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "manual",
|
||||
input: {},
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow preflight failed: Missing workflow variable: expectedPlan; Missing workflow credential: planSecret",
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
|
||||
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing required block inputs before creating an execution", async () => {
|
||||
mocks.getAvailableVariableNames.mockResolvedValue(["recipient"]);
|
||||
mocks.getAvailableCredentialNames.mockResolvedValue(["tenantCredential"]);
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({
|
||||
...tenantWorkflow,
|
||||
blocks: [
|
||||
{
|
||||
...triggerBlock,
|
||||
id: "block_email",
|
||||
type: "email",
|
||||
input: { provider: "tenantCredential", to: "{{$vars.recipient}}", subject: "Welcome" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.execute("workflow_tenant", {
|
||||
type: "manual",
|
||||
input: {},
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow preflight failed: Missing required input on block block_email: body",
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.getVariablesContext).not.toHaveBeenCalled();
|
||||
expect(mocks.getCredentialsContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues waiting tenant executions with scoped updates", async () => {
|
||||
mocks.getWorkflowExecution.mockResolvedValue(
|
||||
executionRow({
|
||||
status: "waiting",
|
||||
waitingBlockId: "block_trigger",
|
||||
input: '{"step":1}',
|
||||
output: JSON.stringify({ priorBlock: { ok: true }, $params: { stale: true }, $session: { stale: true } }),
|
||||
appliesToAllTenants: false,
|
||||
})
|
||||
);
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(tenantWorkflow);
|
||||
|
||||
await WorkflowsExecutionsService.continueExecution("execution_1", {
|
||||
type: "api",
|
||||
input: { approved: true },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
});
|
||||
|
||||
expect(mocks.getWorkflowById).toHaveBeenCalledWith("workflow_tenant", { tenantId: "tenant_1", userId: "user_1" });
|
||||
expect(mocks.getVariablesContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
|
||||
expect(mocks.executeBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowContext: expect.objectContaining({
|
||||
priorBlock: { ok: true },
|
||||
$params: { approved: true },
|
||||
$session: expect.objectContaining({
|
||||
tenant: { id: "tenant_1", name: "Acme" },
|
||||
user: expect.objectContaining({ id: "user_1", email: "ada@example.com" }),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
|
||||
"execution_1",
|
||||
{ tenantId: "tenant_1", userId: "user_1" },
|
||||
expect.objectContaining({ status: "success" }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects continuing executions when the backing workflow is no longer live", async () => {
|
||||
mocks.getWorkflowExecution.mockResolvedValue(
|
||||
executionRow({
|
||||
appliesToAllTenants: false,
|
||||
input: '{"step":1}',
|
||||
status: "waiting",
|
||||
waitingBlockId: "block_trigger",
|
||||
})
|
||||
);
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({ ...tenantWorkflow, status: "draft" });
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.continueExecution("execution_1", {
|
||||
type: "manual",
|
||||
input: { approved: true },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow is not live.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(mocks.executeBlock).not.toHaveBeenCalled();
|
||||
expect(mocks.updateWorkflowExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects stream executions when the workflow is not live before creating a row", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValueOnce({ ...tenantWorkflow, status: "draft" });
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.stream("workflow_tenant", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: "Workflow is not live.",
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(mocks.workflowExecutionCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.executeBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts stream executions with a running row and tenant scoped background execution", async () => {
|
||||
mocks.getWorkflowById.mockResolvedValue(tenantWorkflow);
|
||||
|
||||
await expect(
|
||||
WorkflowsExecutionsService.stream("workflow_tenant", {
|
||||
input: { name: "Ada" },
|
||||
session: { tenantId: "tenant_1", userId: "user_1" },
|
||||
})
|
||||
).resolves.toEqual({ id: "execution_1" });
|
||||
|
||||
expect(mocks.workflowExecutionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_tenant",
|
||||
createdByUserId: "user_1",
|
||||
type: "stream",
|
||||
input: '{"name":"Ada"}',
|
||||
status: "running",
|
||||
output: "{}",
|
||||
appliesToAllTenants: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
await vi.waitFor(() => expect(mocks.executeBlock).toHaveBeenCalledOnce());
|
||||
expect(mocks.getVariablesContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
|
||||
expect(mocks.getCredentialsContext).toHaveBeenCalledWith({ tenantId: "tenant_1", appliesToAllTenants: false });
|
||||
expect(mocks.updateWorkflowExecution).toHaveBeenCalledWith(
|
||||
"execution_1",
|
||||
{ tenantId: "tenant_1", userId: "user_1" },
|
||||
expect.objectContaining({ status: "success" }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
import { loadWebConfig } from "@toolctrl/config";
|
||||
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox";
|
||||
import { buildWorkflowExecutionContinueRequestedOutboxEvent, buildWorkflowExecutionRequestedOutboxEvent, type QueueJsonValue } from "@toolctrl/queue";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { getWorkflowExecution, updateWorkflowExecution } from "../db/workflowExecutions.db.server";
|
||||
import { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
|
||||
import WorkflowExecutionUtils from "../helpers/WorkflowExecutionUtils";
|
||||
import WorkflowsService from "./WorkflowsService";
|
||||
import WorkflowBlockService from "./blocks/WorkflowBlockService";
|
||||
import WorkflowVariablesAndCredentialsService from "./WorkflowVariablesAndCredentialsService";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import type { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { WorkflowStatus } from "../dtos/WorkflowStatus";
|
||||
import { emitter } from "~/utils/stream/emitter.server";
|
||||
import WorkflowPreflightService from "./WorkflowPreflightService";
|
||||
import { emitPluginHook, PLUGIN_HOOK_WORKFLOW_EXECUTED, PLUGIN_HOOK_WORKFLOW_FAILED } from "@toolctrl/plugin-runtime";
|
||||
import { redactWorkflowCredentials } from "./WorkflowsVariablesService";
|
||||
|
||||
function assertWorkflowIsLive(workflow: Pick<WorkflowDto, "status">) {
|
||||
if (workflow.status !== "live") {
|
||||
throw Object.assign(new Error("Workflow is not live."), { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
function toQueueJsonValue(value: unknown): QueueJsonValue {
|
||||
return JSON.parse(JSON.stringify(value ?? null)) as QueueJsonValue;
|
||||
}
|
||||
|
||||
function workflowContinuationSourceId({
|
||||
execution,
|
||||
}: {
|
||||
execution: { id: string; waitingBlockId: string | null; updatedAt: Date | string | number };
|
||||
}): string {
|
||||
const updatedAt = execution.updatedAt instanceof Date ? execution.updatedAt.getTime() : new Date(execution.updatedAt).getTime();
|
||||
const waitVersion = Number.isFinite(updatedAt) ? String(updatedAt) : "unknown";
|
||||
return `${execution.id}:${execution.waitingBlockId ?? "unknown"}:${waitVersion}`;
|
||||
}
|
||||
|
||||
function parsePersistedWorkflowContext(output: string | null | undefined): Record<string, unknown> {
|
||||
if (!output) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(
|
||||
workflowId: string,
|
||||
{
|
||||
type,
|
||||
input,
|
||||
session,
|
||||
execution,
|
||||
fromBlockId,
|
||||
appliesToAllTenants,
|
||||
}: {
|
||||
type: "manual" | "api" | "stream" | "trigger";
|
||||
input: { [key: string]: any } | null;
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
execution?: { id: string; output?: string | null } | null;
|
||||
fromBlockId?: string | null;
|
||||
appliesToAllTenants?: boolean;
|
||||
}
|
||||
): Promise<WorkflowExecutionDto> {
|
||||
const workflow = await WorkflowsService.getById(workflowId, session);
|
||||
if (!workflow) {
|
||||
throw new Error("Workflow not found: " + workflowId);
|
||||
}
|
||||
if (type === "api" && session.tenantId !== null && (workflow.tenantId === null || workflow.appliesToAllTenants === true)) {
|
||||
throw Object.assign(new Error("Workflow is not available for tenant API execution."), { status: 403 });
|
||||
}
|
||||
assertWorkflowIsLive(workflow);
|
||||
const effectiveAppliesToAllTenants = appliesToAllTenants === true || workflow.appliesToAllTenants === true;
|
||||
const preflight = await WorkflowPreflightService.check({
|
||||
workflow,
|
||||
tenantId: session.tenantId,
|
||||
appliesToAllTenants: effectiveAppliesToAllTenants,
|
||||
});
|
||||
WorkflowPreflightService.throwIfFailed(preflight);
|
||||
if (!execution) {
|
||||
execution = await db.workflowExecution.create({
|
||||
data: {
|
||||
tenantId: session.tenantId,
|
||||
workflowId: workflow.id,
|
||||
createdByUserId: session.userId,
|
||||
type,
|
||||
input: JSON.stringify(input),
|
||||
status: "running",
|
||||
output: null,
|
||||
duration: null,
|
||||
endedAt: null,
|
||||
error: null,
|
||||
appliesToAllTenants: effectiveAppliesToAllTenants,
|
||||
},
|
||||
});
|
||||
}
|
||||
let firstBlock: WorkflowBlockDto | undefined = undefined;
|
||||
if (fromBlockId) {
|
||||
firstBlock = workflow.blocks.find((f) => f.id === fromBlockId);
|
||||
if (!firstBlock) {
|
||||
throw new Error("Workflow has no block with id " + fromBlockId);
|
||||
}
|
||||
} else {
|
||||
firstBlock = workflow.blocks.find((f) => f.isTrigger);
|
||||
if (!firstBlock) {
|
||||
throw new Error("Workflow has no trigger block");
|
||||
}
|
||||
}
|
||||
const startTime = performance.now();
|
||||
let error: string | null = null;
|
||||
let result: {
|
||||
status: WorkflowStatus;
|
||||
workflowContext: { [key: string]: any };
|
||||
waitingBlockId: string | null;
|
||||
} = { status: "running", workflowContext: {}, waitingBlockId: null };
|
||||
let tenant = session.tenantId
|
||||
? await db.tenant.findFirstOrThrow({ where: { OR: [{ slug: session.tenantId }, { id: session.tenantId }] } }).catch(() => null)
|
||||
: null;
|
||||
let user = session.userId ? await db.user.findUnique({ where: { id: session.userId } }) : null;
|
||||
let credentialContext: Record<string, unknown> = {};
|
||||
try {
|
||||
const variableContext = await WorkflowVariablesAndCredentialsService.getVariablesContext({
|
||||
tenantId: session.tenantId,
|
||||
appliesToAllTenants: effectiveAppliesToAllTenants,
|
||||
});
|
||||
credentialContext = await WorkflowVariablesAndCredentialsService.getCredentialsContext({
|
||||
tenantId: session.tenantId,
|
||||
appliesToAllTenants: effectiveAppliesToAllTenants,
|
||||
});
|
||||
const persistedContext = parsePersistedWorkflowContext(execution.output);
|
||||
result = await WorkflowBlockService.execute({
|
||||
workflowContext: {
|
||||
...persistedContext,
|
||||
$params: input,
|
||||
$session: {
|
||||
tenant: tenant ? { id: tenant.id, name: tenant.name } : null,
|
||||
user: user ? { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName } : null,
|
||||
},
|
||||
$vars: variableContext,
|
||||
$credentials: credentialContext,
|
||||
},
|
||||
workflowExecutionId: execution.id,
|
||||
workflow,
|
||||
block: firstBlock,
|
||||
fromBlock: null,
|
||||
session,
|
||||
});
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
if (loadWebConfig().config.nodeEnv === "development") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e.stack);
|
||||
}
|
||||
}
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
result.workflowContext = redactWorkflowCredentials(result.workflowContext, credentialContext);
|
||||
delete result.workflowContext.$credentials;
|
||||
error = redactWorkflowCredentials(error, credentialContext);
|
||||
const updatedExecution = await finishWorkflowExecutionAndPublishHook({
|
||||
executionId: execution.id,
|
||||
workflowId: workflow.id,
|
||||
type,
|
||||
session,
|
||||
status: error ? "error" : result.status,
|
||||
output: JSON.stringify(result.workflowContext),
|
||||
duration: Math.round(duration),
|
||||
error,
|
||||
waitingBlockId: result.waitingBlockId,
|
||||
});
|
||||
emitter.emit(`workflowExecutionUpdate:${execution.id}`, JSON.stringify(WorkflowExecutionUtils.rowToDto(updatedExecution)));
|
||||
return WorkflowExecutionUtils.rowToDto(updatedExecution);
|
||||
}
|
||||
|
||||
async function finishWorkflowExecutionAndPublishHook({
|
||||
executionId,
|
||||
workflowId,
|
||||
type,
|
||||
session,
|
||||
status,
|
||||
output,
|
||||
duration,
|
||||
error,
|
||||
waitingBlockId,
|
||||
}: {
|
||||
executionId: string;
|
||||
workflowId: string;
|
||||
type: "manual" | "api" | "stream" | "trigger";
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
status: WorkflowStatus;
|
||||
output: string;
|
||||
duration: number;
|
||||
error: string | null;
|
||||
waitingBlockId: string | null;
|
||||
}) {
|
||||
return db.$transaction(async (tx) => {
|
||||
const updatedExecution = await updateWorkflowExecution(
|
||||
executionId,
|
||||
session,
|
||||
{
|
||||
status,
|
||||
output,
|
||||
duration,
|
||||
error,
|
||||
waitingBlockId,
|
||||
},
|
||||
tx
|
||||
);
|
||||
await emitWorkflowExecutedHook({
|
||||
tx,
|
||||
executionId,
|
||||
workflowId,
|
||||
type,
|
||||
session,
|
||||
updatedExecution,
|
||||
});
|
||||
return updatedExecution;
|
||||
});
|
||||
}
|
||||
|
||||
async function emitWorkflowExecutedHook({
|
||||
tx,
|
||||
executionId,
|
||||
workflowId,
|
||||
type,
|
||||
session,
|
||||
updatedExecution,
|
||||
}: {
|
||||
tx: unknown;
|
||||
executionId: string;
|
||||
workflowId: string;
|
||||
type: "manual" | "api" | "stream" | "trigger";
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
updatedExecution: { status: string; duration: number | null; error: string | null; appliesToAllTenants: boolean };
|
||||
}) {
|
||||
if (!session.tenantId) return;
|
||||
if (updatedExecution.status !== "success" && updatedExecution.status !== "error") return;
|
||||
const terminalStatus = updatedExecution.status as "success" | "error";
|
||||
const errorCode = terminalStatus === "error" ? "workflow_execution_failed" : null;
|
||||
const occurredAt = new Date().toISOString();
|
||||
const payload = {
|
||||
tenantId: session.tenantId,
|
||||
workflowId,
|
||||
workflowExecutionId: executionId,
|
||||
userId: session.userId,
|
||||
type,
|
||||
status: terminalStatus,
|
||||
durationMs: Math.max(0, Math.round(updatedExecution.duration ?? 0)),
|
||||
errorCode,
|
||||
appliesToAllTenants: updatedExecution.appliesToAllTenants,
|
||||
occurredAt,
|
||||
};
|
||||
|
||||
await emitPluginHook(
|
||||
tx,
|
||||
PLUGIN_HOOK_WORKFLOW_EXECUTED,
|
||||
payload,
|
||||
async (innerTx, args) => {
|
||||
await publishOutboxEvent(innerTx as OutboxPublisherTransaction, {
|
||||
tenantId: args.tenantId,
|
||||
type: args.type,
|
||||
payload: args.payload as unknown as Parameters<typeof publishOutboxEvent>[1]["payload"],
|
||||
dispatchKey: args.dispatchKey,
|
||||
});
|
||||
},
|
||||
{
|
||||
tenantId: session.tenantId,
|
||||
dispatchKey: `workflow-execution:${executionId}:${updatedExecution.status}`,
|
||||
}
|
||||
);
|
||||
|
||||
if (terminalStatus === "error") {
|
||||
await emitPluginHook(
|
||||
tx,
|
||||
PLUGIN_HOOK_WORKFLOW_FAILED,
|
||||
{ ...payload, status: "error", errorCode: "workflow_execution_failed" },
|
||||
async (innerTx, args) => {
|
||||
await publishOutboxEvent(innerTx as OutboxPublisherTransaction, {
|
||||
tenantId: args.tenantId,
|
||||
type: args.type,
|
||||
payload: args.payload as unknown as Parameters<typeof publishOutboxEvent>[1]["payload"],
|
||||
dispatchKey: args.dispatchKey,
|
||||
});
|
||||
},
|
||||
{
|
||||
tenantId: session.tenantId,
|
||||
dispatchKey: `workflow-execution:${executionId}:failed`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function continueExecution(
|
||||
executionId: string,
|
||||
{
|
||||
type,
|
||||
input,
|
||||
session,
|
||||
}: {
|
||||
type: "manual" | "api" | "stream";
|
||||
input: { [key: string]: any } | null;
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
}
|
||||
) {
|
||||
const execution = await getWorkflowExecution(executionId, session);
|
||||
if (!execution) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
if (!execution.waitingBlockId) {
|
||||
throw new Error("Workflow execution is not waiting for a block");
|
||||
}
|
||||
return execute(execution?.workflowId, {
|
||||
type,
|
||||
input,
|
||||
session,
|
||||
fromBlockId: execution.waitingBlockId,
|
||||
execution,
|
||||
appliesToAllTenants: execution.appliesToAllTenants,
|
||||
});
|
||||
}
|
||||
|
||||
async function queueManualExecution(
|
||||
workflowId: string,
|
||||
{
|
||||
input,
|
||||
session,
|
||||
}: {
|
||||
input: { [key: string]: any } | null;
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
}
|
||||
): Promise<WorkflowExecutionDto> {
|
||||
return queueRequestedExecution(workflowId, { type: "manual", input, session });
|
||||
}
|
||||
|
||||
async function queueApiExecution(
|
||||
workflowId: string,
|
||||
{
|
||||
input,
|
||||
session,
|
||||
}: {
|
||||
input: { [key: string]: any } | null;
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
}
|
||||
): Promise<WorkflowExecutionDto> {
|
||||
return queueRequestedExecution(workflowId, { type: "api", input, session });
|
||||
}
|
||||
|
||||
async function queueContinuationExecution(
|
||||
executionId: string,
|
||||
{
|
||||
type,
|
||||
input,
|
||||
session,
|
||||
}: {
|
||||
type: "manual" | "api";
|
||||
input: { [key: string]: any } | null;
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
}
|
||||
): Promise<WorkflowExecutionDto> {
|
||||
const execution = await getWorkflowExecution(executionId, session);
|
||||
if (!execution) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
if (!execution.waitingBlockId) {
|
||||
throw new Error("Workflow execution is not waiting for a block");
|
||||
}
|
||||
if (execution.status !== "waitingBlock" && execution.status !== "waiting") {
|
||||
throw new Error("Workflow execution is not waiting");
|
||||
}
|
||||
const workflow = await WorkflowsService.getById(execution.workflowId, session);
|
||||
if (!workflow) {
|
||||
throw new Error("Workflow not found: " + execution.workflowId);
|
||||
}
|
||||
if (type === "api" && session.tenantId !== null && (workflow.tenantId === null || workflow.appliesToAllTenants === true)) {
|
||||
throw Object.assign(new Error("Workflow is not available for tenant API execution."), { status: 403 });
|
||||
}
|
||||
assertWorkflowIsLive(workflow);
|
||||
const queueInput = toQueueJsonValue(input);
|
||||
await db.$transaction(async (tx) => {
|
||||
await publishOutboxEvent(
|
||||
tx as OutboxPublisherTransaction,
|
||||
buildWorkflowExecutionContinueRequestedOutboxEvent({
|
||||
tenantId: session.tenantId,
|
||||
workflowId: execution.workflowId,
|
||||
executionId: execution.id,
|
||||
type,
|
||||
input: queueInput,
|
||||
userId: session.userId,
|
||||
sourceId: workflowContinuationSourceId({ execution }),
|
||||
})
|
||||
);
|
||||
});
|
||||
const queuedExecution = await getWorkflowExecution(execution.id, session);
|
||||
if (!queuedExecution) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
return WorkflowExecutionUtils.rowToDto(queuedExecution);
|
||||
}
|
||||
|
||||
async function queueRequestedExecution(
|
||||
workflowId: string,
|
||||
{
|
||||
type,
|
||||
input,
|
||||
session,
|
||||
}: {
|
||||
type: "manual" | "api";
|
||||
input: { [key: string]: any } | null;
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
}
|
||||
): Promise<WorkflowExecutionDto> {
|
||||
const workflow = await WorkflowsService.getById(workflowId, session);
|
||||
if (!workflow) {
|
||||
throw new Error("Workflow not found: " + workflowId);
|
||||
}
|
||||
if (type === "api" && session.tenantId !== null && (workflow.tenantId === null || workflow.appliesToAllTenants === true)) {
|
||||
throw Object.assign(new Error("Workflow is not available for tenant API execution."), { status: 403 });
|
||||
}
|
||||
assertWorkflowIsLive(workflow);
|
||||
const effectiveAppliesToAllTenants = workflow.appliesToAllTenants === true;
|
||||
const preflight = await WorkflowPreflightService.check({
|
||||
workflow,
|
||||
tenantId: session.tenantId,
|
||||
appliesToAllTenants: effectiveAppliesToAllTenants,
|
||||
});
|
||||
WorkflowPreflightService.throwIfFailed(preflight);
|
||||
|
||||
const inputValue = toQueueJsonValue(input);
|
||||
const execution = await db.$transaction(async (tx) => {
|
||||
const created = await tx.workflowExecution.create({
|
||||
data: {
|
||||
tenantId: session.tenantId,
|
||||
workflowId: workflow.id,
|
||||
createdByUserId: session.userId,
|
||||
type,
|
||||
input: JSON.stringify(input),
|
||||
status: "running",
|
||||
output: null,
|
||||
duration: null,
|
||||
endedAt: null,
|
||||
error: null,
|
||||
appliesToAllTenants: effectiveAppliesToAllTenants,
|
||||
},
|
||||
});
|
||||
await publishOutboxEvent(
|
||||
tx as OutboxPublisherTransaction,
|
||||
buildWorkflowExecutionRequestedOutboxEvent({
|
||||
tenantId: session.tenantId,
|
||||
workflowId: workflow.id,
|
||||
executionId: created.id,
|
||||
type,
|
||||
input: inputValue,
|
||||
userId: session.userId,
|
||||
sourceId: created.id,
|
||||
})
|
||||
);
|
||||
return created;
|
||||
});
|
||||
const queuedExecution = await getWorkflowExecution(execution.id, session);
|
||||
if (!queuedExecution) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
return WorkflowExecutionUtils.rowToDto(queuedExecution);
|
||||
}
|
||||
|
||||
async function executeExisting(
|
||||
executionId: string,
|
||||
{
|
||||
workflowId,
|
||||
type,
|
||||
input,
|
||||
session,
|
||||
}: {
|
||||
workflowId: string;
|
||||
type: "manual" | "api";
|
||||
input: { [key: string]: any } | null;
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
}
|
||||
): Promise<WorkflowExecutionDto> {
|
||||
const execution = await getWorkflowExecution(executionId, session);
|
||||
if (!execution) {
|
||||
throw new Error("Workflow execution not found");
|
||||
}
|
||||
if (execution.workflowId !== workflowId) {
|
||||
throw new Error("Workflow execution does not belong to workflow " + workflowId);
|
||||
}
|
||||
if (execution.type !== type) {
|
||||
throw new Error("Workflow execution type mismatch");
|
||||
}
|
||||
if (execution.status !== "running") {
|
||||
throw new Error("Workflow execution is not queued");
|
||||
}
|
||||
return execute(workflowId, {
|
||||
type,
|
||||
input,
|
||||
session,
|
||||
execution,
|
||||
appliesToAllTenants: execution.appliesToAllTenants,
|
||||
});
|
||||
}
|
||||
|
||||
async function stream(
|
||||
workflowId: string,
|
||||
{
|
||||
input,
|
||||
session,
|
||||
}: {
|
||||
input: { [key: string]: any } | null;
|
||||
session: { tenantId: string | null; userId: string | null };
|
||||
}
|
||||
) {
|
||||
const workflow = await WorkflowsService.getById(workflowId, session);
|
||||
if (!workflow) {
|
||||
throw new Error("Workflow not found: " + workflowId);
|
||||
}
|
||||
assertWorkflowIsLive(workflow);
|
||||
const execution = await db.workflowExecution.create({
|
||||
data: {
|
||||
tenantId: session.tenantId,
|
||||
workflowId: workflow.id,
|
||||
createdByUserId: session.userId,
|
||||
type: "stream",
|
||||
input: JSON.stringify(input),
|
||||
status: "running",
|
||||
output: "{}",
|
||||
duration: null,
|
||||
endedAt: null,
|
||||
error: null,
|
||||
appliesToAllTenants: workflow.appliesToAllTenants,
|
||||
},
|
||||
});
|
||||
void execute(workflow.id, {
|
||||
type: "stream",
|
||||
input,
|
||||
session,
|
||||
execution,
|
||||
appliesToAllTenants: workflow.appliesToAllTenants,
|
||||
}).catch(async (error: unknown) => {
|
||||
await updateWorkflowExecution(execution.id, session, {
|
||||
status: "error",
|
||||
output: "{}",
|
||||
duration: 0,
|
||||
error: error instanceof Error ? error.message : "Workflow stream execution failed.",
|
||||
waitingBlockId: null,
|
||||
});
|
||||
});
|
||||
return execution;
|
||||
}
|
||||
|
||||
export default {
|
||||
execute,
|
||||
executeExisting,
|
||||
continueExecution,
|
||||
queueContinuationExecution,
|
||||
queueApiExecution,
|
||||
queueManualExecution,
|
||||
stream,
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
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)), "WorkflowsService.ts"), "utf8");
|
||||
|
||||
describe("WorkflowsService DTO conversion", () => {
|
||||
it("does not repair missing condition groups with database writes during reads", () => {
|
||||
const workflowToDtoBody = source.slice(source.indexOf("async function workflowToDto"), source.indexOf("async function addConditionGroup"));
|
||||
|
||||
expect(workflowToDtoBody).not.toContain("await addConditionGroup");
|
||||
expect(workflowToDtoBody).not.toContain("db.workflowBlockConditionGroup.create");
|
||||
expect(workflowToDtoBody).toContain("workflowBlock.conditionGroups.push");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowsService tenant scope guards", () => {
|
||||
it("keeps workflow child mutations scoped through the parent workflow tenant", () => {
|
||||
expect(source).toContain("updateWorkflowBlock({ id, workflowId: workflow.id, tenantId: workflow.tenantId }");
|
||||
expect(source).toContain("createInputExample({ workflowId, tenantId, title, input }");
|
||||
expect(source).toContain("updateInputExample(scope: { id: string; workflowId: string; tenantId: string | null }");
|
||||
expect(source).toContain("deleteInputExample(scope: { id: string; workflowId: string; tenantId: string | null }");
|
||||
expect(source).toContain("workflowBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } }");
|
||||
expect(source).toContain("fromBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } }");
|
||||
expect(source).toContain("toBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } }");
|
||||
expect(source).toContain("workflow: { tenantId: scope.tenantId }");
|
||||
});
|
||||
|
||||
it("does not keep id-only workflow child delete/update calls", () => {
|
||||
expect(source).not.toContain("db.workflowBlock.delete({");
|
||||
expect(source).not.toContain("db.workflowBlockToBlock.delete({");
|
||||
expect(source).not.toContain("db.workflowInputExample.update({");
|
||||
expect(source).not.toContain("db.workflowInputExample.delete({");
|
||||
expect(source).not.toContain("where: { id: scope.id, workflowId: scope.workflowId },");
|
||||
expect(source).not.toContain("where: { id, workflowId: workflow.id },");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type WorkflowsServiceTestMocks = {
|
||||
workflowFindMany: ReturnType<typeof vi.fn>;
|
||||
workflowBlockCount: ReturnType<typeof vi.fn>;
|
||||
workflowBlockDeleteMany: ReturnType<typeof vi.fn>;
|
||||
workflowBlockFindFirstOrThrow: ReturnType<typeof vi.fn>;
|
||||
workflowBlockConditionGroupCreate: ReturnType<typeof vi.fn>;
|
||||
workflowBlockConditionGroupDeleteMany: ReturnType<typeof vi.fn>;
|
||||
workflowBlockToBlockCreate: ReturnType<typeof vi.fn>;
|
||||
workflowBlockToBlockDeleteMany: ReturnType<typeof vi.fn>;
|
||||
createWorkflow: ReturnType<typeof vi.fn>;
|
||||
deleteWorkflow: ReturnType<typeof vi.fn>;
|
||||
createWorkflowBlock: ReturnType<typeof vi.fn>;
|
||||
getWorkflowById: ReturnType<typeof vi.fn>;
|
||||
updateWorkflowBlock: ReturnType<typeof vi.fn>;
|
||||
updateWorkflow: ReturnType<typeof vi.fn>;
|
||||
getAllWorkflowVariables: ReturnType<typeof vi.fn>;
|
||||
getAllWorkflowCredentials: ReturnType<typeof vi.fn>;
|
||||
emitPluginHookFireAndForget: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getMocks(): WorkflowsServiceTestMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowsServiceTestMocks__?: WorkflowsServiceTestMocks };
|
||||
store.__workflowsServiceTestMocks__ ??= {
|
||||
workflowFindMany: vi.fn(),
|
||||
workflowBlockCount: vi.fn(),
|
||||
workflowBlockDeleteMany: vi.fn(),
|
||||
workflowBlockFindFirstOrThrow: vi.fn(),
|
||||
workflowBlockConditionGroupCreate: vi.fn(),
|
||||
workflowBlockConditionGroupDeleteMany: vi.fn(),
|
||||
workflowBlockToBlockCreate: vi.fn(),
|
||||
workflowBlockToBlockDeleteMany: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
createWorkflowBlock: vi.fn(),
|
||||
getWorkflowById: vi.fn(),
|
||||
updateWorkflowBlock: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
getAllWorkflowVariables: vi.fn(),
|
||||
getAllWorkflowCredentials: vi.fn(),
|
||||
emitPluginHookFireAndForget: vi.fn(),
|
||||
};
|
||||
return store.__workflowsServiceTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflow: {
|
||||
findMany: getMocks().workflowFindMany,
|
||||
},
|
||||
workflowBlock: {
|
||||
count: getMocks().workflowBlockCount,
|
||||
deleteMany: getMocks().workflowBlockDeleteMany,
|
||||
findFirstOrThrow: getMocks().workflowBlockFindFirstOrThrow,
|
||||
},
|
||||
workflowBlockConditionGroup: {
|
||||
create: getMocks().workflowBlockConditionGroupCreate,
|
||||
deleteMany: getMocks().workflowBlockConditionGroupDeleteMany,
|
||||
},
|
||||
workflowBlockToBlock: {
|
||||
create: getMocks().workflowBlockToBlockCreate,
|
||||
deleteMany: getMocks().workflowBlockToBlockDeleteMany,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../db/workflows.db.server", () => ({
|
||||
createWorkflow: getMocks().createWorkflow,
|
||||
deleteWorkflow: getMocks().deleteWorkflow,
|
||||
getAllWorkflows: vi.fn(),
|
||||
getAllWorkflowsAppliesToAllTenants: vi.fn(),
|
||||
getWorkflowByAccessibleId: vi.fn(),
|
||||
getWorkflowById: getMocks().getWorkflowById,
|
||||
updateWorkflow: getMocks().updateWorkflow,
|
||||
}));
|
||||
|
||||
vi.mock("../db/workflowBlocks.db.server", () => ({
|
||||
createWorkflowBlock: getMocks().createWorkflowBlock,
|
||||
getWorkflowBlockTypes: vi.fn(),
|
||||
updateWorkflowBlock: getMocks().updateWorkflowBlock,
|
||||
}));
|
||||
|
||||
vi.mock("../db/workflowExecutions.db.server", () => ({
|
||||
getWorkflowExecution: vi.fn(),
|
||||
getWorkflowExecutions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../db/workflowVariable.db.server", () => ({
|
||||
getAllWorkflowVariables: getMocks().getAllWorkflowVariables,
|
||||
}));
|
||||
|
||||
vi.mock("../db/workflowCredentials.db.server", () => ({
|
||||
getAllWorkflowCredentials: getMocks().getAllWorkflowCredentials,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/plugins/services/.server/pluginHookPublisher", () => ({
|
||||
emitPluginHookFireAndForget: getMocks().emitPluginHookFireAndForget,
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (_key: string, options: { defaultValue: string }) => options.defaultValue,
|
||||
}));
|
||||
|
||||
import WorkflowsService from "./WorkflowsService";
|
||||
|
||||
const mocks = getMocks();
|
||||
|
||||
function workflowRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: { id: "tenant_1", name: "Acme", slug: "acme" },
|
||||
name: "Demo workflow",
|
||||
description: "",
|
||||
status: "draft",
|
||||
createdByUserId: "user_1",
|
||||
createdAt: new Date("2026-05-27T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-27T10:00:00.000Z"),
|
||||
appliesToAllTenants: false,
|
||||
blocks: [],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowsService plugin hooks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.workflowFindMany.mockResolvedValue([]);
|
||||
mocks.workflowBlockCount.mockImplementation(async (args: { where?: { id?: { in?: string[] } } }) => args.where?.id?.in?.length ?? 0);
|
||||
mocks.workflowBlockDeleteMany.mockResolvedValue({ count: 1 });
|
||||
mocks.workflowBlockFindFirstOrThrow.mockResolvedValue({ id: "block_1", workflowId: "workflow_1" });
|
||||
mocks.workflowBlockConditionGroupCreate.mockResolvedValue({});
|
||||
mocks.workflowBlockConditionGroupDeleteMany.mockResolvedValue({ count: 1 });
|
||||
mocks.workflowBlockToBlockCreate.mockResolvedValue({ id: "connection_1", fromBlockId: "block_1", toBlockId: "block_2", condition: null });
|
||||
mocks.workflowBlockToBlockDeleteMany.mockResolvedValue({ count: 1 });
|
||||
mocks.createWorkflow.mockResolvedValue(workflowRow());
|
||||
mocks.getWorkflowById.mockResolvedValue(workflowRow());
|
||||
mocks.createWorkflowBlock.mockResolvedValue({ id: "block_new", workflowId: "workflow_1", type: "log" });
|
||||
mocks.updateWorkflowBlock.mockResolvedValue({ id: "block_1", workflowId: "workflow_1", type: "log" });
|
||||
mocks.updateWorkflow.mockResolvedValue(workflowRow({ status: "live" }));
|
||||
mocks.deleteWorkflow.mockResolvedValue({ count: 1 });
|
||||
mocks.getAllWorkflowVariables.mockResolvedValue([]);
|
||||
mocks.getAllWorkflowCredentials.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("emits workflow.created metadata after creating the workflow", async () => {
|
||||
await WorkflowsService.create({ tenantId: "tenant_1", userId: "user_1" });
|
||||
|
||||
expect(mocks.createWorkflow).toHaveBeenCalledWith({
|
||||
tenantId: "tenant_1",
|
||||
name: "Untitled workflow 1",
|
||||
description: "",
|
||||
createdByUserId: "user_1",
|
||||
});
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "workflow.created" }),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
userId: "user_1",
|
||||
status: "draft",
|
||||
appliesToAllTenants: false,
|
||||
}),
|
||||
{
|
||||
tenantId: "tenant_1",
|
||||
dispatchKey: "workflow:workflow_1:created",
|
||||
}
|
||||
);
|
||||
expect(mocks.emitPluginHookFireAndForget.mock.calls[0]?.[1]).not.toHaveProperty("blocks");
|
||||
expect(mocks.emitPluginHookFireAndForget.mock.calls[0]?.[1]).not.toHaveProperty("credentials");
|
||||
expect(mocks.emitPluginHookFireAndForget.mock.calls[0]?.[1]).not.toHaveProperty("input");
|
||||
});
|
||||
|
||||
it("uses stable English persisted generated names for new untitled workflows", async () => {
|
||||
mocks.workflowFindMany.mockResolvedValueOnce([{ name: "Untitled workflow 1" }, { name: "Untitled workflow 2" }]);
|
||||
|
||||
await WorkflowsService.create({ tenantId: "tenant_1", userId: "user_1" });
|
||||
|
||||
expect(mocks.createWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Untitled workflow 3",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("emits workflow.updated metadata with changed fields after updating the workflow", async () => {
|
||||
await WorkflowsService.update(
|
||||
"workflow_1",
|
||||
{ name: "Demo workflow", description: "New description", status: "live" },
|
||||
{ tenantId: "tenant_1", userId: "user_2" }
|
||||
);
|
||||
|
||||
expect(mocks.updateWorkflow).toHaveBeenCalledWith(
|
||||
{ id: "workflow_1", tenantId: "tenant_1" },
|
||||
{
|
||||
name: "Demo workflow",
|
||||
description: "New description",
|
||||
status: "live",
|
||||
}
|
||||
);
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "workflow.updated" }),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
userId: "user_2",
|
||||
status: "live",
|
||||
appliesToAllTenants: false,
|
||||
changedFields: ["description", "status"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
dispatchKey: "workflow:workflow_1:updated:2026-05-27T10:00:00.000Z",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("renames exact generated untitled workflows when an app event trigger is configured", async () => {
|
||||
const workflow = {
|
||||
...(await WorkflowsService.get("workflow_1", { tenantId: "tenant_1" }))!,
|
||||
name: "Untitled workflow 2",
|
||||
status: "draft" as const,
|
||||
};
|
||||
|
||||
await WorkflowsService.updateBlock(
|
||||
"block_event",
|
||||
workflowBlock({
|
||||
id: "block_event",
|
||||
type: "event",
|
||||
input: { event: "contact.created" },
|
||||
isTrigger: true,
|
||||
isBlock: false,
|
||||
}),
|
||||
{ workflow, userId: "user_9" }
|
||||
);
|
||||
|
||||
expect(mocks.updateWorkflow).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ id: "workflow_1", tenantId: "tenant_1" },
|
||||
{
|
||||
name: "App Event: contact.created",
|
||||
}
|
||||
);
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "workflow.updated" }),
|
||||
expect.objectContaining({
|
||||
changedFields: ["blocks", "name"],
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rename user-owned workflow names that only start with generated default copy", async () => {
|
||||
const workflow = {
|
||||
...(await WorkflowsService.get("workflow_1", { tenantId: "tenant_1" }))!,
|
||||
name: "Untitled workflow imported template",
|
||||
status: "draft" as const,
|
||||
};
|
||||
|
||||
await WorkflowsService.updateBlock(
|
||||
"block_event",
|
||||
workflowBlock({
|
||||
id: "block_event",
|
||||
type: "event",
|
||||
input: { event: "contact.created" },
|
||||
isTrigger: true,
|
||||
isBlock: false,
|
||||
}),
|
||||
{ workflow, userId: "user_9" }
|
||||
);
|
||||
|
||||
expect(mocks.updateWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.updateWorkflow).toHaveBeenCalledWith(
|
||||
{ id: "workflow_1", tenantId: "tenant_1" },
|
||||
{
|
||||
status: "draft",
|
||||
}
|
||||
);
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "workflow.updated" }),
|
||||
expect.objectContaining({
|
||||
changedFields: ["blocks"],
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it("emits workflow.deleted metadata after deleting a tenant-scoped workflow", async () => {
|
||||
await WorkflowsService.del("workflow_1", { tenantId: "tenant_1", userId: "user_3" });
|
||||
|
||||
expect(mocks.deleteWorkflow).toHaveBeenCalledWith({ id: "workflow_1", tenantId: "tenant_1" });
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "workflow.deleted" }),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
userId: "user_3",
|
||||
status: "draft",
|
||||
appliesToAllTenants: false,
|
||||
}),
|
||||
{
|
||||
tenantId: "tenant_1",
|
||||
dispatchKey: "workflow:workflow_1:deleted",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit workflow definition hooks for platform-scoped workflows", async () => {
|
||||
mocks.createWorkflow.mockResolvedValueOnce(workflowRow({ id: "workflow_platform_create", tenantId: null, appliesToAllTenants: true }));
|
||||
|
||||
await WorkflowsService.create({ tenantId: null, userId: "admin_1" });
|
||||
|
||||
expect(mocks.emitPluginHookFireAndForget).not.toHaveBeenCalled();
|
||||
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(workflowRow({ tenantId: null, appliesToAllTenants: true }));
|
||||
mocks.updateWorkflow.mockResolvedValueOnce(workflowRow({ tenantId: null, appliesToAllTenants: true, status: "live" }));
|
||||
|
||||
await WorkflowsService.update("workflow_1", { status: "live" }, { tenantId: null, userId: "admin_1" });
|
||||
|
||||
expect(mocks.emitPluginHookFireAndForget).not.toHaveBeenCalled();
|
||||
|
||||
mocks.getWorkflowById.mockResolvedValueOnce(workflowRow({ tenantId: null, appliesToAllTenants: true }));
|
||||
|
||||
await WorkflowsService.del("workflow_1", { tenantId: null, userId: "admin_1" });
|
||||
|
||||
expect(mocks.deleteWorkflow).toHaveBeenCalledWith({ id: "workflow_1", tenantId: null });
|
||||
expect(mocks.emitPluginHookFireAndForget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits workflow.updated hooks for builder graph mutations", async () => {
|
||||
const workflow = {
|
||||
...(await WorkflowsService.get("workflow_1", { tenantId: "tenant_1" }))!,
|
||||
status: "live" as const,
|
||||
blocks: [
|
||||
workflowBlock({ id: "block_1" }),
|
||||
workflowBlock({ id: "block_2" }),
|
||||
],
|
||||
};
|
||||
|
||||
await WorkflowsService.updateBlock("block_1", workflowBlock({ id: "block_1", type: "log" }), { workflow, userId: "user_9" });
|
||||
await WorkflowsService.addBlock({ workflow, fromBlockId: "block_1", type: "log", condition: null, userId: "user_9" });
|
||||
await WorkflowsService.deleteBlock("block_1", { workflow, userId: "user_9" });
|
||||
await WorkflowsService.connectBlocks({ fromBlockId: "block_1", toBlockId: "block_2", condition: null }, { workflow, userId: "user_9" });
|
||||
await WorkflowsService.deleteConnection({ fromBlockId: "block_1", toBlockId: "block_2" }, { workflow, userId: "user_9" });
|
||||
await WorkflowsService.updateConditionsGroups(workflowBlock({ id: "block_1" }), [{ index: 0, type: "AND", conditions: [] }], { workflow, userId: "user_9" });
|
||||
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenCalledTimes(6);
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: "workflow.updated" }),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
workflowId: "workflow_1",
|
||||
userId: "user_9",
|
||||
status: "live",
|
||||
changedFields: ["blocks"],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
dispatchKey: expect.stringContaining("workflow:workflow_1:updated:block-updated:block_1:"),
|
||||
})
|
||||
);
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ name: "workflow.updated" }),
|
||||
expect.objectContaining({ changedFields: ["blocks"] }),
|
||||
expect.objectContaining({ dispatchKey: expect.stringContaining("workflow:workflow_1:updated:block-added:block_new:") })
|
||||
);
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({ name: "workflow.updated" }),
|
||||
expect.objectContaining({ changedFields: ["connections"] }),
|
||||
expect.objectContaining({ dispatchKey: expect.stringContaining("workflow:workflow_1:updated:connection-created:block_1:block_2:") })
|
||||
);
|
||||
expect(mocks.emitPluginHookFireAndForget).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
expect.objectContaining({ name: "workflow.updated" }),
|
||||
expect.objectContaining({ changedFields: ["conditions"] }),
|
||||
expect.objectContaining({ dispatchKey: expect.stringContaining("workflow:workflow_1:updated:conditions-updated:block_1:") })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function workflowBlock(overrides: Partial<import("../dtos/WorkflowBlockDto").WorkflowBlockDto> = {}): import("../dtos/WorkflowBlockDto").WorkflowBlockDto {
|
||||
return {
|
||||
id: "block_1",
|
||||
index: 0,
|
||||
type: "log",
|
||||
variableName: "block_1",
|
||||
description: "",
|
||||
input: { message: "Hello" },
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
import { db } from "~/utils/db.server";
|
||||
import { createWorkflowBlock, getWorkflowBlockTypes, updateWorkflowBlock } from "../db/workflowBlocks.db.server";
|
||||
import {
|
||||
WorkflowWithDetails,
|
||||
createWorkflow,
|
||||
deleteWorkflow,
|
||||
getAllWorkflows,
|
||||
getAllWorkflowsAppliesToAllTenants,
|
||||
getWorkflowByAccessibleId,
|
||||
getWorkflowById,
|
||||
updateWorkflow,
|
||||
} from "../db/workflows.db.server";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { WorkflowBlockInput, WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
|
||||
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
|
||||
import WorkflowUtils from "../helpers/WorkflowUtils";
|
||||
import { WorkflowConditionsGroupDto } from "../dtos/WorkflowConditionDtos";
|
||||
import WorkflowExecutionUtils from "../helpers/WorkflowExecutionUtils";
|
||||
import { getWorkflowExecution, getWorkflowExecutions } from "../db/workflowExecutions.db.server";
|
||||
import { getAllWorkflowVariables } from "../db/workflowVariable.db.server";
|
||||
import { getAllWorkflowCredentials } from "../db/workflowCredentials.db.server";
|
||||
import WorkflowBlockUtils from "../helpers/WorkflowBlockUtils";
|
||||
import { t as i18nT } from "i18next";
|
||||
import {
|
||||
PLUGIN_HOOK_WORKFLOW_CREATED,
|
||||
PLUGIN_HOOK_WORKFLOW_DELETED,
|
||||
PLUGIN_HOOK_WORKFLOW_UPDATED,
|
||||
type PluginHookDefinition,
|
||||
type PluginHookWorkflowDefinitionPayload,
|
||||
} from "@toolctrl/plugin-runtime";
|
||||
import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher";
|
||||
|
||||
// Persisted generated names are intentionally stable English identifiers.
|
||||
// Localize display copy at the UI boundary instead of changing stored names.
|
||||
const GENERATED_UNTITLED_WORKFLOW_PREFIX = "Untitled workflow";
|
||||
const GENERATED_UNTITLED_WORKFLOW_NAME = /^Untitled workflow [1-9]\d*$/u;
|
||||
const GENERATED_APP_EVENT_WORKFLOW_PREFIX = "App Event";
|
||||
|
||||
function workflowError(key: string, defaultValue: string, values?: Record<string, unknown>) {
|
||||
return i18nT(`workflows.errors.${key}`, { defaultValue, ...(values ?? {}) });
|
||||
}
|
||||
|
||||
async function create(session: { tenantId: string | null; userId: string | null }) {
|
||||
const name = await getNextUntitledWorkflowName({ tenantId: session.tenantId });
|
||||
const item = await createWorkflow({
|
||||
tenantId: session.tenantId,
|
||||
name,
|
||||
description: "",
|
||||
createdByUserId: session.userId,
|
||||
});
|
||||
await emitWorkflowCreatedHook(item, { userId: session.userId });
|
||||
return item;
|
||||
}
|
||||
|
||||
async function get(id: string, { tenantId }: { tenantId: string | null }): Promise<WorkflowDto | null> {
|
||||
const item = await getWorkflowById({ id, tenantId });
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
const $variables = await getAllWorkflowVariables({ tenantId });
|
||||
const $credentials = await getAllWorkflowCredentials({ tenantId });
|
||||
return await workflowToDto(item, {
|
||||
$variables: $variables.map((f) => f.name),
|
||||
$credentials: $credentials.map((f) => f.name),
|
||||
});
|
||||
}
|
||||
|
||||
async function getById(id: string, { tenantId }: { tenantId: string | null }): Promise<WorkflowDto | null> {
|
||||
const item = await getWorkflowByAccessibleId({ id, tenantId });
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
const $variables = await getAllWorkflowVariables({ tenantId });
|
||||
const $credentials = await getAllWorkflowCredentials({ tenantId });
|
||||
return await workflowToDto(item, {
|
||||
$variables: $variables.map((f) => f.name),
|
||||
$credentials: $credentials.map((f) => f.name),
|
||||
});
|
||||
}
|
||||
|
||||
async function getAll({ tenantId }: { tenantId: string | null }): Promise<WorkflowDto[]> {
|
||||
const items = await getAllWorkflows({ tenantId });
|
||||
return await Promise.all(items.map((item) => workflowToDto(item)));
|
||||
}
|
||||
|
||||
async function getAllAppliesToAllTenants({ tenantId }: { tenantId: string | null }): Promise<WorkflowDto[]> {
|
||||
const items = await getAllWorkflowsAppliesToAllTenants({ tenantId, status: "live" });
|
||||
return await Promise.all(items.map((item) => workflowToDto(item)));
|
||||
}
|
||||
|
||||
async function workflowToDto(
|
||||
item: WorkflowWithDetails,
|
||||
global?: {
|
||||
$variables: string[];
|
||||
$credentials: string[];
|
||||
}
|
||||
) {
|
||||
const workflow: WorkflowDto = {
|
||||
id: item.id,
|
||||
tenantId: item.tenantId,
|
||||
tenant: item.tenant,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
status: item.status as WorkflowDto["status"],
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
appliesToAllTenants: item.appliesToAllTenants,
|
||||
blocks: await Promise.all(
|
||||
item.blocks.map(async (block, index) => {
|
||||
const workflowBlock = WorkflowBlockUtils.rowToDto(block, index);
|
||||
if (workflowBlock.type === "if") {
|
||||
if (workflowBlock.conditionGroups.length === 0) {
|
||||
workflowBlock.conditionGroups.push({
|
||||
index: 0,
|
||||
type: "AND",
|
||||
conditions: [],
|
||||
});
|
||||
}
|
||||
} else if (workflowBlock.type === "switch") {
|
||||
const hasCase1 = workflowBlock.conditionGroups.find((f) => f.index === 0);
|
||||
const hasCase2 = workflowBlock.conditionGroups.find((f) => f.index === 1);
|
||||
if (!hasCase1) {
|
||||
workflowBlock.conditionGroups.push({ index: 0, type: "AND", conditions: [] });
|
||||
}
|
||||
if (!hasCase2) {
|
||||
workflowBlock.conditionGroups.push({ index: 1, type: "AND", conditions: [] });
|
||||
}
|
||||
}
|
||||
return workflowBlock;
|
||||
})
|
||||
),
|
||||
inputExamples: item.inputExamples.map((inputExample) => {
|
||||
let input: { [key: string]: any } = {};
|
||||
try {
|
||||
input = inputExample.input ? JSON.parse(inputExample.input) : null;
|
||||
} catch {}
|
||||
return {
|
||||
id: inputExample.id,
|
||||
title: inputExample.title,
|
||||
input,
|
||||
createdAt: inputExample.createdAt,
|
||||
};
|
||||
}),
|
||||
_count: item._count,
|
||||
};
|
||||
workflow.blocks.forEach((block) => {
|
||||
const { variableName, index } = WorkflowUtils.getVariableName({ workflow, currentBlock: block });
|
||||
block.variableName = variableName;
|
||||
block.index = index;
|
||||
});
|
||||
workflow.blocks = workflow.blocks.sort((a, b) => a.index - b.index);
|
||||
workflow.inputExamples = workflow.inputExamples.sort((a, b) => {
|
||||
if (a.createdAt && b.createdAt) {
|
||||
return a.createdAt > b.createdAt ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
workflow.$variables = global?.$variables ?? [];
|
||||
workflow.$credentials = global?.$credentials ?? [];
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async function addConditionGroup(workflowBlock: WorkflowBlockDto, workflow: WorkflowDto, data: WorkflowConditionsGroupDto[]) {
|
||||
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [workflowBlock.id]);
|
||||
return await Promise.all(
|
||||
data.map(async (conditionGroup) => {
|
||||
const conditions = conditionGroup.conditions.map((condition) => {
|
||||
return {
|
||||
index: condition.index,
|
||||
variable: condition.variable,
|
||||
operator: condition.operator,
|
||||
value: condition.value,
|
||||
};
|
||||
});
|
||||
await db.workflowBlockConditionGroup.create({
|
||||
data: {
|
||||
workflowBlockId: workflowBlock.id,
|
||||
index: conditionGroup.index,
|
||||
type: conditionGroup.type,
|
||||
conditions: {
|
||||
create: conditions,
|
||||
},
|
||||
},
|
||||
});
|
||||
workflowBlock.conditionGroups.push(conditionGroup);
|
||||
return conditionGroup;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function update(
|
||||
id: string,
|
||||
workflow: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: "draft" | "live" | "archived";
|
||||
blocks?: WorkflowBlockDto[];
|
||||
},
|
||||
session: { tenantId: string | null; userId?: string | null }
|
||||
): Promise<WorkflowDto | null> {
|
||||
const item = await get(id, session);
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
if (item.tenantId !== session.tenantId) {
|
||||
throw new Error(workflowError("cannotEditWorkflow", "You cannot edit this workflow"));
|
||||
}
|
||||
if (workflow.name !== undefined && workflow.name.trim() === "") {
|
||||
workflow.name = await getNextUntitledWorkflowName({ tenantId: session.tenantId });
|
||||
}
|
||||
const changedFields = getChangedWorkflowFields(item, workflow);
|
||||
|
||||
const updatedItem = await updateWorkflow({ id, tenantId: session.tenantId }, {
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
status: workflow.status,
|
||||
});
|
||||
await emitWorkflowDefinitionHook(PLUGIN_HOOK_WORKFLOW_UPDATED, updatedItem, {
|
||||
userId: session.userId ?? null,
|
||||
changedFields,
|
||||
dispatchKey: `workflow:${id}:updated:${updatedItem.updatedAt.toISOString()}`,
|
||||
});
|
||||
|
||||
return get(id, session);
|
||||
}
|
||||
|
||||
async function getNextUntitledWorkflowName({ tenantId }: { tenantId: string | null }) {
|
||||
const allWorkflowNames = await db.workflow.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
},
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
let workflowName = `${GENERATED_UNTITLED_WORKFLOW_PREFIX} 1`;
|
||||
const existingWorkflowNames = allWorkflowNames.map((f) => f.name);
|
||||
if (!existingWorkflowNames.includes(workflowName)) {
|
||||
return workflowName;
|
||||
}
|
||||
let i = 2;
|
||||
while (existingWorkflowNames.includes(workflowName)) {
|
||||
workflowName = `${GENERATED_UNTITLED_WORKFLOW_PREFIX} ${i}`;
|
||||
i++;
|
||||
if (i > 1000) {
|
||||
throw new Error(workflowError("tooManyUntitledWorkflows", "Too many untitled workflows"));
|
||||
}
|
||||
}
|
||||
return workflowName;
|
||||
}
|
||||
|
||||
async function del(id: string, session: { tenantId: string | null; userId?: string | null }) {
|
||||
const item = await get(id, session);
|
||||
if (!item) {
|
||||
throw new Error(workflowError("workflowNotFound", "Workflow not found"));
|
||||
}
|
||||
if (item.tenantId !== session.tenantId) {
|
||||
throw new Error(workflowError("cannotDeleteWorkflow", "You cannot delete this workflow"));
|
||||
}
|
||||
|
||||
const deleted = await deleteWorkflow({ id, tenantId: session.tenantId });
|
||||
await emitWorkflowDefinitionHook(PLUGIN_HOOK_WORKFLOW_DELETED, item, {
|
||||
userId: session.userId ?? null,
|
||||
dispatchKey: `workflow:${id}:deleted`,
|
||||
});
|
||||
return deleted;
|
||||
}
|
||||
|
||||
function getChangedWorkflowFields(
|
||||
item: Pick<WorkflowDto, "name" | "description" | "status">,
|
||||
workflow: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: "draft" | "live" | "archived";
|
||||
}
|
||||
): string[] {
|
||||
const changedFields: string[] = [];
|
||||
if (workflow.name !== undefined && workflow.name !== item.name) {
|
||||
changedFields.push("name");
|
||||
}
|
||||
if (workflow.description !== undefined && workflow.description !== item.description) {
|
||||
changedFields.push("description");
|
||||
}
|
||||
if (workflow.status !== undefined && workflow.status !== item.status) {
|
||||
changedFields.push("status");
|
||||
}
|
||||
return changedFields;
|
||||
}
|
||||
|
||||
async function emitWorkflowDefinitionHook(
|
||||
def: PluginHookDefinition<PluginHookWorkflowDefinitionPayload>,
|
||||
workflow: Pick<WorkflowDto, "id" | "tenantId" | "appliesToAllTenants"> & { status: string },
|
||||
{
|
||||
userId,
|
||||
changedFields,
|
||||
dispatchKey,
|
||||
}: {
|
||||
userId: string | null;
|
||||
changedFields?: string[];
|
||||
dispatchKey: string;
|
||||
}
|
||||
) {
|
||||
if (!workflow.tenantId) {
|
||||
return;
|
||||
}
|
||||
const status = toWorkflowDefinitionHookStatus(workflow.status);
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
await emitPluginHookFireAndForget(
|
||||
def,
|
||||
{
|
||||
tenantId: workflow.tenantId,
|
||||
workflowId: workflow.id,
|
||||
userId,
|
||||
status,
|
||||
appliesToAllTenants: workflow.appliesToAllTenants,
|
||||
...(changedFields && changedFields.length > 0 ? { changedFields } : {}),
|
||||
occurredAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
tenantId: workflow.tenantId,
|
||||
dispatchKey,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function emitWorkflowCreatedHook(
|
||||
workflow: Pick<WorkflowDto, "id" | "tenantId" | "appliesToAllTenants"> & { status: string },
|
||||
{ userId }: { userId: string | null }
|
||||
) {
|
||||
await emitWorkflowDefinitionHook(PLUGIN_HOOK_WORKFLOW_CREATED, workflow, {
|
||||
userId,
|
||||
dispatchKey: `workflow:${workflow.id}:created`,
|
||||
});
|
||||
}
|
||||
|
||||
function toWorkflowDefinitionHookStatus(status: string): PluginHookWorkflowDefinitionPayload["status"] | null {
|
||||
if (status === "draft" || status === "live" || status === "archived") {
|
||||
return status;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateBlock(id: string, block: WorkflowBlockDto, { workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }) {
|
||||
const changedFields = ["blocks"];
|
||||
const item = await updateWorkflowBlock({ id, workflowId: workflow.id, tenantId: workflow.tenantId }, {
|
||||
type: block.type,
|
||||
input: JSON.stringify(block.input),
|
||||
description: block.description,
|
||||
isTrigger: block.isTrigger,
|
||||
isBlock: block.isBlock,
|
||||
// positionX: block.position.x,
|
||||
// positionY: block.position.y,
|
||||
});
|
||||
if (workflow.tenantId === null) {
|
||||
await updateWorkflowApplyToAllTenants(item.workflowId, workflow.tenantId);
|
||||
}
|
||||
if (block.type === "event" && block.input.event) {
|
||||
if (isGeneratedUntitledWorkflowName(workflow.name)) {
|
||||
await updateWorkflow({ id: item.workflowId, tenantId: workflow.tenantId }, {
|
||||
name: `${GENERATED_APP_EVENT_WORKFLOW_PREFIX}: ${block.input.event}`,
|
||||
});
|
||||
changedFields.push("name");
|
||||
}
|
||||
}
|
||||
await emitWorkflowBuilderMutationHook(workflow, {
|
||||
changedFields,
|
||||
action: "block-updated",
|
||||
targetId: id,
|
||||
userId,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
function isGeneratedUntitledWorkflowName(name: string) {
|
||||
return GENERATED_UNTITLED_WORKFLOW_NAME.test(name);
|
||||
}
|
||||
|
||||
async function addBlock({
|
||||
workflow,
|
||||
type,
|
||||
fromBlockId,
|
||||
condition,
|
||||
userId,
|
||||
}: {
|
||||
workflow: WorkflowDto;
|
||||
type: string;
|
||||
fromBlockId: string | undefined;
|
||||
condition: string | null;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === type);
|
||||
if (!workflowBlock) {
|
||||
throw new Error(workflowError("invalidBlockType", "Invalid workflow block type: {{type}}", { type }));
|
||||
}
|
||||
let inputData: { [key: string]: any } = {};
|
||||
workflowBlock.inputs.forEach((input) => {
|
||||
let blockInput = input as WorkflowBlockInput;
|
||||
if (blockInput.defaultValue !== undefined) {
|
||||
inputData[blockInput.name] = blockInput.defaultValue;
|
||||
}
|
||||
});
|
||||
const toBlock = await createWorkflowBlock({
|
||||
workflowId: workflow.id,
|
||||
tenantId: workflow.tenantId,
|
||||
type,
|
||||
input: JSON.stringify(inputData),
|
||||
description: "",
|
||||
isTrigger: workflowBlock.type === "trigger",
|
||||
isBlock: workflowBlock.type === "action",
|
||||
// positionX: 0,
|
||||
// positionY: 0,
|
||||
});
|
||||
|
||||
if (fromBlockId) {
|
||||
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [fromBlockId, toBlock.id]);
|
||||
await db.workflowBlockToBlock.create({
|
||||
data: {
|
||||
fromBlockId,
|
||||
toBlockId: toBlock.id,
|
||||
condition,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (workflowBlock.type === "trigger") {
|
||||
const blocksWithoutParents = WorkflowUtils.getBlocksWithoutCallers(workflow);
|
||||
if (blocksWithoutParents.length === 1) {
|
||||
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [toBlock.id, blocksWithoutParents[0].id]);
|
||||
await db.workflowBlockToBlock.create({
|
||||
data: {
|
||||
fromBlockId: toBlock.id,
|
||||
toBlockId: blocksWithoutParents[0].id,
|
||||
condition: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (workflow.tenantId === null) {
|
||||
await updateWorkflowApplyToAllTenants(workflow.id, workflow.tenantId);
|
||||
}
|
||||
|
||||
await emitWorkflowBuilderMutationHook(workflow, {
|
||||
changedFields: ["blocks"],
|
||||
action: "block-added",
|
||||
targetId: toBlock.id,
|
||||
userId: userId ?? null,
|
||||
});
|
||||
return toBlock;
|
||||
}
|
||||
|
||||
async function updateWorkflowApplyToAllTenants(workflowId: string, tenantId: string | null) {
|
||||
const blocks = await getWorkflowBlockTypes(workflowId);
|
||||
let appliesToAllTenants = false;
|
||||
blocks.forEach((type) => {
|
||||
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === type);
|
||||
if (workflowBlock?.appliesToAllTenants) {
|
||||
appliesToAllTenants = true;
|
||||
}
|
||||
});
|
||||
const item = await updateWorkflow({ id: workflowId, tenantId }, {
|
||||
appliesToAllTenants,
|
||||
});
|
||||
if (item.tenantId && item.appliesToAllTenants) {
|
||||
// just for good measure
|
||||
await updateWorkflow({ id: workflowId, tenantId: item.tenantId }, {
|
||||
appliesToAllTenants: false,
|
||||
});
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async function deleteBlock(id: string, { workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }) {
|
||||
const item = await db.workflowBlock.findFirstOrThrow({
|
||||
where: { id, workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
|
||||
});
|
||||
const deleted = await db.workflowBlock.deleteMany({
|
||||
where: { id, workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(workflowError("workflowBlockNotFoundWithId", "Workflow block {{id}} not found", { id }));
|
||||
}
|
||||
if (workflow.tenantId === null) {
|
||||
await updateWorkflowApplyToAllTenants(item.workflowId, workflow.tenantId);
|
||||
}
|
||||
await emitWorkflowBuilderMutationHook(workflow, {
|
||||
changedFields: ["blocks"],
|
||||
action: "block-deleted",
|
||||
targetId: id,
|
||||
userId,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
async function connectBlocks(
|
||||
{ fromBlockId, toBlockId, condition }: { fromBlockId: string; toBlockId: string; condition: string | null },
|
||||
{ workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }
|
||||
) {
|
||||
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [fromBlockId, toBlockId]);
|
||||
const item = await db.workflowBlockToBlock.create({
|
||||
data: {
|
||||
fromBlockId,
|
||||
toBlockId,
|
||||
condition,
|
||||
},
|
||||
});
|
||||
await emitWorkflowBuilderMutationHook(workflow, {
|
||||
changedFields: ["connections"],
|
||||
action: "connection-created",
|
||||
targetId: `${fromBlockId}:${toBlockId}`,
|
||||
userId,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
async function deleteConnection(
|
||||
params: { fromBlockId: string; toBlockId: string } | { id: string },
|
||||
{ workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }
|
||||
) {
|
||||
if ("id" in params) {
|
||||
const deleted = await db.workflowBlockToBlock.deleteMany({
|
||||
where: {
|
||||
id: params.id,
|
||||
fromBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
|
||||
toBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
|
||||
},
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(workflowError("workflowConnectionNotFoundWithId", "Workflow connection {{id}} not found", { id: params.id }));
|
||||
}
|
||||
await emitWorkflowBuilderMutationHook(workflow, {
|
||||
changedFields: ["connections"],
|
||||
action: "connection-deleted",
|
||||
targetId: params.id,
|
||||
userId,
|
||||
});
|
||||
return deleted;
|
||||
} else {
|
||||
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [params.fromBlockId, params.toBlockId]);
|
||||
const deleted = await db.workflowBlockToBlock.deleteMany({
|
||||
where: {
|
||||
fromBlockId: params.fromBlockId,
|
||||
toBlockId: params.toBlockId,
|
||||
fromBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
|
||||
toBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
|
||||
},
|
||||
});
|
||||
if (deleted.count > 0) {
|
||||
await emitWorkflowBuilderMutationHook(workflow, {
|
||||
changedFields: ["connections"],
|
||||
action: "connection-deleted",
|
||||
targetId: `${params.fromBlockId}:${params.toBlockId}`,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateConditionsGroups(
|
||||
block: WorkflowBlockDto,
|
||||
conditionGroups: WorkflowConditionsGroupDto[],
|
||||
{ workflow, userId = null }: { workflow: WorkflowDto; userId?: string | null }
|
||||
) {
|
||||
await requireWorkflowBlocksInWorkflow({ workflowId: workflow.id, tenantId: workflow.tenantId }, [block.id]);
|
||||
await db.workflowBlockConditionGroup.deleteMany({
|
||||
where: {
|
||||
workflowBlockId: block.id,
|
||||
workflowBlock: { workflowId: workflow.id, workflow: { tenantId: workflow.tenantId } },
|
||||
},
|
||||
});
|
||||
await Promise.all(
|
||||
conditionGroups
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map(async (group, groupIndex) => {
|
||||
const conditions = group.conditions
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((condition, conditionIndex) => {
|
||||
return {
|
||||
index: conditionIndex,
|
||||
variable: condition.variable,
|
||||
operator: condition.operator,
|
||||
value: condition.value,
|
||||
};
|
||||
});
|
||||
await db.workflowBlockConditionGroup.create({
|
||||
data: {
|
||||
workflowBlockId: block.id,
|
||||
index: groupIndex,
|
||||
type: group.type,
|
||||
conditions: {
|
||||
create: conditions,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
await emitWorkflowBuilderMutationHook(workflow, {
|
||||
changedFields: ["conditions"],
|
||||
action: "conditions-updated",
|
||||
targetId: block.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async function emitWorkflowBuilderMutationHook(
|
||||
workflow: Pick<WorkflowDto, "id" | "tenantId" | "appliesToAllTenants" | "status">,
|
||||
{
|
||||
changedFields,
|
||||
action,
|
||||
targetId,
|
||||
userId = null,
|
||||
}: {
|
||||
changedFields: string[];
|
||||
action: string;
|
||||
targetId: string;
|
||||
userId?: string | null;
|
||||
}
|
||||
) {
|
||||
if (!workflow.tenantId) {
|
||||
return;
|
||||
}
|
||||
const updatedWorkflow = await updateWorkflow({ id: workflow.id, tenantId: workflow.tenantId }, {
|
||||
status: workflow.status,
|
||||
});
|
||||
await emitWorkflowDefinitionHook(PLUGIN_HOOK_WORKFLOW_UPDATED, updatedWorkflow, {
|
||||
userId,
|
||||
changedFields,
|
||||
dispatchKey: `workflow:${workflow.id}:updated:${action}:${targetId}:${updatedWorkflow.updatedAt.toISOString()}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function getExecutions(item: WorkflowDto, { tenantId }: { tenantId: string | null }) {
|
||||
const items = await getWorkflowExecutions({ workflowId: item.id }, { tenantId });
|
||||
return items.map((f) => WorkflowExecutionUtils.rowToDto(f));
|
||||
}
|
||||
|
||||
async function getExecution(id: string, { tenantId }: { tenantId: string | null }) {
|
||||
const item = await getWorkflowExecution(id, { tenantId });
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return WorkflowExecutionUtils.rowToDto(item);
|
||||
}
|
||||
|
||||
async function createInputExample({ workflowId, tenantId, title, input }: { workflowId: string; tenantId: string | null; title: string; input: string }) {
|
||||
await db.workflow.findFirstOrThrow({
|
||||
where: { id: workflowId, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
return await db.workflowInputExample.create({
|
||||
data: {
|
||||
workflowId,
|
||||
title,
|
||||
input,
|
||||
},
|
||||
});
|
||||
}
|
||||
async function updateInputExample(scope: { id: string; workflowId: string; tenantId: string | null }, { title, input }: { title: string; input: string }) {
|
||||
const updated = await db.workflowInputExample.updateMany({
|
||||
where: { id: scope.id, workflowId: scope.workflowId, workflow: { tenantId: scope.tenantId } },
|
||||
data: {
|
||||
title,
|
||||
input,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(workflowError("workflowInputExampleNotFoundWithId", "Workflow input example {{id}} not found", { id: scope.id }));
|
||||
}
|
||||
return await db.workflowInputExample.findFirstOrThrow({
|
||||
where: { id: scope.id, workflowId: scope.workflowId, workflow: { tenantId: scope.tenantId } },
|
||||
});
|
||||
}
|
||||
async function deleteInputExample(scope: { id: string; workflowId: string; tenantId: string | null }) {
|
||||
const deleted = await db.workflowInputExample.deleteMany({
|
||||
where: { id: scope.id, workflowId: scope.workflowId, workflow: { tenantId: scope.tenantId } },
|
||||
});
|
||||
if (deleted.count !== 1) {
|
||||
throw new Error(workflowError("workflowInputExampleNotFoundWithId", "Workflow input example {{id}} not found", { id: scope.id }));
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async function requireWorkflowBlocksInWorkflow({ workflowId, tenantId }: { workflowId: string; tenantId: string | null }, blockIds: string[]) {
|
||||
const ids = Array.from(new Set(blockIds.filter(Boolean)));
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
const count = await db.workflowBlock.count({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
workflowId,
|
||||
workflow: { tenantId },
|
||||
},
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new Error(workflowError("blockNotFound", "Workflow block not found"));
|
||||
}
|
||||
}
|
||||
export default {
|
||||
create,
|
||||
get,
|
||||
getById,
|
||||
getAll,
|
||||
getAllAppliesToAllTenants,
|
||||
update,
|
||||
del,
|
||||
updateBlock,
|
||||
addBlock,
|
||||
deleteBlock,
|
||||
connectBlocks,
|
||||
deleteConnection,
|
||||
updateConditionsGroups,
|
||||
getExecutions,
|
||||
getExecution,
|
||||
createInputExample,
|
||||
updateInputExample,
|
||||
deleteInputExample,
|
||||
};
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
|
||||
type TemplateImportMocks = {
|
||||
transaction: ReturnType<typeof vi.fn>;
|
||||
workflowVariableFindMany: ReturnType<typeof vi.fn>;
|
||||
workflowVariableCreateMany: ReturnType<typeof vi.fn>;
|
||||
workflowCredentialFindMany: ReturnType<typeof vi.fn>;
|
||||
workflowCreate: ReturnType<typeof vi.fn>;
|
||||
workflowBlockCreate: ReturnType<typeof vi.fn>;
|
||||
workflowBlockConditionGroupCreate: ReturnType<typeof vi.fn>;
|
||||
workflowBlockConditionCreateMany: ReturnType<typeof vi.fn>;
|
||||
workflowBlockToBlockCreateMany: ReturnType<typeof vi.fn>;
|
||||
workflowInputExampleCreateMany: ReturnType<typeof vi.fn>;
|
||||
getAllEntities: ReturnType<typeof vi.fn>;
|
||||
getWorkflow: ReturnType<typeof vi.fn>;
|
||||
emitWorkflowCreatedHook: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getMocks(): TemplateImportMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowTemplateImportMocks__?: TemplateImportMocks };
|
||||
store.__workflowTemplateImportMocks__ ??= {
|
||||
transaction: vi.fn(),
|
||||
workflowVariableFindMany: vi.fn(),
|
||||
workflowVariableCreateMany: vi.fn(),
|
||||
workflowCredentialFindMany: vi.fn(),
|
||||
workflowCreate: vi.fn(),
|
||||
workflowBlockCreate: vi.fn(),
|
||||
workflowBlockConditionGroupCreate: vi.fn(),
|
||||
workflowBlockConditionCreateMany: vi.fn(),
|
||||
workflowBlockToBlockCreateMany: vi.fn(),
|
||||
workflowInputExampleCreateMany: vi.fn(),
|
||||
getAllEntities: vi.fn(),
|
||||
getWorkflow: vi.fn(),
|
||||
emitWorkflowCreatedHook: vi.fn(),
|
||||
};
|
||||
return store.__workflowTemplateImportMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
$transaction: getMocks().transaction,
|
||||
workflowVariable: { findMany: getMocks().workflowVariableFindMany, createMany: getMocks().workflowVariableCreateMany },
|
||||
workflowCredential: { findMany: getMocks().workflowCredentialFindMany },
|
||||
workflow: {
|
||||
create: getMocks().workflowCreate,
|
||||
},
|
||||
workflowBlock: { create: getMocks().workflowBlockCreate },
|
||||
workflowBlockConditionGroup: { create: getMocks().workflowBlockConditionGroupCreate },
|
||||
workflowBlockCondition: { createMany: getMocks().workflowBlockConditionCreateMany },
|
||||
workflowBlockToBlock: { createMany: getMocks().workflowBlockToBlockCreateMany },
|
||||
workflowInputExample: { createMany: getMocks().workflowInputExampleCreateMany },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/db/entities/entities.db.server", () => ({
|
||||
getAllEntities: getMocks().getAllEntities,
|
||||
}));
|
||||
|
||||
vi.mock("./WorkflowsService", () => ({
|
||||
default: {
|
||||
get: getMocks().getWorkflow,
|
||||
},
|
||||
emitWorkflowCreatedHook: getMocks().emitWorkflowCreatedHook,
|
||||
}));
|
||||
|
||||
import WorkflowsTemplatesService from "./WorkflowsTemplatesService";
|
||||
|
||||
const mocks = getMocks();
|
||||
|
||||
function importedWorkflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_imported_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: null,
|
||||
name: "Imported workflow",
|
||||
description: "",
|
||||
status: "draft",
|
||||
createdAt: new Date("2026-05-28T00:00:00.000Z"),
|
||||
updatedAt: null,
|
||||
appliesToAllTenants: false,
|
||||
blocks: [],
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const template = {
|
||||
title: "Workflow templates",
|
||||
workflows: [
|
||||
{
|
||||
name: "Imported workflow",
|
||||
description: "Creates a tenant workflow",
|
||||
blocks: [{ id: "log", type: "log", description: "Log event", input: {}, conditionGroups: [] }],
|
||||
toBlocks: [],
|
||||
inputExamples: [{ title: "Example", input: { leadId: "lead_1" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function installTransactionMock() {
|
||||
const tx = {
|
||||
workflowVariable: { createMany: mocks.workflowVariableCreateMany },
|
||||
workflow: { create: mocks.workflowCreate },
|
||||
workflowBlock: { create: mocks.workflowBlockCreate },
|
||||
workflowBlockConditionGroup: { create: mocks.workflowBlockConditionGroupCreate },
|
||||
workflowBlockCondition: { createMany: mocks.workflowBlockConditionCreateMany },
|
||||
workflowBlockToBlock: { createMany: mocks.workflowBlockToBlockCreateMany },
|
||||
workflowInputExample: { createMany: mocks.workflowInputExampleCreateMany },
|
||||
};
|
||||
mocks.transaction.mockImplementation(async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx));
|
||||
}
|
||||
|
||||
describe("WorkflowsTemplatesService import hooks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
installTransactionMock();
|
||||
mocks.workflowVariableFindMany.mockResolvedValue([]);
|
||||
mocks.workflowCredentialFindMany.mockResolvedValue([]);
|
||||
mocks.workflowCreate.mockResolvedValue({ id: "workflow_imported_1" });
|
||||
mocks.workflowBlockCreate.mockResolvedValue({ id: "block_imported_1" });
|
||||
mocks.workflowBlockToBlockCreateMany.mockResolvedValue({ count: 0 });
|
||||
mocks.workflowInputExampleCreateMany.mockResolvedValue({ count: 1 });
|
||||
mocks.getAllEntities.mockResolvedValue([]);
|
||||
mocks.getWorkflow.mockResolvedValue(importedWorkflow());
|
||||
});
|
||||
|
||||
it("emits workflow.created hooks for tenant-scoped template imports after import completes", async () => {
|
||||
await WorkflowsTemplatesService.importWorkflows(template, { tenantId: "tenant_1", userId: "user_1" });
|
||||
|
||||
expect(mocks.workflowCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: "tenant_1",
|
||||
name: "Imported workflow",
|
||||
status: "draft",
|
||||
createdByUserId: "user_1",
|
||||
appliesToAllTenants: false,
|
||||
}),
|
||||
});
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.workflowInputExampleCreateMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
workflowId: "workflow_imported_1",
|
||||
title: "Example",
|
||||
input: JSON.stringify({ leadId: "lead_1" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.getWorkflow).toHaveBeenCalledWith("workflow_imported_1", { tenantId: "tenant_1", userId: "user_1" });
|
||||
expect(mocks.emitWorkflowCreatedHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "workflow_imported_1",
|
||||
tenantId: "tenant_1",
|
||||
status: "draft",
|
||||
}),
|
||||
{ userId: "user_1" }
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps platform-scoped template imports out of tenant plugin hook dispatch", async () => {
|
||||
mocks.getWorkflow.mockResolvedValueOnce(importedWorkflow({ tenantId: null, appliesToAllTenants: true }));
|
||||
|
||||
await WorkflowsTemplatesService.importWorkflows(template, { tenantId: null, userId: "admin_1" });
|
||||
|
||||
expect(mocks.emitWorkflowCreatedHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bulk inserts workflow edges and block conditions inside the import transaction", async () => {
|
||||
const connectedTemplate = {
|
||||
title: "Workflow templates",
|
||||
workflows: [
|
||||
{
|
||||
name: "Connected workflow",
|
||||
blocks: [
|
||||
{
|
||||
id: "trigger",
|
||||
type: "manual",
|
||||
input: {},
|
||||
conditionGroups: [
|
||||
{
|
||||
type: "AND",
|
||||
conditions: [
|
||||
{ variable: "input.status", operator: "=", value: "ready" },
|
||||
{ variable: "input.score", operator: ">", value: "10" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "log", type: "log", input: {}, conditionGroups: [] },
|
||||
],
|
||||
toBlocks: [{ fromBlockId: "trigger", toBlockId: "log", condition: "true" }],
|
||||
inputExamples: [{ title: "Example", input: { leadId: "lead_1" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
mocks.workflowBlockCreate.mockResolvedValueOnce({ id: "block_trigger" }).mockResolvedValueOnce({ id: "block_log" });
|
||||
mocks.workflowBlockConditionGroupCreate.mockResolvedValueOnce({ id: "condition_group_1" });
|
||||
|
||||
await WorkflowsTemplatesService.importWorkflows(connectedTemplate, { tenantId: "tenant_1", userId: "user_1" });
|
||||
|
||||
expect(mocks.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.workflowBlockConditionCreateMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
workflowBlockConditionGroupId: "condition_group_1",
|
||||
index: 0,
|
||||
variable: "input.status",
|
||||
operator: "=",
|
||||
value: "ready",
|
||||
},
|
||||
{
|
||||
workflowBlockConditionGroupId: "condition_group_1",
|
||||
index: 1,
|
||||
variable: "input.score",
|
||||
operator: ">",
|
||||
value: "10",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.workflowBlockToBlockCreateMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
fromBlockId: "block_trigger",
|
||||
toBlockId: "block_log",
|
||||
condition: "true",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("validates missing block references before opening the import transaction", async () => {
|
||||
await expect(
|
||||
WorkflowsTemplatesService.importWorkflows(
|
||||
{
|
||||
title: "Workflow templates",
|
||||
workflows: [
|
||||
{
|
||||
name: "Invalid workflow",
|
||||
blocks: [{ id: "log", type: "log", input: {}, conditionGroups: [] }],
|
||||
toBlocks: [{ fromBlockId: "log", toBlockId: "missing" }],
|
||||
inputExamples: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ tenantId: "tenant_1", userId: "user_1" }
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: "invalidWorkflowTemplateConnection",
|
||||
names: ["missing"],
|
||||
});
|
||||
|
||||
expect(mocks.transaction).not.toHaveBeenCalled();
|
||||
expect(mocks.workflowCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.workflowBlockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import WorkflowsTemplatesService, { getWorkflowTemplateImportErrorMessage, WorkflowTemplateImportError } from "./WorkflowsTemplatesService";
|
||||
|
||||
describe("WorkflowsTemplatesService import errors", () => {
|
||||
it("translates missing credential import errors with the missing names", () => {
|
||||
const error = new WorkflowTemplateImportError("missingCredentialsForImport", ["mail", "stripe"], "fallback");
|
||||
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.names}`;
|
||||
|
||||
expect(getWorkflowTemplateImportErrorMessage(error, t)).toBe("workflows.errors.missingCredentialsForImport:mail, stripe");
|
||||
});
|
||||
|
||||
it("translates missing entity import errors with the missing names", () => {
|
||||
const error = new WorkflowTemplateImportError("missingEntitiesForImport", ["Contact"], "fallback");
|
||||
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.names}`;
|
||||
|
||||
expect(getWorkflowTemplateImportErrorMessage(error, t)).toBe("workflows.errors.missingEntitiesForImport:Contact");
|
||||
});
|
||||
|
||||
it("maps invalid JSON errors to the localized invalid JSON copy", () => {
|
||||
const t = (key: string) => key;
|
||||
|
||||
expect(getWorkflowTemplateImportErrorMessage(new SyntaxError("Unexpected token"), t)).toBe("workflows.errors.invalidJson");
|
||||
});
|
||||
|
||||
it("does not expose English fallback copy for localized import errors", () => {
|
||||
const source = fs.readFileSync(path.join(process.cwd(), "apps/web/app/modules/workflowEngine/services/WorkflowsTemplatesService.ts"), "utf8");
|
||||
|
||||
expect(source).not.toContain('defaultValue: "Invalid JSON"');
|
||||
expect(source).not.toContain('defaultValue: "Something went wrong"');
|
||||
expect(source).not.toContain("defaultValue: error.message");
|
||||
});
|
||||
|
||||
it("does not expose generic service error messages to the import alert", () => {
|
||||
const t = (key: string) => key;
|
||||
|
||||
expect(getWorkflowTemplateImportErrorMessage(new Error("Invalid workflow block type: nope"), t)).toBe("workflows.errors.unknown");
|
||||
});
|
||||
|
||||
it("translates unsupported block type import errors with the block type", () => {
|
||||
const error = new WorkflowTemplateImportError("invalidWorkflowBlockType", ["legacyBlock"], "fallback");
|
||||
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.names}`;
|
||||
|
||||
expect(getWorkflowTemplateImportErrorMessage(error, t)).toBe("workflows.errors.invalidWorkflowBlockType:legacyBlock");
|
||||
});
|
||||
|
||||
it("translates invalid template connection errors with the referenced block id", () => {
|
||||
const error = new WorkflowTemplateImportError("invalidWorkflowTemplateConnection", ["missing-block"], "fallback");
|
||||
const t = (key: string, options?: Record<string, unknown>) => `${key}:${options?.names}`;
|
||||
|
||||
expect(getWorkflowTemplateImportErrorMessage(error, t)).toBe("workflows.errors.invalidWorkflowTemplateConnection:missing-block");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowsTemplatesService templates", () => {
|
||||
it("exports edges using block variable names via indexed block lookup", async () => {
|
||||
const template = await WorkflowsTemplatesService.getTemplate(
|
||||
[
|
||||
{
|
||||
name: "Lead workflow",
|
||||
description: "Qualify leads",
|
||||
blocks: [
|
||||
{
|
||||
id: "block_db_1",
|
||||
variableName: "trigger",
|
||||
type: "event",
|
||||
description: "Lead captured",
|
||||
input: { event: "marketing.lead.captured" },
|
||||
conditionGroups: [],
|
||||
toBlocks: [{ id: "edge_1", toBlockId: "block_db_3", condition: "qualified" }],
|
||||
},
|
||||
{
|
||||
id: "block_db_2",
|
||||
variableName: "audit",
|
||||
type: "log",
|
||||
description: "Audit",
|
||||
input: { message: "audit" },
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
},
|
||||
{
|
||||
id: "block_db_3",
|
||||
variableName: "notify",
|
||||
type: "alertUser",
|
||||
description: "Notify",
|
||||
input: { message: "notify" },
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
},
|
||||
],
|
||||
inputExamples: [{ title: "Sample", input: { leadId: "lead_1" } }],
|
||||
},
|
||||
] as never,
|
||||
[{ name: "threshold", value: "10" }] as never
|
||||
);
|
||||
|
||||
expect(template.workflows[0]?.toBlocks).toEqual([
|
||||
{
|
||||
fromBlockId: "trigger",
|
||||
toBlockId: "notify",
|
||||
condition: "qualified",
|
||||
},
|
||||
]);
|
||||
expect(template.variables).toEqual([{ name: "threshold", value: "10" }]);
|
||||
});
|
||||
|
||||
it("uses the provided export title when present", async () => {
|
||||
const template = await WorkflowsTemplatesService.getTemplate([], [], "Workflow-Export");
|
||||
|
||||
expect(template.title).toBe("Workflow-Export");
|
||||
});
|
||||
});
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
import { db } from "~/utils/db.server";
|
||||
import { WorkflowTemplateDto, WorkflowsTemplateDto } from "../dtos/WorkflowsTemplateDto";
|
||||
import { WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
|
||||
import WorkflowsService, { emitWorkflowCreatedHook } from "./WorkflowsService";
|
||||
import { WorkflowDto } from "../dtos/WorkflowDto";
|
||||
import { Prisma, WorkflowVariable } from "@toolctrl/db/prisma-browser";
|
||||
import { getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
export type WorkflowTemplateImportErrorCode =
|
||||
| "invalidJson"
|
||||
| "invalidWorkflowBlockType"
|
||||
| "invalidWorkflowTemplateConnection"
|
||||
| "missingCredentialsForImport"
|
||||
| "missingEntitiesForImport";
|
||||
|
||||
export class WorkflowTemplateImportError extends Error {
|
||||
override readonly name = "WorkflowTemplateImportError";
|
||||
|
||||
constructor(
|
||||
readonly code: WorkflowTemplateImportErrorCode,
|
||||
readonly names: readonly string[],
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowTemplateImportDbClient = Pick<
|
||||
Prisma.TransactionClient,
|
||||
"workflow" | "workflowBlock" | "workflowBlockConditionGroup" | "workflowBlockCondition" | "workflowBlockToBlock" | "workflowInputExample" | "workflowVariable"
|
||||
>;
|
||||
|
||||
export function getWorkflowTemplateImportErrorMessage(error: unknown, t: (key: string, options?: Record<string, unknown>) => string) {
|
||||
if (error instanceof WorkflowTemplateImportError) {
|
||||
return t(`workflows.errors.${error.code}`, {
|
||||
names: error.names.join(", "),
|
||||
});
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
return t("workflows.errors.invalidJson");
|
||||
}
|
||||
return t("workflows.errors.unknown");
|
||||
}
|
||||
|
||||
async function importWorkflows(template: WorkflowsTemplateDto, session: { tenantId: string | null; userId: string | null }) {
|
||||
const validatedWorkflows = template.workflows.map(validateWorkflowTemplateForImport);
|
||||
const allWorkflowVariables = await db.workflowVariable.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
});
|
||||
if (template.credentialsRequired) {
|
||||
const allCredentials = await db.workflowCredential.findMany({
|
||||
where: { tenantId: session.tenantId },
|
||||
});
|
||||
const missingCredentials = template.credentialsRequired.filter((f) => !allCredentials.find((g) => g.name === f));
|
||||
if (missingCredentials.length > 0) {
|
||||
throw new WorkflowTemplateImportError(
|
||||
"missingCredentialsForImport",
|
||||
missingCredentials,
|
||||
"To import this workflow, you must create the following credentials first: " + missingCredentials.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
const requiredEntities: string[] = [];
|
||||
template.workflows.forEach((worklow) => {
|
||||
const entityBlocks = worklow.blocks.filter((f) => f.type.startsWith("row"));
|
||||
entityBlocks.forEach((entityBlock) => {
|
||||
if (entityBlock.input?.entity && !requiredEntities.includes(entityBlock.input.entity)) {
|
||||
requiredEntities.push(entityBlock.input.entity);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (requiredEntities.length > 0) {
|
||||
const allEntities = await getAllEntities({ tenantId: session.tenantId });
|
||||
const missingEntities = requiredEntities.filter((f) => !allEntities.find((g) => g.name === f));
|
||||
if (missingEntities.length > 0) {
|
||||
throw new WorkflowTemplateImportError(
|
||||
"missingEntitiesForImport",
|
||||
missingEntities,
|
||||
"To import this workflow, you must create the following entities first: " + missingEntities.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingVariableNames = new Set(allWorkflowVariables.map((variable) => variable.name));
|
||||
const variablesToCreate = (template.variables ?? []).filter((variable) => !existingVariableNames.has(variable.name));
|
||||
const workflowIds = await db.$transaction(async (tx) => {
|
||||
if (variablesToCreate.length > 0) {
|
||||
await tx.workflowVariable.createMany({
|
||||
data: variablesToCreate.map((variable) => ({
|
||||
tenantId: session.tenantId,
|
||||
name: variable.name,
|
||||
value: variable.value,
|
||||
createdByUserId: session.userId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return await Promise.all(
|
||||
validatedWorkflows.map(async (workflow) => {
|
||||
return await importWorkflow(workflow, session, tx);
|
||||
})
|
||||
);
|
||||
});
|
||||
const workflows = await Promise.all(
|
||||
workflowIds.map(async (workflowId) => {
|
||||
const workflow = await WorkflowsService.get(workflowId, session);
|
||||
if (!workflow) {
|
||||
throw new Error(`Imported workflow could not be loaded: ${workflowId}`);
|
||||
}
|
||||
return workflow;
|
||||
})
|
||||
);
|
||||
await Promise.all(workflows.map(async (importedWorkflow) => {
|
||||
if (importedWorkflow.tenantId) {
|
||||
await emitWorkflowCreatedHook(importedWorkflow, { userId: session.userId });
|
||||
}
|
||||
}));
|
||||
return workflows;
|
||||
}
|
||||
|
||||
type ValidatedWorkflowTemplate = WorkflowsTemplateDto["workflows"][0] & {
|
||||
appliesToAllTenants: boolean;
|
||||
blocks: Array<WorkflowsTemplateDto["workflows"][0]["blocks"][number] & { workflowBlockType: (typeof WorkflowBlockTypes)[number] }>;
|
||||
};
|
||||
|
||||
function validateWorkflowTemplateForImport(template: WorkflowsTemplateDto["workflows"][0]): ValidatedWorkflowTemplate {
|
||||
let appliesToAllTenants = false;
|
||||
const blockIds = new Set(template.blocks.map((block) => block.id));
|
||||
const blocks = template.blocks.map((block) => {
|
||||
const workflowBlockType = WorkflowBlockTypes.find((f) => f.value === block.type);
|
||||
if (!workflowBlockType) {
|
||||
throw new WorkflowTemplateImportError("invalidWorkflowBlockType", [block.type], "Workflow template contains an unsupported block type: " + block.type);
|
||||
}
|
||||
if (workflowBlockType.appliesToAllTenants) {
|
||||
appliesToAllTenants = true;
|
||||
}
|
||||
return { ...block, workflowBlockType };
|
||||
});
|
||||
for (const toBlock of template.toBlocks) {
|
||||
if (!blockIds.has(toBlock.toBlockId)) {
|
||||
throw new WorkflowTemplateImportError(
|
||||
"invalidWorkflowTemplateConnection",
|
||||
[toBlock.toBlockId],
|
||||
`Workflow template references a missing block: ${toBlock.toBlockId}`
|
||||
);
|
||||
}
|
||||
if (!blockIds.has(toBlock.fromBlockId)) {
|
||||
throw new WorkflowTemplateImportError(
|
||||
"invalidWorkflowTemplateConnection",
|
||||
[toBlock.fromBlockId],
|
||||
`Workflow template references a missing block: ${toBlock.fromBlockId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ...template, appliesToAllTenants, blocks };
|
||||
}
|
||||
|
||||
async function importWorkflow(
|
||||
template: ValidatedWorkflowTemplate,
|
||||
session: { tenantId: string | null; userId: string | null },
|
||||
client: WorkflowTemplateImportDbClient
|
||||
) {
|
||||
const workflow = await client.workflow.create({
|
||||
data: {
|
||||
tenantId: session.tenantId,
|
||||
name: template.name,
|
||||
description: template.description || "",
|
||||
status: "draft",
|
||||
createdByUserId: session.userId,
|
||||
appliesToAllTenants: template.appliesToAllTenants,
|
||||
},
|
||||
});
|
||||
const blocksIdMap: { [key: string]: string } = {};
|
||||
for (const block of template.blocks) {
|
||||
const newBlock = await client.workflowBlock.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: block.type,
|
||||
description: block.description || "",
|
||||
isTrigger: block.workflowBlockType.type === "trigger",
|
||||
isBlock: block.workflowBlockType.type === "action",
|
||||
// positionX: 0,
|
||||
// positionY: 0,
|
||||
input: JSON.stringify(block.input || {}),
|
||||
},
|
||||
});
|
||||
blocksIdMap[block.id] = newBlock.id;
|
||||
if (block.conditionGroups) {
|
||||
let idx = 0;
|
||||
for (const { type, conditions } of block.conditionGroups) {
|
||||
const conditionGroup = await client.workflowBlockConditionGroup.create({
|
||||
data: {
|
||||
workflowBlockId: newBlock.id,
|
||||
index: idx,
|
||||
type: type || ("AND" as "AND" | "OR"),
|
||||
},
|
||||
});
|
||||
if (conditions.length > 0) {
|
||||
await client.workflowBlockCondition.createMany({
|
||||
data: conditions.map(({ variable, operator, value }, idxCondition) => ({
|
||||
workflowBlockConditionGroupId: conditionGroup.id,
|
||||
index: idxCondition,
|
||||
variable,
|
||||
operator,
|
||||
value,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (template.toBlocks.length > 0) {
|
||||
await client.workflowBlockToBlock.createMany({
|
||||
data: template.toBlocks.map((toBlock) => ({
|
||||
fromBlockId: blocksIdMap[toBlock.fromBlockId],
|
||||
toBlockId: blocksIdMap[toBlock.toBlockId],
|
||||
condition: toBlock.condition || null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (template.inputExamples.length > 0) {
|
||||
await client.workflowInputExample.createMany({
|
||||
data: template.inputExamples.map((inputExample) => ({
|
||||
workflowId: workflow.id,
|
||||
title: inputExample.title,
|
||||
input: JSON.stringify(inputExample.input),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return workflow.id;
|
||||
}
|
||||
|
||||
async function getTemplate(items: WorkflowDto[], variables: WorkflowVariable[], title = "Workflows"): Promise<WorkflowsTemplateDto> {
|
||||
const workflows: WorkflowTemplateDto[] = [];
|
||||
items.forEach((workflow) => {
|
||||
let toBlocks: { fromBlockId: string; toBlockId: string; condition?: string }[] = [];
|
||||
const item: WorkflowTemplateDto = {
|
||||
name: workflow.name,
|
||||
description: workflow.description || undefined,
|
||||
blocks: workflow.blocks.map((block) => {
|
||||
return {
|
||||
id: block.variableName,
|
||||
// index: block.index,
|
||||
type: block.type,
|
||||
description: block.description || undefined,
|
||||
input: block.input,
|
||||
conditionGroups: block.conditionGroups?.map((conditionGroup) => ({
|
||||
type: conditionGroup.type,
|
||||
case: block.type === "switch" ? `case${conditionGroup.index + 1}` : undefined,
|
||||
conditions: conditionGroup.conditions.map((condition) => ({
|
||||
variable: condition.variable,
|
||||
operator: condition.operator,
|
||||
value: condition.value,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
toBlocks,
|
||||
inputExamples: workflow.inputExamples.map((inputExample) => ({
|
||||
title: inputExample.title,
|
||||
input: inputExample.input,
|
||||
})),
|
||||
};
|
||||
const blocksById = new Map(workflow.blocks.map((block) => [block.id, block]));
|
||||
workflow.blocks.forEach((block) => {
|
||||
block.toBlocks.forEach(({ id, toBlockId, condition }) => {
|
||||
const toBlock = blocksById.get(toBlockId);
|
||||
if (!toBlock) {
|
||||
throw new Error(`Block ${id} not found`);
|
||||
}
|
||||
toBlocks.push({
|
||||
fromBlockId: block.variableName,
|
||||
toBlockId: toBlock.variableName,
|
||||
condition: condition || undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
item.toBlocks = toBlocks;
|
||||
workflows.push(item);
|
||||
});
|
||||
return {
|
||||
title,
|
||||
workflows,
|
||||
variables: variables.map((variable) => ({ name: variable.name, value: variable.value })),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
importWorkflows,
|
||||
getTemplate,
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import Ajv from "ajv";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function validate(input: any, schema: any): string | null {
|
||||
const schemaError = validateJsonSchema(schema);
|
||||
if (schemaError) {
|
||||
return schemaError;
|
||||
}
|
||||
const validate = new Ajv().compile(schema);
|
||||
const valid = validate(input);
|
||||
if (!valid && validate.errors) {
|
||||
return validate.errors
|
||||
.map((f) => {
|
||||
if (f.propertyName) {
|
||||
return `${f.propertyName}: ${f.message}`;
|
||||
}
|
||||
if (f.instancePath) {
|
||||
return `${f.instancePath}: ${f.message}`;
|
||||
}
|
||||
return f.message;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonSchema(input: unknown): { schema: unknown | null; error: string | null } {
|
||||
if (typeof input === "string") {
|
||||
if (input.trim().length === 0) {
|
||||
return { schema: null, error: null };
|
||||
}
|
||||
try {
|
||||
const schema = JSON.parse(input);
|
||||
return { schema, error: validateJsonSchema(schema) };
|
||||
} catch (error) {
|
||||
return { schema: null, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
if (input === null || input === undefined) {
|
||||
return { schema: null, error: null };
|
||||
}
|
||||
|
||||
return { schema: input, error: validateJsonSchema(input) };
|
||||
}
|
||||
|
||||
function validateJsonSchema(schema: unknown): string | null {
|
||||
try {
|
||||
new Ajv().compile(schema as any);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
parseJsonSchema,
|
||||
validate,
|
||||
validateJsonSchema,
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import Handlebars from "handlebars";
|
||||
|
||||
export const REDACTED_WORKFLOW_CREDENTIAL = "[REDACTED_CREDENTIAL]";
|
||||
|
||||
const CREDENTIAL_REFERENCE_PATTERN = /\{\{\{?[^}]*\$credentials(?:\b|\.|\[)/u;
|
||||
|
||||
export function containsWorkflowCredentialReference(value: unknown): boolean {
|
||||
return typeof value === "string" && CREDENTIAL_REFERENCE_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function redactWorkflowCredentials<T>(value: T, credentials: unknown): T {
|
||||
const credentialValues = collectCredentialValues(credentials);
|
||||
if (credentialValues.length === 0) {
|
||||
return value;
|
||||
}
|
||||
return redactValue(value, credentialValues, new WeakSet<object>()) as T;
|
||||
}
|
||||
|
||||
function collectCredentialValues(credentials: unknown): string[] {
|
||||
const values = new Set<string>();
|
||||
const visit = (current: unknown) => {
|
||||
if (typeof current === "string" && current.length > 0) {
|
||||
values.add(current);
|
||||
return;
|
||||
}
|
||||
if (!current || typeof current !== "object") {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
current.forEach(visit);
|
||||
return;
|
||||
}
|
||||
Object.values(current as Record<string, unknown>).forEach(visit);
|
||||
};
|
||||
visit(credentials);
|
||||
return [...values].sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
function redactValue(value: unknown, credentialValues: string[], seen: WeakSet<object>): unknown {
|
||||
if (typeof value === "string") {
|
||||
return credentialValues.reduce((redacted, credentialValue) => redacted.split(credentialValue).join(REDACTED_WORKFLOW_CREDENTIAL), value);
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return value;
|
||||
}
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactValue(item, credentialValues, seen));
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, redactValue(item, credentialValues, seen)]));
|
||||
}
|
||||
|
||||
export function parseVariable(value: string | null, workflowContext: { [key: string]: any }) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// let context = contexts.workflowContext;
|
||||
// if (value.match(/{{\s*\$vars\./)) {
|
||||
// context = contexts.workflowVariablesContext;
|
||||
// } else if (value.match(/{{\s*\$credentials\./)) {
|
||||
// context = contexts.workflowCredentialsContext;
|
||||
// }
|
||||
try {
|
||||
const compiled = Handlebars.compile(value);
|
||||
let result = compiled(workflowContext);
|
||||
if (result.toString() === "[object Object]") {
|
||||
result = JSON.stringify(result);
|
||||
}
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
return "Error parsing string: " + e.message;
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
rowsApi: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
del: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
getEntity: vi.fn(),
|
||||
getRowPropertiesFromJson: vi.fn(),
|
||||
getApiFormat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/api/.server/RowsApi", () => ({
|
||||
RowsApi: mocks.rowsApi,
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/rows/repositories/EntitiesSingleton", () => ({
|
||||
default: { getEntity: mocks.getEntity },
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/ApiHelper", () => ({
|
||||
default: {
|
||||
getRowPropertiesFromJson: mocks.getRowPropertiesFromJson,
|
||||
getApiFormat: mocks.getApiFormat,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/modules/emails/services/EmailService.server", () => ({
|
||||
default: { send: vi.fn() },
|
||||
}));
|
||||
|
||||
import WorkflowBlockProService from "./WorkflowBlockProService";
|
||||
|
||||
const entity = { id: "entity-1", name: "contact", slug: "contacts", title: "Contacts" };
|
||||
const session = { tenantId: "tenant-1", userId: "user-1" };
|
||||
const rowValues = { dynamicProperties: [{ property: { name: "email" }, value: "hello@example.com" }] };
|
||||
|
||||
function block(overrides: Partial<WorkflowBlockDto>): WorkflowBlockDto {
|
||||
return {
|
||||
id: "block-1",
|
||||
index: 0,
|
||||
type: "rowCreate",
|
||||
variableName: "row",
|
||||
description: "",
|
||||
input: {},
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [{ id: "edge-1", toBlockId: "next", condition: null }],
|
||||
...overrides,
|
||||
} as WorkflowBlockDto;
|
||||
}
|
||||
|
||||
function params(workflowBlock: WorkflowBlockDto) {
|
||||
return {
|
||||
block: workflowBlock,
|
||||
fromBlock: null,
|
||||
workflow: { id: "workflow-1", blocks: [workflowBlock] },
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution-1",
|
||||
session,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("WorkflowBlockProService row blocks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getEntity.mockResolvedValue(entity);
|
||||
mocks.getRowPropertiesFromJson.mockReturnValue(rowValues);
|
||||
mocks.getApiFormat.mockImplementation((_entity, row) => ({ id: row.id, formatted: true }));
|
||||
mocks.rowsApi.create.mockResolvedValue({ id: "row-created" });
|
||||
mocks.rowsApi.update.mockResolvedValue({ id: "row-updated" });
|
||||
mocks.rowsApi.del.mockResolvedValue(undefined);
|
||||
mocks.rowsApi.get.mockResolvedValue({ item: { id: "row-1", email: "old@example.com" } });
|
||||
});
|
||||
|
||||
test("passes workflow event context to rowCreate mutations", async () => {
|
||||
const result = await WorkflowBlockProService.executeBlock(
|
||||
params(
|
||||
block({
|
||||
type: "rowCreate",
|
||||
input: { entity: "contact", row: JSON.stringify({ email: "hello@example.com" }) },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(mocks.rowsApi.create).toHaveBeenCalledWith({
|
||||
entity,
|
||||
tenantId: "tenant-1",
|
||||
userId: "user-1",
|
||||
rowValues,
|
||||
eventContext: { source: "workflow" },
|
||||
});
|
||||
expect(result.output).toEqual({ row: { id: "row-created", formatted: true } });
|
||||
});
|
||||
|
||||
test("passes workflow event context to rowUpdate mutations", async () => {
|
||||
await WorkflowBlockProService.executeBlock(
|
||||
params(
|
||||
block({
|
||||
type: "rowUpdate",
|
||||
input: { entity: "contact", id: "row-1", data: JSON.stringify({ email: "new@example.com" }) },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(mocks.rowsApi.get).toHaveBeenCalledWith("row-1", {
|
||||
entity,
|
||||
tenantId: "tenant-1",
|
||||
userId: "user-1",
|
||||
});
|
||||
expect(mocks.rowsApi.update).toHaveBeenCalledWith("row-1", {
|
||||
entity,
|
||||
tenantId: "tenant-1",
|
||||
userId: "user-1",
|
||||
rowValues,
|
||||
eventContext: { source: "workflow" },
|
||||
});
|
||||
});
|
||||
|
||||
test("passes workflow event context to rowDelete mutations", async () => {
|
||||
await WorkflowBlockProService.executeBlock(
|
||||
params(
|
||||
block({
|
||||
type: "rowDelete",
|
||||
input: { entity: "contact", id: "row-1" },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(mocks.rowsApi.del).toHaveBeenCalledWith("row-1", {
|
||||
entity,
|
||||
tenantId: "tenant-1",
|
||||
userId: "user-1",
|
||||
eventContext: { source: "workflow" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import { BlockExecutionParamsDto } from "../../dtos/BlockExecutionParamsDto";
|
||||
import { BlockExecutionResultDto } from "../../dtos/BlockExecutionResultDto";
|
||||
import WorkflowsValidationService from "../WorkflowsValidationService";
|
||||
import { parseVariable } from "../WorkflowsVariablesService";
|
||||
import ApiHelper from "~/utils/helpers/ApiHelper";
|
||||
import EntitiesSingleton from "~/modules/rows/repositories/EntitiesSingleton";
|
||||
import EmailService from "~/modules/emails/services/EmailService.server";
|
||||
import { WorkflowBlockType } from "../../dtos/WorkflowBlockTypes";
|
||||
|
||||
export const PRO_WORKFLOW_BLOCK_TYPES = ["waitForInput", "rowGet", "rowCreate", "rowUpdate", "rowDelete", "email"] satisfies WorkflowBlockType[];
|
||||
|
||||
async function executeBlock(args: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
switch (args.block.type) {
|
||||
case "waitForInput":
|
||||
return await executeWaitForInputBlock(args);
|
||||
case "rowGet":
|
||||
return await executeRowGetBlock(args);
|
||||
case "rowCreate":
|
||||
return await executeRowCreateBlock(args);
|
||||
case "rowUpdate":
|
||||
return await executeRowUpdateBlock(args);
|
||||
case "rowDelete":
|
||||
return await executeRowDeleteBlock(args);
|
||||
case "email":
|
||||
return await executeEmailBlock(args);
|
||||
default:
|
||||
throw new Error("Block type not implemented: " + args.block.type);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWaitForInputBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
let input = workflowContext["$params"].input;
|
||||
if (!input) {
|
||||
input = {};
|
||||
}
|
||||
return {
|
||||
output: { input },
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeRowGetBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const translated = parseVariable(JSON.stringify(block.input, null, 2), workflowContext)!;
|
||||
const inputData = JSON.parse(translated);
|
||||
const entityName = inputData.entity;
|
||||
const id = inputData.id;
|
||||
const error = WorkflowsValidationService.validate(
|
||||
{ entity: entityName, id },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
entity: { type: "string" },
|
||||
id: { type: "string" },
|
||||
},
|
||||
required: ["entity", "id"],
|
||||
}
|
||||
);
|
||||
if (error) {
|
||||
return { output: null, error, throwsError: true, toBlockIds: [] };
|
||||
}
|
||||
const entity = await EntitiesSingleton.getEntity({ name: entityName });
|
||||
|
||||
const existing = await RowsApi.get(id, {
|
||||
entity,
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId ?? undefined,
|
||||
}).catch(() => {
|
||||
return null;
|
||||
});
|
||||
|
||||
return {
|
||||
output: {
|
||||
row: !existing ? null : ApiHelper.getApiFormat(entity, existing.item),
|
||||
},
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeRowCreateBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const translated = parseVariable(JSON.stringify(block.input, null, 2), workflowContext)!;
|
||||
const inputData = JSON.parse(translated);
|
||||
const entityName = inputData.entity;
|
||||
const row = JSON.parse(inputData.row);
|
||||
const error = WorkflowsValidationService.validate(
|
||||
{ entity: entityName, row },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
entity: { type: "string" },
|
||||
row: { type: "object" },
|
||||
},
|
||||
required: ["entity", "row"],
|
||||
}
|
||||
);
|
||||
if (error) {
|
||||
return { output: null, error, throwsError: true, toBlockIds: [] };
|
||||
}
|
||||
const entity = await EntitiesSingleton.getEntity({ name: entityName });
|
||||
const rowValues = ApiHelper.getRowPropertiesFromJson(undefined, entity, row);
|
||||
const created = await RowsApi.create({
|
||||
entity,
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId ?? undefined,
|
||||
rowValues,
|
||||
eventContext: { source: "workflow" },
|
||||
});
|
||||
|
||||
return {
|
||||
output: {
|
||||
row: ApiHelper.getApiFormat(entity, created),
|
||||
},
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeRowUpdateBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const translated = parseVariable(JSON.stringify(block.input, null, 2), workflowContext)!;
|
||||
const inputData = JSON.parse(translated);
|
||||
const entityName = inputData.entity;
|
||||
const id = inputData.id;
|
||||
const data = JSON.parse(inputData.data);
|
||||
const error = WorkflowsValidationService.validate(
|
||||
{ entity: entityName, id, data },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
entity: { type: "string" },
|
||||
id: { type: "string" },
|
||||
data: { type: "object" },
|
||||
},
|
||||
required: ["entity", "id", "data"],
|
||||
}
|
||||
);
|
||||
if (error) {
|
||||
return { output: null, error, throwsError: true, toBlockIds: [] };
|
||||
}
|
||||
const entity = await EntitiesSingleton.getEntity({ name: entityName });
|
||||
|
||||
const existing = await RowsApi.get(id, {
|
||||
entity,
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId ?? undefined,
|
||||
});
|
||||
|
||||
const rowValues = ApiHelper.getRowPropertiesFromJson(undefined, entity, data, existing.item);
|
||||
const updated = await RowsApi.update(id, {
|
||||
entity,
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId ?? undefined,
|
||||
rowValues,
|
||||
eventContext: { source: "workflow" },
|
||||
});
|
||||
|
||||
return {
|
||||
output: {
|
||||
row: ApiHelper.getApiFormat(entity, updated),
|
||||
},
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeRowDeleteBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const translated = parseVariable(JSON.stringify(block.input, null, 2), workflowContext)!;
|
||||
const inputData = JSON.parse(translated);
|
||||
const entityName = inputData.entity;
|
||||
const id = inputData.id;
|
||||
const error = WorkflowsValidationService.validate(
|
||||
{ entity: entityName, id },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
entity: { type: "string" },
|
||||
id: { type: "string" },
|
||||
},
|
||||
required: ["entity", "id"],
|
||||
}
|
||||
);
|
||||
if (error) {
|
||||
return { output: null, error, throwsError: true, toBlockIds: [] };
|
||||
}
|
||||
const entity = await EntitiesSingleton.getEntity({ name: entityName });
|
||||
|
||||
try {
|
||||
const existing = await RowsApi.get(id, {
|
||||
entity,
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId ?? undefined,
|
||||
});
|
||||
|
||||
await RowsApi.del(id, {
|
||||
entity,
|
||||
tenantId: session.tenantId,
|
||||
userId: session.userId ?? undefined,
|
||||
eventContext: { source: "workflow" },
|
||||
});
|
||||
|
||||
return {
|
||||
output: {
|
||||
row: ApiHelper.getApiFormat(entity, existing.item),
|
||||
},
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { output: null, error: e.message, throwsError: true, toBlockIds: block.toBlocks.map((f) => f.toBlockId) };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeEmailBlock({ block, workflowContext, session }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const provider = parseVariable(block.input.provider, workflowContext);
|
||||
const to = parseVariable(block.input.to, workflowContext)!;
|
||||
const subject = parseVariable(block.input.subject, workflowContext)!;
|
||||
const body = parseVariable(block.input.body, workflowContext)!;
|
||||
|
||||
if (!provider || !to || !subject || !body) {
|
||||
return {
|
||||
output: null,
|
||||
error: "Missing email data: provider, to, subject, body",
|
||||
throwsError: true,
|
||||
toBlockIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (provider !== "mailjet") {
|
||||
return {
|
||||
output: null,
|
||||
error: "Invalid email provider: " + provider,
|
||||
throwsError: true,
|
||||
toBlockIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sent = await EmailService.send({
|
||||
provider,
|
||||
data: { to, subject, body },
|
||||
});
|
||||
return {
|
||||
output: { sent },
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
executeBlock,
|
||||
};
|
||||
+547
@@ -0,0 +1,547 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
|
||||
import type { WorkflowDto } from "../../dtos/WorkflowDto";
|
||||
|
||||
type WorkflowBlockServiceMocks = {
|
||||
createBlockExecution: ReturnType<typeof vi.fn>;
|
||||
updateBlockExecution: ReturnType<typeof vi.fn>;
|
||||
getWorkflowExecution: ReturnType<typeof vi.fn>;
|
||||
emit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function getMocks(): WorkflowBlockServiceMocks {
|
||||
const store = globalThis as typeof globalThis & { __workflowBlockServiceTestMocks__?: WorkflowBlockServiceMocks };
|
||||
store.__workflowBlockServiceTestMocks__ ??= {
|
||||
createBlockExecution: vi.fn(),
|
||||
updateBlockExecution: vi.fn(),
|
||||
getWorkflowExecution: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
return store.__workflowBlockServiceTestMocks__;
|
||||
}
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
workflowBlockExecution: {
|
||||
create: getMocks().createBlockExecution,
|
||||
update: getMocks().updateBlockExecution,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../db/workflowExecutions.db.server", () => ({
|
||||
getWorkflowExecution: getMocks().getWorkflowExecution,
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/stream/emitter.server", () => ({
|
||||
emitter: { emit: getMocks().emit },
|
||||
}));
|
||||
|
||||
import WorkflowBlockService from "./WorkflowBlockService";
|
||||
|
||||
const mocks = getMocks();
|
||||
|
||||
function block(overrides: Partial<WorkflowBlockDto>): WorkflowBlockDto {
|
||||
return {
|
||||
id: "block",
|
||||
index: 0,
|
||||
type: "doNothing",
|
||||
variableName: "block",
|
||||
description: "",
|
||||
input: {},
|
||||
isTrigger: false,
|
||||
isBlock: true,
|
||||
conditionGroups: [],
|
||||
toBlocks: [],
|
||||
...overrides,
|
||||
} as WorkflowBlockDto;
|
||||
}
|
||||
|
||||
function workflow(blocks: WorkflowBlockDto[]): WorkflowDto {
|
||||
return {
|
||||
id: "workflow_1",
|
||||
tenantId: "tenant_1",
|
||||
tenant: { id: "tenant_1", name: "Acme", slug: "acme" },
|
||||
name: "Workflow",
|
||||
description: "",
|
||||
status: "live",
|
||||
createdAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
|
||||
appliesToAllTenants: false,
|
||||
blocks,
|
||||
inputExamples: [],
|
||||
_count: { executions: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const session = { tenantId: "tenant_1", userId: "user_1" };
|
||||
const publicHttpFetchOptions = (fetchImpl: typeof fetch) => ({
|
||||
fetchImpl,
|
||||
addressResolver: async () => ["93.184.216.34"],
|
||||
});
|
||||
|
||||
describe("WorkflowBlockService.execute", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
let id = 0;
|
||||
mocks.createBlockExecution.mockImplementation(async (args) => ({ id: `block_run_${++id}`, ...args.data }));
|
||||
mocks.updateBlockExecution.mockResolvedValue({});
|
||||
mocks.getWorkflowExecution.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
test("executes a doNothing chain and records each block run", async () => {
|
||||
const first = block({ id: "first", variableName: "first", toBlocks: [{ id: "edge_1", toBlockId: "second", condition: null }] });
|
||||
const second = block({ id: "second", variableName: "second" });
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([first, second]),
|
||||
block: first,
|
||||
fromBlock: null,
|
||||
session,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("success");
|
||||
expect(result.waitingBlockId).toBeNull();
|
||||
expect(mocks.createBlockExecution).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.createBlockExecution).toHaveBeenNthCalledWith(2, expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "second" }) }));
|
||||
});
|
||||
|
||||
test("stops before executing waitForInput and returns waiting state", async () => {
|
||||
const first = block({ id: "first", variableName: "first", toBlocks: [{ id: "edge_1", toBlockId: "wait", condition: null }] });
|
||||
const wait = block({ id: "wait", type: "waitForInput", variableName: "wait" });
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([first, wait]),
|
||||
block: first,
|
||||
fromBlock: null,
|
||||
session,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("waitingBlock");
|
||||
expect(result.waitingBlockId).toBe("wait");
|
||||
expect(mocks.createBlockExecution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("executes variable blocks into the workflow variable context", async () => {
|
||||
const variable = block({
|
||||
id: "var",
|
||||
type: "variable",
|
||||
variableName: "var",
|
||||
input: { variableName: "answer", value: "{{input}}" },
|
||||
});
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, input: "42" },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([variable]),
|
||||
block: variable,
|
||||
fromBlock: null,
|
||||
session,
|
||||
});
|
||||
|
||||
expect(result.workflowContext.$vars.answer).toBe("42");
|
||||
expect(result.workflowContext.var).toBe("42");
|
||||
});
|
||||
|
||||
test("rejects variable blocks that would persist workflow credentials", async () => {
|
||||
const variable = block({
|
||||
id: "var",
|
||||
type: "variable",
|
||||
variableName: "var",
|
||||
input: { variableName: "secretCopy", value: "token={{ $credentials.apiToken }}" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, $credentials: { apiToken: "secret" } },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: { ...workflow([variable]), $credentials: ["apiToken"] },
|
||||
block: variable,
|
||||
fromBlock: null,
|
||||
session,
|
||||
})
|
||||
).rejects.toThrow("Variable blocks cannot persist workflow credentials.");
|
||||
|
||||
expect(mocks.updateBlockExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: "error",
|
||||
error: "Variable blocks cannot persist workflow credentials.",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects handlebar variants that would persist workflow credentials", async () => {
|
||||
for (const value of ["token={{{ $credentials.apiToken }}}", 'token={{lookup $credentials "apiToken"}}', "{{#with $credentials}}{{apiToken}}{{/with}}"]) {
|
||||
const variable = block({
|
||||
id: "var",
|
||||
type: "variable",
|
||||
variableName: "var",
|
||||
input: { variableName: "secretCopy", value },
|
||||
});
|
||||
|
||||
await expect(
|
||||
WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, $credentials: { apiToken: "secret" } },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: { ...workflow([variable]), $credentials: ["apiToken"] },
|
||||
block: variable,
|
||||
fromBlock: null,
|
||||
session,
|
||||
})
|
||||
).rejects.toThrow("Variable blocks cannot persist workflow credentials.");
|
||||
}
|
||||
});
|
||||
|
||||
test("redacts workflow credentials from log outputs without writing runtime logs", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const log = block({
|
||||
id: "log",
|
||||
type: "log",
|
||||
variableName: "log",
|
||||
input: { message: "token={{$credentials.apiToken}}" },
|
||||
});
|
||||
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, $credentials: { apiToken: "sk_live_secret" } },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: { ...workflow([log]), $credentials: ["apiToken"] },
|
||||
block: log,
|
||||
fromBlock: null,
|
||||
session,
|
||||
});
|
||||
|
||||
expect(result.workflowContext.log).toEqual({ message: "token=[REDACTED_CREDENTIAL]" });
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(mocks.updateBlockExecution.mock.calls)).not.toContain("sk_live_secret");
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("routes if blocks to the matching branch", async () => {
|
||||
const ifBlock = block({
|
||||
id: "if",
|
||||
type: "if",
|
||||
variableName: "condition",
|
||||
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "{{flag}}", operator: "=", value: "yes" }] }],
|
||||
toBlocks: [
|
||||
{ id: "edge_true", toBlockId: "true_block", condition: "true" },
|
||||
{ id: "edge_false", toBlockId: "false_block", condition: "false" },
|
||||
],
|
||||
});
|
||||
const trueBlock = block({ id: "true_block", variableName: "trueBlock" });
|
||||
const falseBlock = block({ id: "false_block", variableName: "falseBlock" });
|
||||
|
||||
await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, flag: "yes" },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([ifBlock, trueBlock, falseBlock]),
|
||||
block: ifBlock,
|
||||
fromBlock: null,
|
||||
session,
|
||||
});
|
||||
|
||||
expect(mocks.createBlockExecution).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "true_block" }) }));
|
||||
expect(mocks.createBlockExecution).not.toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "false_block" }) }));
|
||||
});
|
||||
|
||||
test("routes switch blocks to the default branch when no case matches", async () => {
|
||||
const switchBlock = block({
|
||||
id: "switch",
|
||||
type: "switch",
|
||||
variableName: "switchResult",
|
||||
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "{{status}}", operator: "=", value: "active" }] }],
|
||||
toBlocks: [
|
||||
{ id: "edge_case", toBlockId: "case_block", condition: "case1" },
|
||||
{ id: "edge_default", toBlockId: "default_block", condition: "default" },
|
||||
],
|
||||
});
|
||||
const caseBlock = block({ id: "case_block", variableName: "caseBlock" });
|
||||
const defaultBlock = block({ id: "default_block", variableName: "defaultBlock" });
|
||||
|
||||
await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, status: "inactive" },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([switchBlock, caseBlock, defaultBlock]),
|
||||
block: switchBlock,
|
||||
fromBlock: null,
|
||||
session,
|
||||
});
|
||||
|
||||
expect(mocks.createBlockExecution).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "default_block" }) }));
|
||||
expect(mocks.createBlockExecution).not.toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ workflowBlockId: "case_block" }) }));
|
||||
});
|
||||
|
||||
test("validates event blocks and returns event params", async () => {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const event = block({ id: "event", type: "event", variableName: "event", input: { event: "row.created" } });
|
||||
const input = { data: { id: "row_1", title: "Row 1", row: {}, entity: {} } };
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, $params: input },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([event]),
|
||||
block: event,
|
||||
fromBlock: null,
|
||||
session,
|
||||
});
|
||||
|
||||
expect(result.workflowContext.event).toEqual(input);
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("executes http request blocks with guarded redirects and non-json responses", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: "https://api.example.com/ok" } }))
|
||||
.mockResolvedValueOnce(new Response("created", { status: 201, headers: { "content-type": "text/plain" } }));
|
||||
const httpRequest = block({
|
||||
id: "http",
|
||||
type: "httpRequest",
|
||||
variableName: "http",
|
||||
input: {
|
||||
url: "https://api.example.com/start",
|
||||
method: "POST",
|
||||
body: '{"name":"Acme"}',
|
||||
headers: '{"content-type":"application/json"}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([httpRequest]),
|
||||
block: httpRequest,
|
||||
fromBlock: null,
|
||||
session,
|
||||
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
|
||||
});
|
||||
|
||||
expect(result.workflowContext.http).toEqual({ statusCode: 201, body: "created", error: null, truncated: false });
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://api.example.com/start",
|
||||
expect.objectContaining({ method: "POST", redirect: "manual", signal: expect.any(AbortSignal) })
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.example.com/ok",
|
||||
expect.objectContaining({ method: "POST", redirect: "manual", signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
test("marks oversized http response bodies as truncated", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response("a".repeat(20_000), { status: 200, headers: { "content-type": "text/plain" } }));
|
||||
const httpRequest = block({
|
||||
id: "http",
|
||||
type: "httpRequest",
|
||||
variableName: "http",
|
||||
input: { url: "https://api.example.com/large", method: "GET" },
|
||||
});
|
||||
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([httpRequest]),
|
||||
block: httpRequest,
|
||||
fromBlock: null,
|
||||
session,
|
||||
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
|
||||
});
|
||||
|
||||
expect(result.workflowContext.http).toEqual({
|
||||
statusCode: 200,
|
||||
body: "a".repeat(16_384),
|
||||
error: null,
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps credentials available for http requests but redacts echoed secrets from persisted output", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () => new Response(JSON.stringify({ echoed: "sk_live_secret" }), { status: 200, headers: { "content-type": "application/json" } })
|
||||
);
|
||||
const httpRequest = block({
|
||||
id: "http",
|
||||
type: "httpRequest",
|
||||
variableName: "http",
|
||||
input: {
|
||||
url: "https://api.example.com/echo",
|
||||
method: "POST",
|
||||
body: "token={{$credentials.apiToken}}",
|
||||
headers: '{"authorization":"Bearer {{$credentials.apiToken}}"}',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, $credentials: { apiToken: "sk_live_secret" } },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: { ...workflow([httpRequest]), $credentials: ["apiToken"] },
|
||||
block: httpRequest,
|
||||
fromBlock: null,
|
||||
session,
|
||||
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/echo",
|
||||
expect.objectContaining({
|
||||
body: "token=sk_live_secret",
|
||||
headers: { authorization: "Bearer sk_live_secret" },
|
||||
})
|
||||
);
|
||||
expect(result.workflowContext.http.body).toEqual({ echoed: "sk_live_secret" });
|
||||
expect(mocks.updateBlockExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
output: JSON.stringify({ statusCode: 200, body: { echoed: "[REDACTED_CREDENTIAL]" }, error: null, truncated: false }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects private http request targets without fetching", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
const httpRequest = block({
|
||||
id: "http",
|
||||
type: "httpRequest",
|
||||
variableName: "http",
|
||||
input: { url: "http://127.0.0.1/metadata", method: "GET" },
|
||||
});
|
||||
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([httpRequest]),
|
||||
block: httpRequest,
|
||||
fromBlock: null,
|
||||
session,
|
||||
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result.workflowContext.http.statusCode).toBe(0);
|
||||
expect(result.workflowContext.http.error).toBe("Outbound HTTP endpoint host is not allowed.");
|
||||
expect(mocks.updateBlockExecution).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: "error" }) }));
|
||||
});
|
||||
|
||||
test("rejects http request redirects to private targets without following them", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://10.0.0.1/metadata" } }));
|
||||
const httpRequest = block({
|
||||
id: "http",
|
||||
type: "httpRequest",
|
||||
variableName: "http",
|
||||
input: { url: "https://api.example.com/start", method: "GET" },
|
||||
});
|
||||
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([httpRequest]),
|
||||
block: httpRequest,
|
||||
fromBlock: null,
|
||||
session,
|
||||
httpFetchOptions: publicHttpFetchOptions(fetchMock as typeof fetch),
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.workflowContext.http.error).toBe("Outbound HTTP endpoint host is not allowed.");
|
||||
});
|
||||
|
||||
test("stores http request 4xx responses and throws only when configured", async () => {
|
||||
const nonThrowingFetch = vi.fn(
|
||||
async () => new Response(JSON.stringify({ message: "Nope" }), { status: 404, statusText: "Not Found", headers: { "content-type": "application/json" } })
|
||||
);
|
||||
const nonThrowingRequest = block({
|
||||
id: "http",
|
||||
type: "httpRequest",
|
||||
variableName: "http",
|
||||
input: { url: "https://api.example.com/missing", method: "GET", throwsError: false },
|
||||
});
|
||||
|
||||
const result = await WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([nonThrowingRequest]),
|
||||
block: nonThrowingRequest,
|
||||
fromBlock: null,
|
||||
session,
|
||||
httpFetchOptions: publicHttpFetchOptions(nonThrowingFetch as typeof fetch),
|
||||
});
|
||||
|
||||
expect(result.workflowContext.http).toEqual({ statusCode: 404, body: { message: "Nope" }, error: "Not Found", truncated: false });
|
||||
expect(mocks.updateBlockExecution).toHaveBeenLastCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: "success" }) }));
|
||||
|
||||
vi.clearAllMocks();
|
||||
mocks.createBlockExecution.mockResolvedValueOnce({ id: "block_run_throwing" });
|
||||
mocks.updateBlockExecution.mockResolvedValue({});
|
||||
mocks.getWorkflowExecution.mockResolvedValue(null);
|
||||
const throwingFetch = vi.fn(
|
||||
async () => new Response(JSON.stringify({ message: "Nope" }), { status: 400, statusText: "Bad Request", headers: { "content-type": "application/json" } })
|
||||
);
|
||||
const httpRequest = block({
|
||||
id: "http",
|
||||
type: "httpRequest",
|
||||
variableName: "http",
|
||||
input: { url: "https://api.example.com/fail", method: "GET", throwsError: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([httpRequest]),
|
||||
block: httpRequest,
|
||||
fromBlock: null,
|
||||
session,
|
||||
httpFetchOptions: publicHttpFetchOptions(throwingFetch as typeof fetch),
|
||||
})
|
||||
).rejects.toThrow("Bad Request");
|
||||
});
|
||||
|
||||
test("records invalid manual validation schemas as controlled block errors", async () => {
|
||||
const manual = block({
|
||||
id: "manual",
|
||||
type: "manual",
|
||||
variableName: "manual",
|
||||
input: { validation: "{not-json" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {}, $params: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([manual]),
|
||||
block: manual,
|
||||
fromBlock: null,
|
||||
session,
|
||||
})
|
||||
).rejects.toThrow(/Expected property name|JSON/u);
|
||||
|
||||
expect(mocks.updateBlockExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: "error",
|
||||
error: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects cycles before recursively re-entering a block", async () => {
|
||||
const first = block({ id: "first", variableName: "first", toBlocks: [{ id: "edge_1", toBlockId: "second", condition: null }] });
|
||||
const second = block({ id: "second", variableName: "second", toBlocks: [{ id: "edge_2", toBlockId: "first", condition: null }] });
|
||||
|
||||
await expect(
|
||||
WorkflowBlockService.execute({
|
||||
workflowContext: { $vars: {} },
|
||||
workflowExecutionId: "execution_1",
|
||||
workflow: workflow([first, second]),
|
||||
block: first,
|
||||
fromBlock: null,
|
||||
session,
|
||||
})
|
||||
).rejects.toThrow("Workflow execution cycle detected at block first");
|
||||
});
|
||||
});
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
import { db } from "~/utils/db.server";
|
||||
import { WorkflowBlockDto } from "../../dtos/WorkflowBlockDto";
|
||||
import { WorkflowDto } from "../../dtos/WorkflowDto";
|
||||
import { WorkflowBlockType, WorkflowBlockTypes } from "../../dtos/WorkflowBlockTypes";
|
||||
import { containsWorkflowCredentialReference, parseVariable, redactWorkflowCredentials } from "../WorkflowsVariablesService";
|
||||
import WorkflowsConditionsService from "../WorkflowsConditionsService";
|
||||
import WorkflowConditionUtils from "../../helpers/WorkflowConditionUtils";
|
||||
import { emitter } from "~/utils/stream/emitter.server";
|
||||
import { BlockExecutionResultDto } from "../../dtos/BlockExecutionResultDto";
|
||||
import WorkflowUtils from "../../helpers/WorkflowUtils";
|
||||
import { BlockExecutionParamsDto } from "../../dtos/BlockExecutionParamsDto";
|
||||
import WorkflowsBlocksProService from "./WorkflowBlockProService";
|
||||
import { WorkflowStatus } from "../../dtos/WorkflowStatus";
|
||||
import { getWorkflowExecution } from "../../db/workflowExecutions.db.server";
|
||||
import WorkflowExecutionUtils from "../../helpers/WorkflowExecutionUtils";
|
||||
import WorkflowsValidationService from "../WorkflowsValidationService";
|
||||
import { fetchOutboundHttpWithRedirects, readLimitedResponseText, type OutboundHttpFetchOptions } from "~/utils/outboundHttp.server";
|
||||
|
||||
const MAX_WORKFLOW_EXECUTION_DEPTH = 200;
|
||||
const WORKFLOW_HTTP_RESPONSE_BODY_LIMIT_BYTES = 16_384;
|
||||
export const CORE_WORKFLOW_BLOCK_TYPES = [
|
||||
"manual",
|
||||
"doNothing",
|
||||
"log",
|
||||
"httpRequest",
|
||||
"if",
|
||||
"switch",
|
||||
"alertUser",
|
||||
"iterator",
|
||||
"variable",
|
||||
"event",
|
||||
] satisfies WorkflowBlockType[];
|
||||
|
||||
async function execute({
|
||||
workflowContext,
|
||||
workflowExecutionId,
|
||||
workflow,
|
||||
block,
|
||||
fromBlock,
|
||||
session,
|
||||
executionPath = [],
|
||||
httpFetchOptions,
|
||||
}: {
|
||||
workflowContext: { [key: string]: any };
|
||||
workflowExecutionId: string;
|
||||
workflow: WorkflowDto;
|
||||
block: WorkflowBlockDto;
|
||||
fromBlock: WorkflowBlockDto | null;
|
||||
session: {
|
||||
tenantId: string | null;
|
||||
userId: string | null;
|
||||
};
|
||||
executionPath?: string[];
|
||||
httpFetchOptions?: OutboundHttpFetchOptions;
|
||||
}): Promise<{
|
||||
status: WorkflowStatus;
|
||||
workflowContext: { [key: string]: any };
|
||||
waitingBlockId: string | null;
|
||||
}> {
|
||||
if (!WorkflowUtils.canRun(workflow)) {
|
||||
throw new Error("Workflow is not ready to run");
|
||||
}
|
||||
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === block.type);
|
||||
if (!workflowBlock) {
|
||||
throw new Error("Invalid workflow block type: " + block.type);
|
||||
}
|
||||
if (executionPath.includes(block.id)) {
|
||||
throw new Error(`Workflow execution cycle detected at block ${block.id}`);
|
||||
}
|
||||
if (executionPath.length >= MAX_WORKFLOW_EXECUTION_DEPTH) {
|
||||
throw new Error(`Workflow execution exceeded maximum depth of ${MAX_WORKFLOW_EXECUTION_DEPTH}`);
|
||||
}
|
||||
const nextExecutionPath = [...executionPath, block.id];
|
||||
|
||||
let blockExecution = await db.workflowBlockExecution.create({
|
||||
data: {
|
||||
workflowExecutionId,
|
||||
workflowBlockId: block.id,
|
||||
fromWorkflowBlockId: fromBlock ? fromBlock.id : null,
|
||||
status: "pending",
|
||||
startedAt: new Date(),
|
||||
input: block.input ? JSON.stringify(block.input) : null,
|
||||
output: null,
|
||||
endedAt: null,
|
||||
duration: null,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
const blockStartTime = performance.now();
|
||||
let result: BlockExecutionResultDto | null = {
|
||||
output: null,
|
||||
toBlockIds: [],
|
||||
error: null,
|
||||
};
|
||||
try {
|
||||
result = await executeBlock({ block, workflowContext, workflow, workflowExecutionId, session, executionPath: nextExecutionPath, httpFetchOptions });
|
||||
workflowContext = {
|
||||
...workflowContext,
|
||||
[block.variableName]: result.output,
|
||||
};
|
||||
} catch (e: any) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (!result) {
|
||||
result = {
|
||||
error: message,
|
||||
output: null,
|
||||
toBlockIds: [],
|
||||
};
|
||||
} else {
|
||||
result.error = message;
|
||||
}
|
||||
result.throwsError = true;
|
||||
}
|
||||
|
||||
const blockEndTime = performance.now();
|
||||
const blockDuration = blockEndTime - blockStartTime;
|
||||
|
||||
const redactedOutput = redactWorkflowCredentials(result.output, workflowContext.$credentials);
|
||||
const redactedError = redactWorkflowCredentials(result.error, workflowContext.$credentials);
|
||||
|
||||
await db.workflowBlockExecution.update({
|
||||
where: { id: blockExecution.id },
|
||||
data: {
|
||||
output: redactedOutput ? JSON.stringify(redactedOutput) : null,
|
||||
status: result.error ? "error" : "success",
|
||||
duration: Math.round(blockDuration),
|
||||
endedAt: new Date(),
|
||||
error: redactedError,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedExecution = await getWorkflowExecution(workflowExecutionId, session);
|
||||
if (updatedExecution) {
|
||||
emitter.emit(`workflowExecutionUpdate:${workflowExecutionId}`, JSON.stringify(WorkflowExecutionUtils.rowToDto(updatedExecution)));
|
||||
}
|
||||
|
||||
if (result.error && result.throwsError) {
|
||||
throw new Error(redactedError ?? result.error);
|
||||
}
|
||||
|
||||
for (const nextBlockId of result.toBlockIds) {
|
||||
const toBlock = workflow.blocks.find((f) => f.id === nextBlockId);
|
||||
if (toBlock) {
|
||||
if (toBlock.type === "waitForInput") {
|
||||
return {
|
||||
status: "waitingBlock",
|
||||
workflowContext: workflowContext,
|
||||
waitingBlockId: toBlock.id,
|
||||
};
|
||||
}
|
||||
const result = await execute({
|
||||
workflowContext,
|
||||
workflowExecutionId,
|
||||
workflow,
|
||||
block: toBlock,
|
||||
fromBlock: block,
|
||||
session,
|
||||
executionPath: nextExecutionPath,
|
||||
httpFetchOptions,
|
||||
});
|
||||
workflowContext = {
|
||||
...workflowContext,
|
||||
...result.workflowContext,
|
||||
};
|
||||
if (result.status === "waitingBlock") {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.error ? "error" : "success",
|
||||
workflowContext: workflowContext,
|
||||
waitingBlockId: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeBlock(args: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
switch (args.block.type) {
|
||||
case "manual":
|
||||
return await executeManualBlock(args);
|
||||
case "doNothing":
|
||||
return { output: null, toBlockIds: args.block.toBlocks.map((f) => f.toBlockId) };
|
||||
case "log":
|
||||
return await executeLogBlock(args);
|
||||
case "httpRequest":
|
||||
return await executeHttpRequestBlock(args);
|
||||
case "if":
|
||||
return await executeIfBlock(args);
|
||||
case "switch":
|
||||
return await executeSwitchBlock(args);
|
||||
case "alertUser":
|
||||
return await executeAlertUserBlock(args);
|
||||
case "iterator":
|
||||
return await executeIteratorBlock(args);
|
||||
case "variable":
|
||||
return await executeVariableBlock(args);
|
||||
case "event":
|
||||
return await executeEventBlock(args);
|
||||
default:
|
||||
return await WorkflowsBlocksProService.executeBlock(args);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeManualBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
if (block.input.validation) {
|
||||
const parsed = WorkflowsValidationService.parseJsonSchema(block.input.validation);
|
||||
if (parsed.error) {
|
||||
return { output: null, error: parsed.error, throwsError: true, toBlockIds: [] };
|
||||
}
|
||||
if (parsed.schema === null) {
|
||||
return {
|
||||
output: null,
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
const error = WorkflowsValidationService.validate(workflowContext["$params"], parsed.schema);
|
||||
if (error) {
|
||||
return { output: null, error, throwsError: true, toBlockIds: [] };
|
||||
}
|
||||
block.input.validation = undefined;
|
||||
}
|
||||
return {
|
||||
output: null,
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeLogBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const message = redactWorkflowCredentials(parseVariable(block.input.message, workflowContext), workflowContext.$credentials);
|
||||
return {
|
||||
output: {
|
||||
message,
|
||||
},
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeHttpRequestBlock({ block, workflowContext, httpFetchOptions }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const result: BlockExecutionResultDto = {
|
||||
output: {
|
||||
statusCode: 0,
|
||||
body: null,
|
||||
error: null,
|
||||
truncated: false,
|
||||
},
|
||||
error: null,
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
const config = {
|
||||
url: parseVariable(block.input.url, workflowContext),
|
||||
method: parseVariable(block.input.method, workflowContext),
|
||||
body: parseVariable(block.input.body, workflowContext),
|
||||
headers: parseVariable(block.input.headers, workflowContext),
|
||||
throwsError: block.input.throwsError,
|
||||
};
|
||||
if (!config.url) {
|
||||
throw new Error("URL is required");
|
||||
}
|
||||
if (!config.method) {
|
||||
throw new Error("Method is required");
|
||||
}
|
||||
try {
|
||||
const response = await fetchOutboundHttpWithRedirects(
|
||||
config.url,
|
||||
{
|
||||
method: config.method,
|
||||
body: config.body,
|
||||
headers: parseHttpRequestHeaders(config.headers),
|
||||
},
|
||||
httpFetchOptions
|
||||
);
|
||||
const responseText = await readLimitedResponseText(response, WORKFLOW_HTTP_RESPONSE_BODY_LIMIT_BYTES);
|
||||
result.output.statusCode = response.status;
|
||||
result.output.body = parseHttpResponseBody(responseText.body, response.headers.get("content-type"));
|
||||
result.output.truncated = responseText.truncated;
|
||||
if (result.output.statusCode >= 400) {
|
||||
result.output.error = response.statusText;
|
||||
result.error = config.throwsError ? response.statusText : null;
|
||||
result.throwsError = config.throwsError;
|
||||
}
|
||||
} catch (e: any) {
|
||||
const error = e.message;
|
||||
result.error = error;
|
||||
result.output.error = error;
|
||||
result.throwsError = config.throwsError;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseHttpRequestHeaders(headers: string | Record<string, string> | null | undefined): HeadersInit | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof headers !== "string") {
|
||||
return headers;
|
||||
}
|
||||
const parsed = JSON.parse(headers);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Headers must be a JSON object");
|
||||
}
|
||||
return parsed as Record<string, string>;
|
||||
}
|
||||
|
||||
function parseHttpResponseBody(body: string, contentType: string | null): unknown {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
if (contentType?.toLowerCase().includes("application/json")) {
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function executeIfBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const trueBlockId = block.toBlocks.find((f) => f.condition === "true")?.toBlockId;
|
||||
const falseBlockId = block.toBlocks.find((f) => f.condition === "false")?.toBlockId;
|
||||
if (!trueBlockId) {
|
||||
throw new Error("Missing true block");
|
||||
}
|
||||
if (!falseBlockId) {
|
||||
throw new Error("Missing false block");
|
||||
}
|
||||
if (block.conditionGroups.length !== 1) {
|
||||
throw new Error("If block must have exactly one condition group");
|
||||
}
|
||||
const result = WorkflowsConditionsService.validateGroups({ conditionGroup: block.conditionGroups[0], workflowContext });
|
||||
return {
|
||||
output: {
|
||||
condition: result,
|
||||
expression: block.conditionGroups[0].conditions.map((f) => WorkflowConditionUtils.getConditionString(f)).join(` ${block.conditionGroups[0].type} `),
|
||||
},
|
||||
toBlockIds: result ? [trueBlockId] : [falseBlockId],
|
||||
};
|
||||
}
|
||||
|
||||
async function executeSwitchBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
for (const conditionGroup of block.conditionGroups) {
|
||||
const result = WorkflowsConditionsService.validateGroups({ conditionGroup, workflowContext });
|
||||
if (result) {
|
||||
const condition = `case${conditionGroup.index + 1}`;
|
||||
const foundBlockId = block.toBlocks.find((f) => f.condition === condition)?.toBlockId;
|
||||
if (!foundBlockId) {
|
||||
throw new Error(`[${block.variableName}] Missing block for condition: ${condition}`);
|
||||
}
|
||||
return {
|
||||
output: {
|
||||
condition,
|
||||
expression: conditionGroup.conditions.map((f) => WorkflowConditionUtils.getConditionString(f)).join(` ${conditionGroup.type} `),
|
||||
},
|
||||
toBlockIds: [foundBlockId],
|
||||
};
|
||||
}
|
||||
}
|
||||
const defaultBlockId = block.toBlocks.find((f) => f.condition === "default")?.toBlockId;
|
||||
if (!defaultBlockId) {
|
||||
throw new Error("Missing default block");
|
||||
}
|
||||
return {
|
||||
output: {
|
||||
condition: "default",
|
||||
},
|
||||
toBlockIds: [defaultBlockId],
|
||||
};
|
||||
}
|
||||
|
||||
async function executeAlertUserBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const message = redactWorkflowCredentials(parseVariable(block.input.message, workflowContext), workflowContext.$credentials);
|
||||
return {
|
||||
output: {
|
||||
type: block.input.type ?? "success",
|
||||
message,
|
||||
},
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeIteratorBlock({
|
||||
block,
|
||||
workflowContext,
|
||||
workflow,
|
||||
session,
|
||||
workflowExecutionId,
|
||||
executionPath,
|
||||
httpFetchOptions,
|
||||
}: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const loopNextBlockId = block.toBlocks.find((f) => f.condition === "loopNext")?.toBlockId;
|
||||
const loopEndBlockId = block.toBlocks.find((f) => f.condition === "loopEnd")?.toBlockId;
|
||||
const loopNextBlock = workflow.blocks.find((f) => f.id === loopNextBlockId);
|
||||
if (!loopNextBlock) {
|
||||
throw new Error(`[${block.variableName}] Missing loop next block`);
|
||||
}
|
||||
if (!loopEndBlockId) {
|
||||
throw new Error(`[${block.variableName}] Missing loop end block`);
|
||||
}
|
||||
|
||||
let variableName = block.input.variableName;
|
||||
// remove {{ and }} with regex
|
||||
variableName = variableName.replace(/{{/g, "").replace(/}}/g, "");
|
||||
|
||||
function getNestedProperty(obj: any, path: string) {
|
||||
const keys = path.replace(/^/, "").split(".");
|
||||
return getNestedPropertyHelper(obj, keys);
|
||||
}
|
||||
|
||||
function getNestedPropertyHelper(currentObject: any, remainingKeys: string[]): any {
|
||||
if (remainingKeys.length === 0) {
|
||||
return currentObject;
|
||||
}
|
||||
|
||||
const currentKey = remainingKeys[0];
|
||||
|
||||
if (currentObject !== null && typeof currentObject === "object" && currentKey in currentObject) {
|
||||
return getNestedPropertyHelper(currentObject[currentKey], remainingKeys.slice(1));
|
||||
} else {
|
||||
throw new Error(`Property not found for key: ${currentKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
const array = getNestedProperty(workflowContext, variableName);
|
||||
if (!array) {
|
||||
throw new Error(`Variable "${variableName}" not found`);
|
||||
}
|
||||
if (!Array.isArray(array)) {
|
||||
throw new Error(`Variable "${variableName}" is not an array`);
|
||||
}
|
||||
for (let index = 0; index < array.length; index++) {
|
||||
const item = array[index];
|
||||
workflowContext = {
|
||||
...workflowContext,
|
||||
iterator: { index, item, isFirst: index === 0, isLast: index === array.length - 1 },
|
||||
};
|
||||
await execute({ workflowContext, workflowExecutionId, workflow, block: loopNextBlock, fromBlock: block, session, executionPath, httpFetchOptions });
|
||||
}
|
||||
return {
|
||||
output: array.length,
|
||||
toBlockIds: [loopEndBlockId],
|
||||
};
|
||||
}
|
||||
|
||||
async function executeVariableBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const variableName = block.input.variableName;
|
||||
if (containsWorkflowCredentialReference(block.input.value)) {
|
||||
return {
|
||||
output: null,
|
||||
error: "Variable blocks cannot persist workflow credentials.",
|
||||
throwsError: true,
|
||||
toBlockIds: [],
|
||||
};
|
||||
}
|
||||
const value = parseVariable(block.input.value, workflowContext);
|
||||
workflowContext.$vars[variableName] = value;
|
||||
return {
|
||||
output: value,
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
async function executeEventBlock({ block, workflowContext }: BlockExecutionParamsDto): Promise<BlockExecutionResultDto> {
|
||||
const error = WorkflowsValidationService.validate(workflowContext["$params"], {
|
||||
type: "object",
|
||||
properties: { data: { type: "object" } },
|
||||
required: ["data"],
|
||||
});
|
||||
if (error) {
|
||||
return { output: null, error, throwsError: true, toBlockIds: [] };
|
||||
}
|
||||
|
||||
if (["row.created", "row.updated", "row.deleted"].includes(block.input.event)) {
|
||||
const error = WorkflowsValidationService.validate(workflowContext["$params"].data, {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
title: { type: "string" },
|
||||
row: { type: "object" },
|
||||
entity: { type: "object" },
|
||||
},
|
||||
required: ["id", "title", "row", "entity"],
|
||||
});
|
||||
if (error) {
|
||||
return { output: null, error, throwsError: true, toBlockIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
block.input.validation = undefined;
|
||||
return {
|
||||
output: workflowContext.$params,
|
||||
toBlockIds: block.toBlocks.map((f) => f.toBlockId),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
execute,
|
||||
};
|
||||
Reference in new issue
Block a user