diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c3db0aa8..1ad54d915 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,11 +41,11 @@ jobs: # cdk-v1.0.0, cdk-v1.0.0-beta.1 # pulumi-v1.0.0, pulumi-v1.0.0-beta.1 # core-v1.0.0, core-v1.0.0-beta.1 + # world-aws-v0.1.0, world-aws-v0.1.0-beta.1 - # Extract package name (everything before -v) - if [[ "$TAG" =~ ^([a-z]+)-v ]]; then - PACKAGE="${BASH_REMATCH[1]}" - else + # Extract package name (everything before last -v) + PACKAGE="${TAG%-v*}" + if [[ "$PACKAGE" == "$TAG" || -z "$PACKAGE" ]]; then echo "Error: Tag must be in format -v (e.g., cli-v1.0.0)" exit 1 fi @@ -68,8 +68,12 @@ jobs: NPM_PACKAGE="@wraps/core" FILTER="@wraps/core" ;; + world-aws) + NPM_PACKAGE="@wraps.dev/world-aws" + FILTER="@wraps.dev/world-aws" + ;; *) - echo "Error: Unknown package '$PACKAGE'. Must be one of: cli, cdk, pulumi, core" + echo "Error: Unknown package '$PACKAGE'. Must be one of: cli, cdk, pulumi, core, world-aws" exit 1 ;; esac diff --git a/CLAUDE.md b/CLAUDE.md index 16f3d4df6..037088ec3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ wraps/ # Monorepo root │ │ ├── actions/ # Server actions │ │ ├── components/ │ │ │ ├── (ee)/ # Enterprise components -│ │ │ │ └── workflow-builder/ # React Flow workflow canvas +│ │ │ │ └── automation-builder/ # React Flow automation canvas │ │ │ ├── template-editor/ # TipTap email template editor │ │ │ └── ui/ # shadcn/ui components │ │ ├── hooks/ # Custom hooks @@ -543,9 +543,9 @@ These are enforced by `baseline.toml` and will fail CI: - **packages/cli/src/utils/shared/config.ts**: Centralized API/app URL helpers - **packages/db/src/schema/**: All database table definitions - **apps/api/src/routes/**: API route handlers -- **apps/api/src/services/workflow-events.ts**: Workflow event emission +- **apps/api/src/services/automation-events.ts**: Automation event emission - **apps/web/src/actions/**: Next.js server actions -- **apps/web/src/components/(ee)/workflow-builder/**: Workflow builder (React Flow) +- **apps/web/src/components/(ee)/automation-builder/**: Automation builder (React Flow) - **apps/web/src/components/template-editor/**: Email template editor (TipTap) ## Common Tasks diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md index 2035e283d..7d7a3ed47 100644 --- a/apps/api/CLAUDE.md +++ b/apps/api/CLAUDE.md @@ -69,5 +69,5 @@ pnpm --filter @wraps/api test:coverage src/__tests__/my-test.test.ts ## Key Files - `src/routes/` - API route handlers -- `src/services/workflow-events.ts` - Event emission helpers +- `src/services/automation-events.ts` - Event emission helpers - `src/middleware/auth.ts` - Authentication middleware diff --git a/apps/api/src/(ee)/__tests__/workflow-definition-drift.test.ts b/apps/api/src/(ee)/__tests__/workflow-definition-drift.test.ts index 2bc4c3ad1..adb178383 100644 --- a/apps/api/src/(ee)/__tests__/workflow-definition-drift.test.ts +++ b/apps/api/src/(ee)/__tests__/workflow-definition-drift.test.ts @@ -302,15 +302,19 @@ vi.mock("../../services/credentials", () => ({ }), })); -vi.mock("../../services/workflow-queue", () => ({ +vi.mock("../../services/automation-queue", () => ({ + enqueueAutomationStep: mockEnqueueWorkflowStep, enqueueWorkflowStep: mockEnqueueWorkflowStep, + enqueueAutomationStepBatch: mockEnqueueWorkflowStepBatch, enqueueWorkflowStepBatch: mockEnqueueWorkflowStepBatch, scheduleWaitTimeout: mockScheduleWaitTimeout, + scheduleAutomationStep: mockScheduleWorkflowStep, scheduleWorkflowStep: mockScheduleWorkflowStep, deleteScheduledStep: mockDeleteScheduledStep, })); -vi.mock("../../services/workflow-scheduler", () => ({ +vi.mock("../../services/automation-scheduler", () => ({ + createNextAutomationSchedule: mockCreateNextWorkflowSchedule, createNextWorkflowSchedule: mockCreateNextWorkflowSchedule, })); diff --git a/apps/api/src/(ee)/__tests__/workflow-dlq-consumer.test.ts b/apps/api/src/(ee)/__tests__/workflow-dlq-consumer.test.ts index 563ab0b70..591173804 100644 --- a/apps/api/src/(ee)/__tests__/workflow-dlq-consumer.test.ts +++ b/apps/api/src/(ee)/__tests__/workflow-dlq-consumer.test.ts @@ -72,7 +72,8 @@ const mockDbSelect = vi.fn(); const mockDbUpdate = vi.fn(); const mockCreateNextWorkflowSchedule = vi.fn(); -vi.mock("../../services/workflow-scheduler", () => ({ +vi.mock("../../services/automation-scheduler", () => ({ + createNextAutomationSchedule: mockCreateNextWorkflowSchedule, createNextWorkflowSchedule: mockCreateNextWorkflowSchedule, })); diff --git a/apps/api/src/(ee)/__tests__/workflow-events-cancel.test.ts b/apps/api/src/(ee)/__tests__/workflow-events-cancel.integration.test.ts similarity index 99% rename from apps/api/src/(ee)/__tests__/workflow-events-cancel.test.ts rename to apps/api/src/(ee)/__tests__/workflow-events-cancel.integration.test.ts index 888728486..6eb3b85f8 100644 --- a/apps/api/src/(ee)/__tests__/workflow-events-cancel.test.ts +++ b/apps/api/src/(ee)/__tests__/workflow-events-cancel.integration.test.ts @@ -28,7 +28,8 @@ import { } from "vitest"; // Mock the workflow-queue module to avoid actual AWS calls -vi.mock("../../services/workflow-queue", () => ({ +vi.mock("../../services/automation-queue", () => ({ + enqueueAutomationStep: vi.fn().mockResolvedValue(undefined), enqueueWorkflowStep: vi.fn().mockResolvedValue(undefined), deleteScheduledStep: vi.fn().mockResolvedValue(undefined), })); diff --git a/apps/api/src/(ee)/__tests__/workflow-processor-core.test.ts b/apps/api/src/(ee)/__tests__/workflow-processor-core.test.ts index 924d5176d..13026c677 100644 --- a/apps/api/src/(ee)/__tests__/workflow-processor-core.test.ts +++ b/apps/api/src/(ee)/__tests__/workflow-processor-core.test.ts @@ -276,15 +276,19 @@ vi.mock("../../services/credentials", () => ({ }), })); -vi.mock("../../services/workflow-queue", () => ({ +vi.mock("../../services/automation-queue", () => ({ + enqueueAutomationStep: mockEnqueueWorkflowStep, enqueueWorkflowStep: mockEnqueueWorkflowStep, + enqueueAutomationStepBatch: mockEnqueueWorkflowStepBatch, enqueueWorkflowStepBatch: mockEnqueueWorkflowStepBatch, scheduleWaitTimeout: mockScheduleWaitTimeout, + scheduleAutomationStep: mockScheduleWorkflowStep, scheduleWorkflowStep: mockScheduleWorkflowStep, deleteScheduledStep: mockDeleteScheduledStep, })); -vi.mock("../../services/workflow-scheduler", () => ({ +vi.mock("../../services/automation-scheduler", () => ({ + createNextAutomationSchedule: mockCreateNextWorkflowSchedule, createNextWorkflowSchedule: mockCreateNextWorkflowSchedule, })); diff --git a/apps/api/src/(ee)/__tests__/workflow-processor-steps.test.ts b/apps/api/src/(ee)/__tests__/workflow-processor-steps.test.ts index 2bf550d28..bbbf75061 100644 --- a/apps/api/src/(ee)/__tests__/workflow-processor-steps.test.ts +++ b/apps/api/src/(ee)/__tests__/workflow-processor-steps.test.ts @@ -228,15 +228,19 @@ vi.mock("../../services/credentials", () => ({ }), })); -vi.mock("../../services/workflow-queue", () => ({ +vi.mock("../../services/automation-queue", () => ({ + enqueueAutomationStep: mockEnqueueWorkflowStep, enqueueWorkflowStep: mockEnqueueWorkflowStep, + enqueueAutomationStepBatch: mockEnqueueWorkflowStepBatch, enqueueWorkflowStepBatch: mockEnqueueWorkflowStepBatch, scheduleWaitTimeout: mockScheduleWaitTimeout, + scheduleAutomationStep: mockScheduleWorkflowStep, scheduleWorkflowStep: mockScheduleWorkflowStep, deleteScheduledStep: mockDeleteScheduledStep, })); -vi.mock("../../services/workflow-scheduler", () => ({ +vi.mock("../../services/automation-scheduler", () => ({ + createNextAutomationSchedule: mockCreateNextWorkflowSchedule, createNextWorkflowSchedule: mockCreateNextWorkflowSchedule, })); diff --git a/apps/api/src/(ee)/__tests__/workflow-processor-trigger.test.ts b/apps/api/src/(ee)/__tests__/workflow-processor-trigger.test.ts index 63c73bc5a..3e144ac2f 100644 --- a/apps/api/src/(ee)/__tests__/workflow-processor-trigger.test.ts +++ b/apps/api/src/(ee)/__tests__/workflow-processor-trigger.test.ts @@ -158,15 +158,19 @@ vi.mock("../../services/credentials", () => ({ }), })); -vi.mock("../../services/workflow-queue", () => ({ +vi.mock("../../services/automation-queue", () => ({ + enqueueAutomationStep: mockEnqueueWorkflowStep, enqueueWorkflowStep: mockEnqueueWorkflowStep, + enqueueAutomationStepBatch: mockEnqueueWorkflowStepBatch, enqueueWorkflowStepBatch: mockEnqueueWorkflowStepBatch, scheduleWaitTimeout: mockScheduleWaitTimeout, + scheduleAutomationStep: mockScheduleWorkflowStep, scheduleWorkflowStep: mockScheduleWorkflowStep, deleteScheduledStep: mockDeleteScheduledStep, })); -vi.mock("../../services/workflow-scheduler", () => ({ +vi.mock("../../services/automation-scheduler", () => ({ + createNextAutomationSchedule: mockCreateNextWorkflowSchedule, createNextWorkflowSchedule: mockCreateNextWorkflowSchedule, })); diff --git a/apps/api/src/(ee)/routes/automations.ts b/apps/api/src/(ee)/routes/automations.ts new file mode 100644 index 000000000..e98b20b83 --- /dev/null +++ b/apps/api/src/(ee)/routes/automations.ts @@ -0,0 +1,388 @@ +/** + * Automation Trigger Routes + * + * API endpoints for directly triggering automations. + * Used for automations with triggerType "api" that are triggered + * by external systems or customer code. + */ + +import { automation, contact, db, eq } from "@wraps/db"; +import { and, inArray } from "drizzle-orm"; +import { t } from "elysia"; + +import { log } from "../../lib/logger"; +import { + type AuthContext, + createAuthenticatedRoutes, +} from "../../middleware/auth"; +import { rateLimitMiddleware } from "../../middleware/rate-limit"; +import { + type AutomationJob, + enqueueAutomationStep, + enqueueAutomationStepBatch, +} from "../../services/automation-queue"; + +// Common response schemas +const _errorResponse = t.Object({ + success: t.Literal(false), + error: t.String({ description: "Error message" }), +}); + +// OpenAPI 3.0 compatible arbitrary properties object +const dataSchema = t.Optional( + t.Object( + {}, + { + additionalProperties: true, + description: "Data to pass to the automation", + } + ) +); + +export const automationsRoutes = createAuthenticatedRoutes("/v1/automations") + .use(rateLimitMiddleware) + + /** + * Trigger an automation via API + * + * POST /v1/automations/:automationId/trigger + * + * Triggers a specific automation for a contact. The automation must have + * triggerType "api" and be enabled. + */ + .post( + "/:automationId/trigger", + async (ctx) => { + const { params, body } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + const { automationId } = params; + const { contactId, contactEmail, data } = body; + + // Find the automation + const [a] = await db + .select() + .from(automation) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, auth.organizationId) + ) + ) + .limit(1); + + if (!a) { + return { + success: false, + error: "Automation not found", + }; + } + + // Check automation is enabled + if (a.status !== "enabled") { + return { + success: false, + error: "Automation is not enabled", + }; + } + + // Check automation has api trigger type + if (a.triggerType !== "api") { + return { + success: false, + error: `Automation has trigger type "${a.triggerType}", expected "api"`, + }; + } + + // Find the contact + let contactRecord: typeof contact.$inferSelect | undefined; + + if (contactId) { + const [c] = await db + .select() + .from(contact) + .where( + and( + eq(contact.id, contactId), + eq(contact.organizationId, auth.organizationId) + ) + ) + .limit(1); + contactRecord = c; + } else if (contactEmail) { + const [c] = await db + .select() + .from(contact) + .where( + and( + eq(contact.email, contactEmail), + eq(contact.organizationId, auth.organizationId) + ) + ) + .limit(1); + contactRecord = c; + } + + if (!contactRecord) { + return { + success: false, + error: "Contact not found", + }; + } + + // Enqueue the automation trigger + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: contactRecord.id, + organizationId: auth.organizationId, + eventData: data || {}, + }); + + log.info("Automation API trigger", { + automationId: a.id, + contactId: contactRecord.id, + }); + + return { + success: true, + message: "Automation triggered successfully", + automationId: a.id, + automationName: a.name, + contactId: contactRecord.id, + }; + }, + { + params: t.Object({ + automationId: t.String({ + description: "Automation ID to trigger", + maxLength: 36, + }), + }), + body: t.Object({ + contactId: t.Optional( + t.String({ description: "Contact ID", maxLength: 36 }) + ), + contactEmail: t.Optional( + t.String({ + description: "Contact email (alternative to contactId)", + maxLength: 255, + }) + ), + data: dataSchema, + }), + response: { + 200: t.Object({ + success: t.Boolean(), + message: t.Optional(t.String()), + automationId: t.Optional(t.String()), + automationName: t.Optional(t.String()), + contactId: t.Optional(t.String()), + error: t.Optional(t.String()), + }), + }, + detail: { + summary: "Trigger automation", + description: + "Trigger a specific automation for a contact. The automation must have triggerType 'api' and be enabled.", + tags: ["automations"], + }, + } + ) + + /** + * Batch trigger an automation for multiple contacts + * + * POST /v1/automations/:automationId/trigger/batch + * + * Triggers an automation for multiple contacts at once. + */ + .post( + "/:automationId/trigger/batch", + async (ctx) => { + const { params, body } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + const { automationId } = params; + const { contacts, data } = body; + + // Find the automation + const [a] = await db + .select() + .from(automation) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, auth.organizationId) + ) + ) + .limit(1); + + if (!a) { + return { + success: false, + error: "Automation not found", + }; + } + + // Check automation is enabled + if (a.status !== "enabled") { + return { + success: false, + error: "Automation is not enabled", + }; + } + + // Check automation has api trigger type + if (a.triggerType !== "api") { + return { + success: false, + error: `Automation has trigger type "${a.triggerType}", expected "api"`, + }; + } + + const results = { + triggered: 0, + errors: [] as string[], + }; + + // Batch fetch all contacts in 2 queries (by ID and by email) instead of N queries + const contactIds = contacts + .filter((c) => c.contactId) + .map((c) => c.contactId as string); + const contactEmails = contacts + .filter((c) => c.contactEmail && !c.contactId) + .map((c) => c.contactEmail as string); + + // Fetch contacts by ID + const contactsById = new Map(); + if (contactIds.length > 0) { + const foundById = await db + .select() + .from(contact) + .where( + and( + inArray(contact.id, contactIds), + eq(contact.organizationId, auth.organizationId) + ) + ); + for (const c of foundById) { + contactsById.set(c.id, c); + } + } + + // Fetch contacts by email + const contactsByEmail = new Map(); + if (contactEmails.length > 0) { + const foundByEmail = await db + .select() + .from(contact) + .where( + and( + inArray(contact.email, contactEmails), + eq(contact.organizationId, auth.organizationId) + ) + ); + for (const c of foundByEmail) { + if (c.email) { + contactsByEmail.set(c.email, c); + } + } + } + + // Process each contact request and collect jobs for batch enqueue + const jobs: AutomationJob[] = []; + for (const c of contacts) { + let contactRecord: typeof contact.$inferSelect | undefined; + + if (c.contactId) { + contactRecord = contactsById.get(c.contactId); + } else if (c.contactEmail) { + contactRecord = contactsByEmail.get(c.contactEmail); + } + + if (!contactRecord) { + results.errors.push( + `Contact not found: ${c.contactId || c.contactEmail}` + ); + continue; + } + + jobs.push({ + type: "trigger", + workflowId: a.id, + contactId: contactRecord.id, + organizationId: auth.organizationId, + eventData: { ...(data || {}), ...(c.data || {}) }, + }); + + results.triggered++; + } + + // Batch enqueue all trigger jobs + await enqueueAutomationStepBatch(jobs); + + log.info("Automation API batch trigger", { + automationId: a.id, + triggered: results.triggered, + }); + + return { + success: results.errors.length === 0, + automationId: a.id, + automationName: a.name, + ...results, + }; + }, + { + params: t.Object({ + automationId: t.String({ + description: "Automation ID to trigger", + maxLength: 36, + }), + }), + body: t.Object({ + contacts: t.Array( + t.Object({ + contactId: t.Optional(t.String({ maxLength: 36 })), + contactEmail: t.Optional(t.String({ maxLength: 255 })), + data: t.Optional(t.Object({}, { additionalProperties: true })), + }), + { + description: "List of contacts to trigger the automation for", + } + ), + data: t.Optional( + t.Object( + {}, + { + additionalProperties: true, + description: "Common data to pass to all automation triggers", + } + ) + ), + }), + response: { + 200: t.Object({ + success: t.Boolean(), + automationId: t.Optional(t.String()), + automationName: t.Optional(t.String()), + triggered: t.Optional( + t.Number({ description: "Number of contacts triggered" }) + ), + errors: t.Optional( + t.Array(t.String(), { description: "Error messages if any" }) + ), + error: t.Optional(t.String()), + }), + }, + detail: { + summary: "Batch trigger automation", + description: + "Trigger an automation for multiple contacts at once. Each contact can have its own data that gets merged with common data.", + tags: ["automations"], + }, + } + ); + +// Backward-compat alias +/** @deprecated Use `automationsRoutes` instead */ +export const workflowsRoutes = automationsRoutes; diff --git a/apps/api/src/(ee)/routes/workflows.ts b/apps/api/src/(ee)/routes/workflows.ts index 7d76d2193..f68400522 100644 --- a/apps/api/src/(ee)/routes/workflows.ts +++ b/apps/api/src/(ee)/routes/workflows.ts @@ -1,379 +1,5 @@ /** - * Workflow Trigger Routes - * - * API endpoints for directly triggering workflows. - * Used for workflows with triggerType "api" that are triggered - * by external systems or customer code. + * @deprecated Import from `./automations` instead. + * This file is a backward-compatibility shim. */ - -import { contact, db, eq, workflow } from "@wraps/db"; -import { and, inArray } from "drizzle-orm"; -import { t } from "elysia"; - -import { log } from "../../lib/logger"; -import { - type AuthContext, - createAuthenticatedRoutes, -} from "../../middleware/auth"; -import { rateLimitMiddleware } from "../../middleware/rate-limit"; -import { - enqueueWorkflowStep, - enqueueWorkflowStepBatch, - type WorkflowJob, -} from "../../services/workflow-queue"; - -// Common response schemas -const _errorResponse = t.Object({ - success: t.Literal(false), - error: t.String({ description: "Error message" }), -}); - -// OpenAPI 3.0 compatible arbitrary properties object -const dataSchema = t.Optional( - t.Object( - {}, - { additionalProperties: true, description: "Data to pass to the workflow" } - ) -); - -export const workflowsRoutes = createAuthenticatedRoutes("/v1/workflows") - .use(rateLimitMiddleware) - - /** - * Trigger a workflow via API - * - * POST /v1/workflows/:workflowId/trigger - * - * Triggers a specific workflow for a contact. The workflow must have - * triggerType "api" and be enabled. - */ - .post( - "/:workflowId/trigger", - async (ctx) => { - const { params, body } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - const { workflowId } = params; - const { contactId, contactEmail, data } = body; - - // Find the workflow - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, auth.organizationId) - ) - ) - .limit(1); - - if (!wf) { - return { - success: false, - error: "Workflow not found", - }; - } - - // Check workflow is enabled - if (wf.status !== "enabled") { - return { - success: false, - error: "Workflow is not enabled", - }; - } - - // Check workflow has api trigger type - if (wf.triggerType !== "api") { - return { - success: false, - error: `Workflow has trigger type "${wf.triggerType}", expected "api"`, - }; - } - - // Find the contact - let contactRecord: typeof contact.$inferSelect | undefined; - - if (contactId) { - const [c] = await db - .select() - .from(contact) - .where( - and( - eq(contact.id, contactId), - eq(contact.organizationId, auth.organizationId) - ) - ) - .limit(1); - contactRecord = c; - } else if (contactEmail) { - const [c] = await db - .select() - .from(contact) - .where( - and( - eq(contact.email, contactEmail), - eq(contact.organizationId, auth.organizationId) - ) - ) - .limit(1); - contactRecord = c; - } - - if (!contactRecord) { - return { - success: false, - error: "Contact not found", - }; - } - - // Enqueue the workflow trigger - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: contactRecord.id, - organizationId: auth.organizationId, - eventData: data || {}, - }); - - log.info("Workflow API trigger", { - workflowId: wf.id, - contactId: contactRecord.id, - }); - - return { - success: true, - message: "Workflow triggered successfully", - workflowId: wf.id, - workflowName: wf.name, - contactId: contactRecord.id, - }; - }, - { - params: t.Object({ - workflowId: t.String({ - description: "Workflow ID to trigger", - maxLength: 36, - }), - }), - body: t.Object({ - contactId: t.Optional( - t.String({ description: "Contact ID", maxLength: 36 }) - ), - contactEmail: t.Optional( - t.String({ - description: "Contact email (alternative to contactId)", - maxLength: 255, - }) - ), - data: dataSchema, - }), - response: { - 200: t.Object({ - success: t.Boolean(), - message: t.Optional(t.String()), - workflowId: t.Optional(t.String()), - workflowName: t.Optional(t.String()), - contactId: t.Optional(t.String()), - error: t.Optional(t.String()), - }), - }, - detail: { - summary: "Trigger workflow", - description: - "Trigger a specific workflow for a contact. The workflow must have triggerType 'api' and be enabled.", - tags: ["workflows"], - }, - } - ) - - /** - * Batch trigger a workflow for multiple contacts - * - * POST /v1/workflows/:workflowId/trigger/batch - * - * Triggers a workflow for multiple contacts at once. - */ - .post( - "/:workflowId/trigger/batch", - async (ctx) => { - const { params, body } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - const { workflowId } = params; - const { contacts, data } = body; - - // Find the workflow - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, auth.organizationId) - ) - ) - .limit(1); - - if (!wf) { - return { - success: false, - error: "Workflow not found", - }; - } - - // Check workflow is enabled - if (wf.status !== "enabled") { - return { - success: false, - error: "Workflow is not enabled", - }; - } - - // Check workflow has api trigger type - if (wf.triggerType !== "api") { - return { - success: false, - error: `Workflow has trigger type "${wf.triggerType}", expected "api"`, - }; - } - - const results = { - triggered: 0, - errors: [] as string[], - }; - - // Batch fetch all contacts in 2 queries (by ID and by email) instead of N queries - const contactIds = contacts - .filter((c) => c.contactId) - .map((c) => c.contactId as string); - const contactEmails = contacts - .filter((c) => c.contactEmail && !c.contactId) - .map((c) => c.contactEmail as string); - - // Fetch contacts by ID - const contactsById = new Map(); - if (contactIds.length > 0) { - const foundById = await db - .select() - .from(contact) - .where( - and( - inArray(contact.id, contactIds), - eq(contact.organizationId, auth.organizationId) - ) - ); - for (const c of foundById) { - contactsById.set(c.id, c); - } - } - - // Fetch contacts by email - const contactsByEmail = new Map(); - if (contactEmails.length > 0) { - const foundByEmail = await db - .select() - .from(contact) - .where( - and( - inArray(contact.email, contactEmails), - eq(contact.organizationId, auth.organizationId) - ) - ); - for (const c of foundByEmail) { - if (c.email) { - contactsByEmail.set(c.email, c); - } - } - } - - // Process each contact request and collect jobs for batch enqueue - const jobs: WorkflowJob[] = []; - for (const c of contacts) { - let contactRecord: typeof contact.$inferSelect | undefined; - - if (c.contactId) { - contactRecord = contactsById.get(c.contactId); - } else if (c.contactEmail) { - contactRecord = contactsByEmail.get(c.contactEmail); - } - - if (!contactRecord) { - results.errors.push( - `Contact not found: ${c.contactId || c.contactEmail}` - ); - continue; - } - - jobs.push({ - type: "trigger", - workflowId: wf.id, - contactId: contactRecord.id, - organizationId: auth.organizationId, - eventData: { ...(data || {}), ...(c.data || {}) }, - }); - - results.triggered++; - } - - // Batch enqueue all trigger jobs - await enqueueWorkflowStepBatch(jobs); - - log.info("Workflow API batch trigger", { - workflowId: wf.id, - triggered: results.triggered, - }); - - return { - success: results.errors.length === 0, - workflowId: wf.id, - workflowName: wf.name, - ...results, - }; - }, - { - params: t.Object({ - workflowId: t.String({ - description: "Workflow ID to trigger", - maxLength: 36, - }), - }), - body: t.Object({ - contacts: t.Array( - t.Object({ - contactId: t.Optional(t.String({ maxLength: 36 })), - contactEmail: t.Optional(t.String({ maxLength: 255 })), - data: t.Optional(t.Object({}, { additionalProperties: true })), - }), - { description: "List of contacts to trigger the workflow for" } - ), - data: t.Optional( - t.Object( - {}, - { - additionalProperties: true, - description: "Common data to pass to all workflow triggers", - } - ) - ), - }), - response: { - 200: t.Object({ - success: t.Boolean(), - workflowId: t.Optional(t.String()), - workflowName: t.Optional(t.String()), - triggered: t.Optional( - t.Number({ description: "Number of contacts triggered" }) - ), - errors: t.Optional( - t.Array(t.String(), { description: "Error messages if any" }) - ), - error: t.Optional(t.String()), - }), - }, - detail: { - summary: "Batch trigger workflow", - description: - "Trigger a workflow for multiple contacts at once. Each contact can have its own data that gets merged with common data.", - tags: ["workflows"], - }, - } - ); +export * from "./automations"; diff --git a/apps/api/src/(ee)/workers/automation-dlq-consumer.ts b/apps/api/src/(ee)/workers/automation-dlq-consumer.ts new file mode 100644 index 000000000..185a58c65 --- /dev/null +++ b/apps/api/src/(ee)/workers/automation-dlq-consumer.ts @@ -0,0 +1,227 @@ +/** + * Workflow DLQ Consumer + * + * Processes messages that failed 3 SQS retries and landed in the dead-letter + * queue. Marks affected workflow executions as "failed" in the database so + * they are visible in the dashboard instead of silently expiring. + * + * IMPORTANT: This handler must never throw. A throw from a DLQ consumer + * causes pointless SQS retries with no DLQ-of-DLQ to catch them. + */ + +import { + db, + eq, + type TriggerConfig, + workflow, + workflowExecution, +} from "@wraps/db"; +import type { SQSEvent, SQSHandler } from "aws-lambda"; +import { and, sql } from "drizzle-orm"; + +import { log } from "../../lib/logger"; +import type { AutomationJob } from "../../services/automation-queue"; +import { createNextAutomationSchedule } from "../../services/automation-scheduler"; + +const TERMINAL_STATUSES = new Set(["completed", "cancelled", "failed"]); + +export const handler: SQSHandler = async (event: SQSEvent) => { + for (const record of event.Records) { + try { + const job: AutomationJob = JSON.parse(record.body); + + log.warn("DLQ: processing failed job", { + type: job.type, + messageId: record.messageId, + receiveCount: record.attributes.ApproximateReceiveCount, + }); + + switch (job.type) { + case "execute": + await handleExecute(job); + break; + case "resume": + await handleResume(job); + break; + case "trigger": + await handleTrigger(job); + break; + case "schedule-trigger": + await handleScheduleTrigger(job); + break; + } + } catch (error) { + // Never throw from a DLQ consumer + log.error("DLQ: failed to process record", error, { + messageId: record.messageId, + body: record.body.slice(0, 500), + }); + } + } +}; + +async function handleExecute(job: Extract) { + await failExecution( + job.executionId, + `Step ${job.stepId} failed after SQS retries exhausted`, + job.stepId + ); +} + +async function handleResume(job: Extract) { + // Load execution to get currentStepId + const execution = await db + .select({ + id: workflowExecution.id, + status: workflowExecution.status, + currentStepId: workflowExecution.currentStepId, + }) + .from(workflowExecution) + .where(eq(workflowExecution.id, job.executionId)) + .limit(1); + + if (!execution[0]) { + log.warn("DLQ: resume — execution not found", { + executionId: job.executionId, + }); + return; + } + + if (TERMINAL_STATUSES.has(execution[0].status)) { + log.info("DLQ: resume — execution already terminal", { + executionId: job.executionId, + status: execution[0].status, + }); + return; + } + + await failExecution( + job.executionId, + `Resume (${job.branch}) failed after SQS retries exhausted`, + execution[0].currentStepId ?? "unknown" + ); +} + +async function handleTrigger(job: Extract) { + // Check if an execution was created before the failure + const executions = await db + .select({ + id: workflowExecution.id, + status: workflowExecution.status, + }) + .from(workflowExecution) + .where( + and( + eq(workflowExecution.workflowId, job.workflowId), + eq(workflowExecution.contactId, job.contactId), + sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` + ) + ) + .limit(1); + + if (executions[0]) { + await failExecution( + executions[0].id, + "Trigger failed after SQS retries exhausted", + "trigger" + ); + return; + } + + log.warn("DLQ: trigger — no active execution found, nothing to fail", { + workflowId: job.workflowId, + contactId: job.contactId, + }); +} + +async function handleScheduleTrigger( + job: Extract +) { + const [wf] = await db + .select({ + id: workflow.id, + organizationId: workflow.organizationId, + status: workflow.status, + triggerType: workflow.triggerType, + triggerConfig: workflow.triggerConfig, + }) + .from(workflow) + .where(eq(workflow.id, job.workflowId)) + .limit(1); + + if (!wf || wf.status !== "enabled" || wf.triggerType !== "schedule") { + log.warn("DLQ: schedule-trigger — workflow not eligible for chain repair", { + workflowId: job.workflowId, + status: wf?.status, + triggerType: wf?.triggerType, + }); + return; + } + + const config = wf.triggerConfig as TriggerConfig; + if (!config.schedule) { + log.warn("DLQ: schedule-trigger — no cron expression", { + workflowId: job.workflowId, + }); + return; + } + + try { + await createNextAutomationSchedule({ + workflowId: wf.id, + organizationId: wf.organizationId, + cronExpression: config.schedule, + timezone: config.timezone, + }); + log.info("DLQ: schedule-trigger — chain repaired", { + workflowId: wf.id, + }); + } catch (error) { + log.error("DLQ: schedule-trigger — chain repair failed", error, { + workflowId: wf.id, + }); + } +} + +/** + * Mark an execution as failed and update workflow counters. + * + * Duplicated from workflow-processor to avoid pulling in SES/Pinpoint/Handlebars + * transitive dependencies into this lightweight Lambda. + */ +async function failExecution( + executionId: string, + error: string, + stepId: string +): Promise { + const [execution] = await db + .update(workflowExecution) + .set({ + status: "failed", + error, + errorStepId: stepId, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, executionId)) + .returning(); + + if (execution) { + await db + .update(workflow) + .set({ + activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, + failedExecutions: sql`${workflow.failedExecutions} + 1`, + }) + .where(eq(workflow.id, execution.workflowId)); + + log.warn("DLQ: execution marked as failed", { + executionId, + workflowId: execution.workflowId, + error, + stepId, + }); + } else { + log.warn("DLQ: failExecution returned no rows", { executionId }); + } +} diff --git a/apps/api/src/(ee)/workers/automation-processor.ts b/apps/api/src/(ee)/workers/automation-processor.ts new file mode 100644 index 000000000..a6475d31b --- /dev/null +++ b/apps/api/src/(ee)/workers/automation-processor.ts @@ -0,0 +1,2211 @@ +/** + * Workflow Processor Worker + * + * SQS Lambda handler that processes workflow step executions. + * Handles different step types and routes to next steps. + */ + +import { + PinpointSMSVoiceV2Client, + SendTextMessageCommand, +} from "@aws-sdk/client-pinpoint-sms-voice-v2"; +import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import { toPlainText } from "@react-email/render"; +import { + type AutomationDefinitionSnapshot, + type AutomationStep, + type AutomationStepConfig, + type AutomationTransition, + awsAccount, + CASCADE_ENGAGEMENT_FIELD, + contact, + contactIdsMatchingCondition, + contactTopic, + db, + eq, + messageSend, + organization, + type PreferredChannel, + segment, + type TriggerConfig, + template, + workflow, + workflowExecution, + workflowStepExecution, +} from "@wraps/db"; +import { + generateSESTemplateName, + transformVariablesForSes, + upsertSESTemplate, +} from "@wraps/email"; +import type { SQSBatchResponse, SQSEvent } from "aws-lambda"; +import { and, sql } from "drizzle-orm"; +import Handlebars from "handlebars"; + +import { trackFirstEmailSent } from "../../lib/activation-tracking"; +import { log } from "../../lib/logger"; +import { generateUnsubscribeToken } from "../../lib/unsubscribe-token"; +import { + type AutomationJob, + deleteScheduledStep, + enqueueAutomationStep, + enqueueAutomationStepBatch, + scheduleAutomationStep, + scheduleWaitTimeout, +} from "../../services/automation-queue"; +import { createNextAutomationSchedule } from "../../services/automation-scheduler"; +import { getCredentials } from "../../services/credentials"; + +export const handler = async (event: SQSEvent): Promise => { + const results = await Promise.allSettled( + event.Records.map(async (record) => { + const job: AutomationJob = JSON.parse(record.body); + + switch (job.type) { + case "execute": + await processStep(job.executionId, job.stepId); + break; + case "resume": + await resumeExecution(job.executionId, job.branch); + break; + case "trigger": + await triggerWorkflow( + job.workflowId, + job.contactId, + job.organizationId, + job.eventData + ); + break; + case "schedule-trigger": + await processScheduleTrigger(job.workflowId, job.organizationId); + break; + } + }) + ); + + const batchItemFailures = results + .map((result, idx) => { + if (result.status === "rejected") { + log.error("Error processing workflow job", result.reason); + return { itemIdentifier: event.Records[idx].messageId }; + } + return null; + }) + .filter((f): f is { itemIdentifier: string } => f !== null); + + return { batchItemFailures }; +}; + +/** + * Trigger a new workflow execution for a contact + */ +async function triggerWorkflow( + workflowId: string, + contactId: string, + organizationId: string, + eventData?: Record +): Promise { + // Load workflow (scoped by org for defense-in-depth) + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + + if (!wf || wf.status !== "enabled") { + log.warn("Workflow not found or not enabled", { workflowId }); + return; + } + + // Check reentry delay for completed executions (only when reentry not allowed) + if ( + !wf.allowReentry && + wf.reentryDelaySeconds && + wf.reentryDelaySeconds > 0 + ) { + const reentryCutoff = new Date(Date.now() - wf.reentryDelaySeconds * 1000); + const recentlyCompleted = await db.query.workflowExecution.findFirst({ + where: and( + eq(workflowExecution.workflowId, workflowId), + eq(workflowExecution.contactId, contactId), + eq(workflowExecution.status, "completed"), + sql`${workflowExecution.completedAt} > ${reentryCutoff}` + ), + }); + + if (recentlyCompleted) { + log.info("Workflow skip: reentry delay", { + contactId, + workflowId, + reentryDelaySeconds: wf.reentryDelaySeconds, + }); + await incrementDroppedExecutions(workflowId); + return; + } + } + + // Check contact cooldown (any workflow in this org) + if (wf.contactCooldownSeconds && wf.contactCooldownSeconds > 0) { + const cooldownCutoff = new Date( + Date.now() - wf.contactCooldownSeconds * 1000 + ); + const recentExecution = await db.query.workflowExecution.findFirst({ + where: and( + eq(workflowExecution.organizationId, organizationId), + eq(workflowExecution.contactId, contactId), + sql`${workflowExecution.createdAt} > ${cooldownCutoff}` + ), + }); + + if (recentExecution) { + log.info("Workflow skip: contact cooldown", { + contactId, + cooldownSeconds: wf.contactCooldownSeconds, + }); + await incrementDroppedExecutions(workflowId); + return; + } + } + + // Check maxConcurrentExecutions limit + if (wf.maxConcurrentExecutions && wf.maxConcurrentExecutions > 0) { + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(workflowExecution) + .where( + and( + eq(workflowExecution.workflowId, workflowId), + sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` + ) + ); + + if (count >= wf.maxConcurrentExecutions) { + log.info("Workflow skip: max concurrent", { + workflowId, + current: count, + max: wf.maxConcurrentExecutions, + }); + await incrementDroppedExecutions(workflowId); + return; + } + } + + // Find the trigger step to get the first connected step + const steps = wf.steps as AutomationStep[]; + const transitions = wf.transitions as AutomationTransition[]; + + const triggerStep = steps.find((s) => s.type === "trigger"); + if (!triggerStep) { + log.error("No trigger step found in workflow", undefined, { workflowId }); + return; + } + + // Find the first step after trigger + const firstTransition = transitions.find( + (t) => t.fromStepId === triggerStep.id + ); + const firstStepId = firstTransition?.toStepId; + + if (!firstStepId) { + log.warn("Workflow has no steps after trigger", { workflowId }); + return; + } + + // Snapshot the definition so in-flight executions are immune to edits + const definitionSnapshot: AutomationDefinitionSnapshot = { + steps, + transitions, + workflowVersion: wf.version, + }; + + // Create execution + update stats in a transaction to prevent counter drift + const execution = await db.transaction(async (tx) => { + // Uses ON CONFLICT DO NOTHING with partial unique index to prevent race conditions + // when allowReentry=false. The index only applies to active statuses. + const [row] = await tx + .insert(workflowExecution) + .values({ + workflowId, + contactId, + organizationId, + allowReentry: wf.allowReentry, // Denormalized for partial unique index + status: "active", + currentStepId: firstStepId, + definitionSnapshot, + triggerData: eventData ?? {}, + startedAt: new Date(), + }) + .onConflictDoNothing() + .returning(); + + if (!row) return null; + + await tx + .update(workflow) + .set({ + totalExecutions: sql`${workflow.totalExecutions} + 1`, + activeExecutions: sql`${workflow.activeExecutions} + 1`, + lastTriggeredAt: new Date(), + }) + .where(eq(workflow.id, workflowId)); + + return row; + }); + + // If no row returned, a conflict occurred (contact already in workflow) + if (!execution) { + log.info("Workflow skip: duplicate execution", { contactId, workflowId }); + await incrementDroppedExecutions(workflowId); + return; + } + + // Process first step + await enqueueAutomationStep({ + type: "execute", + executionId: execution.id, + stepId: firstStepId, + organizationId, + }); +} + +// Maximum contacts to process per schedule trigger +const MAX_CONTACTS_PER_TRIGGER = 1000; + +/** + * Process a schedule-trigger job. + * + * Fires when a one-time EventBridge Schedule goes off for a workflow. + * Loads the workflow, verifies it's still enabled, fans out trigger jobs + * to all matching contacts, then chains the next schedule. + */ +async function processScheduleTrigger( + workflowId: string, + organizationId: string +): Promise { + const now = new Date(); + + // Load workflow + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + + if (!wf) { + log.info("Schedule trigger: workflow not found, chain stops", { + workflowId, + }); + return; + } + + if (wf.status !== "enabled" || wf.triggerType !== "schedule") { + log.info("Schedule trigger: workflow not eligible, chain stops", { + workflowId, + status: wf.status, + triggerType: wf.triggerType, + }); + return; + } + + const config = wf.triggerConfig as TriggerConfig; + + if (!config.schedule) { + log.info("Schedule trigger: no cron schedule, chain stops", { workflowId }); + return; + } + + log.info("Schedule trigger: processing workflow", { + workflowId, + workflowName: wf.name, + }); + + // Get contacts to trigger for + let contacts: { id: string }[]; + + if (config.segmentId) { + contacts = await getSegmentContacts(config.segmentId, organizationId); + } else { + // Get all active contacts in the organization + contacts = await db + .select({ id: contact.id }) + .from(contact) + .where( + and( + eq(contact.organizationId, organizationId), + eq(contact.status, "active") + ) + ) + .limit(MAX_CONTACTS_PER_TRIGGER); + } + + log.info("Schedule trigger: triggering workflow for contacts", { + workflowId, + contactCount: contacts.length, + }); + + // Batch enqueue trigger jobs for all contacts + await enqueueAutomationStepBatch( + contacts.map((c) => ({ + type: "trigger" as const, + workflowId, + contactId: c.id, + organizationId, + eventData: { + triggerType: "schedule", + triggeredAt: now.toISOString(), + cronExpression: config.schedule, + }, + })) + ); + + // Update last triggered timestamp + await db + .update(workflow) + .set({ lastTriggeredAt: now }) + .where(eq(workflow.id, workflowId)); + + // Chain: create the next schedule + // Isolated in try/catch — failure must NOT propagate to SQS retry, + // which would duplicate the contact fan-out that already succeeded above. + try { + await createNextAutomationSchedule({ + workflowId, + organizationId, + cronExpression: config.schedule, + timezone: config.timezone, + }); + log.info("Schedule trigger: complete, next schedule chained", { + workflowId, + executionsTriggered: contacts.length, + }); + } catch (chainError) { + log.error( + "Schedule trigger: CHAIN BROKEN — failed to create next schedule", + chainError, + { + workflowId, + organizationId, + cronExpression: config.schedule, + chainBroken: true, + } + ); + // Do NOT re-throw. Contact fan-out and lastTriggeredAt already succeeded. + // The DLQ handler and reconciliation job will detect and repair broken chains. + } +} + +/** + * Get contacts that match a segment's filter criteria. + * Uses bulk evaluation (3 queries total) instead of per-contact evaluation. + */ +async function getSegmentContacts( + segmentId: string, + organizationId: string +): Promise<{ id: string }[]> { + // 1. Fetch segment condition + const [seg] = await db + .select({ condition: segment.condition }) + .from(segment) + .where(eq(segment.id, segmentId)) + .limit(1); + + if (!seg) { + log.warn("Schedule trigger: segment not found", { segmentId }); + return []; + } + + // 2. Get all active contacts in the organization + const allContacts = await db + .select({ id: contact.id }) + .from(contact) + .where( + and( + eq(contact.organizationId, organizationId), + eq(contact.status, "active") + ) + ) + .limit(MAX_CONTACTS_PER_TRIGGER); + + if (allContacts.length === 0) { + return []; + } + + log.info("Schedule trigger: evaluating segment", { + segmentId, + contactCount: allContacts.length, + }); + + // 3. SQL-based batch evaluation (1 query) + const matchingIds = await contactIdsMatchingCondition( + db, + allContacts.map((c) => c.id), + organizationId, + seg.condition + ); + + const matchingIdSet = new Set(matchingIds); + const matchingContacts = allContacts.filter((c) => matchingIdSet.has(c.id)); + + log.info("Schedule trigger: segment evaluation complete", { + segmentId, + matchingCount: matchingContacts.length, + }); + + return matchingContacts; +} + +/** + * Process a single workflow step + */ +async function processStep(executionId: string, stepId: string): Promise { + // Load execution with workflow and contact + const execution = await db.query.workflowExecution.findFirst({ + where: eq(workflowExecution.id, executionId), + }); + + if (!execution) { + log.error("Execution not found", undefined, { executionId }); + return; + } + + if (execution.status === "cancelled" || execution.status === "completed") { + log.info("Execution already completed", { + executionId, + status: execution.status, + }); + return; + } + + // Load workflow (scoped by org for defense-in-depth) + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, execution.workflowId), + eq(workflow.organizationId, execution.organizationId) + ) + ) + .limit(1); + + if (!wf) { + log.error("Workflow not found", undefined, { + workflowId: execution.workflowId, + }); + return; + } + + // Load contact (scoped by org for defense-in-depth) + const [contactRecord] = await db + .select() + .from(contact) + .where( + and( + eq(contact.id, execution.contactId), + eq(contact.organizationId, execution.organizationId) + ) + ) + .limit(1); + + if (!contactRecord) { + log.error("Contact not found", undefined, { + contactId: execution.contactId, + }); + await failExecution(executionId, "Contact not found", stepId); + return; + } + + // Use the frozen definition snapshot (immune to live edits) with + // fallback to the live definition for pre-snapshot executions + const snapshot = + execution.definitionSnapshot as AutomationDefinitionSnapshot | null; + const steps = snapshot?.steps ?? (wf.steps as AutomationStep[]); + const step = steps.find((s) => s.id === stepId); + + if (!step) { + log.error("Step not found in workflow", undefined, { stepId }); + await failExecution(executionId, `Step ${stepId} not found`, stepId); + return; + } + + // Atomic idempotency check and step execution creation + // Uses ON CONFLICT to prevent race conditions with duplicate SQS messages + const idempotencyKey = `${executionId}-${stepId}`; + + const [stepExec] = await db + .insert(workflowStepExecution) + .values({ + executionId, + stepId, + stepType: step.type, + status: "executing", + idempotencyKey, + startedAt: new Date(), + }) + .onConflictDoUpdate({ + target: workflowStepExecution.idempotencyKey, + set: { + // Only update if not already completed (prevents re-execution) + status: sql`CASE WHEN ${workflowStepExecution.status} = 'completed' THEN ${workflowStepExecution.status} ELSE 'executing' END`, + startedAt: sql`CASE WHEN ${workflowStepExecution.status} = 'completed' THEN ${workflowStepExecution.startedAt} ELSE ${new Date().toISOString()}::timestamp END`, + }, + }) + .returning(); + + // If step was already completed, skip execution + if (stepExec.status === "completed") { + log.info("Step already executed", { stepId, executionId }); + return; + } + + // Update execution current step + await db + .update(workflowExecution) + .set({ currentStepId: stepId, status: "active", updatedAt: new Date() }) + .where(eq(workflowExecution.id, executionId)); + + // Execute step based on type + try { + const result = await executeStep( + step, + execution, + contactRecord, + wf.organizationId + ); + + // Mark step as completed + await db + .update(workflowStepExecution) + .set({ + status: "completed", + branch: result.branch, + result: result.data, + completedAt: new Date(), + }) + .where(eq(workflowStepExecution.id, stepExec.id)); + + // Handle step result — use snapshot transitions for routing + const snapshotWf = snapshot + ? { ...wf, steps, transitions: snapshot.transitions } + : wf; + if (result.action === "next") { + await processNextStep(execution, step, snapshotWf, result.branch); + } else if (result.action === "wait") { + // Step is waiting (e.g., delay scheduled, waiting for event) + // Execution status already updated by the step handler + } else if (result.action === "exit") { + await completeExecution(executionId); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Step failed", error, { stepId, executionId }); + + await db + .update(workflowStepExecution) + .set({ + status: "failed", + error: errorMessage, + completedAt: new Date(), + }) + .where(eq(workflowStepExecution.id, stepExec.id)); + + await failExecution(executionId, errorMessage, stepId); + } +} + +/** + * Execute a single step and return the result + */ +type WorkflowBranch = + | "yes" + | "no" + | "timeout" + | "default" + | "opened" + | "clicked" + | "bounced"; + +async function executeStep( + step: AutomationStep, + execution: typeof workflowExecution.$inferSelect, + contactRecord: typeof contact.$inferSelect, + organizationId: string +): Promise<{ + action: "next" | "wait" | "exit"; + branch?: WorkflowBranch; + data?: Record; +}> { + const config = step.config; + + switch (config.type) { + case "trigger": + // Trigger is just an entry point, proceed to next + return { action: "next" }; + + case "send_email": + return await handleSendEmail( + config, + execution, + contactRecord, + organizationId + ); + + case "send_sms": + return await handleSendSms( + config, + execution, + contactRecord, + organizationId + ); + + case "delay": + return await handleDelay(config, execution, step.id, organizationId); + + case "condition": + return await handleCondition(config, contactRecord, execution, step); + + case "update_contact": + return await handleUpdateContact(config, contactRecord); + + case "webhook": + return await handleWebhook(config, contactRecord, execution); + + case "wait_for_event": + return await handleWaitForEvent( + config, + execution, + step.id, + organizationId + ); + + case "wait_for_email_engagement": + return await handleWaitForEmailEngagement( + config, + execution, + step, + organizationId + ); + + case "subscribe_topic": + return await handleSubscribeTopic(config, contactRecord); + + case "unsubscribe_topic": + return await handleUnsubscribeTopic(config, contactRecord); + + case "exit": + return { action: "exit" }; + + default: + throw new Error( + `Unknown step type: ${(config as { type: string }).type}` + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STEP HANDLERS +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleSendEmail( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + contactRecord: typeof contact.$inferSelect, + organizationId: string +): Promise<{ action: "next"; data: Record }> { + // Check contact has email + if (!contactRecord.email) { + log.info("Workflow: contact has no email, skipping", { + contactId: contactRecord.id, + }); + return { + action: "next", + data: { + skipped: true, + reason: "no_email", + timestamp: new Date().toISOString(), + }, + }; + } + + // Check contact email status + if ( + contactRecord.emailStatus === "unsubscribed" || + contactRecord.emailStatus === "bounced" || + contactRecord.emailStatus === "complained" + ) { + log.info("Workflow: contact email suppressed, skipping", { + contactId: contactRecord.id, + emailStatus: contactRecord.emailStatus, + }); + return { + action: "next", + data: { + skipped: true, + reason: `email_status_${contactRecord.emailStatus}`, + timestamp: new Date().toISOString(), + }, + }; + } + + // Get the workflow to find the AWS account and sender defaults (scoped by org) + const [wf] = await db + .select({ + awsAccountId: workflow.awsAccountId, + defaultFrom: workflow.defaultFrom, + defaultFromName: workflow.defaultFromName, + defaultReplyTo: workflow.defaultReplyTo, + }) + .from(workflow) + .where( + and( + eq(workflow.id, execution.workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + + if (!wf?.awsAccountId) { + log.warn("Workflow: no AWS account configured", { + workflowId: execution.workflowId, + }); + return { + action: "next", + data: { + skipped: true, + reason: "no_aws_account", + timestamp: new Date().toISOString(), + }, + }; + } + + // Get AWS account region + const [account] = await db + .select({ region: awsAccount.region }) + .from(awsAccount) + .where(eq(awsAccount.id, wf.awsAccountId)) + .limit(1); + + if (!account) { + throw new Error(`AWS account ${wf.awsAccountId} not found`); + } + + // Get template (scoped by org for defense-in-depth) + const [tmpl] = await db + .select({ + id: template.id, + name: template.name, + subject: template.subject, + compiledHtml: template.compiledHtml, + emailType: template.emailType, + sesTemplateName: template.sesTemplateName, + }) + .from(template) + .where( + and( + eq(template.id, config.templateId), + eq(template.organizationId, organizationId) + ) + ) + .limit(1); + + if (!tmpl) { + throw new Error(`Template ${config.templateId} not found`); + } + + if (!tmpl.compiledHtml) { + throw new Error(`Template ${config.templateId} has no compiled HTML`); + } + + // Get organization for name + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1); + + // Get credentials for customer's AWS account + const credentials = await getCredentials(wf.awsAccountId); + + // Create SES client + const sesClient = new SESv2Client({ + region: account.region, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }, + }); + + // Build variable replacement data + const replacementData: Record = { + email: contactRecord.email, + contactEmail: contactRecord.email, + }; + + const addIfPresent = (key: string, value: string | null | undefined) => { + if (value) { + replacementData[key] = value; + } + }; + + addIfPresent("firstName", contactRecord.firstName); + addIfPresent("lastName", contactRecord.lastName); + addIfPresent("company", contactRecord.company); + addIfPresent("jobTitle", contactRecord.jobTitle); + addIfPresent("contactFirstName", contactRecord.firstName); + addIfPresent("contactLastName", contactRecord.lastName); + addIfPresent("contactCompany", contactRecord.company); + addIfPresent("contactJobTitle", contactRecord.jobTitle); + addIfPresent("organizationName", org?.name); + + // Add contact properties + const properties = contactRecord.properties as Record | null; + if (properties) { + for (const [key, value] of Object.entries(properties)) { + const strValue = value != null ? String(value) : null; + if (strValue) { + replacementData[key] = strValue; + } + } + } + + // Add trigger data + const triggerData = execution.triggerData as Record | null; + if (triggerData) { + for (const [key, value] of Object.entries(triggerData)) { + const strValue = value != null ? String(value) : null; + if (strValue) { + replacementData[key] = strValue; + } + } + } + + // Generate unsubscribe URLs for marketing emails + const isMarketing = tmpl.emailType === "marketing"; + const apiBaseUrl = process.env.API_BASE_URL || "https://api.wraps.dev"; + const appBaseUrl = process.env.APP_BASE_URL || "https://app.wraps.dev"; + + let unsubscribeUrl: string | undefined; + let preferencesUrl: string | undefined; + + if (isMarketing) { + const unsubscribeToken = await generateUnsubscribeToken( + contactRecord.id, + organizationId + ); + unsubscribeUrl = `${apiBaseUrl}/unsubscribe/${unsubscribeToken}`; + preferencesUrl = `${appBaseUrl}/preferences/${unsubscribeToken}`; + replacementData.unsubscribeUrl = unsubscribeUrl; + replacementData.preferencesUrl = preferencesUrl; + } + + // Build from address (step config > workflow default > fallback) + const fromAddress = + config.from || + wf.defaultFrom || + `noreply@${process.env.DEFAULT_DOMAIN || "wraps.dev"}`; + const fromName = config.fromName || wf.defaultFromName; + const fromDisplay = fromName ? `${fromName} <${fromAddress}>` : fromAddress; + const replyTo = config.replyTo || wf.defaultReplyTo; + + // Build headers for marketing emails + const headers: Array<{ Name: string; Value: string }> = []; + if (isMarketing && unsubscribeUrl) { + headers.push( + { Name: "List-Unsubscribe", Value: `<${unsubscribeUrl}>` }, + { Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click" } + ); + } + + // Common email tags + const emailTags = [ + { Name: "workflowId", Value: execution.workflowId }, + { Name: "executionId", Value: execution.id }, + { Name: "organizationId", Value: organizationId }, + { Name: "templateId", Value: config.templateId }, + { Name: "source", Value: "automation" }, + ]; + + // Try to use SES template if available + let sesTemplateName = tmpl.sesTemplateName; + + // Auto-publish if not published to SES (requires compiledHtml) + if (!sesTemplateName && tmpl.compiledHtml) { + sesTemplateName = await autoPublishTemplate( + tmpl as { + id: string; + name: string; + subject: string | null; + compiledHtml: string; + }, + credentials, + account.region + ); + } + + let result: { MessageId?: string }; + let subject: string; + + if (sesTemplateName) { + // Use SES template - let SES handle variable substitution + // Transform subject for SES (handles both simple vars and fallbacks) + subject = sanitizeEmailSubject(tmpl.subject || "Message"); + + result = await sesClient.send( + new SendEmailCommand({ + FromEmailAddress: fromDisplay, + ReplyToAddresses: replyTo ? [replyTo] : undefined, + Destination: { + ToAddresses: [contactRecord.email], + }, + Content: { + Template: { + TemplateName: sesTemplateName, + TemplateData: JSON.stringify(replacementData), + Headers: headers.length > 0 ? headers : undefined, + }, + }, + ConfigurationSetName: "wraps-email-tracking", + EmailTags: emailTags, + }) + ); + + log.info("Workflow: email sent via SES template", { + template: sesTemplateName, + to: contactRecord.email, + }); + } else { + // Fallback: Apply variable substitution locally and send raw HTML + const html = substituteVariables(tmpl.compiledHtml, replacementData, { + escapeHtml: true, + }); + + // Build subject with variable substitution + const rawSubject = substituteVariables( + tmpl.subject || "Message", + replacementData + ); + subject = sanitizeEmailSubject(rawSubject); + + result = await sesClient.send( + new SendEmailCommand({ + FromEmailAddress: fromDisplay, + ReplyToAddresses: replyTo ? [replyTo] : undefined, + Destination: { + ToAddresses: [contactRecord.email], + }, + Content: { + Simple: { + Subject: { Data: subject }, + Body: { + Html: { Data: html }, + Text: { Data: htmlToPlainText(html) }, + }, + Headers: headers.length > 0 ? headers : undefined, + }, + }, + ConfigurationSetName: "wraps-email-tracking", + EmailTags: emailTags, + }) + ); + + log.info("Workflow: email sent via raw HTML", { to: contactRecord.email }); + } + + const messageId = result.MessageId ?? ""; + + // Record the send in messageSend table + // Note: workflowExecutionId is not yet in schema, will be added later + await db.insert(messageSend).values({ + organizationId, + contactId: contactRecord.id, + awsAccountId: wf.awsAccountId, + channel: "email", + sourceType: "workflow", + recipient: contactRecord.email, + subject, + from: fromAddress, + fromName: fromName || null, + emailTemplateId: config.templateId, + messageId, + status: "sent", + sentAt: new Date(), + }); + + // Track first email sent (must await in Lambda) + await trackFirstEmailSent(organizationId, { + channel: "email", + source: "workflow", + }); + + // Update contact email metrics + await db + .update(contact) + .set({ + lastEmailSentAt: new Date(), + emailsSent: sql`COALESCE(${contact.emailsSent}, 0) + 1`, + }) + .where(eq(contact.id, contactRecord.id)); + + return { + action: "next", + data: { + messageId, + templateId: config.templateId, + recipient: contactRecord.email, + subject, + timestamp: new Date().toISOString(), + }, + }; +} + +/** + * Substitute variables in text with values from a data object + * Uses Handlebars to properly evaluate conditional syntax like: + * {{#if contactFirstName}}{{contactFirstName}}{{else}}there{{/if}} + * + * This is needed because compiledHtml contains SES-compatible Handlebars syntax + * from transformVariablesForSes, and workflow sends use direct HTML (not SES templates). + * + * Handlebars automatically escapes HTML in {{variable}} expressions for safety. + * + * @exported for testing + */ +export function substituteVariables( + text: string, + data: Record, + _options: { escapeHtml?: boolean } = {} +): string { + try { + // Compile and execute the Handlebars template + const template = Handlebars.compile(text, { noEscape: false }); + return template(data); + } catch (error) { + // If Handlebars fails, fall back to simple regex replacement + log.warn("Workflow: Handlebars compilation failed, using fallback", { + error: String(error), + }); + return text.replace( + /\{\{\s*(?:contact\.)?([a-zA-Z0-9_]+)\s*\}\}/g, + (_match, key) => { + const value = data[key.trim()]; + return value ?? ""; + } + ); + } +} + +/** + * Sanitize email subject line + * - Removes newlines to prevent header injection + * - Collapses whitespace + * - Truncates to reasonable length (998 chars per RFC 2822) + */ +export function sanitizeEmailSubject(subject: string): string { + return subject + .replace(/[\r\n]+/g, " ") // Remove newlines (header injection prevention) + .replace(/\s+/g, " ") // Collapse whitespace + .trim() + .slice(0, 998); // RFC 2822 max line length +} + +/** + * Convert HTML to plain text for email fallback + * Uses react-email's toPlainText for robust HTML-to-text conversion + */ +function htmlToPlainText(html: string): string { + return toPlainText(html); +} + +/** + * Auto-publish a template to SES if not already published. + * Uses the existing compiledHtml from the template. + * Returns the SES template name if successful, or null if publishing fails. + */ +async function autoPublishTemplate( + tmpl: { + id: string; + name: string; + subject: string | null; + compiledHtml: string; + }, + credentials: { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }, + region: string +): Promise { + try { + // 1. Transform variables for SES compatibility + // compiledHtml already has {{contact.firstName}} format + // We need to transform to {{contactFirstName}} format for SES + // Also handles fallbacks: {{name|fallback}} → {{#if name}}{{name}}{{else}}fallback{{/if}} + const sesHtml = transformVariablesForSes(tmpl.compiledHtml); + const sesText = htmlToPlainText(sesHtml); + const sesSubject = transformVariablesForSes(tmpl.subject || "Message"); + + // 2. Generate template name and publish to SES + const sesTemplateName = generateSESTemplateName(tmpl.id, tmpl.name); + await upsertSESTemplate(credentials, region, { + templateName: sesTemplateName, + subject: sesSubject, + htmlPart: sesHtml, + textPart: sesText, + }); + + // 3. Update template in DB with SES template name + await db + .update(template) + .set({ + sesTemplateName, + publishedAt: new Date(), + }) + .where(eq(template.id, tmpl.id)); + + log.info("Workflow: auto-published SES template", { + templateId: tmpl.id, + sesTemplateName, + }); + return sesTemplateName; + } catch (error) { + log.error("Workflow: auto-publish failed", error); + return null; // Fall back to raw HTML + } +} + +/** + * Validate phone number is in E.164 format + * E.164: +[country code][subscriber number] (e.g., +15551234567) + */ +export function isValidE164Phone(phone: string): boolean { + // E.164 format: + followed by 10-15 digits + const e164Regex = /^\+[1-9]\d{9,14}$/; + return e164Regex.test(phone); +} + +async function handleSendSms( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + contactRecord: typeof contact.$inferSelect, + organizationId: string +): Promise<{ action: "next"; data: Record }> { + // Get the contact's phone number + if (!contactRecord.phone) { + log.info("Workflow: contact has no phone, skipping SMS", { + contactId: contactRecord.id, + }); + return { + action: "next", + data: { + skipped: true, + reason: "no_phone", + timestamp: new Date().toISOString(), + }, + }; + } + + // Validate phone number format (E.164) + if (!isValidE164Phone(contactRecord.phone)) { + log.warn("Workflow: invalid phone format", { + contactId: contactRecord.id, + phone: contactRecord.phone, + }); + return { + action: "next", + data: { + skipped: true, + reason: "invalid_phone_format", + phone: contactRecord.phone, + timestamp: new Date().toISOString(), + }, + }; + } + + // Get the workflow to find the AWS account and sender defaults (scoped by org) + const [wf] = await db + .select({ + awsAccountId: workflow.awsAccountId, + defaultSenderId: workflow.defaultSenderId, + }) + .from(workflow) + .where( + and( + eq(workflow.id, execution.workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + + if (!wf?.awsAccountId) { + log.warn("Workflow: no AWS account configured for SMS", { + workflowId: execution.workflowId, + }); + return { + action: "next", + data: { + skipped: true, + reason: "no_aws_account", + timestamp: new Date().toISOString(), + }, + }; + } + + // Get the AWS account region + const [account] = await db + .select({ region: awsAccount.region }) + .from(awsAccount) + .where(eq(awsAccount.id, wf.awsAccountId)) + .limit(1); + + if (!account) { + throw new Error(`AWS account ${wf.awsAccountId} not found`); + } + + // Get credentials for the customer's AWS account + const credentials = await getCredentials(wf.awsAccountId); + + // Create Pinpoint SMS Voice V2 client with assumed credentials + const smsClient = new PinpointSMSVoiceV2Client({ + region: account.region, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }, + }); + + // Build message body with variable substitution + const rawBody = config.body || ""; + if (!rawBody) { + log.warn("Workflow: SMS step has no message body"); + return { + action: "next", + data: { + skipped: true, + reason: "no_message_body", + timestamp: new Date().toISOString(), + }, + }; + } + + // Build replacement data (same pattern as handleSendEmail) + const replacementData: Record = {}; + + const addIfPresent = (key: string, value: string | null | undefined) => { + if (value) replacementData[key] = value; + }; + + addIfPresent("email", contactRecord.email); + addIfPresent("contactEmail", contactRecord.email); + addIfPresent("firstName", contactRecord.firstName); + addIfPresent("lastName", contactRecord.lastName); + addIfPresent("company", contactRecord.company); + addIfPresent("jobTitle", contactRecord.jobTitle); + addIfPresent("contactFirstName", contactRecord.firstName); + addIfPresent("contactLastName", contactRecord.lastName); + addIfPresent("contactCompany", contactRecord.company); + addIfPresent("contactJobTitle", contactRecord.jobTitle); + addIfPresent("phone", contactRecord.phone); + + // Add contact properties + const properties = contactRecord.properties as Record | null; + if (properties) { + for (const [key, value] of Object.entries(properties)) { + const strValue = value != null ? String(value) : null; + if (strValue) replacementData[key] = strValue; + } + } + + // Add trigger data + const triggerData = execution.triggerData as Record | null; + if (triggerData) { + for (const [key, value] of Object.entries(triggerData)) { + const strValue = value != null ? String(value) : null; + if (strValue) replacementData[key] = strValue; + } + } + + const normalizedBody = transformVariablesForSes(rawBody); + const messageBody = substituteVariables(normalizedBody, replacementData); + + // Build sender ID (step config > workflow default) + const senderId = config.senderId || wf.defaultSenderId; + + // Send SMS + const command = new SendTextMessageCommand({ + DestinationPhoneNumber: contactRecord.phone, + MessageBody: messageBody, + ConfigurationSetName: "wraps-sms-config", + MessageType: "TRANSACTIONAL", + ...(senderId && { OriginationIdentity: senderId }), + }); + + const response = await smsClient.send(command); + + log.info("Workflow: SMS sent", { + to: contactRecord.phone, + messageId: response.MessageId, + }); + + // Update contact SMS metrics + await db + .update(contact) + .set({ + lastSmsSentAt: new Date(), + smsSent: sql`COALESCE(${contact.smsSent}, 0) + 1`, + }) + .where(eq(contact.id, contactRecord.id)); + + return { + action: "next", + data: { + messageId: response.MessageId, + recipient: contactRecord.phone, + body: messageBody, + timestamp: new Date().toISOString(), + }, + }; +} + +async function handleDelay( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + stepId: string, + organizationId: string +): Promise<{ action: "wait" }> { + // Calculate delay in seconds + let delaySeconds = config.amount; + switch (config.unit) { + case "minutes": + delaySeconds *= 60; + break; + case "hours": + delaySeconds *= 3600; + break; + case "days": + delaySeconds *= 86_400; + break; + case "weeks": + delaySeconds *= 604_800; + break; + } + + // Use snapshot transitions (immune to live edits) with fallback for pre-snapshot executions + const snapshot = + execution.definitionSnapshot as AutomationDefinitionSnapshot | null; + let transitions: AutomationTransition[] | undefined; + + if (snapshot) { + transitions = snapshot.transitions; + } else { + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, execution.workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + transitions = wf?.transitions as AutomationTransition[] | undefined; + } + + const nextTransition = transitions?.find((t) => t.fromStepId === stepId); + + if (!nextTransition) { + // No next step - complete execution + await completeExecution(execution.id); + return { action: "wait" }; + } + + // Schedule the next step + const schedulerName = await scheduleAutomationStep({ + executionId: execution.id, + stepId: nextTransition.toStepId, + organizationId, + delaySeconds, + }); + + // Update execution status + await db + .update(workflowExecution) + .set({ + status: "paused", + nextStepScheduledAt: new Date(Date.now() + delaySeconds * 1000), + delaySchedulerName: schedulerName, + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, execution.id)); + + return { action: "wait" }; +} + +async function handleCondition( + config: Extract, + contactRecord: typeof contact.$inferSelect, + execution: typeof workflowExecution.$inferSelect, + step: AutomationStep +): Promise<{ action: "next"; branch: "yes" | "no" }> { + // Handle engagement.status — used by cascade condition steps to check + // whether the contact engaged with a previous email. The preceding + // wait_for_email_engagement step records its branch ("opened", "clicked", + // "bounced", or "timeout") on the step execution row. + if (config.field === CASCADE_ENGAGEMENT_FIELD) { + // Scope to the same cascade group to avoid picking up engagement results + // from a different cascade node in the same workflow execution. + // Cascade step IDs follow the pattern: ${cascadeGroupId}-cond-${i}, + // and wait steps are: ${cascadeGroupId}-wait-${i}. + const cascadeGroupId = step.cascadeGroupId; + const waitStepFilter = cascadeGroupId + ? sql`${workflowStepExecution.stepId} LIKE ${`${cascadeGroupId}-wait-%`}` + : undefined; + + const previousWaitStep = await db + .select({ branch: workflowStepExecution.branch }) + .from(workflowStepExecution) + .where( + and( + eq(workflowStepExecution.executionId, execution.id), + eq(workflowStepExecution.stepType, "wait_for_email_engagement"), + eq(workflowStepExecution.status, "completed"), + waitStepFilter + ) + ) + .orderBy(sql`${workflowStepExecution.completedAt} DESC`) + .limit(1); + + const engaged = + previousWaitStep[0]?.branch === "opened" || + previousWaitStep[0]?.branch === "clicked"; + + // The cascade expansion uses operator "equals" / value "true", + // so "true" === "true" when engaged, "false" !== "true" when not. + const fieldValue = String(engaged); + const conditionMet = evaluateCondition( + fieldValue, + config.operator, + config.value + ); + + return { + action: "next", + branch: conditionMet ? "yes" : "no", + }; + } + + // Get the field value from contact properties + const properties = contactRecord.properties as Record | null; + const triggerData = execution.triggerData as Record | null; + + // Strip "properties." prefix — the editor generates field values like + // "properties.plan" for custom properties, but the actual key in the + // properties object is just "plan". + const field = config.field.startsWith("properties.") + ? config.field.slice("properties.".length) + : config.field; + + // Try contact fields first, then contact.properties, then trigger data + let fieldValue: unknown; + if (field in contactRecord) { + fieldValue = contactRecord[field as keyof typeof contactRecord]; + } else if (properties && field in properties) { + fieldValue = properties[field]; + } else if (triggerData && field in triggerData) { + fieldValue = triggerData[field]; + } + + // Evaluate condition + const conditionMet = evaluateCondition( + fieldValue, + config.operator, + config.value + ); + + return { + action: "next", + branch: conditionMet ? "yes" : "no", + }; +} + +export function evaluateCondition( + fieldValue: unknown, + operator: string, + compareValue: unknown +): boolean { + const strFieldValue = String(fieldValue ?? ""); + const strCompareValue = String(compareValue ?? ""); + + switch (operator) { + case "equals": + return strFieldValue === strCompareValue; + case "not_equals": + return strFieldValue !== strCompareValue; + case "contains": + return strFieldValue.includes(strCompareValue); + case "not_contains": + return !strFieldValue.includes(strCompareValue); + case "starts_with": + return strFieldValue.startsWith(strCompareValue); + case "ends_with": + return strFieldValue.endsWith(strCompareValue); + case "greater_than": + return Number(fieldValue) > Number(compareValue); + case "less_than": + return Number(fieldValue) < Number(compareValue); + case "greater_than_or_equals": + return Number(fieldValue) >= Number(compareValue); + case "less_than_or_equals": + return Number(fieldValue) <= Number(compareValue); + case "is_true": + return ( + fieldValue === true || strFieldValue === "true" || strFieldValue === "1" + ); + case "is_false": + return ( + fieldValue === false || + fieldValue === null || + fieldValue === undefined || + strFieldValue === "false" || + strFieldValue === "0" || + strFieldValue === "" + ); + case "is_set": + return ( + fieldValue !== null && fieldValue !== undefined && fieldValue !== "" + ); + case "is_not_set": + return ( + fieldValue === null || fieldValue === undefined || fieldValue === "" + ); + default: + log.warn("Unknown condition operator", { operator }); + return false; + } +} + +const FIRST_CLASS_CONTACT_FIELDS = new Set([ + "preferredChannel", + "firstName", + "lastName", + "company", + "jobTitle", +]); + +export async function handleUpdateContact( + config: Extract, + contactRecord: typeof contact.$inferSelect +): Promise<{ action: "next"; data: Record }> { + const updates = config.updates || []; + const currentProperties = + (contactRecord.properties as Record) || {}; + const newProperties = { ...currentProperties }; + const directUpdates: Partial = {}; + + for (const update of updates) { + const isFirstClass = FIRST_CLASS_CONTACT_FIELDS.has(update.field); + + switch (update.operation) { + case "set": + if (isFirstClass) { + switch (update.field) { + case "preferredChannel": + directUpdates.preferredChannel = + update.value as PreferredChannel | null; + break; + case "firstName": + directUpdates.firstName = update.value as string | null; + break; + case "lastName": + directUpdates.lastName = update.value as string | null; + break; + case "company": + directUpdates.company = update.value as string | null; + break; + case "jobTitle": + directUpdates.jobTitle = update.value as string | null; + break; + } + } else { + newProperties[update.field] = update.value; + } + break; + case "unset": + if (isFirstClass) { + switch (update.field) { + case "preferredChannel": + directUpdates.preferredChannel = null; + break; + case "firstName": + directUpdates.firstName = null; + break; + case "lastName": + directUpdates.lastName = null; + break; + case "company": + directUpdates.company = null; + break; + case "jobTitle": + directUpdates.jobTitle = null; + break; + } + } else { + delete newProperties[update.field]; + } + break; + case "increment": + newProperties[update.field] = + (Number(newProperties[update.field]) || 0) + Number(update.value); + break; + case "decrement": + newProperties[update.field] = + (Number(newProperties[update.field]) || 0) - Number(update.value); + break; + case "append": { + const arr = Array.isArray(newProperties[update.field]) + ? newProperties[update.field] + : []; + (arr as unknown[]).push(update.value); + newProperties[update.field] = arr; + break; + } + case "remove": + if (Array.isArray(newProperties[update.field])) { + newProperties[update.field] = ( + newProperties[update.field] as unknown[] + ).filter((v) => v !== update.value); + } + break; + } + } + + await db + .update(contact) + .set({ + ...directUpdates, + properties: newProperties, + updatedAt: new Date(), + }) + .where( + and( + eq(contact.id, contactRecord.id), + eq(contact.organizationId, contactRecord.organizationId) + ) + ); + + return { + action: "next", + data: { updatedFields: updates.map((u) => u.field) }, + }; +} + +const BLOCKED_IPV4_RANGES = [ + { prefix: "127.", label: "loopback" }, + { prefix: "10.", label: "private (10/8)" }, + { prefix: "169.254.", label: "link-local/IMDS" }, + { prefix: "0.", label: "unspecified" }, +] as const; + +/** @exported for testing */ +export function isBlockedIp(ip: string): string | null { + // IPv4-mapped IPv6 (::ffff:1.2.3.4) — extract the IPv4 and re-check + if (ip.startsWith("::ffff:")) { + const v4 = ip.slice(7); + if (v4.includes(".")) return isBlockedIp(v4); + } + + for (const range of BLOCKED_IPV4_RANGES) { + if (ip.startsWith(range.prefix)) return range.label; + } + // 100.64.0.0/10 (Carrier-grade NAT / AWS VPC) + if (ip.startsWith("100.")) { + const second = Number.parseInt(ip.split(".")[1], 10); + if (second >= 64 && second <= 127) return "private (100.64/10 CGN)"; + } + // 172.16.0.0/12 + if (ip.startsWith("172.")) { + const second = Number.parseInt(ip.split(".")[1], 10); + if (second >= 16 && second <= 31) return "private (172.16/12)"; + } + // 192.168.0.0/16 + if (ip.startsWith("192.168.")) return "private (192.168/16)"; + // IPv6 + if (ip === "::1" || ip === "::") return "loopback"; + if (ip.startsWith("fe80:")) return "link-local"; + if (ip.startsWith("fd") || ip.startsWith("fc")) return "private (ULA)"; + return null; +} + +/** @exported for testing */ +export async function validateWebhookUrl(url: string): Promise { + const parsed = new URL(url); + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error(`Webhook URL must use http(s), got ${parsed.protocol}`); + } + + const dns = await import("node:dns/promises"); + const { address } = await dns.lookup(parsed.hostname); + const blockedReason = isBlockedIp(address); + if (blockedReason) { + throw new Error( + `Webhook URL resolves to blocked address (${blockedReason}): ${parsed.hostname} -> ${address}` + ); + } +} + +async function handleWebhook( + config: Extract, + contactRecord: typeof contact.$inferSelect, + execution: typeof workflowExecution.$inferSelect +): Promise<{ action: "next"; data: Record }> { + try { + await validateWebhookUrl(config.url); + } catch (error) { + log.error("Webhook SSRF blocked", error, { url: config.url }); + return { + action: "next", + data: { + error: error instanceof Error ? error.message : "Invalid webhook URL", + blocked: true, + }, + }; + } + + const body = { + contact: { + id: contactRecord.id, + email: contactRecord.email, + properties: contactRecord.properties, + }, + execution: { + id: execution.id, + workflowId: execution.workflowId, + triggerData: execution.triggerData, + }, + ...(config.body || {}), + }; + + try { + const response = await fetch(config.url, { + method: config.method, + headers: { + "Content-Type": "application/json", + ...(config.headers || {}), + }, + body: config.method !== "GET" ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(10_000), + }); + + return { + action: "next", + data: { + status: response.status, + ok: response.ok, + }, + }; + } catch (error) { + log.error("Webhook failed", error); + return { + action: "next", + data: { + error: error instanceof Error ? error.message : "Webhook failed", + }, + }; + } +} + +async function handleWaitForEvent( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + stepId: string, + organizationId: string +): Promise<{ action: "wait" }> { + const timeoutSeconds = config.timeoutSeconds || 86_400; // Default 24 hours + const timeoutAt = new Date(Date.now() + timeoutSeconds * 1000); + + // Schedule timeout + const schedulerName = await scheduleWaitTimeout({ + executionId: execution.id, + stepId, + organizationId, + timeoutSeconds, + }); + + // Update execution to waiting state + await db + .update(workflowExecution) + .set({ + status: "waiting", + waitingForEvent: config.eventName, + waitTimeoutAt: timeoutAt, + waitTimeoutSchedulerName: schedulerName, + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, execution.id)); + + return { action: "wait" }; +} + +export async function handleWaitForEmailEngagement( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + step: AutomationStep, + organizationId: string +): Promise<{ action: "wait" }> { + const timeoutSeconds = config.timeoutSeconds || 259_200; // Default 3 days + const timeoutAt = new Date(Date.now() + timeoutSeconds * 1000); + + // Scope to cascade group if applicable, so we match the correct email + const cascadeGroupId = step.cascadeGroupId; + const sendStepFilter = cascadeGroupId + ? sql`${workflowStepExecution.stepId} LIKE ${`${cascadeGroupId}-send-%`}` + : undefined; + + // Find the previous send_email step execution to get the message ID + const previousStepExecs = await db + .select() + .from(workflowStepExecution) + .where( + and( + eq(workflowStepExecution.executionId, execution.id), + eq(workflowStepExecution.stepType, "send_email"), + eq(workflowStepExecution.status, "completed"), + sendStepFilter + ) + ) + .orderBy(sql`${workflowStepExecution.completedAt} DESC`) + .limit(1); + + const lastEmailStep = previousStepExecs[0]; + const messageId = lastEmailStep?.result + ? (lastEmailStep.result as Record).messageId + : undefined; + + // Schedule timeout + const schedulerName = await scheduleWaitTimeout({ + executionId: execution.id, + stepId: step.id, + organizationId, + timeoutSeconds, + }); + + // Update execution to waiting state + // We use 'email_engagement' as a special event name prefix + await db + .update(workflowExecution) + .set({ + status: "waiting", + waitingForEvent: `email_engagement:${messageId || "unknown"}`, + waitTimeoutAt: timeoutAt, + waitTimeoutSchedulerName: schedulerName, + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, execution.id)); + + return { action: "wait" }; +} + +async function handleSubscribeTopic( + config: Extract, + contactRecord: typeof contact.$inferSelect +): Promise<{ action: "next"; data: Record }> { + // Upsert contact-topic subscription + await db + .insert(contactTopic) + .values({ + contactId: contactRecord.id, + topicId: config.topicId, + status: "subscribed", + subscribedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [contactTopic.contactId, contactTopic.topicId], + set: { + status: "subscribed", + subscribedAt: new Date(), + unsubscribedAt: null, + }, + }); + + return { + action: "next", + data: { + topicId: config.topicId, + channel: config.channel, + action: "subscribed", + }, + }; +} + +async function handleUnsubscribeTopic( + config: Extract, + contactRecord: typeof contact.$inferSelect +): Promise<{ action: "next"; data: Record }> { + // Update subscription to unsubscribe + await db + .update(contactTopic) + .set({ + status: "unsubscribed", + unsubscribedAt: new Date(), + }) + .where( + and( + eq(contactTopic.contactId, contactRecord.id), + eq(contactTopic.topicId, config.topicId) + ) + ); + + return { + action: "next", + data: { + topicId: config.topicId, + channel: config.channel, + action: "unsubscribed", + }, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// EXECUTION FLOW HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Process the next step in the workflow + */ +async function processNextStep( + execution: typeof workflowExecution.$inferSelect, + currentStep: AutomationStep, + wf: typeof workflow.$inferSelect, + branch?: WorkflowBranch +): Promise { + const transitions = wf.transitions as AutomationTransition[]; + + // Find matching transition + let nextTransition: AutomationTransition | undefined; + + if (branch) { + // Look for transition with matching branch + nextTransition = transitions.find( + (t) => t.fromStepId === currentStep.id && t.condition?.branch === branch + ); + } + + // Fallback to branchless transition only when no specific branch was requested. + // When a branch IS specified (e.g., condition "yes"/"no"), falling back to a + // branchless transition would incorrectly route through an unrelated path. + if (!(nextTransition || branch)) { + nextTransition = transitions.find( + (t) => t.fromStepId === currentStep.id && !t.condition + ); + } + + if (!nextTransition) { + // No next step - complete execution + await completeExecution(execution.id); + return; + } + + // Enqueue next step for processing + await enqueueAutomationStep({ + type: "execute", + executionId: execution.id, + stepId: nextTransition.toStepId, + organizationId: wf.organizationId, + }); +} + +/** + * Resume a paused/waiting execution. + * + * Uses an atomic UPDATE … WHERE status='waiting' RETURNING * to claim the + * execution. If another handler (engagement webhook vs timeout scheduler) + * already claimed it, Postgres returns zero rows and we bail out — no + * duplicate emails, no corrupted state. + */ +async function resumeExecution( + executionId: string, + branch: WorkflowBranch +): Promise { + // Atomic claim: only one caller can transition waiting → active + const [claimed] = await db + .update(workflowExecution) + .set({ + status: "active", + waitingForEvent: null, + waitTimeoutAt: null, + // Keep waitTimeoutSchedulerName so RETURNING gives us the old value + // for cancellation below. Stale name on an active execution is harmless. + delaySchedulerName: null, + updatedAt: new Date(), + }) + .where( + and( + eq(workflowExecution.id, executionId), + eq(workflowExecution.status, "waiting") + ) + ) + .returning(); + + if (!claimed) { + log.info("Execution already claimed by another handler", { + executionId, + branch, + }); + return; + } + + // Cancel the timeout scheduler if we were resumed by an engagement event + if (branch !== "timeout" && claimed.waitTimeoutSchedulerName) { + await deleteScheduledStep(claimed.waitTimeoutSchedulerName); + } + + // Load workflow for infrastructure config (awsAccountId, sender defaults) + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, claimed.workflowId), + eq(workflow.organizationId, claimed.organizationId) + ) + ) + .limit(1); + + if (!wf) { + log.error("Workflow not found", undefined, { + workflowId: claimed.workflowId, + }); + await failExecution( + executionId, + "Workflow not found", + claimed.currentStepId ?? "unknown" + ); + return; + } + + // Use snapshot (immune to live edits) with fallback for pre-snapshot executions + const snapshot = + claimed.definitionSnapshot as AutomationDefinitionSnapshot | null; + const steps = snapshot?.steps ?? (wf.steps as AutomationStep[]); + const currentStep = steps.find((s) => s.id === claimed.currentStepId); + + if (!currentStep) { + log.error("Current step not found", undefined, { + stepId: claimed.currentStepId, + }); + await failExecution( + executionId, + `Step ${claimed.currentStepId} not found`, + claimed.currentStepId ?? "unknown" + ); + return; + } + + // Record step completion with branch + // Note: wait steps are already marked "completed" (with branch=null) when the + // wait state is entered. This UPDATE overwrites the branch with the actual + // resume reason. The atomic claim above is the real race-condition gate. + await db + .update(workflowStepExecution) + .set({ + status: "completed", + branch, + completedAt: new Date(), + }) + .where( + and( + eq(workflowStepExecution.executionId, executionId), + eq(workflowStepExecution.stepId, currentStep.id) + ) + ); + + // Process next step based on branch — use snapshot transitions for routing + const snapshotWf = snapshot + ? { ...wf, steps, transitions: snapshot.transitions } + : wf; + await processNextStep(claimed, currentStep, snapshotWf, branch); +} + +/** + * Mark execution as completed + */ +async function completeExecution(executionId: string): Promise { + await db.transaction(async (tx) => { + const [execution] = await tx + .update(workflowExecution) + .set({ + status: "completed", + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, executionId)) + .returning(); + + if (execution) { + await tx + .update(workflow) + .set({ + activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, + completedExecutions: sql`${workflow.completedExecutions} + 1`, + }) + .where(eq(workflow.id, execution.workflowId)); + } + }); +} + +/** + * Increment the dropped executions counter on a workflow + */ +async function incrementDroppedExecutions(workflowId: string): Promise { + await db + .update(workflow) + .set({ + droppedExecutions: sql`${workflow.droppedExecutions} + 1`, + }) + .where(eq(workflow.id, workflowId)); +} + +/** + * Mark execution as failed + */ +async function failExecution( + executionId: string, + error: string, + stepId: string +): Promise { + await db.transaction(async (tx) => { + const [execution] = await tx + .update(workflowExecution) + .set({ + status: "failed", + error, + errorStepId: stepId, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, executionId)) + .returning(); + + if (execution) { + await tx + .update(workflow) + .set({ + activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, + failedExecutions: sql`${workflow.failedExecutions} + 1`, + }) + .where(eq(workflow.id, execution.workflowId)); + } + }); +} diff --git a/apps/api/src/(ee)/workers/automation-stats.ts b/apps/api/src/(ee)/workers/automation-stats.ts new file mode 100644 index 000000000..9e5283734 --- /dev/null +++ b/apps/api/src/(ee)/workers/automation-stats.ts @@ -0,0 +1,100 @@ +import { db, eq, workflow, workflowExecution } from "@wraps/db"; +import { sql } from "drizzle-orm"; + +export type ReconcileResult = { + workflowId: string; + before: { + totalExecutions: number; + activeExecutions: number; + completedExecutions: number; + failedExecutions: number; + }; + actual: { + totalExecutions: number; + activeExecutions: number; + completedExecutions: number; + failedExecutions: number; + }; + drifted: boolean; +}; + +const ACTIVE_STATUSES = new Set(["active", "pending", "paused", "waiting"]); + +export async function reconcileWorkflowStats( + workflowId: string, + options?: { fix?: boolean } +): Promise { + // Load current denormalized stats + const [wf] = await db + .select({ + id: workflow.id, + totalExecutions: workflow.totalExecutions, + activeExecutions: workflow.activeExecutions, + completedExecutions: workflow.completedExecutions, + failedExecutions: workflow.failedExecutions, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)); + + // Count actual executions grouped by status + const counts = await db + .select({ + status: workflowExecution.status, + count: sql`count(*)::int`, + }) + .from(workflowExecution) + .where(eq(workflowExecution.workflowId, workflowId)) + .groupBy(workflowExecution.status); + + // Compute actual totals + let totalExecutions = 0; + let activeExecutions = 0; + let completedExecutions = 0; + let failedExecutions = 0; + + for (const row of counts) { + totalExecutions += row.count; + if (ACTIVE_STATUSES.has(row.status)) { + activeExecutions += row.count; + } else if (row.status === "completed") { + completedExecutions = row.count; + } else if (row.status === "failed") { + failedExecutions = row.count; + } + // "cancelled" counts toward total but not active/completed/failed + } + + const before = { + totalExecutions: wf.totalExecutions, + activeExecutions: wf.activeExecutions, + completedExecutions: wf.completedExecutions, + failedExecutions: wf.failedExecutions, + }; + + const actual = { + totalExecutions, + activeExecutions, + completedExecutions, + failedExecutions, + }; + + const drifted = + before.totalExecutions !== actual.totalExecutions || + before.activeExecutions !== actual.activeExecutions || + before.completedExecutions !== actual.completedExecutions || + before.failedExecutions !== actual.failedExecutions; + + if (drifted && options?.fix) { + await db + .update(workflow) + .set({ + totalExecutions: actual.totalExecutions, + activeExecutions: actual.activeExecutions, + completedExecutions: actual.completedExecutions, + failedExecutions: actual.failedExecutions, + }) + .where(eq(workflow.id, workflowId)); + } + + return { workflowId, before, actual, drifted }; +} diff --git a/apps/api/src/(ee)/workers/workflow-dlq-consumer.ts b/apps/api/src/(ee)/workers/workflow-dlq-consumer.ts index 7c40b2224..fb1521bc3 100644 --- a/apps/api/src/(ee)/workers/workflow-dlq-consumer.ts +++ b/apps/api/src/(ee)/workers/workflow-dlq-consumer.ts @@ -1,227 +1,5 @@ /** - * Workflow DLQ Consumer - * - * Processes messages that failed 3 SQS retries and landed in the dead-letter - * queue. Marks affected workflow executions as "failed" in the database so - * they are visible in the dashboard instead of silently expiring. - * - * IMPORTANT: This handler must never throw. A throw from a DLQ consumer - * causes pointless SQS retries with no DLQ-of-DLQ to catch them. + * @deprecated Import from `./automation-dlq-consumer` instead. + * This file is a backward-compatibility shim. */ - -import { - db, - eq, - type TriggerConfig, - workflow, - workflowExecution, -} from "@wraps/db"; -import type { SQSEvent, SQSHandler } from "aws-lambda"; -import { and, sql } from "drizzle-orm"; - -import { log } from "../../lib/logger"; -import type { WorkflowJob } from "../../services/workflow-queue"; -import { createNextWorkflowSchedule } from "../../services/workflow-scheduler"; - -const TERMINAL_STATUSES = new Set(["completed", "cancelled", "failed"]); - -export const handler: SQSHandler = async (event: SQSEvent) => { - for (const record of event.Records) { - try { - const job: WorkflowJob = JSON.parse(record.body); - - log.warn("DLQ: processing failed job", { - type: job.type, - messageId: record.messageId, - receiveCount: record.attributes.ApproximateReceiveCount, - }); - - switch (job.type) { - case "execute": - await handleExecute(job); - break; - case "resume": - await handleResume(job); - break; - case "trigger": - await handleTrigger(job); - break; - case "schedule-trigger": - await handleScheduleTrigger(job); - break; - } - } catch (error) { - // Never throw from a DLQ consumer - log.error("DLQ: failed to process record", error, { - messageId: record.messageId, - body: record.body.slice(0, 500), - }); - } - } -}; - -async function handleExecute(job: Extract) { - await failExecution( - job.executionId, - `Step ${job.stepId} failed after SQS retries exhausted`, - job.stepId - ); -} - -async function handleResume(job: Extract) { - // Load execution to get currentStepId - const execution = await db - .select({ - id: workflowExecution.id, - status: workflowExecution.status, - currentStepId: workflowExecution.currentStepId, - }) - .from(workflowExecution) - .where(eq(workflowExecution.id, job.executionId)) - .limit(1); - - if (!execution[0]) { - log.warn("DLQ: resume — execution not found", { - executionId: job.executionId, - }); - return; - } - - if (TERMINAL_STATUSES.has(execution[0].status)) { - log.info("DLQ: resume — execution already terminal", { - executionId: job.executionId, - status: execution[0].status, - }); - return; - } - - await failExecution( - job.executionId, - `Resume (${job.branch}) failed after SQS retries exhausted`, - execution[0].currentStepId ?? "unknown" - ); -} - -async function handleTrigger(job: Extract) { - // Check if an execution was created before the failure - const executions = await db - .select({ - id: workflowExecution.id, - status: workflowExecution.status, - }) - .from(workflowExecution) - .where( - and( - eq(workflowExecution.workflowId, job.workflowId), - eq(workflowExecution.contactId, job.contactId), - sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` - ) - ) - .limit(1); - - if (executions[0]) { - await failExecution( - executions[0].id, - "Trigger failed after SQS retries exhausted", - "trigger" - ); - return; - } - - log.warn("DLQ: trigger — no active execution found, nothing to fail", { - workflowId: job.workflowId, - contactId: job.contactId, - }); -} - -async function handleScheduleTrigger( - job: Extract -) { - const [wf] = await db - .select({ - id: workflow.id, - organizationId: workflow.organizationId, - status: workflow.status, - triggerType: workflow.triggerType, - triggerConfig: workflow.triggerConfig, - }) - .from(workflow) - .where(eq(workflow.id, job.workflowId)) - .limit(1); - - if (!wf || wf.status !== "enabled" || wf.triggerType !== "schedule") { - log.warn("DLQ: schedule-trigger — workflow not eligible for chain repair", { - workflowId: job.workflowId, - status: wf?.status, - triggerType: wf?.triggerType, - }); - return; - } - - const config = wf.triggerConfig as TriggerConfig; - if (!config.schedule) { - log.warn("DLQ: schedule-trigger — no cron expression", { - workflowId: job.workflowId, - }); - return; - } - - try { - await createNextWorkflowSchedule({ - workflowId: wf.id, - organizationId: wf.organizationId, - cronExpression: config.schedule, - timezone: config.timezone, - }); - log.info("DLQ: schedule-trigger — chain repaired", { - workflowId: wf.id, - }); - } catch (error) { - log.error("DLQ: schedule-trigger — chain repair failed", error, { - workflowId: wf.id, - }); - } -} - -/** - * Mark an execution as failed and update workflow counters. - * - * Duplicated from workflow-processor to avoid pulling in SES/Pinpoint/Handlebars - * transitive dependencies into this lightweight Lambda. - */ -async function failExecution( - executionId: string, - error: string, - stepId: string -): Promise { - const [execution] = await db - .update(workflowExecution) - .set({ - status: "failed", - error, - errorStepId: stepId, - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, executionId)) - .returning(); - - if (execution) { - await db - .update(workflow) - .set({ - activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, - failedExecutions: sql`${workflow.failedExecutions} + 1`, - }) - .where(eq(workflow.id, execution.workflowId)); - - log.warn("DLQ: execution marked as failed", { - executionId, - workflowId: execution.workflowId, - error, - stepId, - }); - } else { - log.warn("DLQ: failExecution returned no rows", { executionId }); - } -} +export * from "./automation-dlq-consumer"; diff --git a/apps/api/src/(ee)/workers/workflow-processor.ts b/apps/api/src/(ee)/workers/workflow-processor.ts index d0080d5cb..cc0a862ac 100644 --- a/apps/api/src/(ee)/workers/workflow-processor.ts +++ b/apps/api/src/(ee)/workers/workflow-processor.ts @@ -1,2212 +1,5 @@ /** - * Workflow Processor Worker - * - * SQS Lambda handler that processes workflow step executions. - * Handles different step types and routes to next steps. + * @deprecated Import from `./automation-processor` instead. + * This file is a backward-compatibility shim. */ - -import { - PinpointSMSVoiceV2Client, - SendTextMessageCommand, -} from "@aws-sdk/client-pinpoint-sms-voice-v2"; -import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; -import { toPlainText } from "@react-email/render"; -import { - awsAccount, - CASCADE_ENGAGEMENT_FIELD, - contact, - contactIdsMatchingCondition, - contactTopic, - db, - eq, - messageSend, - organization, - type PreferredChannel, - segment, - type TriggerConfig, - template, - type WorkflowDefinitionSnapshot, - type WorkflowStep, - type WorkflowStepConfig, - type WorkflowTransition, - workflow, - workflowExecution, - workflowStepExecution, -} from "@wraps/db"; -import { - generateSESTemplateName, - transformVariablesForSes, - upsertSESTemplate, -} from "@wraps/email"; -import type { SQSBatchResponse, SQSEvent } from "aws-lambda"; -import { and, sql } from "drizzle-orm"; -import Handlebars from "handlebars"; - -import { trackFirstEmailSent } from "../../lib/activation-tracking"; -import { log } from "../../lib/logger"; -import { generateUnsubscribeToken } from "../../lib/unsubscribe-token"; - -import { getCredentials } from "../../services/credentials"; -import { - deleteScheduledStep, - enqueueWorkflowStep, - enqueueWorkflowStepBatch, - scheduleWaitTimeout, - scheduleWorkflowStep, - type WorkflowJob, -} from "../../services/workflow-queue"; -import { createNextWorkflowSchedule } from "../../services/workflow-scheduler"; - -export const handler = async (event: SQSEvent): Promise => { - const results = await Promise.allSettled( - event.Records.map(async (record) => { - const job: WorkflowJob = JSON.parse(record.body); - - switch (job.type) { - case "execute": - await processStep(job.executionId, job.stepId); - break; - case "resume": - await resumeExecution(job.executionId, job.branch); - break; - case "trigger": - await triggerWorkflow( - job.workflowId, - job.contactId, - job.organizationId, - job.eventData - ); - break; - case "schedule-trigger": - await processScheduleTrigger(job.workflowId, job.organizationId); - break; - } - }) - ); - - const batchItemFailures = results - .map((result, idx) => { - if (result.status === "rejected") { - log.error("Error processing workflow job", result.reason); - return { itemIdentifier: event.Records[idx].messageId }; - } - return null; - }) - .filter((f): f is { itemIdentifier: string } => f !== null); - - return { batchItemFailures }; -}; - -/** - * Trigger a new workflow execution for a contact - */ -async function triggerWorkflow( - workflowId: string, - contactId: string, - organizationId: string, - eventData?: Record -): Promise { - // Load workflow (scoped by org for defense-in-depth) - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - if (!wf || wf.status !== "enabled") { - log.warn("Workflow not found or not enabled", { workflowId }); - return; - } - - // Check reentry delay for completed executions (only when reentry not allowed) - if ( - !wf.allowReentry && - wf.reentryDelaySeconds && - wf.reentryDelaySeconds > 0 - ) { - const reentryCutoff = new Date(Date.now() - wf.reentryDelaySeconds * 1000); - const recentlyCompleted = await db.query.workflowExecution.findFirst({ - where: and( - eq(workflowExecution.workflowId, workflowId), - eq(workflowExecution.contactId, contactId), - eq(workflowExecution.status, "completed"), - sql`${workflowExecution.completedAt} > ${reentryCutoff}` - ), - }); - - if (recentlyCompleted) { - log.info("Workflow skip: reentry delay", { - contactId, - workflowId, - reentryDelaySeconds: wf.reentryDelaySeconds, - }); - await incrementDroppedExecutions(workflowId); - return; - } - } - - // Check contact cooldown (any workflow in this org) - if (wf.contactCooldownSeconds && wf.contactCooldownSeconds > 0) { - const cooldownCutoff = new Date( - Date.now() - wf.contactCooldownSeconds * 1000 - ); - const recentExecution = await db.query.workflowExecution.findFirst({ - where: and( - eq(workflowExecution.organizationId, organizationId), - eq(workflowExecution.contactId, contactId), - sql`${workflowExecution.createdAt} > ${cooldownCutoff}` - ), - }); - - if (recentExecution) { - log.info("Workflow skip: contact cooldown", { - contactId, - cooldownSeconds: wf.contactCooldownSeconds, - }); - await incrementDroppedExecutions(workflowId); - return; - } - } - - // Check maxConcurrentExecutions limit - if (wf.maxConcurrentExecutions && wf.maxConcurrentExecutions > 0) { - const [{ count }] = await db - .select({ count: sql`count(*)::int` }) - .from(workflowExecution) - .where( - and( - eq(workflowExecution.workflowId, workflowId), - sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` - ) - ); - - if (count >= wf.maxConcurrentExecutions) { - log.info("Workflow skip: max concurrent", { - workflowId, - current: count, - max: wf.maxConcurrentExecutions, - }); - await incrementDroppedExecutions(workflowId); - return; - } - } - - // Find the trigger step to get the first connected step - const steps = wf.steps as WorkflowStep[]; - const transitions = wf.transitions as WorkflowTransition[]; - - const triggerStep = steps.find((s) => s.type === "trigger"); - if (!triggerStep) { - log.error("No trigger step found in workflow", undefined, { workflowId }); - return; - } - - // Find the first step after trigger - const firstTransition = transitions.find( - (t) => t.fromStepId === triggerStep.id - ); - const firstStepId = firstTransition?.toStepId; - - if (!firstStepId) { - log.warn("Workflow has no steps after trigger", { workflowId }); - return; - } - - // Snapshot the definition so in-flight executions are immune to edits - const definitionSnapshot: WorkflowDefinitionSnapshot = { - steps, - transitions, - workflowVersion: wf.version, - }; - - // Create execution + update stats in a transaction to prevent counter drift - const execution = await db.transaction(async (tx) => { - // Uses ON CONFLICT DO NOTHING with partial unique index to prevent race conditions - // when allowReentry=false. The index only applies to active statuses. - const [row] = await tx - .insert(workflowExecution) - .values({ - workflowId, - contactId, - organizationId, - allowReentry: wf.allowReentry, // Denormalized for partial unique index - status: "active", - currentStepId: firstStepId, - definitionSnapshot, - triggerData: eventData ?? {}, - startedAt: new Date(), - }) - .onConflictDoNothing() - .returning(); - - if (!row) return null; - - await tx - .update(workflow) - .set({ - totalExecutions: sql`${workflow.totalExecutions} + 1`, - activeExecutions: sql`${workflow.activeExecutions} + 1`, - lastTriggeredAt: new Date(), - }) - .where(eq(workflow.id, workflowId)); - - return row; - }); - - // If no row returned, a conflict occurred (contact already in workflow) - if (!execution) { - log.info("Workflow skip: duplicate execution", { contactId, workflowId }); - await incrementDroppedExecutions(workflowId); - return; - } - - // Process first step - await enqueueWorkflowStep({ - type: "execute", - executionId: execution.id, - stepId: firstStepId, - organizationId, - }); -} - -// Maximum contacts to process per schedule trigger -const MAX_CONTACTS_PER_TRIGGER = 1000; - -/** - * Process a schedule-trigger job. - * - * Fires when a one-time EventBridge Schedule goes off for a workflow. - * Loads the workflow, verifies it's still enabled, fans out trigger jobs - * to all matching contacts, then chains the next schedule. - */ -async function processScheduleTrigger( - workflowId: string, - organizationId: string -): Promise { - const now = new Date(); - - // Load workflow - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - if (!wf) { - log.info("Schedule trigger: workflow not found, chain stops", { - workflowId, - }); - return; - } - - if (wf.status !== "enabled" || wf.triggerType !== "schedule") { - log.info("Schedule trigger: workflow not eligible, chain stops", { - workflowId, - status: wf.status, - triggerType: wf.triggerType, - }); - return; - } - - const config = wf.triggerConfig as TriggerConfig; - - if (!config.schedule) { - log.info("Schedule trigger: no cron schedule, chain stops", { workflowId }); - return; - } - - log.info("Schedule trigger: processing workflow", { - workflowId, - workflowName: wf.name, - }); - - // Get contacts to trigger for - let contacts: { id: string }[]; - - if (config.segmentId) { - contacts = await getSegmentContacts(config.segmentId, organizationId); - } else { - // Get all active contacts in the organization - contacts = await db - .select({ id: contact.id }) - .from(contact) - .where( - and( - eq(contact.organizationId, organizationId), - eq(contact.status, "active") - ) - ) - .limit(MAX_CONTACTS_PER_TRIGGER); - } - - log.info("Schedule trigger: triggering workflow for contacts", { - workflowId, - contactCount: contacts.length, - }); - - // Batch enqueue trigger jobs for all contacts - await enqueueWorkflowStepBatch( - contacts.map((c) => ({ - type: "trigger" as const, - workflowId, - contactId: c.id, - organizationId, - eventData: { - triggerType: "schedule", - triggeredAt: now.toISOString(), - cronExpression: config.schedule, - }, - })) - ); - - // Update last triggered timestamp - await db - .update(workflow) - .set({ lastTriggeredAt: now }) - .where(eq(workflow.id, workflowId)); - - // Chain: create the next schedule - // Isolated in try/catch — failure must NOT propagate to SQS retry, - // which would duplicate the contact fan-out that already succeeded above. - try { - await createNextWorkflowSchedule({ - workflowId, - organizationId, - cronExpression: config.schedule, - timezone: config.timezone, - }); - log.info("Schedule trigger: complete, next schedule chained", { - workflowId, - executionsTriggered: contacts.length, - }); - } catch (chainError) { - log.error( - "Schedule trigger: CHAIN BROKEN — failed to create next schedule", - chainError, - { - workflowId, - organizationId, - cronExpression: config.schedule, - chainBroken: true, - } - ); - // Do NOT re-throw. Contact fan-out and lastTriggeredAt already succeeded. - // The DLQ handler and reconciliation job will detect and repair broken chains. - } -} - -/** - * Get contacts that match a segment's filter criteria. - * Uses bulk evaluation (3 queries total) instead of per-contact evaluation. - */ -async function getSegmentContacts( - segmentId: string, - organizationId: string -): Promise<{ id: string }[]> { - // 1. Fetch segment condition - const [seg] = await db - .select({ condition: segment.condition }) - .from(segment) - .where(eq(segment.id, segmentId)) - .limit(1); - - if (!seg) { - log.warn("Schedule trigger: segment not found", { segmentId }); - return []; - } - - // 2. Get all active contacts in the organization - const allContacts = await db - .select({ id: contact.id }) - .from(contact) - .where( - and( - eq(contact.organizationId, organizationId), - eq(contact.status, "active") - ) - ) - .limit(MAX_CONTACTS_PER_TRIGGER); - - if (allContacts.length === 0) { - return []; - } - - log.info("Schedule trigger: evaluating segment", { - segmentId, - contactCount: allContacts.length, - }); - - // 3. SQL-based batch evaluation (1 query) - const matchingIds = await contactIdsMatchingCondition( - db, - allContacts.map((c) => c.id), - organizationId, - seg.condition - ); - - const matchingIdSet = new Set(matchingIds); - const matchingContacts = allContacts.filter((c) => matchingIdSet.has(c.id)); - - log.info("Schedule trigger: segment evaluation complete", { - segmentId, - matchingCount: matchingContacts.length, - }); - - return matchingContacts; -} - -/** - * Process a single workflow step - */ -async function processStep(executionId: string, stepId: string): Promise { - // Load execution with workflow and contact - const execution = await db.query.workflowExecution.findFirst({ - where: eq(workflowExecution.id, executionId), - }); - - if (!execution) { - log.error("Execution not found", undefined, { executionId }); - return; - } - - if (execution.status === "cancelled" || execution.status === "completed") { - log.info("Execution already completed", { - executionId, - status: execution.status, - }); - return; - } - - // Load workflow (scoped by org for defense-in-depth) - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, execution.workflowId), - eq(workflow.organizationId, execution.organizationId) - ) - ) - .limit(1); - - if (!wf) { - log.error("Workflow not found", undefined, { - workflowId: execution.workflowId, - }); - return; - } - - // Load contact (scoped by org for defense-in-depth) - const [contactRecord] = await db - .select() - .from(contact) - .where( - and( - eq(contact.id, execution.contactId), - eq(contact.organizationId, execution.organizationId) - ) - ) - .limit(1); - - if (!contactRecord) { - log.error("Contact not found", undefined, { - contactId: execution.contactId, - }); - await failExecution(executionId, "Contact not found", stepId); - return; - } - - // Use the frozen definition snapshot (immune to live edits) with - // fallback to the live definition for pre-snapshot executions - const snapshot = - execution.definitionSnapshot as WorkflowDefinitionSnapshot | null; - const steps = snapshot?.steps ?? (wf.steps as WorkflowStep[]); - const step = steps.find((s) => s.id === stepId); - - if (!step) { - log.error("Step not found in workflow", undefined, { stepId }); - await failExecution(executionId, `Step ${stepId} not found`, stepId); - return; - } - - // Atomic idempotency check and step execution creation - // Uses ON CONFLICT to prevent race conditions with duplicate SQS messages - const idempotencyKey = `${executionId}-${stepId}`; - - const [stepExec] = await db - .insert(workflowStepExecution) - .values({ - executionId, - stepId, - stepType: step.type, - status: "executing", - idempotencyKey, - startedAt: new Date(), - }) - .onConflictDoUpdate({ - target: workflowStepExecution.idempotencyKey, - set: { - // Only update if not already completed (prevents re-execution) - status: sql`CASE WHEN ${workflowStepExecution.status} = 'completed' THEN ${workflowStepExecution.status} ELSE 'executing' END`, - startedAt: sql`CASE WHEN ${workflowStepExecution.status} = 'completed' THEN ${workflowStepExecution.startedAt} ELSE ${new Date().toISOString()}::timestamp END`, - }, - }) - .returning(); - - // If step was already completed, skip execution - if (stepExec.status === "completed") { - log.info("Step already executed", { stepId, executionId }); - return; - } - - // Update execution current step - await db - .update(workflowExecution) - .set({ currentStepId: stepId, status: "active", updatedAt: new Date() }) - .where(eq(workflowExecution.id, executionId)); - - // Execute step based on type - try { - const result = await executeStep( - step, - execution, - contactRecord, - wf.organizationId - ); - - // Mark step as completed - await db - .update(workflowStepExecution) - .set({ - status: "completed", - branch: result.branch, - result: result.data, - completedAt: new Date(), - }) - .where(eq(workflowStepExecution.id, stepExec.id)); - - // Handle step result — use snapshot transitions for routing - const snapshotWf = snapshot - ? { ...wf, steps, transitions: snapshot.transitions } - : wf; - if (result.action === "next") { - await processNextStep(execution, step, snapshotWf, result.branch); - } else if (result.action === "wait") { - // Step is waiting (e.g., delay scheduled, waiting for event) - // Execution status already updated by the step handler - } else if (result.action === "exit") { - await completeExecution(executionId); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Step failed", error, { stepId, executionId }); - - await db - .update(workflowStepExecution) - .set({ - status: "failed", - error: errorMessage, - completedAt: new Date(), - }) - .where(eq(workflowStepExecution.id, stepExec.id)); - - await failExecution(executionId, errorMessage, stepId); - } -} - -/** - * Execute a single step and return the result - */ -type WorkflowBranch = - | "yes" - | "no" - | "timeout" - | "default" - | "opened" - | "clicked" - | "bounced"; - -async function executeStep( - step: WorkflowStep, - execution: typeof workflowExecution.$inferSelect, - contactRecord: typeof contact.$inferSelect, - organizationId: string -): Promise<{ - action: "next" | "wait" | "exit"; - branch?: WorkflowBranch; - data?: Record; -}> { - const config = step.config; - - switch (config.type) { - case "trigger": - // Trigger is just an entry point, proceed to next - return { action: "next" }; - - case "send_email": - return await handleSendEmail( - config, - execution, - contactRecord, - organizationId - ); - - case "send_sms": - return await handleSendSms( - config, - execution, - contactRecord, - organizationId - ); - - case "delay": - return await handleDelay(config, execution, step.id, organizationId); - - case "condition": - return await handleCondition(config, contactRecord, execution, step); - - case "update_contact": - return await handleUpdateContact(config, contactRecord); - - case "webhook": - return await handleWebhook(config, contactRecord, execution); - - case "wait_for_event": - return await handleWaitForEvent( - config, - execution, - step.id, - organizationId - ); - - case "wait_for_email_engagement": - return await handleWaitForEmailEngagement( - config, - execution, - step, - organizationId - ); - - case "subscribe_topic": - return await handleSubscribeTopic(config, contactRecord); - - case "unsubscribe_topic": - return await handleUnsubscribeTopic(config, contactRecord); - - case "exit": - return { action: "exit" }; - - default: - throw new Error( - `Unknown step type: ${(config as { type: string }).type}` - ); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// STEP HANDLERS -// ═══════════════════════════════════════════════════════════════════════════ - -async function handleSendEmail( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - contactRecord: typeof contact.$inferSelect, - organizationId: string -): Promise<{ action: "next"; data: Record }> { - // Check contact has email - if (!contactRecord.email) { - log.info("Workflow: contact has no email, skipping", { - contactId: contactRecord.id, - }); - return { - action: "next", - data: { - skipped: true, - reason: "no_email", - timestamp: new Date().toISOString(), - }, - }; - } - - // Check contact email status - if ( - contactRecord.emailStatus === "unsubscribed" || - contactRecord.emailStatus === "bounced" || - contactRecord.emailStatus === "complained" - ) { - log.info("Workflow: contact email suppressed, skipping", { - contactId: contactRecord.id, - emailStatus: contactRecord.emailStatus, - }); - return { - action: "next", - data: { - skipped: true, - reason: `email_status_${contactRecord.emailStatus}`, - timestamp: new Date().toISOString(), - }, - }; - } - - // Get the workflow to find the AWS account and sender defaults (scoped by org) - const [wf] = await db - .select({ - awsAccountId: workflow.awsAccountId, - defaultFrom: workflow.defaultFrom, - defaultFromName: workflow.defaultFromName, - defaultReplyTo: workflow.defaultReplyTo, - }) - .from(workflow) - .where( - and( - eq(workflow.id, execution.workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - if (!wf?.awsAccountId) { - log.warn("Workflow: no AWS account configured", { - workflowId: execution.workflowId, - }); - return { - action: "next", - data: { - skipped: true, - reason: "no_aws_account", - timestamp: new Date().toISOString(), - }, - }; - } - - // Get AWS account region - const [account] = await db - .select({ region: awsAccount.region }) - .from(awsAccount) - .where(eq(awsAccount.id, wf.awsAccountId)) - .limit(1); - - if (!account) { - throw new Error(`AWS account ${wf.awsAccountId} not found`); - } - - // Get template (scoped by org for defense-in-depth) - const [tmpl] = await db - .select({ - id: template.id, - name: template.name, - subject: template.subject, - compiledHtml: template.compiledHtml, - emailType: template.emailType, - sesTemplateName: template.sesTemplateName, - }) - .from(template) - .where( - and( - eq(template.id, config.templateId), - eq(template.organizationId, organizationId) - ) - ) - .limit(1); - - if (!tmpl) { - throw new Error(`Template ${config.templateId} not found`); - } - - if (!tmpl.compiledHtml) { - throw new Error(`Template ${config.templateId} has no compiled HTML`); - } - - // Get organization for name - const [org] = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1); - - // Get credentials for customer's AWS account - const credentials = await getCredentials(wf.awsAccountId); - - // Create SES client - const sesClient = new SESv2Client({ - region: account.region, - credentials: { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, - }, - }); - - // Build variable replacement data - const replacementData: Record = { - email: contactRecord.email, - contactEmail: contactRecord.email, - }; - - const addIfPresent = (key: string, value: string | null | undefined) => { - if (value) { - replacementData[key] = value; - } - }; - - addIfPresent("firstName", contactRecord.firstName); - addIfPresent("lastName", contactRecord.lastName); - addIfPresent("company", contactRecord.company); - addIfPresent("jobTitle", contactRecord.jobTitle); - addIfPresent("contactFirstName", contactRecord.firstName); - addIfPresent("contactLastName", contactRecord.lastName); - addIfPresent("contactCompany", contactRecord.company); - addIfPresent("contactJobTitle", contactRecord.jobTitle); - addIfPresent("organizationName", org?.name); - - // Add contact properties - const properties = contactRecord.properties as Record | null; - if (properties) { - for (const [key, value] of Object.entries(properties)) { - const strValue = value != null ? String(value) : null; - if (strValue) { - replacementData[key] = strValue; - } - } - } - - // Add trigger data - const triggerData = execution.triggerData as Record | null; - if (triggerData) { - for (const [key, value] of Object.entries(triggerData)) { - const strValue = value != null ? String(value) : null; - if (strValue) { - replacementData[key] = strValue; - } - } - } - - // Generate unsubscribe URLs for marketing emails - const isMarketing = tmpl.emailType === "marketing"; - const apiBaseUrl = process.env.API_BASE_URL || "https://api.wraps.dev"; - const appBaseUrl = process.env.APP_BASE_URL || "https://app.wraps.dev"; - - let unsubscribeUrl: string | undefined; - let preferencesUrl: string | undefined; - - if (isMarketing) { - const unsubscribeToken = await generateUnsubscribeToken( - contactRecord.id, - organizationId - ); - unsubscribeUrl = `${apiBaseUrl}/unsubscribe/${unsubscribeToken}`; - preferencesUrl = `${appBaseUrl}/preferences/${unsubscribeToken}`; - replacementData.unsubscribeUrl = unsubscribeUrl; - replacementData.preferencesUrl = preferencesUrl; - } - - // Build from address (step config > workflow default > fallback) - const fromAddress = - config.from || - wf.defaultFrom || - `noreply@${process.env.DEFAULT_DOMAIN || "wraps.dev"}`; - const fromName = config.fromName || wf.defaultFromName; - const fromDisplay = fromName ? `${fromName} <${fromAddress}>` : fromAddress; - const replyTo = config.replyTo || wf.defaultReplyTo; - - // Build headers for marketing emails - const headers: Array<{ Name: string; Value: string }> = []; - if (isMarketing && unsubscribeUrl) { - headers.push( - { Name: "List-Unsubscribe", Value: `<${unsubscribeUrl}>` }, - { Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click" } - ); - } - - // Common email tags - const emailTags = [ - { Name: "workflowId", Value: execution.workflowId }, - { Name: "executionId", Value: execution.id }, - { Name: "organizationId", Value: organizationId }, - { Name: "templateId", Value: config.templateId }, - { Name: "source", Value: "automation" }, - ]; - - // Try to use SES template if available - let sesTemplateName = tmpl.sesTemplateName; - - // Auto-publish if not published to SES (requires compiledHtml) - if (!sesTemplateName && tmpl.compiledHtml) { - sesTemplateName = await autoPublishTemplate( - tmpl as { - id: string; - name: string; - subject: string | null; - compiledHtml: string; - }, - credentials, - account.region - ); - } - - let result: { MessageId?: string }; - let subject: string; - - if (sesTemplateName) { - // Use SES template - let SES handle variable substitution - // Transform subject for SES (handles both simple vars and fallbacks) - subject = sanitizeEmailSubject(tmpl.subject || "Message"); - - result = await sesClient.send( - new SendEmailCommand({ - FromEmailAddress: fromDisplay, - ReplyToAddresses: replyTo ? [replyTo] : undefined, - Destination: { - ToAddresses: [contactRecord.email], - }, - Content: { - Template: { - TemplateName: sesTemplateName, - TemplateData: JSON.stringify(replacementData), - Headers: headers.length > 0 ? headers : undefined, - }, - }, - ConfigurationSetName: "wraps-email-tracking", - EmailTags: emailTags, - }) - ); - - log.info("Workflow: email sent via SES template", { - template: sesTemplateName, - to: contactRecord.email, - }); - } else { - // Fallback: Apply variable substitution locally and send raw HTML - const html = substituteVariables(tmpl.compiledHtml, replacementData, { - escapeHtml: true, - }); - - // Build subject with variable substitution - const rawSubject = substituteVariables( - tmpl.subject || "Message", - replacementData - ); - subject = sanitizeEmailSubject(rawSubject); - - result = await sesClient.send( - new SendEmailCommand({ - FromEmailAddress: fromDisplay, - ReplyToAddresses: replyTo ? [replyTo] : undefined, - Destination: { - ToAddresses: [contactRecord.email], - }, - Content: { - Simple: { - Subject: { Data: subject }, - Body: { - Html: { Data: html }, - Text: { Data: htmlToPlainText(html) }, - }, - Headers: headers.length > 0 ? headers : undefined, - }, - }, - ConfigurationSetName: "wraps-email-tracking", - EmailTags: emailTags, - }) - ); - - log.info("Workflow: email sent via raw HTML", { to: contactRecord.email }); - } - - const messageId = result.MessageId ?? ""; - - // Record the send in messageSend table - // Note: workflowExecutionId is not yet in schema, will be added later - await db.insert(messageSend).values({ - organizationId, - contactId: contactRecord.id, - awsAccountId: wf.awsAccountId, - channel: "email", - sourceType: "workflow", - recipient: contactRecord.email, - subject, - from: fromAddress, - fromName: fromName || null, - emailTemplateId: config.templateId, - messageId, - status: "sent", - sentAt: new Date(), - }); - - // Track first email sent (must await in Lambda) - await trackFirstEmailSent(organizationId, { - channel: "email", - source: "workflow", - }); - - // Update contact email metrics - await db - .update(contact) - .set({ - lastEmailSentAt: new Date(), - emailsSent: sql`COALESCE(${contact.emailsSent}, 0) + 1`, - }) - .where(eq(contact.id, contactRecord.id)); - - return { - action: "next", - data: { - messageId, - templateId: config.templateId, - recipient: contactRecord.email, - subject, - timestamp: new Date().toISOString(), - }, - }; -} - -/** - * Substitute variables in text with values from a data object - * Uses Handlebars to properly evaluate conditional syntax like: - * {{#if contactFirstName}}{{contactFirstName}}{{else}}there{{/if}} - * - * This is needed because compiledHtml contains SES-compatible Handlebars syntax - * from transformVariablesForSes, and workflow sends use direct HTML (not SES templates). - * - * Handlebars automatically escapes HTML in {{variable}} expressions for safety. - * - * @exported for testing - */ -export function substituteVariables( - text: string, - data: Record, - _options: { escapeHtml?: boolean } = {} -): string { - try { - // Compile and execute the Handlebars template - const template = Handlebars.compile(text, { noEscape: false }); - return template(data); - } catch (error) { - // If Handlebars fails, fall back to simple regex replacement - log.warn("Workflow: Handlebars compilation failed, using fallback", { - error: String(error), - }); - return text.replace( - /\{\{\s*(?:contact\.)?([a-zA-Z0-9_]+)\s*\}\}/g, - (_match, key) => { - const value = data[key.trim()]; - return value ?? ""; - } - ); - } -} - -/** - * Sanitize email subject line - * - Removes newlines to prevent header injection - * - Collapses whitespace - * - Truncates to reasonable length (998 chars per RFC 2822) - */ -export function sanitizeEmailSubject(subject: string): string { - return subject - .replace(/[\r\n]+/g, " ") // Remove newlines (header injection prevention) - .replace(/\s+/g, " ") // Collapse whitespace - .trim() - .slice(0, 998); // RFC 2822 max line length -} - -/** - * Convert HTML to plain text for email fallback - * Uses react-email's toPlainText for robust HTML-to-text conversion - */ -function htmlToPlainText(html: string): string { - return toPlainText(html); -} - -/** - * Auto-publish a template to SES if not already published. - * Uses the existing compiledHtml from the template. - * Returns the SES template name if successful, or null if publishing fails. - */ -async function autoPublishTemplate( - tmpl: { - id: string; - name: string; - subject: string | null; - compiledHtml: string; - }, - credentials: { - accessKeyId: string; - secretAccessKey: string; - sessionToken?: string; - }, - region: string -): Promise { - try { - // 1. Transform variables for SES compatibility - // compiledHtml already has {{contact.firstName}} format - // We need to transform to {{contactFirstName}} format for SES - // Also handles fallbacks: {{name|fallback}} → {{#if name}}{{name}}{{else}}fallback{{/if}} - const sesHtml = transformVariablesForSes(tmpl.compiledHtml); - const sesText = htmlToPlainText(sesHtml); - const sesSubject = transformVariablesForSes(tmpl.subject || "Message"); - - // 2. Generate template name and publish to SES - const sesTemplateName = generateSESTemplateName(tmpl.id, tmpl.name); - await upsertSESTemplate(credentials, region, { - templateName: sesTemplateName, - subject: sesSubject, - htmlPart: sesHtml, - textPart: sesText, - }); - - // 3. Update template in DB with SES template name - await db - .update(template) - .set({ - sesTemplateName, - publishedAt: new Date(), - }) - .where(eq(template.id, tmpl.id)); - - log.info("Workflow: auto-published SES template", { - templateId: tmpl.id, - sesTemplateName, - }); - return sesTemplateName; - } catch (error) { - log.error("Workflow: auto-publish failed", error); - return null; // Fall back to raw HTML - } -} - -/** - * Validate phone number is in E.164 format - * E.164: +[country code][subscriber number] (e.g., +15551234567) - */ -export function isValidE164Phone(phone: string): boolean { - // E.164 format: + followed by 10-15 digits - const e164Regex = /^\+[1-9]\d{9,14}$/; - return e164Regex.test(phone); -} - -async function handleSendSms( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - contactRecord: typeof contact.$inferSelect, - organizationId: string -): Promise<{ action: "next"; data: Record }> { - // Get the contact's phone number - if (!contactRecord.phone) { - log.info("Workflow: contact has no phone, skipping SMS", { - contactId: contactRecord.id, - }); - return { - action: "next", - data: { - skipped: true, - reason: "no_phone", - timestamp: new Date().toISOString(), - }, - }; - } - - // Validate phone number format (E.164) - if (!isValidE164Phone(contactRecord.phone)) { - log.warn("Workflow: invalid phone format", { - contactId: contactRecord.id, - phone: contactRecord.phone, - }); - return { - action: "next", - data: { - skipped: true, - reason: "invalid_phone_format", - phone: contactRecord.phone, - timestamp: new Date().toISOString(), - }, - }; - } - - // Get the workflow to find the AWS account and sender defaults (scoped by org) - const [wf] = await db - .select({ - awsAccountId: workflow.awsAccountId, - defaultSenderId: workflow.defaultSenderId, - }) - .from(workflow) - .where( - and( - eq(workflow.id, execution.workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - if (!wf?.awsAccountId) { - log.warn("Workflow: no AWS account configured for SMS", { - workflowId: execution.workflowId, - }); - return { - action: "next", - data: { - skipped: true, - reason: "no_aws_account", - timestamp: new Date().toISOString(), - }, - }; - } - - // Get the AWS account region - const [account] = await db - .select({ region: awsAccount.region }) - .from(awsAccount) - .where(eq(awsAccount.id, wf.awsAccountId)) - .limit(1); - - if (!account) { - throw new Error(`AWS account ${wf.awsAccountId} not found`); - } - - // Get credentials for the customer's AWS account - const credentials = await getCredentials(wf.awsAccountId); - - // Create Pinpoint SMS Voice V2 client with assumed credentials - const smsClient = new PinpointSMSVoiceV2Client({ - region: account.region, - credentials: { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, - }, - }); - - // Build message body with variable substitution - const rawBody = config.body || ""; - if (!rawBody) { - log.warn("Workflow: SMS step has no message body"); - return { - action: "next", - data: { - skipped: true, - reason: "no_message_body", - timestamp: new Date().toISOString(), - }, - }; - } - - // Build replacement data (same pattern as handleSendEmail) - const replacementData: Record = {}; - - const addIfPresent = (key: string, value: string | null | undefined) => { - if (value) replacementData[key] = value; - }; - - addIfPresent("email", contactRecord.email); - addIfPresent("contactEmail", contactRecord.email); - addIfPresent("firstName", contactRecord.firstName); - addIfPresent("lastName", contactRecord.lastName); - addIfPresent("company", contactRecord.company); - addIfPresent("jobTitle", contactRecord.jobTitle); - addIfPresent("contactFirstName", contactRecord.firstName); - addIfPresent("contactLastName", contactRecord.lastName); - addIfPresent("contactCompany", contactRecord.company); - addIfPresent("contactJobTitle", contactRecord.jobTitle); - addIfPresent("phone", contactRecord.phone); - - // Add contact properties - const properties = contactRecord.properties as Record | null; - if (properties) { - for (const [key, value] of Object.entries(properties)) { - const strValue = value != null ? String(value) : null; - if (strValue) replacementData[key] = strValue; - } - } - - // Add trigger data - const triggerData = execution.triggerData as Record | null; - if (triggerData) { - for (const [key, value] of Object.entries(triggerData)) { - const strValue = value != null ? String(value) : null; - if (strValue) replacementData[key] = strValue; - } - } - - const normalizedBody = transformVariablesForSes(rawBody); - const messageBody = substituteVariables(normalizedBody, replacementData); - - // Build sender ID (step config > workflow default) - const senderId = config.senderId || wf.defaultSenderId; - - // Send SMS - const command = new SendTextMessageCommand({ - DestinationPhoneNumber: contactRecord.phone, - MessageBody: messageBody, - ConfigurationSetName: "wraps-sms-config", - MessageType: "TRANSACTIONAL", - ...(senderId && { OriginationIdentity: senderId }), - }); - - const response = await smsClient.send(command); - - log.info("Workflow: SMS sent", { - to: contactRecord.phone, - messageId: response.MessageId, - }); - - // Update contact SMS metrics - await db - .update(contact) - .set({ - lastSmsSentAt: new Date(), - smsSent: sql`COALESCE(${contact.smsSent}, 0) + 1`, - }) - .where(eq(contact.id, contactRecord.id)); - - return { - action: "next", - data: { - messageId: response.MessageId, - recipient: contactRecord.phone, - body: messageBody, - timestamp: new Date().toISOString(), - }, - }; -} - -async function handleDelay( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - stepId: string, - organizationId: string -): Promise<{ action: "wait" }> { - // Calculate delay in seconds - let delaySeconds = config.amount; - switch (config.unit) { - case "minutes": - delaySeconds *= 60; - break; - case "hours": - delaySeconds *= 3600; - break; - case "days": - delaySeconds *= 86_400; - break; - case "weeks": - delaySeconds *= 604_800; - break; - } - - // Use snapshot transitions (immune to live edits) with fallback for pre-snapshot executions - const snapshot = - execution.definitionSnapshot as WorkflowDefinitionSnapshot | null; - let transitions: WorkflowTransition[] | undefined; - - if (snapshot) { - transitions = snapshot.transitions; - } else { - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, execution.workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - transitions = wf?.transitions as WorkflowTransition[] | undefined; - } - - const nextTransition = transitions?.find((t) => t.fromStepId === stepId); - - if (!nextTransition) { - // No next step - complete execution - await completeExecution(execution.id); - return { action: "wait" }; - } - - // Schedule the next step - const schedulerName = await scheduleWorkflowStep({ - executionId: execution.id, - stepId: nextTransition.toStepId, - organizationId, - delaySeconds, - }); - - // Update execution status - await db - .update(workflowExecution) - .set({ - status: "paused", - nextStepScheduledAt: new Date(Date.now() + delaySeconds * 1000), - delaySchedulerName: schedulerName, - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, execution.id)); - - return { action: "wait" }; -} - -async function handleCondition( - config: Extract, - contactRecord: typeof contact.$inferSelect, - execution: typeof workflowExecution.$inferSelect, - step: WorkflowStep -): Promise<{ action: "next"; branch: "yes" | "no" }> { - // Handle engagement.status — used by cascade condition steps to check - // whether the contact engaged with a previous email. The preceding - // wait_for_email_engagement step records its branch ("opened", "clicked", - // "bounced", or "timeout") on the step execution row. - if (config.field === CASCADE_ENGAGEMENT_FIELD) { - // Scope to the same cascade group to avoid picking up engagement results - // from a different cascade node in the same workflow execution. - // Cascade step IDs follow the pattern: ${cascadeGroupId}-cond-${i}, - // and wait steps are: ${cascadeGroupId}-wait-${i}. - const cascadeGroupId = step.cascadeGroupId; - const waitStepFilter = cascadeGroupId - ? sql`${workflowStepExecution.stepId} LIKE ${`${cascadeGroupId}-wait-%`}` - : undefined; - - const previousWaitStep = await db - .select({ branch: workflowStepExecution.branch }) - .from(workflowStepExecution) - .where( - and( - eq(workflowStepExecution.executionId, execution.id), - eq(workflowStepExecution.stepType, "wait_for_email_engagement"), - eq(workflowStepExecution.status, "completed"), - waitStepFilter - ) - ) - .orderBy(sql`${workflowStepExecution.completedAt} DESC`) - .limit(1); - - const engaged = - previousWaitStep[0]?.branch === "opened" || - previousWaitStep[0]?.branch === "clicked"; - - // The cascade expansion uses operator "equals" / value "true", - // so "true" === "true" when engaged, "false" !== "true" when not. - const fieldValue = String(engaged); - const conditionMet = evaluateCondition( - fieldValue, - config.operator, - config.value - ); - - return { - action: "next", - branch: conditionMet ? "yes" : "no", - }; - } - - // Get the field value from contact properties - const properties = contactRecord.properties as Record | null; - const triggerData = execution.triggerData as Record | null; - - // Strip "properties." prefix — the editor generates field values like - // "properties.plan" for custom properties, but the actual key in the - // properties object is just "plan". - const field = config.field.startsWith("properties.") - ? config.field.slice("properties.".length) - : config.field; - - // Try contact fields first, then contact.properties, then trigger data - let fieldValue: unknown; - if (field in contactRecord) { - fieldValue = contactRecord[field as keyof typeof contactRecord]; - } else if (properties && field in properties) { - fieldValue = properties[field]; - } else if (triggerData && field in triggerData) { - fieldValue = triggerData[field]; - } - - // Evaluate condition - const conditionMet = evaluateCondition( - fieldValue, - config.operator, - config.value - ); - - return { - action: "next", - branch: conditionMet ? "yes" : "no", - }; -} - -export function evaluateCondition( - fieldValue: unknown, - operator: string, - compareValue: unknown -): boolean { - const strFieldValue = String(fieldValue ?? ""); - const strCompareValue = String(compareValue ?? ""); - - switch (operator) { - case "equals": - return strFieldValue === strCompareValue; - case "not_equals": - return strFieldValue !== strCompareValue; - case "contains": - return strFieldValue.includes(strCompareValue); - case "not_contains": - return !strFieldValue.includes(strCompareValue); - case "starts_with": - return strFieldValue.startsWith(strCompareValue); - case "ends_with": - return strFieldValue.endsWith(strCompareValue); - case "greater_than": - return Number(fieldValue) > Number(compareValue); - case "less_than": - return Number(fieldValue) < Number(compareValue); - case "greater_than_or_equals": - return Number(fieldValue) >= Number(compareValue); - case "less_than_or_equals": - return Number(fieldValue) <= Number(compareValue); - case "is_true": - return ( - fieldValue === true || strFieldValue === "true" || strFieldValue === "1" - ); - case "is_false": - return ( - fieldValue === false || - fieldValue === null || - fieldValue === undefined || - strFieldValue === "false" || - strFieldValue === "0" || - strFieldValue === "" - ); - case "is_set": - return ( - fieldValue !== null && fieldValue !== undefined && fieldValue !== "" - ); - case "is_not_set": - return ( - fieldValue === null || fieldValue === undefined || fieldValue === "" - ); - default: - log.warn("Unknown condition operator", { operator }); - return false; - } -} - -const FIRST_CLASS_CONTACT_FIELDS = new Set([ - "preferredChannel", - "firstName", - "lastName", - "company", - "jobTitle", -]); - -export async function handleUpdateContact( - config: Extract, - contactRecord: typeof contact.$inferSelect -): Promise<{ action: "next"; data: Record }> { - const updates = config.updates || []; - const currentProperties = - (contactRecord.properties as Record) || {}; - const newProperties = { ...currentProperties }; - const directUpdates: Partial = {}; - - for (const update of updates) { - const isFirstClass = FIRST_CLASS_CONTACT_FIELDS.has(update.field); - - switch (update.operation) { - case "set": - if (isFirstClass) { - switch (update.field) { - case "preferredChannel": - directUpdates.preferredChannel = - update.value as PreferredChannel | null; - break; - case "firstName": - directUpdates.firstName = update.value as string | null; - break; - case "lastName": - directUpdates.lastName = update.value as string | null; - break; - case "company": - directUpdates.company = update.value as string | null; - break; - case "jobTitle": - directUpdates.jobTitle = update.value as string | null; - break; - } - } else { - newProperties[update.field] = update.value; - } - break; - case "unset": - if (isFirstClass) { - switch (update.field) { - case "preferredChannel": - directUpdates.preferredChannel = null; - break; - case "firstName": - directUpdates.firstName = null; - break; - case "lastName": - directUpdates.lastName = null; - break; - case "company": - directUpdates.company = null; - break; - case "jobTitle": - directUpdates.jobTitle = null; - break; - } - } else { - delete newProperties[update.field]; - } - break; - case "increment": - newProperties[update.field] = - (Number(newProperties[update.field]) || 0) + Number(update.value); - break; - case "decrement": - newProperties[update.field] = - (Number(newProperties[update.field]) || 0) - Number(update.value); - break; - case "append": { - const arr = Array.isArray(newProperties[update.field]) - ? newProperties[update.field] - : []; - (arr as unknown[]).push(update.value); - newProperties[update.field] = arr; - break; - } - case "remove": - if (Array.isArray(newProperties[update.field])) { - newProperties[update.field] = ( - newProperties[update.field] as unknown[] - ).filter((v) => v !== update.value); - } - break; - } - } - - await db - .update(contact) - .set({ - ...directUpdates, - properties: newProperties, - updatedAt: new Date(), - }) - .where( - and( - eq(contact.id, contactRecord.id), - eq(contact.organizationId, contactRecord.organizationId) - ) - ); - - return { - action: "next", - data: { updatedFields: updates.map((u) => u.field) }, - }; -} - -const BLOCKED_IPV4_RANGES = [ - { prefix: "127.", label: "loopback" }, - { prefix: "10.", label: "private (10/8)" }, - { prefix: "169.254.", label: "link-local/IMDS" }, - { prefix: "0.", label: "unspecified" }, -] as const; - -/** @exported for testing */ -export function isBlockedIp(ip: string): string | null { - // IPv4-mapped IPv6 (::ffff:1.2.3.4) — extract the IPv4 and re-check - if (ip.startsWith("::ffff:")) { - const v4 = ip.slice(7); - if (v4.includes(".")) return isBlockedIp(v4); - } - - for (const range of BLOCKED_IPV4_RANGES) { - if (ip.startsWith(range.prefix)) return range.label; - } - // 100.64.0.0/10 (Carrier-grade NAT / AWS VPC) - if (ip.startsWith("100.")) { - const second = Number.parseInt(ip.split(".")[1], 10); - if (second >= 64 && second <= 127) return "private (100.64/10 CGN)"; - } - // 172.16.0.0/12 - if (ip.startsWith("172.")) { - const second = Number.parseInt(ip.split(".")[1], 10); - if (second >= 16 && second <= 31) return "private (172.16/12)"; - } - // 192.168.0.0/16 - if (ip.startsWith("192.168.")) return "private (192.168/16)"; - // IPv6 - if (ip === "::1" || ip === "::") return "loopback"; - if (ip.startsWith("fe80:")) return "link-local"; - if (ip.startsWith("fd") || ip.startsWith("fc")) return "private (ULA)"; - return null; -} - -/** @exported for testing */ -export async function validateWebhookUrl(url: string): Promise { - const parsed = new URL(url); - - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - throw new Error(`Webhook URL must use http(s), got ${parsed.protocol}`); - } - - const dns = await import("node:dns/promises"); - const { address } = await dns.lookup(parsed.hostname); - const blockedReason = isBlockedIp(address); - if (blockedReason) { - throw new Error( - `Webhook URL resolves to blocked address (${blockedReason}): ${parsed.hostname} -> ${address}` - ); - } -} - -async function handleWebhook( - config: Extract, - contactRecord: typeof contact.$inferSelect, - execution: typeof workflowExecution.$inferSelect -): Promise<{ action: "next"; data: Record }> { - try { - await validateWebhookUrl(config.url); - } catch (error) { - log.error("Webhook SSRF blocked", error, { url: config.url }); - return { - action: "next", - data: { - error: error instanceof Error ? error.message : "Invalid webhook URL", - blocked: true, - }, - }; - } - - const body = { - contact: { - id: contactRecord.id, - email: contactRecord.email, - properties: contactRecord.properties, - }, - execution: { - id: execution.id, - workflowId: execution.workflowId, - triggerData: execution.triggerData, - }, - ...(config.body || {}), - }; - - try { - const response = await fetch(config.url, { - method: config.method, - headers: { - "Content-Type": "application/json", - ...(config.headers || {}), - }, - body: config.method !== "GET" ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(10_000), - }); - - return { - action: "next", - data: { - status: response.status, - ok: response.ok, - }, - }; - } catch (error) { - log.error("Webhook failed", error); - return { - action: "next", - data: { - error: error instanceof Error ? error.message : "Webhook failed", - }, - }; - } -} - -async function handleWaitForEvent( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - stepId: string, - organizationId: string -): Promise<{ action: "wait" }> { - const timeoutSeconds = config.timeoutSeconds || 86_400; // Default 24 hours - const timeoutAt = new Date(Date.now() + timeoutSeconds * 1000); - - // Schedule timeout - const schedulerName = await scheduleWaitTimeout({ - executionId: execution.id, - stepId, - organizationId, - timeoutSeconds, - }); - - // Update execution to waiting state - await db - .update(workflowExecution) - .set({ - status: "waiting", - waitingForEvent: config.eventName, - waitTimeoutAt: timeoutAt, - waitTimeoutSchedulerName: schedulerName, - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, execution.id)); - - return { action: "wait" }; -} - -export async function handleWaitForEmailEngagement( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - step: WorkflowStep, - organizationId: string -): Promise<{ action: "wait" }> { - const timeoutSeconds = config.timeoutSeconds || 259_200; // Default 3 days - const timeoutAt = new Date(Date.now() + timeoutSeconds * 1000); - - // Scope to cascade group if applicable, so we match the correct email - const cascadeGroupId = step.cascadeGroupId; - const sendStepFilter = cascadeGroupId - ? sql`${workflowStepExecution.stepId} LIKE ${`${cascadeGroupId}-send-%`}` - : undefined; - - // Find the previous send_email step execution to get the message ID - const previousStepExecs = await db - .select() - .from(workflowStepExecution) - .where( - and( - eq(workflowStepExecution.executionId, execution.id), - eq(workflowStepExecution.stepType, "send_email"), - eq(workflowStepExecution.status, "completed"), - sendStepFilter - ) - ) - .orderBy(sql`${workflowStepExecution.completedAt} DESC`) - .limit(1); - - const lastEmailStep = previousStepExecs[0]; - const messageId = lastEmailStep?.result - ? (lastEmailStep.result as Record).messageId - : undefined; - - // Schedule timeout - const schedulerName = await scheduleWaitTimeout({ - executionId: execution.id, - stepId: step.id, - organizationId, - timeoutSeconds, - }); - - // Update execution to waiting state - // We use 'email_engagement' as a special event name prefix - await db - .update(workflowExecution) - .set({ - status: "waiting", - waitingForEvent: `email_engagement:${messageId || "unknown"}`, - waitTimeoutAt: timeoutAt, - waitTimeoutSchedulerName: schedulerName, - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, execution.id)); - - return { action: "wait" }; -} - -async function handleSubscribeTopic( - config: Extract, - contactRecord: typeof contact.$inferSelect -): Promise<{ action: "next"; data: Record }> { - // Upsert contact-topic subscription - await db - .insert(contactTopic) - .values({ - contactId: contactRecord.id, - topicId: config.topicId, - status: "subscribed", - subscribedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [contactTopic.contactId, contactTopic.topicId], - set: { - status: "subscribed", - subscribedAt: new Date(), - unsubscribedAt: null, - }, - }); - - return { - action: "next", - data: { - topicId: config.topicId, - channel: config.channel, - action: "subscribed", - }, - }; -} - -async function handleUnsubscribeTopic( - config: Extract, - contactRecord: typeof contact.$inferSelect -): Promise<{ action: "next"; data: Record }> { - // Update subscription to unsubscribe - await db - .update(contactTopic) - .set({ - status: "unsubscribed", - unsubscribedAt: new Date(), - }) - .where( - and( - eq(contactTopic.contactId, contactRecord.id), - eq(contactTopic.topicId, config.topicId) - ) - ); - - return { - action: "next", - data: { - topicId: config.topicId, - channel: config.channel, - action: "unsubscribed", - }, - }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// EXECUTION FLOW HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Process the next step in the workflow - */ -async function processNextStep( - execution: typeof workflowExecution.$inferSelect, - currentStep: WorkflowStep, - wf: typeof workflow.$inferSelect, - branch?: WorkflowBranch -): Promise { - const transitions = wf.transitions as WorkflowTransition[]; - - // Find matching transition - let nextTransition: WorkflowTransition | undefined; - - if (branch) { - // Look for transition with matching branch - nextTransition = transitions.find( - (t) => t.fromStepId === currentStep.id && t.condition?.branch === branch - ); - } - - // Fallback to branchless transition only when no specific branch was requested. - // When a branch IS specified (e.g., condition "yes"/"no"), falling back to a - // branchless transition would incorrectly route through an unrelated path. - if (!(nextTransition || branch)) { - nextTransition = transitions.find( - (t) => t.fromStepId === currentStep.id && !t.condition - ); - } - - if (!nextTransition) { - // No next step - complete execution - await completeExecution(execution.id); - return; - } - - // Enqueue next step for processing - await enqueueWorkflowStep({ - type: "execute", - executionId: execution.id, - stepId: nextTransition.toStepId, - organizationId: wf.organizationId, - }); -} - -/** - * Resume a paused/waiting execution. - * - * Uses an atomic UPDATE … WHERE status='waiting' RETURNING * to claim the - * execution. If another handler (engagement webhook vs timeout scheduler) - * already claimed it, Postgres returns zero rows and we bail out — no - * duplicate emails, no corrupted state. - */ -async function resumeExecution( - executionId: string, - branch: WorkflowBranch -): Promise { - // Atomic claim: only one caller can transition waiting → active - const [claimed] = await db - .update(workflowExecution) - .set({ - status: "active", - waitingForEvent: null, - waitTimeoutAt: null, - // Keep waitTimeoutSchedulerName so RETURNING gives us the old value - // for cancellation below. Stale name on an active execution is harmless. - delaySchedulerName: null, - updatedAt: new Date(), - }) - .where( - and( - eq(workflowExecution.id, executionId), - eq(workflowExecution.status, "waiting") - ) - ) - .returning(); - - if (!claimed) { - log.info("Execution already claimed by another handler", { - executionId, - branch, - }); - return; - } - - // Cancel the timeout scheduler if we were resumed by an engagement event - if (branch !== "timeout" && claimed.waitTimeoutSchedulerName) { - await deleteScheduledStep(claimed.waitTimeoutSchedulerName); - } - - // Load workflow for infrastructure config (awsAccountId, sender defaults) - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, claimed.workflowId), - eq(workflow.organizationId, claimed.organizationId) - ) - ) - .limit(1); - - if (!wf) { - log.error("Workflow not found", undefined, { - workflowId: claimed.workflowId, - }); - await failExecution( - executionId, - "Workflow not found", - claimed.currentStepId ?? "unknown" - ); - return; - } - - // Use snapshot (immune to live edits) with fallback for pre-snapshot executions - const snapshot = - claimed.definitionSnapshot as WorkflowDefinitionSnapshot | null; - const steps = snapshot?.steps ?? (wf.steps as WorkflowStep[]); - const currentStep = steps.find((s) => s.id === claimed.currentStepId); - - if (!currentStep) { - log.error("Current step not found", undefined, { - stepId: claimed.currentStepId, - }); - await failExecution( - executionId, - `Step ${claimed.currentStepId} not found`, - claimed.currentStepId ?? "unknown" - ); - return; - } - - // Record step completion with branch - // Note: wait steps are already marked "completed" (with branch=null) when the - // wait state is entered. This UPDATE overwrites the branch with the actual - // resume reason. The atomic claim above is the real race-condition gate. - await db - .update(workflowStepExecution) - .set({ - status: "completed", - branch, - completedAt: new Date(), - }) - .where( - and( - eq(workflowStepExecution.executionId, executionId), - eq(workflowStepExecution.stepId, currentStep.id) - ) - ); - - // Process next step based on branch — use snapshot transitions for routing - const snapshotWf = snapshot - ? { ...wf, steps, transitions: snapshot.transitions } - : wf; - await processNextStep(claimed, currentStep, snapshotWf, branch); -} - -/** - * Mark execution as completed - */ -async function completeExecution(executionId: string): Promise { - await db.transaction(async (tx) => { - const [execution] = await tx - .update(workflowExecution) - .set({ - status: "completed", - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, executionId)) - .returning(); - - if (execution) { - await tx - .update(workflow) - .set({ - activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, - completedExecutions: sql`${workflow.completedExecutions} + 1`, - }) - .where(eq(workflow.id, execution.workflowId)); - } - }); -} - -/** - * Increment the dropped executions counter on a workflow - */ -async function incrementDroppedExecutions(workflowId: string): Promise { - await db - .update(workflow) - .set({ - droppedExecutions: sql`${workflow.droppedExecutions} + 1`, - }) - .where(eq(workflow.id, workflowId)); -} - -/** - * Mark execution as failed - */ -async function failExecution( - executionId: string, - error: string, - stepId: string -): Promise { - await db.transaction(async (tx) => { - const [execution] = await tx - .update(workflowExecution) - .set({ - status: "failed", - error, - errorStepId: stepId, - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, executionId)) - .returning(); - - if (execution) { - await tx - .update(workflow) - .set({ - activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, - failedExecutions: sql`${workflow.failedExecutions} + 1`, - }) - .where(eq(workflow.id, execution.workflowId)); - } - }); -} +export * from "./automation-processor"; diff --git a/apps/api/src/(ee)/workers/workflow-stats.ts b/apps/api/src/(ee)/workers/workflow-stats.ts index 9e5283734..9af43200d 100644 --- a/apps/api/src/(ee)/workers/workflow-stats.ts +++ b/apps/api/src/(ee)/workers/workflow-stats.ts @@ -1,100 +1,5 @@ -import { db, eq, workflow, workflowExecution } from "@wraps/db"; -import { sql } from "drizzle-orm"; - -export type ReconcileResult = { - workflowId: string; - before: { - totalExecutions: number; - activeExecutions: number; - completedExecutions: number; - failedExecutions: number; - }; - actual: { - totalExecutions: number; - activeExecutions: number; - completedExecutions: number; - failedExecutions: number; - }; - drifted: boolean; -}; - -const ACTIVE_STATUSES = new Set(["active", "pending", "paused", "waiting"]); - -export async function reconcileWorkflowStats( - workflowId: string, - options?: { fix?: boolean } -): Promise { - // Load current denormalized stats - const [wf] = await db - .select({ - id: workflow.id, - totalExecutions: workflow.totalExecutions, - activeExecutions: workflow.activeExecutions, - completedExecutions: workflow.completedExecutions, - failedExecutions: workflow.failedExecutions, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)); - - // Count actual executions grouped by status - const counts = await db - .select({ - status: workflowExecution.status, - count: sql`count(*)::int`, - }) - .from(workflowExecution) - .where(eq(workflowExecution.workflowId, workflowId)) - .groupBy(workflowExecution.status); - - // Compute actual totals - let totalExecutions = 0; - let activeExecutions = 0; - let completedExecutions = 0; - let failedExecutions = 0; - - for (const row of counts) { - totalExecutions += row.count; - if (ACTIVE_STATUSES.has(row.status)) { - activeExecutions += row.count; - } else if (row.status === "completed") { - completedExecutions = row.count; - } else if (row.status === "failed") { - failedExecutions = row.count; - } - // "cancelled" counts toward total but not active/completed/failed - } - - const before = { - totalExecutions: wf.totalExecutions, - activeExecutions: wf.activeExecutions, - completedExecutions: wf.completedExecutions, - failedExecutions: wf.failedExecutions, - }; - - const actual = { - totalExecutions, - activeExecutions, - completedExecutions, - failedExecutions, - }; - - const drifted = - before.totalExecutions !== actual.totalExecutions || - before.activeExecutions !== actual.activeExecutions || - before.completedExecutions !== actual.completedExecutions || - before.failedExecutions !== actual.failedExecutions; - - if (drifted && options?.fix) { - await db - .update(workflow) - .set({ - totalExecutions: actual.totalExecutions, - activeExecutions: actual.activeExecutions, - completedExecutions: actual.completedExecutions, - failedExecutions: actual.failedExecutions, - }) - .where(eq(workflow.id, workflowId)); - } - - return { workflowId, before, actual, drifted }; -} +/** + * @deprecated Import from `./automation-stats` instead. + * This file is a backward-compatibility shim. + */ +export * from "./automation-stats"; diff --git a/apps/api/src/__tests__/auth.test.ts b/apps/api/src/__tests__/auth.integration.test.ts similarity index 100% rename from apps/api/src/__tests__/auth.test.ts rename to apps/api/src/__tests__/auth.integration.test.ts diff --git a/apps/api/src/__tests__/events-batch.test.ts b/apps/api/src/__tests__/events-batch.integration.test.ts similarity index 100% rename from apps/api/src/__tests__/events-batch.test.ts rename to apps/api/src/__tests__/events-batch.integration.test.ts diff --git a/apps/api/src/__tests__/handle-wait-for-engagement.test.ts b/apps/api/src/__tests__/handle-wait-for-engagement.test.ts index b7175410d..cc05f7756 100644 --- a/apps/api/src/__tests__/handle-wait-for-engagement.test.ts +++ b/apps/api/src/__tests__/handle-wait-for-engagement.test.ts @@ -63,6 +63,15 @@ vi.mock("@wraps/db", () => ({ messageSend: {}, organization: {}, template: {}, + automation: {}, + automationExecution: { id: "workflowExecution.id" }, + automationStepExecution: { + executionId: "wse.executionId", + stepType: "wse.stepType", + status: "wse.status", + stepId: "wse.stepId", + completedAt: "wse.completedAt", + }, workflow: {}, workflowExecution: { id: "workflowExecution.id" }, workflowStepExecution: { @@ -97,15 +106,19 @@ vi.mock("../services/credentials", () => ({ })); // Mock paths relative to THIS test file (src/__tests__/) to reach src/services/ -vi.mock("../services/workflow-queue", () => ({ +vi.mock("../services/automation-queue", () => ({ deleteScheduledStep: vi.fn(), + enqueueAutomationStep: vi.fn(), + enqueueAutomationStepBatch: vi.fn(), enqueueWorkflowStep: vi.fn(), enqueueWorkflowStepBatch: vi.fn(), scheduleWaitTimeout: (...args: any[]) => mockScheduleWaitTimeout(...args), + scheduleAutomationStep: vi.fn(), scheduleWorkflowStep: vi.fn(), })); -vi.mock("../services/workflow-scheduler", () => ({ +vi.mock("../services/automation-scheduler", () => ({ + createNextAutomationSchedule: vi.fn(), createNextWorkflowSchedule: vi.fn(), })); diff --git a/apps/api/src/__tests__/workflows-sync.test.ts b/apps/api/src/__tests__/workflows-sync.test.ts index b54632b79..faad1b718 100644 --- a/apps/api/src/__tests__/workflows-sync.test.ts +++ b/apps/api/src/__tests__/workflows-sync.test.ts @@ -70,6 +70,7 @@ vi.mock("@wraps/db", () => ({ id: "awsAccount.id", organizationId: "awsAccount.organizationId", }, + automation: "workflow", workflow: "workflow", template: "template", eq: vi.fn(), diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f687fc5ba..3fb33de0a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,8 +8,10 @@ import { cors } from "@elysiajs/cors"; import { swagger } from "@elysiajs/swagger"; import { Elysia } from "elysia"; -import { workflowsRoutes } from "./(ee)/routes/workflows"; +import { automationsRoutes } from "./(ee)/routes/automations"; import { getPostHogClient } from "./lib/posthog"; +import { automationScheduleRoutes } from "./routes/automation-schedules"; +import { automationsSyncRoutes } from "./routes/automations-sync"; import { batchRoutes } from "./routes/batch"; import { connectionsRoutes } from "./routes/connections"; import { contactsRoutes } from "./routes/contacts"; @@ -20,8 +22,6 @@ import { templatesSyncRoutes } from "./routes/templates-sync"; import { toolsRoutes } from "./routes/tools"; import { unsubscribeRoutes } from "./routes/unsubscribe"; import { webhooksRoutes } from "./routes/webhooks"; -import { workflowScheduleRoutes } from "./routes/workflow-schedules"; -import { workflowsSyncRoutes } from "./routes/workflows-sync"; /** * OpenAPI documentation configuration @@ -33,7 +33,7 @@ const openApiDocumentation = { title: "Wraps Platform API", version: "1.0.0", description: - "REST API for the Wraps email marketing platform. Send emails, manage contacts, trigger workflows, and process events.", + "REST API for the Wraps email marketing platform. Send emails, manage contacts, trigger automations, and process events.", contact: { name: "Wraps Support", url: "https://wraps.dev", @@ -64,11 +64,11 @@ const openApiDocumentation = { }, { name: "events", - description: "Custom event ingestion for triggering workflows", + description: "Custom event ingestion for triggering automations", }, { - name: "workflows", - description: "API-triggered workflow execution endpoints", + name: "automations", + description: "API-triggered automation execution endpoints", }, { name: "connections", @@ -134,14 +134,14 @@ export const app = new Elysia() .use(contactsRoutes) .use(batchRoutes) .use(eventsRoutes) - .use(workflowsRoutes) + .use(automationsRoutes) .use(webhooksRoutes) .use(unsubscribeRoutes) .use(preferenceEventsRoutes) .use(templatesSyncRoutes) - .use(workflowsSyncRoutes) + .use(automationsSyncRoutes) .use(toolsRoutes) - .use(workflowScheduleRoutes); + .use(automationScheduleRoutes); // Export type for Eden Treaty client export type App = typeof app; diff --git a/apps/api/src/routes/automation-schedules.ts b/apps/api/src/routes/automation-schedules.ts new file mode 100644 index 000000000..a9da9212f --- /dev/null +++ b/apps/api/src/routes/automation-schedules.ts @@ -0,0 +1,217 @@ +/** + * Automation Schedule Routes + * + * Internal routes for managing EventBridge one-time schedules + * for schedule-triggered automations. + * + * Called by server actions on enable/disable/update of scheduled automations. + */ + +import { automation, db, eq } from "@wraps/db"; +import { and } from "drizzle-orm"; +import { t } from "elysia"; + +import { + type AuthContext, + createAuthenticatedRoutes, +} from "../middleware/auth"; +import { + createNextAutomationSchedule, + deleteAutomationSchedule, +} from "../services/automation-scheduler"; + +/** + * Verify the automation belongs to the authenticated organization. + * Returns true if valid, or false if not found. + */ +async function verifyAutomationOwnership( + automationId: string, + organizationId: string +): Promise { + const [a] = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ) + .limit(1); + + return !!a; +} + +export const automationScheduleRoutes = createAuthenticatedRoutes( + "/v1/automation-schedules" +) + /** + * Enable an automation schedule + * + * POST /v1/automation-schedules/:automationId/enable + * + * Creates the next one-time EventBridge Schedule for an automation. + */ + .post( + "/:automationId/enable", + async (ctx) => { + const { params, body, set } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + + // Verify automation belongs to this organization + const isOwner = await verifyAutomationOwnership( + params.automationId, + auth.organizationId + ); + if (!isOwner) { + set.status = 404; + return { success: false, error: "Automation not found" }; + } + + try { + const scheduleName = await createNextAutomationSchedule({ + workflowId: params.automationId, + organizationId: auth.organizationId, + cronExpression: body.cronExpression, + timezone: body.timezone, + }); + + return { success: true, scheduleName }; + } catch (error) { + console.error( + `[automation-schedules] Failed to enable schedule for ${params.automationId}:`, + error + ); + set.status = 500; + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to create schedule", + }; + } + }, + { + params: t.Object({ + automationId: t.String(), + }), + body: t.Object({ + cronExpression: t.String(), + timezone: t.Optional(t.String()), + }), + } + ) + + /** + * Disable an automation schedule + * + * POST /v1/automation-schedules/:automationId/disable + * + * Deletes the pending EventBridge Schedule for an automation. + */ + .post( + "/:automationId/disable", + async (ctx) => { + const { params, set } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + + // Verify automation belongs to this organization + const isOwner = await verifyAutomationOwnership( + params.automationId, + auth.organizationId + ); + if (!isOwner) { + set.status = 404; + return { success: false, error: "Automation not found" }; + } + + try { + await deleteAutomationSchedule(params.automationId); + return { success: true }; + } catch (error) { + console.error( + `[automation-schedules] Failed to disable schedule for ${params.automationId}:`, + error + ); + set.status = 500; + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to delete schedule", + }; + } + }, + { + params: t.Object({ + automationId: t.String(), + }), + } + ) + + /** + * Update an automation schedule (reschedule) + * + * PUT /v1/automation-schedules/:automationId + * + * Deletes the old schedule and creates a new one with updated cron. + */ + .put( + "/:automationId", + async (ctx) => { + const { params, body, set } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + + // Verify automation belongs to this organization + const isOwner = await verifyAutomationOwnership( + params.automationId, + auth.organizationId + ); + if (!isOwner) { + set.status = 404; + return { success: false, error: "Automation not found" }; + } + + try { + // Delete old schedule first + await deleteAutomationSchedule(params.automationId); + + // Create new schedule with updated cron + const scheduleName = await createNextAutomationSchedule({ + workflowId: params.automationId, + organizationId: auth.organizationId, + cronExpression: body.cronExpression, + timezone: body.timezone, + }); + + return { success: true, scheduleName }; + } catch (error) { + console.error( + `[automation-schedules] Failed to update schedule for ${params.automationId}:`, + error + ); + set.status = 500; + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to update schedule", + }; + } + }, + { + params: t.Object({ + automationId: t.String(), + }), + body: t.Object({ + cronExpression: t.String(), + timezone: t.Optional(t.String()), + }), + } + ); + +/** @deprecated Use `automationScheduleRoutes` instead */ +export const workflowScheduleRoutes = automationScheduleRoutes; diff --git a/apps/api/src/routes/automations-sync.ts b/apps/api/src/routes/automations-sync.ts new file mode 100644 index 000000000..bd64bc4fa --- /dev/null +++ b/apps/api/src/routes/automations-sync.ts @@ -0,0 +1,565 @@ +/** + * Automations Sync Routes + * + * CLI-to-platform automation synchronization for "automations as code". + * + * POST /v1/automations/push - Upsert a single automation from CLI + * POST /v1/automations/push/batch - Push multiple automations atomically + * GET /v1/automations/pull - List all code-pushed automations with source + */ + +import { + type AutomationStep, + type AutomationTransition, + type AutomationTriggerType, + and, + automation, + awsAccount, + db, + eq, + type TriggerConfig, + template, +} from "@wraps/db"; +import { inArray, sql } from "drizzle-orm"; +import { t } from "elysia"; +import type { AuthContext } from "../middleware/auth"; +import { createAuthenticatedRoutes } from "../middleware/auth"; + +type DbOrTx = + | typeof db + | Parameters[0]>[0]; + +// ═══════════════════════════════════════════════════════════════════════════ +// ROUTES +// ═══════════════════════════════════════════════════════════════════════════ + +export const automationsSyncRoutes = createAuthenticatedRoutes( + "/v1/automations" +) + // POST /push — Upsert a single automation from CLI + .post( + "/push", + async (ctx) => { + const authContext = (ctx as unknown as { auth: AuthContext }).auth; + const { body } = ctx; + + // Resolve template slugs to IDs + const resolvedSteps = await resolveTemplateReferences( + db, + authContext.organizationId, + body.steps as AutomationStep[] + ); + + const result = await upsertAutomationFromCli(db, authContext, { + ...body, + steps: resolvedSteps, + transitions: body.transitions as AutomationTransition[], + }); + + if (result.conflict) { + ctx.set.status = 409; + return { + error: "conflict", + message: "Automation was edited on the dashboard since last push", + lastEditedFrom: "dashboard", + updatedAt: result.updatedAt, + }; + } + + ctx.set.status = result.created ? 201 : 200; + return { + id: result.id, + slug: result.slug, + status: result.status, + updatedAt: result.updatedAt, + remoteHash: body.sourceHash, + }; + }, + { + body: t.Object({ + slug: t.String({ + description: "Automation slug (filename without extension)", + }), + name: t.String({ description: "Automation display name" }), + description: t.Optional( + t.String({ description: "Automation description" }) + ), + sourceTs: t.String({ description: "Original TypeScript source code" }), + sourceHash: t.String({ description: "SHA256 hash of source file" }), + steps: t.Array( + t.Object({ + id: t.String(), + type: t.String(), + name: t.String(), + position: t.Object({ x: t.Number(), y: t.Number() }), + config: t.Any(), + }), + { description: "Flat array of automation steps" } + ), + transitions: t.Array( + t.Object({ + id: t.String(), + fromStepId: t.String(), + toStepId: t.String(), + condition: t.Optional( + t.Object({ + branch: t.String(), + }) + ), + }), + { description: "Flat array of step transitions" } + ), + triggerType: t.String({ description: "Trigger type" }), + triggerConfig: t.Optional( + t.Any({ description: "Trigger configuration" }) + ), + settings: t.Optional( + t.Object({ + allowReentry: t.Optional(t.Boolean()), + reentryDelaySeconds: t.Optional(t.Number()), + maxConcurrentExecutions: t.Optional(t.Number()), + contactCooldownSeconds: t.Optional(t.Number()), + }) + ), + defaults: t.Optional( + t.Object({ + from: t.Optional(t.String()), + fromName: t.Optional(t.String()), + replyTo: t.Optional(t.String()), + senderId: t.Optional(t.String()), + }) + ), + cliProjectPath: t.Optional( + t.String({ + description: "Path in project (e.g. automations/onboarding.ts)", + }) + ), + force: t.Optional( + t.Boolean({ + description: "Force overwrite even if edited on dashboard", + }) + ), + draft: t.Optional( + t.Boolean({ + description: "Push as draft without enabling the automation", + }) + ), + }), + detail: { + tags: ["automations"], + summary: "Push an automation from CLI", + description: + "Upserts an automation parsed from TypeScript source. Used by `wraps email automations push`.", + }, + } + ) + + // POST /push/batch — Push multiple automations in a transaction + .post( + "/push/batch", + async (ctx) => { + const authContext = (ctx as unknown as { auth: AuthContext }).auth; + const { body } = ctx; + + const results = await db.transaction(async (tx) => { + const settled = await Promise.allSettled( + body.automations.map(async (a) => { + const resolvedSteps = await resolveTemplateReferences( + tx, + authContext.organizationId, + a.steps as AutomationStep[] + ); + return upsertAutomationFromCli(tx, authContext, { + ...a, + steps: resolvedSteps, + transitions: a.transitions as AutomationTransition[], + }); + }) + ); + + // If any rejected with unexpected errors, throw to rollback + const errors = settled.filter( + (s): s is PromiseRejectedResult => s.status === "rejected" + ); + if (errors.length > 0) { + throw errors[0].reason; + } + + return settled + .filter( + (s): s is PromiseFulfilledResult => + s.status === "fulfilled" + ) + .map((s) => s.value); + }); + + // Check if any had conflicts + const conflicts = results.filter((r) => r.conflict); + if (conflicts.length > 0) { + ctx.set.status = 409; + return { + error: "conflict", + conflicts: conflicts.map((c) => ({ + slug: c.slug, + message: "Automation was edited on the dashboard since last push", + updatedAt: c.updatedAt, + })), + results: results + .filter((r) => !r.conflict) + .map((r) => ({ + slug: r.slug, + id: r.id, + status: r.status, + })), + }; + } + + return { + results: results.map((r) => ({ + slug: r.slug, + id: r.id, + status: r.status, + })), + }; + }, + { + body: t.Object({ + automations: t.Array( + t.Object({ + slug: t.String(), + name: t.String(), + description: t.Optional(t.String()), + sourceTs: t.String(), + sourceHash: t.String(), + steps: t.Array( + t.Object({ + id: t.String(), + type: t.String(), + name: t.String(), + position: t.Object({ x: t.Number(), y: t.Number() }), + config: t.Any(), + }) + ), + transitions: t.Array( + t.Object({ + id: t.String(), + fromStepId: t.String(), + toStepId: t.String(), + condition: t.Optional( + t.Object({ + branch: t.String(), + }) + ), + }) + ), + triggerType: t.String(), + triggerConfig: t.Optional(t.Any()), + settings: t.Optional( + t.Object({ + allowReentry: t.Optional(t.Boolean()), + reentryDelaySeconds: t.Optional(t.Number()), + maxConcurrentExecutions: t.Optional(t.Number()), + contactCooldownSeconds: t.Optional(t.Number()), + }) + ), + defaults: t.Optional( + t.Object({ + from: t.Optional(t.String()), + fromName: t.Optional(t.String()), + replyTo: t.Optional(t.String()), + senderId: t.Optional(t.String()), + }) + ), + cliProjectPath: t.Optional(t.String()), + force: t.Optional(t.Boolean()), + draft: t.Optional(t.Boolean()), + }) + ), + }), + detail: { + tags: ["automations"], + summary: "Push multiple automations from CLI", + description: "Batch upsert automations parsed from TypeScript source.", + }, + } + ) + + // GET /pull — List all code-pushed automations with source + .get( + "/pull", + async (ctx) => { + const authContext = (ctx as unknown as { auth: AuthContext }).auth; + + const automations = await db + .select({ + id: automation.id, + slug: automation.slug, + name: automation.name, + description: automation.description, + sourceTs: automation.sourceTs, + sourceHash: automation.sourceHash, + status: automation.status, + triggerType: automation.triggerType, + triggerConfig: automation.triggerConfig, + steps: automation.steps, + transitions: automation.transitions, + updatedAt: automation.updatedAt, + lastEditedFrom: automation.lastEditedFrom, + }) + .from(automation) + .where( + and( + eq(automation.organizationId, authContext.organizationId), + eq(automation.pushedFromCli, true) + ) + ); + + return { + automations: automations + .filter((a) => a.slug != null) + .map((a) => ({ + ...a, + updatedAt: a.updatedAt.toISOString(), + })), + }; + }, + { + detail: { + tags: ["automations"], + summary: "Pull automations for CLI sync", + description: + "Returns all automations pushed from CLI with their TypeScript source.", + }, + } + ); + +/** @deprecated Use `automationsSyncRoutes` instead */ +export const workflowsSyncRoutes = automationsSyncRoutes; + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +type PushBody = { + slug: string; + name: string; + description?: string; + sourceTs: string; + sourceHash: string; + steps: AutomationStep[]; + transitions: AutomationTransition[]; + triggerType: string; + triggerConfig?: TriggerConfig; + settings?: { + allowReentry?: boolean; + reentryDelaySeconds?: number; + maxConcurrentExecutions?: number; + contactCooldownSeconds?: number; + }; + defaults?: { + from?: string; + fromName?: string; + replyTo?: string; + senderId?: string; + }; + cliProjectPath?: string; + force?: boolean; + draft?: boolean; +}; + +type UpsertResult = { + id: string; + slug: string; + status: "draft" | "enabled"; + updatedAt: string; + created: boolean; + conflict?: boolean; +}; + +/** + * Resolve template slug references to UUIDs. + * + * In the CLI, send_email and send_sms steps use template slugs (e.g., "welcome"). + * The API needs to resolve these to actual template UUIDs. + */ +export async function resolveTemplateReferences( + tx: DbOrTx, + organizationId: string, + steps: AutomationStep[] +): Promise { + // Collect all template slugs referenced in steps + const templateSlugs = new Set(); + for (const step of steps) { + if (step.config.type === "send_email" || step.config.type === "send_sms") { + const config = step.config as { templateId?: string; template?: string }; + const slug = config.templateId || config.template; + if (slug) { + templateSlugs.add(slug); + } + } + } + + if (templateSlugs.size === 0) { + return steps; + } + + // Fetch only the templates we need by slug + const templates = await tx + .select({ id: template.id, slug: template.slug }) + .from(template) + .where( + and( + eq(template.organizationId, organizationId), + inArray(template.slug, [...templateSlugs]) + ) + ); + + const slugToId = new Map( + templates.filter((t) => t.slug != null).map((t) => [t.slug!, t.id]) + ); + + // Replace slugs with IDs in step configs + return steps.map((step) => { + if (step.config.type === "send_email" || step.config.type === "send_sms") { + const config = step.config as { templateId?: string; template?: string }; + const slug = config.templateId || config.template; + if (slug && slugToId.has(slug)) { + return { + ...step, + config: { + ...step.config, + templateId: slugToId.get(slug)!, + }, + }; + } + } + return step; + }); +} + +export async function upsertAutomationFromCli( + tx: DbOrTx, + authContext: AuthContext, + body: PushBody +): Promise { + const now = new Date(); + const targetStatus = body.draft ? "draft" : "enabled"; + + // Look up the org's AWS account so automations can send emails/SMS + const [orgAwsAccount] = await tx + .select({ id: awsAccount.id }) + .from(awsAccount) + .where(eq(awsAccount.organizationId, authContext.organizationId)) + .limit(1); + + // Check for existing automation by (organizationId, slug) + const [existing] = await tx + .select({ + id: automation.id, + lastEditedFrom: automation.lastEditedFrom, + updatedAt: automation.updatedAt, + }) + .from(automation) + .where( + and( + eq(automation.organizationId, authContext.organizationId), + eq(automation.slug, body.slug) + ) + ) + .limit(1); + + if (existing) { + // Conflict check: if last edited from dashboard and not forcing, reject + if (existing.lastEditedFrom === "dashboard" && !body.force) { + return { + id: existing.id, + slug: body.slug, + status: targetStatus, + updatedAt: existing.updatedAt.toISOString(), + created: false, + conflict: true, + }; + } + + // Update existing automation (bump version since steps/transitions change) + await tx + .update(automation) + .set({ + name: body.name, + description: body.description, + sourceTs: body.sourceTs, + sourceHash: body.sourceHash, + steps: body.steps, + transitions: body.transitions, + version: sql`${automation.version} + 1`, + triggerType: body.triggerType as AutomationTriggerType, + triggerConfig: body.triggerConfig ?? {}, + awsAccountId: orgAwsAccount?.id ?? null, + allowReentry: body.settings?.allowReentry ?? false, + reentryDelaySeconds: body.settings?.reentryDelaySeconds, + maxConcurrentExecutions: body.settings?.maxConcurrentExecutions, + contactCooldownSeconds: body.settings?.contactCooldownSeconds, + defaultFrom: body.defaults?.from, + defaultFromName: body.defaults?.fromName, + defaultReplyTo: body.defaults?.replyTo, + defaultSenderId: body.defaults?.senderId, + status: targetStatus, + pushedFromCli: true, + lastPushedAt: now, + cliProjectPath: body.cliProjectPath, + lastEditedFrom: "cli", + updatedAt: now, + }) + .where(eq(automation.id, existing.id)); + + return { + id: existing.id, + slug: body.slug, + status: targetStatus, + updatedAt: now.toISOString(), + created: false, + }; + } + + // Insert new automation + const id = crypto.randomUUID(); + await tx.insert(automation).values({ + id, + organizationId: authContext.organizationId, + awsAccountId: orgAwsAccount?.id ?? null, + name: body.name, + slug: body.slug, + description: body.description, + sourceTs: body.sourceTs, + sourceHash: body.sourceHash, + steps: body.steps, + transitions: body.transitions, + triggerType: body.triggerType as AutomationTriggerType, + triggerConfig: body.triggerConfig ?? {}, + allowReentry: body.settings?.allowReentry ?? false, + reentryDelaySeconds: body.settings?.reentryDelaySeconds, + maxConcurrentExecutions: body.settings?.maxConcurrentExecutions ?? 1000, + contactCooldownSeconds: body.settings?.contactCooldownSeconds, + defaultFrom: body.defaults?.from, + defaultFromName: body.defaults?.fromName, + defaultReplyTo: body.defaults?.replyTo, + defaultSenderId: body.defaults?.senderId, + status: targetStatus, + pushedFromCli: true, + lastPushedAt: now, + cliProjectPath: body.cliProjectPath, + lastEditedFrom: "cli", + createdBy: authContext.userId ?? undefined, + }); + + return { + id, + slug: body.slug, + status: targetStatus, + updatedAt: now.toISOString(), + created: true, + }; +} + +/** @deprecated Use `upsertAutomationFromCli` instead */ +export const upsertWorkflowFromCli = upsertAutomationFromCli; diff --git a/apps/api/src/routes/workflow-schedules.ts b/apps/api/src/routes/workflow-schedules.ts index e4c50968b..c1f9e2e2e 100644 --- a/apps/api/src/routes/workflow-schedules.ts +++ b/apps/api/src/routes/workflow-schedules.ts @@ -1,214 +1,5 @@ /** - * Workflow Schedule Routes - * - * Internal routes for managing EventBridge one-time schedules - * for schedule-triggered workflows. - * - * Called by server actions on enable/disable/update of scheduled workflows. + * @deprecated Import from `./automation-schedules` instead. + * This file is a backward-compatibility shim. */ - -import { db, eq, workflow } from "@wraps/db"; -import { and } from "drizzle-orm"; -import { t } from "elysia"; - -import { - type AuthContext, - createAuthenticatedRoutes, -} from "../middleware/auth"; -import { - createNextWorkflowSchedule, - deleteWorkflowSchedule, -} from "../services/workflow-scheduler"; - -/** - * Verify the workflow belongs to the authenticated organization. - * Returns the workflow ID if valid, or null if not found. - */ -async function verifyWorkflowOwnership( - workflowId: string, - organizationId: string -): Promise { - const [wf] = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - return !!wf; -} - -export const workflowScheduleRoutes = createAuthenticatedRoutes( - "/v1/workflow-schedules" -) - /** - * Enable a workflow schedule - * - * POST /v1/workflow-schedules/:workflowId/enable - * - * Creates the next one-time EventBridge Schedule for a workflow. - */ - .post( - "/:workflowId/enable", - async (ctx) => { - const { params, body, set } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - - // Verify workflow belongs to this organization - const isOwner = await verifyWorkflowOwnership( - params.workflowId, - auth.organizationId - ); - if (!isOwner) { - set.status = 404; - return { success: false, error: "Workflow not found" }; - } - - try { - const scheduleName = await createNextWorkflowSchedule({ - workflowId: params.workflowId, - organizationId: auth.organizationId, - cronExpression: body.cronExpression, - timezone: body.timezone, - }); - - return { success: true, scheduleName }; - } catch (error) { - console.error( - `[workflow-schedules] Failed to enable schedule for ${params.workflowId}:`, - error - ); - set.status = 500; - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to create schedule", - }; - } - }, - { - params: t.Object({ - workflowId: t.String(), - }), - body: t.Object({ - cronExpression: t.String(), - timezone: t.Optional(t.String()), - }), - } - ) - - /** - * Disable a workflow schedule - * - * POST /v1/workflow-schedules/:workflowId/disable - * - * Deletes the pending EventBridge Schedule for a workflow. - */ - .post( - "/:workflowId/disable", - async (ctx) => { - const { params, set } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - - // Verify workflow belongs to this organization - const isOwner = await verifyWorkflowOwnership( - params.workflowId, - auth.organizationId - ); - if (!isOwner) { - set.status = 404; - return { success: false, error: "Workflow not found" }; - } - - try { - await deleteWorkflowSchedule(params.workflowId); - return { success: true }; - } catch (error) { - console.error( - `[workflow-schedules] Failed to disable schedule for ${params.workflowId}:`, - error - ); - set.status = 500; - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to delete schedule", - }; - } - }, - { - params: t.Object({ - workflowId: t.String(), - }), - } - ) - - /** - * Update a workflow schedule (reschedule) - * - * PUT /v1/workflow-schedules/:workflowId - * - * Deletes the old schedule and creates a new one with updated cron. - */ - .put( - "/:workflowId", - async (ctx) => { - const { params, body, set } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - - // Verify workflow belongs to this organization - const isOwner = await verifyWorkflowOwnership( - params.workflowId, - auth.organizationId - ); - if (!isOwner) { - set.status = 404; - return { success: false, error: "Workflow not found" }; - } - - try { - // Delete old schedule first - await deleteWorkflowSchedule(params.workflowId); - - // Create new schedule with updated cron - const scheduleName = await createNextWorkflowSchedule({ - workflowId: params.workflowId, - organizationId: auth.organizationId, - cronExpression: body.cronExpression, - timezone: body.timezone, - }); - - return { success: true, scheduleName }; - } catch (error) { - console.error( - `[workflow-schedules] Failed to update schedule for ${params.workflowId}:`, - error - ); - set.status = 500; - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to update schedule", - }; - } - }, - { - params: t.Object({ - workflowId: t.String(), - }), - body: t.Object({ - cronExpression: t.String(), - timezone: t.Optional(t.String()), - }), - } - ); +export * from "./automation-schedules"; diff --git a/apps/api/src/routes/workflows-sync.ts b/apps/api/src/routes/workflows-sync.ts index 413748ab7..c9aa3cc53 100644 --- a/apps/api/src/routes/workflows-sync.ts +++ b/apps/api/src/routes/workflows-sync.ts @@ -1,557 +1,5 @@ /** - * Workflows Sync Routes - * - * CLI-to-platform workflow synchronization for "workflows as code". - * - * POST /v1/workflows/push - Upsert a single workflow from CLI - * POST /v1/workflows/push/batch - Push multiple workflows atomically - * GET /v1/workflows/pull - List all code-pushed workflows with source + * @deprecated Import from `./automations-sync` instead. + * This file is a backward-compatibility shim. */ - -import { - and, - awsAccount, - db, - eq, - type TriggerConfig, - template, - type WorkflowStep, - type WorkflowTransition, - type WorkflowTriggerType, - workflow, -} from "@wraps/db"; -import { inArray, sql } from "drizzle-orm"; -import { t } from "elysia"; -import type { AuthContext } from "../middleware/auth"; -import { createAuthenticatedRoutes } from "../middleware/auth"; - -type DbOrTx = - | typeof db - | Parameters[0]>[0]; - -// ═══════════════════════════════════════════════════════════════════════════ -// ROUTES -// ═══════════════════════════════════════════════════════════════════════════ - -export const workflowsSyncRoutes = createAuthenticatedRoutes("/v1/workflows") - // POST /push — Upsert a single workflow from CLI - .post( - "/push", - async (ctx) => { - const authContext = (ctx as unknown as { auth: AuthContext }).auth; - const { body } = ctx; - - // Resolve template slugs to IDs - const resolvedSteps = await resolveTemplateReferences( - db, - authContext.organizationId, - body.steps as WorkflowStep[] - ); - - const result = await upsertWorkflowFromCli(db, authContext, { - ...body, - steps: resolvedSteps, - transitions: body.transitions as WorkflowTransition[], - }); - - if (result.conflict) { - ctx.set.status = 409; - return { - error: "conflict", - message: "Workflow was edited on the dashboard since last push", - lastEditedFrom: "dashboard", - updatedAt: result.updatedAt, - }; - } - - ctx.set.status = result.created ? 201 : 200; - return { - id: result.id, - slug: result.slug, - status: result.status, - updatedAt: result.updatedAt, - remoteHash: body.sourceHash, - }; - }, - { - body: t.Object({ - slug: t.String({ - description: "Workflow slug (filename without extension)", - }), - name: t.String({ description: "Workflow display name" }), - description: t.Optional( - t.String({ description: "Workflow description" }) - ), - sourceTs: t.String({ description: "Original TypeScript source code" }), - sourceHash: t.String({ description: "SHA256 hash of source file" }), - steps: t.Array( - t.Object({ - id: t.String(), - type: t.String(), - name: t.String(), - position: t.Object({ x: t.Number(), y: t.Number() }), - config: t.Any(), - }), - { description: "Flat array of workflow steps" } - ), - transitions: t.Array( - t.Object({ - id: t.String(), - fromStepId: t.String(), - toStepId: t.String(), - condition: t.Optional( - t.Object({ - branch: t.String(), - }) - ), - }), - { description: "Flat array of step transitions" } - ), - triggerType: t.String({ description: "Trigger type" }), - triggerConfig: t.Optional( - t.Any({ description: "Trigger configuration" }) - ), - settings: t.Optional( - t.Object({ - allowReentry: t.Optional(t.Boolean()), - reentryDelaySeconds: t.Optional(t.Number()), - maxConcurrentExecutions: t.Optional(t.Number()), - contactCooldownSeconds: t.Optional(t.Number()), - }) - ), - defaults: t.Optional( - t.Object({ - from: t.Optional(t.String()), - fromName: t.Optional(t.String()), - replyTo: t.Optional(t.String()), - senderId: t.Optional(t.String()), - }) - ), - cliProjectPath: t.Optional( - t.String({ - description: "Path in project (e.g. workflows/onboarding.ts)", - }) - ), - force: t.Optional( - t.Boolean({ - description: "Force overwrite even if edited on dashboard", - }) - ), - draft: t.Optional( - t.Boolean({ - description: "Push as draft without enabling the workflow", - }) - ), - }), - detail: { - tags: ["workflows"], - summary: "Push a workflow from CLI", - description: - "Upserts a workflow parsed from TypeScript source. Used by `wraps email workflows push`.", - }, - } - ) - - // POST /push/batch — Push multiple workflows in a transaction - .post( - "/push/batch", - async (ctx) => { - const authContext = (ctx as unknown as { auth: AuthContext }).auth; - const { body } = ctx; - - const results = await db.transaction(async (tx) => { - const settled = await Promise.allSettled( - body.workflows.map(async (wf) => { - const resolvedSteps = await resolveTemplateReferences( - tx, - authContext.organizationId, - wf.steps as WorkflowStep[] - ); - return upsertWorkflowFromCli(tx, authContext, { - ...wf, - steps: resolvedSteps, - transitions: wf.transitions as WorkflowTransition[], - }); - }) - ); - - // If any rejected with unexpected errors, throw to rollback - const errors = settled.filter( - (s): s is PromiseRejectedResult => s.status === "rejected" - ); - if (errors.length > 0) { - throw errors[0].reason; - } - - return settled - .filter( - (s): s is PromiseFulfilledResult => - s.status === "fulfilled" - ) - .map((s) => s.value); - }); - - // Check if any had conflicts - const conflicts = results.filter((r) => r.conflict); - if (conflicts.length > 0) { - ctx.set.status = 409; - return { - error: "conflict", - conflicts: conflicts.map((c) => ({ - slug: c.slug, - message: "Workflow was edited on the dashboard since last push", - updatedAt: c.updatedAt, - })), - results: results - .filter((r) => !r.conflict) - .map((r) => ({ - slug: r.slug, - id: r.id, - status: r.status, - })), - }; - } - - return { - results: results.map((r) => ({ - slug: r.slug, - id: r.id, - status: r.status, - })), - }; - }, - { - body: t.Object({ - workflows: t.Array( - t.Object({ - slug: t.String(), - name: t.String(), - description: t.Optional(t.String()), - sourceTs: t.String(), - sourceHash: t.String(), - steps: t.Array( - t.Object({ - id: t.String(), - type: t.String(), - name: t.String(), - position: t.Object({ x: t.Number(), y: t.Number() }), - config: t.Any(), - }) - ), - transitions: t.Array( - t.Object({ - id: t.String(), - fromStepId: t.String(), - toStepId: t.String(), - condition: t.Optional( - t.Object({ - branch: t.String(), - }) - ), - }) - ), - triggerType: t.String(), - triggerConfig: t.Optional(t.Any()), - settings: t.Optional( - t.Object({ - allowReentry: t.Optional(t.Boolean()), - reentryDelaySeconds: t.Optional(t.Number()), - maxConcurrentExecutions: t.Optional(t.Number()), - contactCooldownSeconds: t.Optional(t.Number()), - }) - ), - defaults: t.Optional( - t.Object({ - from: t.Optional(t.String()), - fromName: t.Optional(t.String()), - replyTo: t.Optional(t.String()), - senderId: t.Optional(t.String()), - }) - ), - cliProjectPath: t.Optional(t.String()), - force: t.Optional(t.Boolean()), - draft: t.Optional(t.Boolean()), - }) - ), - }), - detail: { - tags: ["workflows"], - summary: "Push multiple workflows from CLI", - description: "Batch upsert workflows parsed from TypeScript source.", - }, - } - ) - - // GET /pull — List all code-pushed workflows with source - .get( - "/pull", - async (ctx) => { - const authContext = (ctx as unknown as { auth: AuthContext }).auth; - - const workflows = await db - .select({ - id: workflow.id, - slug: workflow.slug, - name: workflow.name, - description: workflow.description, - sourceTs: workflow.sourceTs, - sourceHash: workflow.sourceHash, - status: workflow.status, - triggerType: workflow.triggerType, - triggerConfig: workflow.triggerConfig, - steps: workflow.steps, - transitions: workflow.transitions, - updatedAt: workflow.updatedAt, - lastEditedFrom: workflow.lastEditedFrom, - }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, authContext.organizationId), - eq(workflow.pushedFromCli, true) - ) - ); - - return { - workflows: workflows - .filter((w) => w.slug != null) - .map((w) => ({ - ...w, - updatedAt: w.updatedAt.toISOString(), - })), - }; - }, - { - detail: { - tags: ["workflows"], - summary: "Pull workflows for CLI sync", - description: - "Returns all workflows pushed from CLI with their TypeScript source.", - }, - } - ); - -// ═══════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - -type PushBody = { - slug: string; - name: string; - description?: string; - sourceTs: string; - sourceHash: string; - steps: WorkflowStep[]; - transitions: WorkflowTransition[]; - triggerType: string; - triggerConfig?: TriggerConfig; - settings?: { - allowReentry?: boolean; - reentryDelaySeconds?: number; - maxConcurrentExecutions?: number; - contactCooldownSeconds?: number; - }; - defaults?: { - from?: string; - fromName?: string; - replyTo?: string; - senderId?: string; - }; - cliProjectPath?: string; - force?: boolean; - draft?: boolean; -}; - -type UpsertResult = { - id: string; - slug: string; - status: "draft" | "enabled"; - updatedAt: string; - created: boolean; - conflict?: boolean; -}; - -/** - * Resolve template slug references to UUIDs. - * - * In the CLI, send_email and send_sms steps use template slugs (e.g., "welcome"). - * The API needs to resolve these to actual template UUIDs. - */ -export async function resolveTemplateReferences( - tx: DbOrTx, - organizationId: string, - steps: WorkflowStep[] -): Promise { - // Collect all template slugs referenced in steps - const templateSlugs = new Set(); - for (const step of steps) { - if (step.config.type === "send_email" || step.config.type === "send_sms") { - const config = step.config as { templateId?: string; template?: string }; - const slug = config.templateId || config.template; - if (slug) { - templateSlugs.add(slug); - } - } - } - - if (templateSlugs.size === 0) { - return steps; - } - - // Fetch only the templates we need by slug - const templates = await tx - .select({ id: template.id, slug: template.slug }) - .from(template) - .where( - and( - eq(template.organizationId, organizationId), - inArray(template.slug, [...templateSlugs]) - ) - ); - - const slugToId = new Map( - templates.filter((t) => t.slug != null).map((t) => [t.slug!, t.id]) - ); - - // Replace slugs with IDs in step configs - return steps.map((step) => { - if (step.config.type === "send_email" || step.config.type === "send_sms") { - const config = step.config as { templateId?: string; template?: string }; - const slug = config.templateId || config.template; - if (slug && slugToId.has(slug)) { - return { - ...step, - config: { - ...step.config, - templateId: slugToId.get(slug)!, - }, - }; - } - } - return step; - }); -} - -export async function upsertWorkflowFromCli( - tx: DbOrTx, - authContext: AuthContext, - body: PushBody -): Promise { - const now = new Date(); - const targetStatus = body.draft ? "draft" : "enabled"; - - // Look up the org's AWS account so workflows can send emails/SMS - const [orgAwsAccount] = await tx - .select({ id: awsAccount.id }) - .from(awsAccount) - .where(eq(awsAccount.organizationId, authContext.organizationId)) - .limit(1); - - // Check for existing workflow by (organizationId, slug) - const [existing] = await tx - .select({ - id: workflow.id, - lastEditedFrom: workflow.lastEditedFrom, - updatedAt: workflow.updatedAt, - }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, authContext.organizationId), - eq(workflow.slug, body.slug) - ) - ) - .limit(1); - - if (existing) { - // Conflict check: if last edited from dashboard and not forcing, reject - if (existing.lastEditedFrom === "dashboard" && !body.force) { - return { - id: existing.id, - slug: body.slug, - status: targetStatus, - updatedAt: existing.updatedAt.toISOString(), - created: false, - conflict: true, - }; - } - - // Update existing workflow (bump version since steps/transitions change) - await tx - .update(workflow) - .set({ - name: body.name, - description: body.description, - sourceTs: body.sourceTs, - sourceHash: body.sourceHash, - steps: body.steps, - transitions: body.transitions, - version: sql`${workflow.version} + 1`, - triggerType: body.triggerType as WorkflowTriggerType, - triggerConfig: body.triggerConfig ?? {}, - awsAccountId: orgAwsAccount?.id ?? null, - allowReentry: body.settings?.allowReentry ?? false, - reentryDelaySeconds: body.settings?.reentryDelaySeconds, - maxConcurrentExecutions: body.settings?.maxConcurrentExecutions, - contactCooldownSeconds: body.settings?.contactCooldownSeconds, - defaultFrom: body.defaults?.from, - defaultFromName: body.defaults?.fromName, - defaultReplyTo: body.defaults?.replyTo, - defaultSenderId: body.defaults?.senderId, - status: targetStatus, - pushedFromCli: true, - lastPushedAt: now, - cliProjectPath: body.cliProjectPath, - lastEditedFrom: "cli", - updatedAt: now, - }) - .where(eq(workflow.id, existing.id)); - - return { - id: existing.id, - slug: body.slug, - status: targetStatus, - updatedAt: now.toISOString(), - created: false, - }; - } - - // Insert new workflow - const id = crypto.randomUUID(); - await tx.insert(workflow).values({ - id, - organizationId: authContext.organizationId, - awsAccountId: orgAwsAccount?.id ?? null, - name: body.name, - slug: body.slug, - description: body.description, - sourceTs: body.sourceTs, - sourceHash: body.sourceHash, - steps: body.steps, - transitions: body.transitions, - triggerType: body.triggerType as WorkflowTriggerType, - triggerConfig: body.triggerConfig ?? {}, - allowReentry: body.settings?.allowReentry ?? false, - reentryDelaySeconds: body.settings?.reentryDelaySeconds, - maxConcurrentExecutions: body.settings?.maxConcurrentExecutions ?? 1000, - contactCooldownSeconds: body.settings?.contactCooldownSeconds, - defaultFrom: body.defaults?.from, - defaultFromName: body.defaults?.fromName, - defaultReplyTo: body.defaults?.replyTo, - defaultSenderId: body.defaults?.senderId, - status: targetStatus, - pushedFromCli: true, - lastPushedAt: now, - cliProjectPath: body.cliProjectPath, - lastEditedFrom: "cli", - createdBy: authContext.userId ?? undefined, - }); - - return { - id, - slug: body.slug, - status: targetStatus, - updatedAt: now.toISOString(), - created: true, - }; -} +export * from "./automations-sync"; diff --git a/apps/api/src/services/__tests__/check-segment-entry.test.ts b/apps/api/src/services/__tests__/check-segment-entry.test.ts index b2bed6db5..d562784bc 100644 --- a/apps/api/src/services/__tests__/check-segment-entry.test.ts +++ b/apps/api/src/services/__tests__/check-segment-entry.test.ts @@ -29,6 +29,13 @@ vi.mock("@wraps/db", () => { getSegmentsByIds: vi.fn(), buildConditionSQL: vi.fn(), buildFilterSQL: vi.fn(), + automation: { + id: "id", + organizationId: "organization_id", + status: "status", + triggerType: "trigger_type", + triggerConfig: "trigger_config", + }, workflow: { id: "id", organizationId: "organization_id", @@ -52,6 +59,12 @@ vi.mock("@wraps/db", () => { eventName: "event_name", createdAt: "created_at", }, + automationExecution: { + id: "id", + workflowId: "workflow_id", + contactId: "contact_id", + status: "status", + }, workflowExecution: { id: "id", workflowId: "workflow_id", @@ -61,10 +74,16 @@ vi.mock("@wraps/db", () => { }; }); -// Mock workflow-queue -vi.mock("../workflow-queue", () => ({ - enqueueWorkflowStepBatch: vi.fn(), - enqueueWorkflowStep: vi.fn(), +// Mock workflow-queue — both names must share the same vi.fn() instance +const { mockEnqueueStepBatch, mockEnqueueStep } = vi.hoisted(() => ({ + mockEnqueueStepBatch: vi.fn(), + mockEnqueueStep: vi.fn(), +})); +vi.mock("../automation-queue", () => ({ + enqueueAutomationStepBatch: mockEnqueueStepBatch, + enqueueWorkflowStepBatch: mockEnqueueStepBatch, + enqueueAutomationStep: mockEnqueueStep, + enqueueWorkflowStep: mockEnqueueStep, deleteScheduledStep: vi.fn(), })); @@ -80,11 +99,10 @@ vi.mock("../../lib/logger", () => ({ // Import after mocks are set up import { contactMatchesCondition, getSegmentsByIds } from "@wraps/db"; import { checkSegmentEntry, checkSegmentExit } from "../workflow-events"; -import { enqueueWorkflowStepBatch } from "../workflow-queue"; const mockContactMatches = vi.mocked(contactMatchesCondition); const mockGetSegments = vi.mocked(getSegmentsByIds); -const mockEnqueue = vi.mocked(enqueueWorkflowStepBatch); +const mockEnqueue = mockEnqueueStepBatch; describe("checkSegmentEntry", () => { beforeEach(() => { diff --git a/apps/api/src/services/__tests__/schedule-chain-reconciliation.test.ts b/apps/api/src/services/__tests__/schedule-chain-reconciliation.test.ts index e8bb79945..7dc39cb46 100644 --- a/apps/api/src/services/__tests__/schedule-chain-reconciliation.test.ts +++ b/apps/api/src/services/__tests__/schedule-chain-reconciliation.test.ts @@ -157,16 +157,20 @@ vi.mock("../../services/credentials", () => ({ }), })); -vi.mock("../../services/workflow-queue", () => ({ +vi.mock("../../services/automation-queue", () => ({ + enqueueAutomationStep: mockEnqueueWorkflowStep, enqueueWorkflowStep: mockEnqueueWorkflowStep, + enqueueAutomationStepBatch: mockEnqueueWorkflowStepBatch, enqueueWorkflowStepBatch: mockEnqueueWorkflowStepBatch, scheduleWaitTimeout: mockScheduleWaitTimeout, + scheduleAutomationStep: mockScheduleWorkflowStep, scheduleWorkflowStep: mockScheduleWorkflowStep, deleteScheduledStep: mockDeleteScheduledStep, formatScheduleExpression: vi.fn((d: Date) => `at(${d.toISOString()})`), })); -vi.mock("../../services/workflow-scheduler", () => ({ +vi.mock("../../services/automation-scheduler", () => ({ + createNextAutomationSchedule: mockCreateNextWorkflowSchedule, createNextWorkflowSchedule: mockCreateNextWorkflowSchedule, reconcileScheduleChains: mockReconcileScheduleChains, })); diff --git a/apps/api/src/services/__tests__/workflow-events.test.ts b/apps/api/src/services/__tests__/workflow-events.test.ts index a180bee81..f32a2e5ca 100644 --- a/apps/api/src/services/__tests__/workflow-events.test.ts +++ b/apps/api/src/services/__tests__/workflow-events.test.ts @@ -24,7 +24,11 @@ const enqueuedSteps: Array<{ }> = []; // Mock enqueueWorkflowStep -vi.mock("../workflow-queue", () => ({ +vi.mock("../automation-queue", () => ({ + enqueueAutomationStep: vi.fn().mockImplementation((step) => { + enqueuedSteps.push(step); + return Promise.resolve(); + }), enqueueWorkflowStep: vi.fn().mockImplementation((step) => { enqueuedSteps.push(step); return Promise.resolve(); @@ -79,6 +83,13 @@ vi.mock("@wraps/db", () => ({ contact: { id: "id" }, contactEvent: {}, segment: { id: "id", name: "name" }, + automation: { + id: "id", + organizationId: "organization_id", + status: "status", + triggerType: "trigger_type", + triggerConfig: "trigger_config", + }, workflow: { id: "id", organizationId: "organization_id", @@ -86,6 +97,14 @@ vi.mock("@wraps/db", () => ({ triggerType: "trigger_type", triggerConfig: "trigger_config", }, + automationExecution: { + id: "id", + contactId: "contact_id", + workflowId: "workflow_id", + status: "status", + delaySchedulerName: "delay_scheduler_name", + waitTimeoutSchedulerName: "wait_timeout_scheduler_name", + }, workflowExecution: { id: "id", contactId: "contact_id", diff --git a/apps/api/src/services/__tests__/workflow-scheduler.test.ts b/apps/api/src/services/__tests__/workflow-scheduler.test.ts index b137df048..57cf6a112 100644 --- a/apps/api/src/services/__tests__/workflow-scheduler.test.ts +++ b/apps/api/src/services/__tests__/workflow-scheduler.test.ts @@ -88,7 +88,7 @@ describe("createNextWorkflowSchedule", () => { cronExpression: "0 9 * * 1", }) ).rejects.toThrow( - "EventBridge Scheduler not configured for workflow schedules" + "EventBridge Scheduler not configured for automation schedules" ); }); diff --git a/apps/api/src/services/automation-events.ts b/apps/api/src/services/automation-events.ts new file mode 100644 index 000000000..c0aa417ad --- /dev/null +++ b/apps/api/src/services/automation-events.ts @@ -0,0 +1,696 @@ +/** + * Automation Events Service + * + * Handles emitting internal events to trigger automations. + * Used for contact lifecycle events, topic subscriptions, etc. + */ + +import { + automation, + automationExecution, + contactEvent, + contactMatchesCondition, + db, + eq, + getSegmentsByIds, +} from "@wraps/db"; +import { and, inArray, sql } from "drizzle-orm"; + +import { log } from "../lib/logger"; +import { + type AutomationJob, + deleteScheduledStep, + enqueueAutomationStep, + enqueueAutomationStepBatch, +} from "./automation-queue"; + +/** + * Emit an internal event that may trigger automations + * + * @param params Event parameters + * @returns Number of automations triggered + */ +export async function emitAutomationEvent(params: { + eventName: string; + contactId: string; + organizationId: string; + eventData?: Record; + /** Skip recording to contact_event table (for internal events that are already tracked elsewhere) */ + skipEventRecord?: boolean; +}): Promise<{ workflowsTriggered: number }> { + const { eventName, contactId, organizationId, eventData, skipEventRecord } = + params; + + // Record the event to contact_event table (for segment evaluation) + if (!skipEventRecord) { + try { + await db.insert(contactEvent).values({ + contactId, + organizationId, + eventName, + eventData, + }); + } catch (error) { + // Log but don't fail the event emission + log.error("Failed to record automation event", error, { + eventName, + contactId, + }); + } + } + + // Find matching automations + const matchingAutomations = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "event"), + sql`${automation.triggerConfig}->>'eventName' = ${eventName}` + ) + ); + + // Trigger each matching automation + for (const a of matchingAutomations) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId, + organizationId, + eventData: eventData || {}, + }); + } + + if (matchingAutomations.length > 0) { + log.info("Automation event triggered automations", { + eventName, + contactId, + workflowCount: matchingAutomations.length, + }); + } + + return { workflowsTriggered: matchingAutomations.length }; +} + +/** @deprecated Use `emitAutomationEvent` instead */ +export const emitWorkflowEvent = emitAutomationEvent; + +/** + * Emit contact_created event + * + * Matches automations with either: + * - triggerType: "event" + eventName: "contact_created" (generic event format) + * - triggerType: "contact_created" (direct trigger type from CLI-pushed automations) + */ +export async function emitContactCreated(params: { + contactId: string; + organizationId: string; + contactData?: Record; +}): Promise<{ workflowsTriggered: number }> { + const eventData = { + ...params.contactData, + createdAt: new Date().toISOString(), + }; + + // Match automations with triggerType: "event" + eventName: "contact_created" + const matchingByEvent = await emitAutomationEvent({ + eventName: "contact_created", + contactId: params.contactId, + organizationId: params.organizationId, + eventData, + }); + + // Match automations with triggerType: "contact_created" (CLI-pushed format) + const matchingByTrigger = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "contact_created") + ) + ); + + for (const a of matchingByTrigger) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData, + }); + } + + if (matchingByTrigger.length > 0) { + log.info("contact_created trigger matched automations", { + contactId: params.contactId, + workflowCount: matchingByTrigger.length, + }); + } + + return { + workflowsTriggered: + matchingByEvent.workflowsTriggered + matchingByTrigger.length, + }; +} + +/** + * Emit contact_updated event + * + * Matches automations with either: + * - triggerType: "event" + eventName: "contact_updated" (generic event format) + * - triggerType: "contact_updated" (direct trigger type from CLI-pushed automations) + */ +export async function emitContactUpdated(params: { + contactId: string; + organizationId: string; + updatedFields?: string[]; + contactData?: Record; +}): Promise<{ workflowsTriggered: number }> { + const eventData = { + ...params.contactData, + updatedFields: params.updatedFields, + updatedAt: new Date().toISOString(), + }; + + // Match automations with triggerType: "event" + eventName: "contact_updated" + const matchingByEvent = await emitAutomationEvent({ + eventName: "contact_updated", + contactId: params.contactId, + organizationId: params.organizationId, + eventData, + }); + + // Match automations with triggerType: "contact_updated" (CLI-pushed format) + const matchingByTrigger = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "contact_updated") + ) + ); + + for (const a of matchingByTrigger) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData, + }); + } + + if (matchingByTrigger.length > 0) { + log.info("contact_updated trigger matched automations", { + contactId: params.contactId, + workflowCount: matchingByTrigger.length, + }); + } + + return { + workflowsTriggered: + matchingByEvent.workflowsTriggered + matchingByTrigger.length, + }; +} + +/** + * Emit topic_subscribed event + */ +export async function emitTopicSubscribed(params: { + contactId: string; + organizationId: string; + topicId: string; + topicName?: string; +}): Promise<{ workflowsTriggered: number }> { + // Also check for topic_subscribed trigger type + const matchingByEvent = await emitAutomationEvent({ + eventName: "topic_subscribed", + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + topicId: params.topicId, + topicName: params.topicName, + subscribedAt: new Date().toISOString(), + }, + }); + + // Check for automations with topic_subscribed trigger type + const matchingByTrigger = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "topic_subscribed"), + sql`${automation.triggerConfig}->>'topicId' = ${params.topicId}` + ) + ); + + for (const a of matchingByTrigger) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + topicId: params.topicId, + topicName: params.topicName, + subscribedAt: new Date().toISOString(), + }, + }); + } + + if (matchingByTrigger.length > 0) { + log.info("topic_subscribed trigger matched automations", { + workflowCount: matchingByTrigger.length, + }); + } + + return { + workflowsTriggered: + matchingByEvent.workflowsTriggered + matchingByTrigger.length, + }; +} + +/** + * Emit topic_unsubscribed event + * + * Also cancels any active automation executions that were triggered by + * topic_subscribed for this topic. + */ +export async function emitTopicUnsubscribed(params: { + contactId: string; + organizationId: string; + topicId: string; + topicName?: string; +}): Promise<{ workflowsTriggered: number; executionsCancelled: number }> { + // Cancel any active executions for topic_subscribed automations + const { executionsCancelled } = await cancelExecutionsForTopicUnsubscribe({ + contactId: params.contactId, + organizationId: params.organizationId, + topicId: params.topicId, + }); + + // Check for event-based triggers + const matchingByEvent = await emitAutomationEvent({ + eventName: "topic_unsubscribed", + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + topicId: params.topicId, + topicName: params.topicName, + unsubscribedAt: new Date().toISOString(), + }, + }); + + // Check for automations with topic_unsubscribed trigger type + const matchingByTrigger = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "topic_unsubscribed"), + sql`${automation.triggerConfig}->>'topicId' = ${params.topicId}` + ) + ); + + for (const a of matchingByTrigger) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + topicId: params.topicId, + topicName: params.topicName, + unsubscribedAt: new Date().toISOString(), + }, + }); + } + + if (matchingByTrigger.length > 0) { + log.info("topic_unsubscribed trigger matched automations", { + workflowCount: matchingByTrigger.length, + }); + } + + return { + workflowsTriggered: + matchingByEvent.workflowsTriggered + matchingByTrigger.length, + executionsCancelled, + }; +} + +/** + * Check and emit segment entry events for a contact + * Call this after a contact is created or updated + * + * Uses SQL-based evaluation: batch-fetches segments (1 query), + * then runs one SQL query per segment to check if contact matches. + */ +export async function checkSegmentEntry(params: { + contactId: string; + organizationId: string; +}): Promise<{ workflowsTriggered: number }> { + // 1. Get automations with segment_entry trigger + const segmentAutomations = await db + .select({ + id: automation.id, + triggerConfig: automation.triggerConfig, + }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "segment_entry") + ) + ); + + if (segmentAutomations.length === 0) { + return { workflowsTriggered: 0 }; + } + + // 2. Extract unique segment IDs from automation configs + const segmentIds = [ + ...new Set( + segmentAutomations + .map( + (a) => (a.triggerConfig as { segmentId?: string } | null)?.segmentId + ) + .filter((id): id is string => !!id) + ), + ]; + + if (segmentIds.length === 0) { + return { workflowsTriggered: 0 }; + } + + // 3. Batch-fetch all segments (1 query) + const segmentsMap = await getSegmentsByIds(db, segmentIds); + + // 4. Evaluate via SQL and collect trigger jobs + const jobs: AutomationJob[] = []; + + for (const a of segmentAutomations) { + const config = a.triggerConfig as { segmentId?: string } | null; + if (!config?.segmentId) { + continue; + } + + const seg = segmentsMap.get(config.segmentId); + if (!seg) { + continue; + } + + try { + const matches = await contactMatchesCondition( + db, + params.contactId, + params.organizationId, + seg.condition + ); + + if (matches) { + jobs.push({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + segmentId: config.segmentId, + segmentName: seg.name, + enteredAt: new Date().toISOString(), + }, + }); + + log.info("Segment entry: triggered automation", { + contactId: params.contactId, + segmentId: config.segmentId, + automationId: a.id, + }); + } + } catch (error) { + log.error("Error checking segment entry", error, { + segmentId: config.segmentId, + }); + } + } + + // 5. Batch enqueue all trigger jobs + if (jobs.length > 0) { + await enqueueAutomationStepBatch(jobs); + } + + return { workflowsTriggered: jobs.length }; +} + +/** + * Check and emit segment exit events for a contact + * Call this after a contact is updated + * + * Uses SQL-based evaluation: batch-fetches segments (1 query), + * then runs one SQL query per segment to check if contact no longer matches. + */ +export async function checkSegmentExit(params: { + contactId: string; + organizationId: string; + previousSegmentIds?: string[]; // Optional: segments contact was previously in +}): Promise<{ workflowsTriggered: number }> { + // 1. Get automations with segment_exit trigger + const segmentAutomations = await db + .select({ + id: automation.id, + triggerConfig: automation.triggerConfig, + }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "segment_exit") + ) + ); + + if (segmentAutomations.length === 0) { + return { workflowsTriggered: 0 }; + } + + // 2. Extract unique segment IDs, filtering by previousSegmentIds if provided + const segmentIds = [ + ...new Set( + segmentAutomations + .map( + (a) => (a.triggerConfig as { segmentId?: string } | null)?.segmentId + ) + .filter((id): id is string => { + if (!id) { + return false; + } + if ( + params.previousSegmentIds && + !params.previousSegmentIds.includes(id) + ) { + return false; + } + return true; + }) + ), + ]; + + if (segmentIds.length === 0) { + return { workflowsTriggered: 0 }; + } + + // 3. Batch-fetch all segments (1 query) + const segmentsMap = await getSegmentsByIds(db, segmentIds); + + // 4. Evaluate via SQL and collect trigger jobs + const jobs: AutomationJob[] = []; + + for (const a of segmentAutomations) { + const config = a.triggerConfig as { segmentId?: string } | null; + if (!config?.segmentId) { + continue; + } + + // Skip if not in previousSegmentIds + if ( + params.previousSegmentIds && + !params.previousSegmentIds.includes(config.segmentId) + ) { + continue; + } + + const seg = segmentsMap.get(config.segmentId); + if (!seg) { + continue; + } + + try { + // Check if contact NO LONGER matches the segment via SQL + const matches = await contactMatchesCondition( + db, + params.contactId, + params.organizationId, + seg.condition + ); + + if (!matches) { + jobs.push({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + segmentId: config.segmentId, + segmentName: seg.name, + exitedAt: new Date().toISOString(), + }, + }); + + log.info("Segment exit: triggered automation", { + contactId: params.contactId, + segmentId: config.segmentId, + automationId: a.id, + }); + } + } catch (error) { + log.error("Error checking segment exit", error, { + segmentId: config.segmentId, + }); + } + } + + // 5. Batch enqueue all trigger jobs + if (jobs.length > 0) { + await enqueueAutomationStepBatch(jobs); + } + + return { workflowsTriggered: jobs.length }; +} + +/** + * Cancel active automation executions when a contact unsubscribes from a topic. + * + * This finds all active executions for automations triggered by topic_subscribed + * with the matching topicId and cancels them. + */ +export async function cancelExecutionsForTopicUnsubscribe(params: { + contactId: string; + organizationId: string; + topicId: string; +}): Promise<{ executionsCancelled: number }> { + const { contactId, organizationId, topicId } = params; + + // Find automations triggered by topic_subscribed for this topic + const matchingAutomations = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, organizationId), + eq(automation.triggerType, "topic_subscribed"), + sql`${automation.triggerConfig}->>'topicId' = ${topicId}` + ) + ); + + if (matchingAutomations.length === 0) { + return { executionsCancelled: 0 }; + } + + const automationIds = matchingAutomations.map((a) => a.id); + + // Find active executions for this contact in these automations + const activeExecutions = await db + .select({ + id: automationExecution.id, + workflowId: automationExecution.workflowId, + delaySchedulerName: automationExecution.delaySchedulerName, + waitTimeoutSchedulerName: automationExecution.waitTimeoutSchedulerName, + }) + .from(automationExecution) + .where( + and( + eq(automationExecution.contactId, contactId), + inArray(automationExecution.workflowId, automationIds), + sql`${automationExecution.status} IN ('pending', 'active', 'paused', 'waiting')` + ) + ); + + if (activeExecutions.length === 0) { + return { executionsCancelled: 0 }; + } + + // Clean up any scheduled steps (in parallel) + const schedulerCleanups: Promise[] = []; + for (const execution of activeExecutions) { + if (execution.delaySchedulerName) { + schedulerCleanups.push( + deleteScheduledStep(execution.delaySchedulerName).catch((err) => { + log.error("Failed to delete delay scheduler", err, { + schedulerName: execution.delaySchedulerName, + }); + }) + ); + } + + if (execution.waitTimeoutSchedulerName) { + schedulerCleanups.push( + deleteScheduledStep(execution.waitTimeoutSchedulerName).catch((err) => { + log.error("Failed to delete timeout scheduler", err, { + schedulerName: execution.waitTimeoutSchedulerName, + }); + }) + ); + } + } + await Promise.all(schedulerCleanups); + + // Batch cancel all executions + const executionIds = activeExecutions.map((e) => e.id); + await db + .update(automationExecution) + .set({ + status: "cancelled", + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(inArray(automationExecution.id, executionIds)); + + // Decrement active execution counts per automation + const countsByAutomation = new Map(); + for (const execution of activeExecutions) { + countsByAutomation.set( + execution.workflowId, + (countsByAutomation.get(execution.workflowId) ?? 0) + 1 + ); + } + await Promise.all( + [...countsByAutomation.entries()].map(([aId, count]) => + db + .update(automation) + .set({ + activeExecutions: sql`GREATEST(0, ${automation.activeExecutions} - ${count})`, + }) + .where(eq(automation.id, aId)) + ) + ); + + log.info("Automation: cancelled executions for topic unsubscribe", { + contactId, + topicId, + count: activeExecutions.length, + }); + + return { executionsCancelled: activeExecutions.length }; +} diff --git a/apps/api/src/services/automation-queue.ts b/apps/api/src/services/automation-queue.ts new file mode 100644 index 000000000..389ad892a --- /dev/null +++ b/apps/api/src/services/automation-queue.ts @@ -0,0 +1,287 @@ +/** + * Automation Queue Service + * + * Manages enqueueing automation steps for processing and scheduling delays. + */ + +import { + CreateScheduleCommand, + DeleteScheduleCommand, + SchedulerClient, +} from "@aws-sdk/client-scheduler"; +import { + SendMessageBatchCommand, + SendMessageCommand, + SQSClient, +} from "@aws-sdk/client-sqs"; + +const sqs = new SQSClient({}); +const scheduler = new SchedulerClient({}); + +/** + * Format a date for EventBridge Scheduler at() expression. + * Must be in format: at(yyyy-MM-ddTHH:mm:ss) without milliseconds or timezone. + */ +export function formatScheduleExpression(date: Date): string { + const iso = date.toISOString(); // 2026-01-08T04:37:29.148Z + const withoutMs = iso.split(".")[0]; // 2026-01-08T04:37:29 + return `at(${withoutMs})`; +} + +/** + * Generate a short schedule name that fits within the 64-char limit. + * Uses first 8 chars of each UUID to create unique but short names. + */ +export function generateScheduleName( + prefix: string, + executionId: string, + stepId: string +): string { + const shortExecId = executionId.slice(0, 8); + const shortStepId = stepId.slice(0, 8); + return `${prefix}-${shortExecId}-${shortStepId}`; +} + +const WORKFLOW_QUEUE_URL = process.env.WORKFLOW_QUEUE_URL; +const WORKFLOW_QUEUE_ARN = process.env.WORKFLOW_QUEUE_ARN; +const SCHEDULE_GROUP = process.env.SCHEDULER_GROUP_NAME || "wraps-workflows"; +const SCHEDULER_ROLE_ARN = process.env.SCHEDULER_ROLE_ARN; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + +/** + * Job types for the automation queue + */ +export type AutomationJob = + | { + type: "execute"; + executionId: string; + stepId: string; + organizationId: string; + } + | { + type: "resume"; + executionId: string; + branch: "yes" | "no" | "timeout" | "opened" | "clicked" | "bounced"; + organizationId: string; + } + | { + type: "trigger"; + workflowId: string; + contactId: string; + organizationId: string; + eventData?: Record; + } + | { + type: "schedule-trigger"; + workflowId: string; + organizationId: string; + }; + +/** @deprecated Use `AutomationJob` instead */ +export type WorkflowJob = AutomationJob; + +/** + * Enqueue an automation step for immediate processing + */ +export async function enqueueAutomationStep(job: AutomationJob): Promise { + if (!WORKFLOW_QUEUE_URL) { + if (IS_PRODUCTION) { + throw new Error("WORKFLOW_QUEUE_URL not configured"); + } + console.warn( + "[automation-queue] Skipping enqueue - queue not configured", + job + ); + return; + } + + await sqs.send( + new SendMessageCommand({ + QueueUrl: WORKFLOW_QUEUE_URL, + MessageBody: JSON.stringify(job), + }) + ); +} + +/** @deprecated Use `enqueueAutomationStep` instead */ +export const enqueueWorkflowStep = enqueueAutomationStep; + +/** + * Enqueue multiple automation steps in batch (up to 10 per SQS SendMessageBatch call) + */ +export async function enqueueAutomationStepBatch( + jobs: AutomationJob[] +): Promise { + if (jobs.length === 0) { + return; + } + + if (!WORKFLOW_QUEUE_URL) { + if (IS_PRODUCTION) { + throw new Error("WORKFLOW_QUEUE_URL not configured"); + } + console.warn( + "[automation-queue] Skipping batch enqueue - queue not configured", + { count: jobs.length } + ); + return; + } + + // SQS SendMessageBatch supports max 10 messages per call — fire all chunks in parallel + const chunks: AutomationJob[][] = []; + for (let i = 0; i < jobs.length; i += 10) { + chunks.push(jobs.slice(i, i + 10)); + } + await Promise.all( + chunks.map((chunk, chunkIdx) => + sqs.send( + new SendMessageBatchCommand({ + QueueUrl: WORKFLOW_QUEUE_URL, + Entries: chunk.map((job, idx) => ({ + Id: String(chunkIdx * 10 + idx), + MessageBody: JSON.stringify(job), + })), + }) + ) + ) + ); +} + +/** @deprecated Use `enqueueAutomationStepBatch` instead */ +export const enqueueWorkflowStepBatch = enqueueAutomationStepBatch; + +/** + * Schedule an automation step to execute after a delay + */ +export async function scheduleAutomationStep(params: { + executionId: string; + stepId: string; + organizationId: string; + delaySeconds: number; +}): Promise { + const scheduleName = generateScheduleName( + "wraps-wf", + params.executionId, + params.stepId + ); + + if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { + if (IS_PRODUCTION) { + throw new Error("EventBridge Scheduler not configured for automations"); + } + console.warn( + "[automation-queue] Skipping schedule creation - config not set" + ); + return scheduleName; + } + + const executeAt = new Date(Date.now() + params.delaySeconds * 1000); + const scheduleExpression = formatScheduleExpression(executeAt); + + await scheduler.send( + new CreateScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + ScheduleExpression: scheduleExpression, + ScheduleExpressionTimezone: "UTC", + FlexibleTimeWindow: { Mode: "OFF" }, + ActionAfterCompletion: "DELETE", + Target: { + Arn: WORKFLOW_QUEUE_ARN, + RoleArn: SCHEDULER_ROLE_ARN, + Input: JSON.stringify({ + type: "execute", + executionId: params.executionId, + stepId: params.stepId, + organizationId: params.organizationId, + } satisfies AutomationJob), + }, + }) + ); + + return scheduleName; +} + +/** @deprecated Use `scheduleAutomationStep` instead */ +export const scheduleWorkflowStep = scheduleAutomationStep; + +/** + * Schedule a timeout for wait-for-event step + */ +export async function scheduleWaitTimeout(params: { + executionId: string; + stepId: string; + organizationId: string; + timeoutSeconds: number; +}): Promise { + const scheduleName = generateScheduleName( + "wraps-wf-to", + params.executionId, + params.stepId + ); + + if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { + if (IS_PRODUCTION) { + throw new Error("EventBridge Scheduler not configured for automations"); + } + console.warn( + "[automation-queue] Skipping timeout schedule - config not set" + ); + return scheduleName; + } + + const timeoutAt = new Date(Date.now() + params.timeoutSeconds * 1000); + const scheduleExpression = formatScheduleExpression(timeoutAt); + + await scheduler.send( + new CreateScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + ScheduleExpression: scheduleExpression, + ScheduleExpressionTimezone: "UTC", + FlexibleTimeWindow: { Mode: "OFF" }, + ActionAfterCompletion: "DELETE", + Target: { + Arn: WORKFLOW_QUEUE_ARN, + RoleArn: SCHEDULER_ROLE_ARN, + Input: JSON.stringify({ + type: "resume", + executionId: params.executionId, + branch: "timeout", + organizationId: params.organizationId, + } satisfies AutomationJob), + }, + }) + ); + + return scheduleName; +} + +/** + * Delete a scheduled automation step (for cancellation) + */ +export async function deleteScheduledStep(scheduleName: string): Promise { + if (!SCHEDULER_ROLE_ARN) { + if (!IS_PRODUCTION) { + console.warn( + "[automation-queue] Skipping schedule deletion - config not set" + ); + return; + } + return; + } + + try { + await scheduler.send( + new DeleteScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + }) + ); + } catch (error: unknown) { + if (error instanceof Error && error.name === "ResourceNotFoundException") { + return; + } + throw error; + } +} diff --git a/apps/api/src/services/automation-scheduler.ts b/apps/api/src/services/automation-scheduler.ts new file mode 100644 index 000000000..8430df7fa --- /dev/null +++ b/apps/api/src/services/automation-scheduler.ts @@ -0,0 +1,264 @@ +/** + * Automation Scheduler Service + * + * Manages one-time EventBridge Schedules for schedule-triggered automations. + * Uses croner to compute the next run time from a cron expression, then + * creates a one-time at() schedule that fires at that exact moment. + * When the schedule fires, the processor chains the next one. + */ + +import { + CreateScheduleCommand, + DeleteScheduleCommand, + GetScheduleCommand, + SchedulerClient, +} from "@aws-sdk/client-scheduler"; +import { automation, db, eq, type TriggerConfig } from "@wraps/db"; +import { Cron } from "croner"; +import { and } from "drizzle-orm"; + +import { log } from "../lib/logger"; +import { + type AutomationJob, + formatScheduleExpression, +} from "./automation-queue"; + +const scheduler = new SchedulerClient({}); + +const WORKFLOW_QUEUE_ARN = process.env.WORKFLOW_QUEUE_ARN; +const SCHEDULE_GROUP = process.env.SCHEDULER_GROUP_NAME || "wraps-workflows"; +const SCHEDULER_ROLE_ARN = process.env.SCHEDULER_ROLE_ARN; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + +/** + * Generate a deterministic schedule name for an automation. + * Only one pending schedule per automation at a time. + */ +function getScheduleName(automationId: string): string { + return `wraps-wf-sched-${automationId.slice(0, 8)}`; +} + +/** + * Create the next one-time EventBridge Schedule for a schedule-triggered automation. + * + * Uses croner to compute nextRun() from the cron expression + timezone, + * then creates an at() schedule targeting the automation SQS queue. + */ +export async function createNextAutomationSchedule(params: { + workflowId: string; + organizationId: string; + cronExpression: string; + timezone?: string; +}): Promise { + const { workflowId, organizationId, cronExpression, timezone } = params; + + // Compute next run time + const cron = new Cron(cronExpression, { + timezone: timezone || "UTC", + }); + + const nextRun = cron.nextRun(); + + if (!nextRun) { + log.warn("Scheduler: no future run time, chain ends", { + workflowId, + cronExpression, + }); + return null; + } + + const scheduleName = getScheduleName(workflowId); + + if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { + if (IS_PRODUCTION) { + throw new Error( + "EventBridge Scheduler not configured for automation schedules" + ); + } + log.warn("Scheduler: skipping schedule creation, config not set", { + workflowId, + nextRun: nextRun.toISOString(), + }); + return scheduleName; + } + + const scheduleExpression = formatScheduleExpression(nextRun); + + log.info("Scheduler: creating schedule", { + scheduleName, + workflowId, + nextRun: nextRun.toISOString(), + }); + + await scheduler.send( + new CreateScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + ScheduleExpression: scheduleExpression, + ScheduleExpressionTimezone: "UTC", + FlexibleTimeWindow: { Mode: "OFF" }, + ActionAfterCompletion: "DELETE", + Target: { + Arn: WORKFLOW_QUEUE_ARN, + RoleArn: SCHEDULER_ROLE_ARN, + Input: JSON.stringify({ + type: "schedule-trigger", + workflowId, + organizationId, + } satisfies AutomationJob), + }, + }) + ); + + return scheduleName; +} + +/** @deprecated Use `createNextAutomationSchedule` instead */ +export const createNextWorkflowSchedule = createNextAutomationSchedule; + +/** + * Delete the pending schedule for an automation. + * Handles ResourceNotFoundException gracefully (schedule may have already fired). + */ +export async function deleteAutomationSchedule( + automationId: string +): Promise { + const scheduleName = getScheduleName(automationId); + + if (!SCHEDULER_ROLE_ARN) { + if (!IS_PRODUCTION) { + log.warn("Scheduler: skipping schedule deletion, config not set"); + return; + } + return; + } + + try { + await scheduler.send( + new DeleteScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + }) + ); + log.info("Scheduler: deleted schedule", { + scheduleName, + automationId, + }); + } catch (error: unknown) { + if (error instanceof Error && error.name === "ResourceNotFoundException") { + // Schedule already fired and auto-deleted, or never existed + return; + } + throw error; + } +} + +/** @deprecated Use `deleteAutomationSchedule` instead */ +export const deleteWorkflowSchedule = deleteAutomationSchedule; + +/** + * Reconcile schedule chains for all enabled scheduled automations. + * + * Checks EventBridge for each automation's expected schedule. If missing + * (ResourceNotFoundException), re-creates the next schedule to repair the chain. + */ +export async function reconcileScheduleChains(): Promise<{ + checked: number; + repaired: number; + errors: number; + details: Array<{ workflowId: string; action: string; error?: string }>; +}> { + const details: Array<{ + workflowId: string; + action: string; + error?: string; + }> = []; + + if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { + if (!IS_PRODUCTION) { + log.warn("Reconciliation: skipping, scheduler not configured"); + return { checked: 0, repaired: 0, errors: 0, details }; + } + throw new Error( + "EventBridge Scheduler not configured for automation schedules" + ); + } + + const automations = await db + .select({ + id: automation.id, + organizationId: automation.organizationId, + triggerConfig: automation.triggerConfig, + }) + .from(automation) + .where( + and( + eq(automation.status, "enabled"), + eq(automation.triggerType, "schedule") + ) + ); + + let repaired = 0; + let errors = 0; + + for (const a of automations) { + const config = a.triggerConfig as TriggerConfig; + if (!config.schedule) continue; + + const scheduleName = getScheduleName(a.id); + + try { + await scheduler.send( + new GetScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + }) + ); + details.push({ workflowId: a.id, action: "healthy" }); + } catch (error: unknown) { + if ( + error instanceof Error && + error.name === "ResourceNotFoundException" + ) { + try { + await createNextAutomationSchedule({ + workflowId: a.id, + organizationId: a.organizationId, + cronExpression: config.schedule, + timezone: config.timezone, + }); + repaired++; + details.push({ workflowId: a.id, action: "repaired" }); + log.info("Reconciliation: repaired broken chain", { + workflowId: a.id, + }); + } catch (repairError) { + errors++; + details.push({ + workflowId: a.id, + action: "repair_failed", + error: + repairError instanceof Error + ? repairError.message + : String(repairError), + }); + } + } else { + errors++; + details.push({ + workflowId: a.id, + action: "check_failed", + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + log.info("Reconciliation: complete", { + checked: automations.length, + repaired, + errors, + }); + + return { checked: automations.length, repaired, errors, details }; +} diff --git a/apps/api/src/services/workflow-events.ts b/apps/api/src/services/workflow-events.ts index f6344547e..77d6bf9b6 100644 --- a/apps/api/src/services/workflow-events.ts +++ b/apps/api/src/services/workflow-events.ts @@ -1,693 +1,5 @@ /** - * Workflow Events Service - * - * Handles emitting internal events to trigger workflows. - * Used for contact lifecycle events, topic subscriptions, etc. + * @deprecated Import from `./automation-events` instead. + * This file is a backward-compatibility shim. */ - -import { - contactEvent, - contactMatchesCondition, - db, - eq, - getSegmentsByIds, - workflow, - workflowExecution, -} from "@wraps/db"; -import { and, inArray, sql } from "drizzle-orm"; - -import { log } from "../lib/logger"; -import { - deleteScheduledStep, - enqueueWorkflowStep, - enqueueWorkflowStepBatch, - type WorkflowJob, -} from "./workflow-queue"; - -/** - * Emit an internal event that may trigger workflows - * - * @param params Event parameters - * @returns Number of workflows triggered - */ -export async function emitWorkflowEvent(params: { - eventName: string; - contactId: string; - organizationId: string; - eventData?: Record; - /** Skip recording to contact_event table (for internal events that are already tracked elsewhere) */ - skipEventRecord?: boolean; -}): Promise<{ workflowsTriggered: number }> { - const { eventName, contactId, organizationId, eventData, skipEventRecord } = - params; - - // Record the event to contact_event table (for segment evaluation) - if (!skipEventRecord) { - try { - await db.insert(contactEvent).values({ - contactId, - organizationId, - eventName, - eventData, - }); - } catch (error) { - // Log but don't fail the event emission - log.error("Failed to record workflow event", error, { - eventName, - contactId, - }); - } - } - - // Find matching workflows - const matchingWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "event"), - sql`${workflow.triggerConfig}->>'eventName' = ${eventName}` - ) - ); - - // Trigger each matching workflow - for (const wf of matchingWorkflows) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId, - organizationId, - eventData: eventData || {}, - }); - } - - if (matchingWorkflows.length > 0) { - log.info("Workflow event triggered workflows", { - eventName, - contactId, - workflowCount: matchingWorkflows.length, - }); - } - - return { workflowsTriggered: matchingWorkflows.length }; -} - -/** - * Emit contact_created event - * - * Matches workflows with either: - * - triggerType: "event" + eventName: "contact_created" (generic event format) - * - triggerType: "contact_created" (direct trigger type from CLI-pushed workflows) - */ -export async function emitContactCreated(params: { - contactId: string; - organizationId: string; - contactData?: Record; -}): Promise<{ workflowsTriggered: number }> { - const eventData = { - ...params.contactData, - createdAt: new Date().toISOString(), - }; - - // Match workflows with triggerType: "event" + eventName: "contact_created" - const matchingByEvent = await emitWorkflowEvent({ - eventName: "contact_created", - contactId: params.contactId, - organizationId: params.organizationId, - eventData, - }); - - // Match workflows with triggerType: "contact_created" (CLI-pushed format) - const matchingByTrigger = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "contact_created") - ) - ); - - for (const wf of matchingByTrigger) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData, - }); - } - - if (matchingByTrigger.length > 0) { - log.info("contact_created trigger matched workflows", { - contactId: params.contactId, - workflowCount: matchingByTrigger.length, - }); - } - - return { - workflowsTriggered: - matchingByEvent.workflowsTriggered + matchingByTrigger.length, - }; -} - -/** - * Emit contact_updated event - * - * Matches workflows with either: - * - triggerType: "event" + eventName: "contact_updated" (generic event format) - * - triggerType: "contact_updated" (direct trigger type from CLI-pushed workflows) - */ -export async function emitContactUpdated(params: { - contactId: string; - organizationId: string; - updatedFields?: string[]; - contactData?: Record; -}): Promise<{ workflowsTriggered: number }> { - const eventData = { - ...params.contactData, - updatedFields: params.updatedFields, - updatedAt: new Date().toISOString(), - }; - - // Match workflows with triggerType: "event" + eventName: "contact_updated" - const matchingByEvent = await emitWorkflowEvent({ - eventName: "contact_updated", - contactId: params.contactId, - organizationId: params.organizationId, - eventData, - }); - - // Match workflows with triggerType: "contact_updated" (CLI-pushed format) - const matchingByTrigger = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "contact_updated") - ) - ); - - for (const wf of matchingByTrigger) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData, - }); - } - - if (matchingByTrigger.length > 0) { - log.info("contact_updated trigger matched workflows", { - contactId: params.contactId, - workflowCount: matchingByTrigger.length, - }); - } - - return { - workflowsTriggered: - matchingByEvent.workflowsTriggered + matchingByTrigger.length, - }; -} - -/** - * Emit topic_subscribed event - */ -export async function emitTopicSubscribed(params: { - contactId: string; - organizationId: string; - topicId: string; - topicName?: string; -}): Promise<{ workflowsTriggered: number }> { - // Also check for topic_subscribed trigger type - const matchingByEvent = await emitWorkflowEvent({ - eventName: "topic_subscribed", - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - topicId: params.topicId, - topicName: params.topicName, - subscribedAt: new Date().toISOString(), - }, - }); - - // Check for workflows with topic_subscribed trigger type - const matchingByTrigger = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "topic_subscribed"), - sql`${workflow.triggerConfig}->>'topicId' = ${params.topicId}` - ) - ); - - for (const wf of matchingByTrigger) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - topicId: params.topicId, - topicName: params.topicName, - subscribedAt: new Date().toISOString(), - }, - }); - } - - if (matchingByTrigger.length > 0) { - log.info("topic_subscribed trigger matched workflows", { - workflowCount: matchingByTrigger.length, - }); - } - - return { - workflowsTriggered: - matchingByEvent.workflowsTriggered + matchingByTrigger.length, - }; -} - -/** - * Emit topic_unsubscribed event - * - * Also cancels any active workflow executions that were triggered by - * topic_subscribed for this topic. - */ -export async function emitTopicUnsubscribed(params: { - contactId: string; - organizationId: string; - topicId: string; - topicName?: string; -}): Promise<{ workflowsTriggered: number; executionsCancelled: number }> { - // Cancel any active executions for topic_subscribed workflows - const { executionsCancelled } = await cancelExecutionsForTopicUnsubscribe({ - contactId: params.contactId, - organizationId: params.organizationId, - topicId: params.topicId, - }); - - // Check for event-based triggers - const matchingByEvent = await emitWorkflowEvent({ - eventName: "topic_unsubscribed", - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - topicId: params.topicId, - topicName: params.topicName, - unsubscribedAt: new Date().toISOString(), - }, - }); - - // Check for workflows with topic_unsubscribed trigger type - const matchingByTrigger = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "topic_unsubscribed"), - sql`${workflow.triggerConfig}->>'topicId' = ${params.topicId}` - ) - ); - - for (const wf of matchingByTrigger) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - topicId: params.topicId, - topicName: params.topicName, - unsubscribedAt: new Date().toISOString(), - }, - }); - } - - if (matchingByTrigger.length > 0) { - log.info("topic_unsubscribed trigger matched workflows", { - workflowCount: matchingByTrigger.length, - }); - } - - return { - workflowsTriggered: - matchingByEvent.workflowsTriggered + matchingByTrigger.length, - executionsCancelled, - }; -} - -/** - * Check and emit segment entry events for a contact - * Call this after a contact is created or updated - * - * Uses SQL-based evaluation: batch-fetches segments (1 query), - * then runs one SQL query per segment to check if contact matches. - */ -export async function checkSegmentEntry(params: { - contactId: string; - organizationId: string; -}): Promise<{ workflowsTriggered: number }> { - // 1. Get workflows with segment_entry trigger - const segmentWorkflows = await db - .select({ - id: workflow.id, - triggerConfig: workflow.triggerConfig, - }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "segment_entry") - ) - ); - - if (segmentWorkflows.length === 0) { - return { workflowsTriggered: 0 }; - } - - // 2. Extract unique segment IDs from workflow configs - const segmentIds = [ - ...new Set( - segmentWorkflows - .map( - (wf) => (wf.triggerConfig as { segmentId?: string } | null)?.segmentId - ) - .filter((id): id is string => !!id) - ), - ]; - - if (segmentIds.length === 0) { - return { workflowsTriggered: 0 }; - } - - // 3. Batch-fetch all segments (1 query) - const segmentsMap = await getSegmentsByIds(db, segmentIds); - - // 4. Evaluate via SQL and collect trigger jobs - const jobs: WorkflowJob[] = []; - - for (const wf of segmentWorkflows) { - const config = wf.triggerConfig as { segmentId?: string } | null; - if (!config?.segmentId) { - continue; - } - - const seg = segmentsMap.get(config.segmentId); - if (!seg) { - continue; - } - - try { - const matches = await contactMatchesCondition( - db, - params.contactId, - params.organizationId, - seg.condition - ); - - if (matches) { - jobs.push({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - segmentId: config.segmentId, - segmentName: seg.name, - enteredAt: new Date().toISOString(), - }, - }); - - log.info("Segment entry: triggered workflow", { - contactId: params.contactId, - segmentId: config.segmentId, - workflowId: wf.id, - }); - } - } catch (error) { - log.error("Error checking segment entry", error, { - segmentId: config.segmentId, - }); - } - } - - // 5. Batch enqueue all trigger jobs - if (jobs.length > 0) { - await enqueueWorkflowStepBatch(jobs); - } - - return { workflowsTriggered: jobs.length }; -} - -/** - * Check and emit segment exit events for a contact - * Call this after a contact is updated - * - * Uses SQL-based evaluation: batch-fetches segments (1 query), - * then runs one SQL query per segment to check if contact no longer matches. - */ -export async function checkSegmentExit(params: { - contactId: string; - organizationId: string; - previousSegmentIds?: string[]; // Optional: segments contact was previously in -}): Promise<{ workflowsTriggered: number }> { - // 1. Get workflows with segment_exit trigger - const segmentWorkflows = await db - .select({ - id: workflow.id, - triggerConfig: workflow.triggerConfig, - }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "segment_exit") - ) - ); - - if (segmentWorkflows.length === 0) { - return { workflowsTriggered: 0 }; - } - - // 2. Extract unique segment IDs, filtering by previousSegmentIds if provided - const segmentIds = [ - ...new Set( - segmentWorkflows - .map( - (wf) => (wf.triggerConfig as { segmentId?: string } | null)?.segmentId - ) - .filter((id): id is string => { - if (!id) { - return false; - } - if ( - params.previousSegmentIds && - !params.previousSegmentIds.includes(id) - ) { - return false; - } - return true; - }) - ), - ]; - - if (segmentIds.length === 0) { - return { workflowsTriggered: 0 }; - } - - // 3. Batch-fetch all segments (1 query) - const segmentsMap = await getSegmentsByIds(db, segmentIds); - - // 4. Evaluate via SQL and collect trigger jobs - const jobs: WorkflowJob[] = []; - - for (const wf of segmentWorkflows) { - const config = wf.triggerConfig as { segmentId?: string } | null; - if (!config?.segmentId) { - continue; - } - - // Skip if not in previousSegmentIds - if ( - params.previousSegmentIds && - !params.previousSegmentIds.includes(config.segmentId) - ) { - continue; - } - - const seg = segmentsMap.get(config.segmentId); - if (!seg) { - continue; - } - - try { - // Check if contact NO LONGER matches the segment via SQL - const matches = await contactMatchesCondition( - db, - params.contactId, - params.organizationId, - seg.condition - ); - - if (!matches) { - jobs.push({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - segmentId: config.segmentId, - segmentName: seg.name, - exitedAt: new Date().toISOString(), - }, - }); - - log.info("Segment exit: triggered workflow", { - contactId: params.contactId, - segmentId: config.segmentId, - workflowId: wf.id, - }); - } - } catch (error) { - log.error("Error checking segment exit", error, { - segmentId: config.segmentId, - }); - } - } - - // 5. Batch enqueue all trigger jobs - if (jobs.length > 0) { - await enqueueWorkflowStepBatch(jobs); - } - - return { workflowsTriggered: jobs.length }; -} - -/** - * Cancel active workflow executions when a contact unsubscribes from a topic. - * - * This finds all active executions for workflows triggered by topic_subscribed - * with the matching topicId and cancels them. - */ -export async function cancelExecutionsForTopicUnsubscribe(params: { - contactId: string; - organizationId: string; - topicId: string; -}): Promise<{ executionsCancelled: number }> { - const { contactId, organizationId, topicId } = params; - - // Find workflows triggered by topic_subscribed for this topic - const matchingWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, organizationId), - eq(workflow.triggerType, "topic_subscribed"), - sql`${workflow.triggerConfig}->>'topicId' = ${topicId}` - ) - ); - - if (matchingWorkflows.length === 0) { - return { executionsCancelled: 0 }; - } - - const workflowIds = matchingWorkflows.map((w) => w.id); - - // Find active executions for this contact in these workflows - const activeExecutions = await db - .select({ - id: workflowExecution.id, - workflowId: workflowExecution.workflowId, - delaySchedulerName: workflowExecution.delaySchedulerName, - waitTimeoutSchedulerName: workflowExecution.waitTimeoutSchedulerName, - }) - .from(workflowExecution) - .where( - and( - eq(workflowExecution.contactId, contactId), - inArray(workflowExecution.workflowId, workflowIds), - sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` - ) - ); - - if (activeExecutions.length === 0) { - return { executionsCancelled: 0 }; - } - - // Clean up any scheduled steps (in parallel) - const schedulerCleanups: Promise[] = []; - for (const execution of activeExecutions) { - if (execution.delaySchedulerName) { - schedulerCleanups.push( - deleteScheduledStep(execution.delaySchedulerName).catch((err) => { - log.error("Failed to delete delay scheduler", err, { - schedulerName: execution.delaySchedulerName, - }); - }) - ); - } - - if (execution.waitTimeoutSchedulerName) { - schedulerCleanups.push( - deleteScheduledStep(execution.waitTimeoutSchedulerName).catch((err) => { - log.error("Failed to delete timeout scheduler", err, { - schedulerName: execution.waitTimeoutSchedulerName, - }); - }) - ); - } - } - await Promise.all(schedulerCleanups); - - // Batch cancel all executions - const executionIds = activeExecutions.map((e) => e.id); - await db - .update(workflowExecution) - .set({ - status: "cancelled", - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(inArray(workflowExecution.id, executionIds)); - - // Decrement active execution counts per workflow - const countsByWorkflow = new Map(); - for (const execution of activeExecutions) { - countsByWorkflow.set( - execution.workflowId, - (countsByWorkflow.get(execution.workflowId) ?? 0) + 1 - ); - } - await Promise.all( - [...countsByWorkflow.entries()].map(([wfId, count]) => - db - .update(workflow) - .set({ - activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - ${count})`, - }) - .where(eq(workflow.id, wfId)) - ) - ); - - log.info("Workflow: cancelled executions for topic unsubscribe", { - contactId, - topicId, - count: activeExecutions.length, - }); - - return { executionsCancelled: activeExecutions.length }; -} +export * from "./automation-events"; diff --git a/apps/api/src/services/workflow-queue.ts b/apps/api/src/services/workflow-queue.ts index e4ec9715a..b87d05385 100644 --- a/apps/api/src/services/workflow-queue.ts +++ b/apps/api/src/services/workflow-queue.ts @@ -1,273 +1,5 @@ /** - * Workflow Queue Service - * - * Manages enqueueing workflow steps for processing and scheduling delays. + * @deprecated Import from `./automation-queue` instead. + * This file is a backward-compatibility shim. */ - -import { - CreateScheduleCommand, - DeleteScheduleCommand, - SchedulerClient, -} from "@aws-sdk/client-scheduler"; -import { - SendMessageBatchCommand, - SendMessageCommand, - SQSClient, -} from "@aws-sdk/client-sqs"; - -const sqs = new SQSClient({}); -const scheduler = new SchedulerClient({}); - -/** - * Format a date for EventBridge Scheduler at() expression. - * Must be in format: at(yyyy-MM-ddTHH:mm:ss) without milliseconds or timezone. - */ -export function formatScheduleExpression(date: Date): string { - const iso = date.toISOString(); // 2026-01-08T04:37:29.148Z - const withoutMs = iso.split(".")[0]; // 2026-01-08T04:37:29 - return `at(${withoutMs})`; -} - -/** - * Generate a short schedule name that fits within the 64-char limit. - * Uses first 8 chars of each UUID to create unique but short names. - */ -export function generateScheduleName( - prefix: string, - executionId: string, - stepId: string -): string { - const shortExecId = executionId.slice(0, 8); - const shortStepId = stepId.slice(0, 8); - return `${prefix}-${shortExecId}-${shortStepId}`; -} - -const WORKFLOW_QUEUE_URL = process.env.WORKFLOW_QUEUE_URL; -const WORKFLOW_QUEUE_ARN = process.env.WORKFLOW_QUEUE_ARN; -const SCHEDULE_GROUP = process.env.SCHEDULER_GROUP_NAME || "wraps-workflows"; -const SCHEDULER_ROLE_ARN = process.env.SCHEDULER_ROLE_ARN; -const IS_PRODUCTION = process.env.NODE_ENV === "production"; - -/** - * Job types for the workflow queue - */ -export type WorkflowJob = - | { - type: "execute"; - executionId: string; - stepId: string; - organizationId: string; - } - | { - type: "resume"; - executionId: string; - branch: "yes" | "no" | "timeout" | "opened" | "clicked" | "bounced"; - organizationId: string; - } - | { - type: "trigger"; - workflowId: string; - contactId: string; - organizationId: string; - eventData?: Record; - } - | { - type: "schedule-trigger"; - workflowId: string; - organizationId: string; - }; - -/** - * Enqueue a workflow step for immediate processing - */ -export async function enqueueWorkflowStep(job: WorkflowJob): Promise { - if (!WORKFLOW_QUEUE_URL) { - if (IS_PRODUCTION) { - throw new Error("WORKFLOW_QUEUE_URL not configured"); - } - console.warn( - "[workflow-queue] Skipping enqueue - queue not configured", - job - ); - return; - } - - await sqs.send( - new SendMessageCommand({ - QueueUrl: WORKFLOW_QUEUE_URL, - MessageBody: JSON.stringify(job), - }) - ); -} - -/** - * Enqueue multiple workflow steps in batch (up to 10 per SQS SendMessageBatch call) - */ -export async function enqueueWorkflowStepBatch( - jobs: WorkflowJob[] -): Promise { - if (jobs.length === 0) { - return; - } - - if (!WORKFLOW_QUEUE_URL) { - if (IS_PRODUCTION) { - throw new Error("WORKFLOW_QUEUE_URL not configured"); - } - console.warn( - "[workflow-queue] Skipping batch enqueue - queue not configured", - { count: jobs.length } - ); - return; - } - - // SQS SendMessageBatch supports max 10 messages per call — fire all chunks in parallel - const chunks: WorkflowJob[][] = []; - for (let i = 0; i < jobs.length; i += 10) { - chunks.push(jobs.slice(i, i + 10)); - } - await Promise.all( - chunks.map((chunk, chunkIdx) => - sqs.send( - new SendMessageBatchCommand({ - QueueUrl: WORKFLOW_QUEUE_URL, - Entries: chunk.map((job, idx) => ({ - Id: String(chunkIdx * 10 + idx), - MessageBody: JSON.stringify(job), - })), - }) - ) - ) - ); -} - -/** - * Schedule a workflow step to execute after a delay - */ -export async function scheduleWorkflowStep(params: { - executionId: string; - stepId: string; - organizationId: string; - delaySeconds: number; -}): Promise { - const scheduleName = generateScheduleName( - "wraps-wf", - params.executionId, - params.stepId - ); - - if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { - if (IS_PRODUCTION) { - throw new Error("EventBridge Scheduler not configured for workflows"); - } - console.warn( - "[workflow-queue] Skipping schedule creation - config not set" - ); - return scheduleName; - } - - const executeAt = new Date(Date.now() + params.delaySeconds * 1000); - const scheduleExpression = formatScheduleExpression(executeAt); - - await scheduler.send( - new CreateScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - ScheduleExpression: scheduleExpression, - ScheduleExpressionTimezone: "UTC", - FlexibleTimeWindow: { Mode: "OFF" }, - ActionAfterCompletion: "DELETE", - Target: { - Arn: WORKFLOW_QUEUE_ARN, - RoleArn: SCHEDULER_ROLE_ARN, - Input: JSON.stringify({ - type: "execute", - executionId: params.executionId, - stepId: params.stepId, - organizationId: params.organizationId, - } satisfies WorkflowJob), - }, - }) - ); - - return scheduleName; -} - -/** - * Schedule a timeout for wait-for-event step - */ -export async function scheduleWaitTimeout(params: { - executionId: string; - stepId: string; - organizationId: string; - timeoutSeconds: number; -}): Promise { - const scheduleName = generateScheduleName( - "wraps-wf-to", - params.executionId, - params.stepId - ); - - if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { - if (IS_PRODUCTION) { - throw new Error("EventBridge Scheduler not configured for workflows"); - } - console.warn("[workflow-queue] Skipping timeout schedule - config not set"); - return scheduleName; - } - - const timeoutAt = new Date(Date.now() + params.timeoutSeconds * 1000); - const scheduleExpression = formatScheduleExpression(timeoutAt); - - await scheduler.send( - new CreateScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - ScheduleExpression: scheduleExpression, - ScheduleExpressionTimezone: "UTC", - FlexibleTimeWindow: { Mode: "OFF" }, - ActionAfterCompletion: "DELETE", - Target: { - Arn: WORKFLOW_QUEUE_ARN, - RoleArn: SCHEDULER_ROLE_ARN, - Input: JSON.stringify({ - type: "resume", - executionId: params.executionId, - branch: "timeout", - organizationId: params.organizationId, - } satisfies WorkflowJob), - }, - }) - ); - - return scheduleName; -} - -/** - * Delete a scheduled workflow step (for cancellation) - */ -export async function deleteScheduledStep(scheduleName: string): Promise { - if (!SCHEDULER_ROLE_ARN) { - if (!IS_PRODUCTION) { - console.warn( - "[workflow-queue] Skipping schedule deletion - config not set" - ); - return; - } - return; - } - - try { - await scheduler.send( - new DeleteScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - }) - ); - } catch (error: unknown) { - if (error instanceof Error && error.name === "ResourceNotFoundException") { - return; - } - throw error; - } -} +export * from "./automation-queue"; diff --git a/apps/api/src/services/workflow-scheduler.ts b/apps/api/src/services/workflow-scheduler.ts index aea6c7418..0a0a164d8 100644 --- a/apps/api/src/services/workflow-scheduler.ts +++ b/apps/api/src/services/workflow-scheduler.ts @@ -1,249 +1,5 @@ /** - * Workflow Scheduler Service - * - * Manages one-time EventBridge Schedules for schedule-triggered workflows. - * Uses croner to compute the next run time from a cron expression, then - * creates a one-time at() schedule that fires at that exact moment. - * When the schedule fires, the processor chains the next one. + * @deprecated Import from `./automation-scheduler` instead. + * This file is a backward-compatibility shim. */ - -import { - CreateScheduleCommand, - DeleteScheduleCommand, - GetScheduleCommand, - SchedulerClient, -} from "@aws-sdk/client-scheduler"; -import { db, eq, type TriggerConfig, workflow } from "@wraps/db"; -import { Cron } from "croner"; -import { and } from "drizzle-orm"; - -import { log } from "../lib/logger"; -import { formatScheduleExpression, type WorkflowJob } from "./workflow-queue"; - -const scheduler = new SchedulerClient({}); - -const WORKFLOW_QUEUE_ARN = process.env.WORKFLOW_QUEUE_ARN; -const SCHEDULE_GROUP = process.env.SCHEDULER_GROUP_NAME || "wraps-workflows"; -const SCHEDULER_ROLE_ARN = process.env.SCHEDULER_ROLE_ARN; -const IS_PRODUCTION = process.env.NODE_ENV === "production"; - -/** - * Generate a deterministic schedule name for a workflow. - * Only one pending schedule per workflow at a time. - */ -function getScheduleName(workflowId: string): string { - return `wraps-wf-sched-${workflowId.slice(0, 8)}`; -} - -/** - * Create the next one-time EventBridge Schedule for a schedule-triggered workflow. - * - * Uses croner to compute nextRun() from the cron expression + timezone, - * then creates an at() schedule targeting the workflow SQS queue. - */ -export async function createNextWorkflowSchedule(params: { - workflowId: string; - organizationId: string; - cronExpression: string; - timezone?: string; -}): Promise { - const { workflowId, organizationId, cronExpression, timezone } = params; - - // Compute next run time - const cron = new Cron(cronExpression, { - timezone: timezone || "UTC", - }); - - const nextRun = cron.nextRun(); - - if (!nextRun) { - log.warn("Scheduler: no future run time, chain ends", { - workflowId, - cronExpression, - }); - return null; - } - - const scheduleName = getScheduleName(workflowId); - - if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { - if (IS_PRODUCTION) { - throw new Error( - "EventBridge Scheduler not configured for workflow schedules" - ); - } - log.warn("Scheduler: skipping schedule creation, config not set", { - workflowId, - nextRun: nextRun.toISOString(), - }); - return scheduleName; - } - - const scheduleExpression = formatScheduleExpression(nextRun); - - log.info("Scheduler: creating schedule", { - scheduleName, - workflowId, - nextRun: nextRun.toISOString(), - }); - - await scheduler.send( - new CreateScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - ScheduleExpression: scheduleExpression, - ScheduleExpressionTimezone: "UTC", - FlexibleTimeWindow: { Mode: "OFF" }, - ActionAfterCompletion: "DELETE", - Target: { - Arn: WORKFLOW_QUEUE_ARN, - RoleArn: SCHEDULER_ROLE_ARN, - Input: JSON.stringify({ - type: "schedule-trigger", - workflowId, - organizationId, - } satisfies WorkflowJob), - }, - }) - ); - - return scheduleName; -} - -/** - * Delete the pending schedule for a workflow. - * Handles ResourceNotFoundException gracefully (schedule may have already fired). - */ -export async function deleteWorkflowSchedule( - workflowId: string -): Promise { - const scheduleName = getScheduleName(workflowId); - - if (!SCHEDULER_ROLE_ARN) { - if (!IS_PRODUCTION) { - log.warn("Scheduler: skipping schedule deletion, config not set"); - return; - } - return; - } - - try { - await scheduler.send( - new DeleteScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - }) - ); - log.info("Scheduler: deleted schedule", { scheduleName, workflowId }); - } catch (error: unknown) { - if (error instanceof Error && error.name === "ResourceNotFoundException") { - // Schedule already fired and auto-deleted, or never existed - return; - } - throw error; - } -} - -/** - * Reconcile schedule chains for all enabled scheduled workflows. - * - * Checks EventBridge for each workflow's expected schedule. If missing - * (ResourceNotFoundException), re-creates the next schedule to repair the chain. - */ -export async function reconcileScheduleChains(): Promise<{ - checked: number; - repaired: number; - errors: number; - details: Array<{ workflowId: string; action: string; error?: string }>; -}> { - const details: Array<{ - workflowId: string; - action: string; - error?: string; - }> = []; - - if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { - if (!IS_PRODUCTION) { - log.warn("Reconciliation: skipping, scheduler not configured"); - return { checked: 0, repaired: 0, errors: 0, details }; - } - throw new Error( - "EventBridge Scheduler not configured for workflow schedules" - ); - } - - const workflows = await db - .select({ - id: workflow.id, - organizationId: workflow.organizationId, - triggerConfig: workflow.triggerConfig, - }) - .from(workflow) - .where( - and(eq(workflow.status, "enabled"), eq(workflow.triggerType, "schedule")) - ); - - let repaired = 0; - let errors = 0; - - for (const wf of workflows) { - const config = wf.triggerConfig as TriggerConfig; - if (!config.schedule) continue; - - const scheduleName = getScheduleName(wf.id); - - try { - await scheduler.send( - new GetScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - }) - ); - details.push({ workflowId: wf.id, action: "healthy" }); - } catch (error: unknown) { - if ( - error instanceof Error && - error.name === "ResourceNotFoundException" - ) { - try { - await createNextWorkflowSchedule({ - workflowId: wf.id, - organizationId: wf.organizationId, - cronExpression: config.schedule, - timezone: config.timezone, - }); - repaired++; - details.push({ workflowId: wf.id, action: "repaired" }); - log.info("Reconciliation: repaired broken chain", { - workflowId: wf.id, - }); - } catch (repairError) { - errors++; - details.push({ - workflowId: wf.id, - action: "repair_failed", - error: - repairError instanceof Error - ? repairError.message - : String(repairError), - }); - } - } else { - errors++; - details.push({ - workflowId: wf.id, - action: "check_failed", - error: error instanceof Error ? error.message : String(error), - }); - } - } - } - - log.info("Reconciliation: complete", { - checked: workflows.length, - repaired, - errors, - }); - - return { checked: workflows.length, repaired, errors, details }; -} +export * from "./automation-scheduler"; diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index af826c05e..d73610bd7 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -131,7 +131,7 @@ export async function createContact(organizationId: string, data: Input) { |------|---------| | `src/actions/` | Server actions (contacts, templates, workflows, orgs, etc.) | | `src/app/(dashboard)/[orgSlug]/` | Dashboard pages | -| `src/components/(ee)/workflow-builder/` | Workflow builder (React Flow + Zustand) | +| `src/components/(ee)/automation-builder/` | Automation builder (React Flow + Zustand) | | `src/components/template-editor/` | Email template editor (TipTap + React Email) | | `src/hooks/` | Custom hooks (template queries, org context, etc.) | | `src/lib/` | Utilities (auth, logger, contacts, validation, serializers) | @@ -148,7 +148,7 @@ export async function createContact(organizationId: string, data: Input) { ## Workflow Builder - `@xyflow/react` (React Flow v12) for visual canvas -- Zustand store in `use-workflow-store.ts` manages all state +- Zustand store in `use-automation-store.ts` manages all state - `validateWorkflow(steps, transitions)` returns `{ isValid, errors, errorsByNodeId }` - **Cascade nodes**: Single UI node that expands to multiple primitives (send + condition + wait) on save diff --git a/apps/web/src/actions/__tests__/workflows.test.ts b/apps/web/src/actions/__tests__/workflows.test.ts index 3abf88a8e..ae17b90e8 100644 --- a/apps/web/src/actions/__tests__/workflows.test.ts +++ b/apps/web/src/actions/__tests__/workflows.test.ts @@ -300,13 +300,13 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Welcome Flow"); - expect(result.workflow.description).toBe("Sends welcome emails"); - expect(result.workflow.status).toBe("draft"); - expect(result.workflow.triggerType).toBe("event"); - expect(result.workflow.steps).toBeDefined(); - expect((result.workflow.steps as WorkflowStep[]).length).toBe(1); - expect((result.workflow.steps as WorkflowStep[])[0].type).toBe( + expect(result.automation.name).toBe("Welcome Flow"); + expect(result.automation.description).toBe("Sends welcome emails"); + expect(result.automation.status).toBe("draft"); + expect(result.automation.triggerType).toBe("event"); + expect(result.automation.steps).toBeDefined(); + expect((result.automation.steps as WorkflowStep[]).length).toBe(1); + expect((result.automation.steps as WorkflowStep[])[0].type).toBe( "trigger" ); } @@ -342,8 +342,8 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Trimmed Name"); - expect(result.workflow.description).toBe("Trimmed description"); + expect(result.automation.name).toBe("Trimmed Name"); + expect(result.automation.description).toBe("Trimmed description"); } }); @@ -377,7 +377,7 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(3); + expect(result.automations).toHaveLength(3); expect(result.total).toBe(3); } }); @@ -390,7 +390,7 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(2); + expect(result.automations).toHaveLength(2); expect(result.total).toBe(3); expect(result.page).toBe(1); expect(result.pageSize).toBe(2); @@ -404,8 +404,8 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(1); - expect(result.workflows[0].name).toBe("Workflow A"); + expect(result.automations).toHaveLength(1); + expect(result.automations[0].name).toBe("Workflow A"); } }); @@ -417,7 +417,7 @@ describe("Workflows Server Actions", () => { } // Add action step, awsAccountId, defaultFrom, and enable - const wf = listResult.workflows[0]; + const wf = listResult.automations[0]; await updateWorkflow(wf.id, testOrganization.id, { awsAccountId: testAwsAccount.id, defaultFrom: "test@example.com", @@ -449,8 +449,8 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(1); - expect(result.workflows[0].status).toBe("enabled"); + expect(result.automations).toHaveLength(1); + expect(result.automations[0].status).toBe("enabled"); } }); @@ -461,7 +461,7 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(3); + expect(result.automations).toHaveLength(3); } }); }); @@ -481,13 +481,13 @@ describe("Workflows Server Actions", () => { } const result = await getWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Get Test Workflow"); + expect(result.automation.name).toBe("Get Test Workflow"); } }); @@ -510,14 +510,14 @@ describe("Workflows Server Actions", () => { } const result = await getWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.createdByUser).toBeDefined(); - expect(result.workflow.createdByUser?.email).toBe(testUser.email); + expect(result.automation.createdByUser).toBeDefined(); + expect(result.automation.createdByUser?.email).toBe(testUser.email); } }); }); @@ -537,14 +537,14 @@ describe("Workflows Server Actions", () => { } const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { name: "New Name" } ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("New Name"); + expect(result.automation.name).toBe("New Name"); } }); @@ -558,7 +558,7 @@ describe("Workflows Server Actions", () => { } const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { triggerType: "segment_entry", @@ -568,8 +568,10 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.triggerType).toBe("segment_entry"); - expect(result.workflow.triggerConfig).toEqual({ segmentId: "seg-123" }); + expect(result.automation.triggerType).toBe("segment_entry"); + expect(result.automation.triggerConfig).toEqual({ + segmentId: "seg-123", + }); } }); @@ -608,16 +610,16 @@ describe("Workflows Server Actions", () => { ]; const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { steps: newSteps, transitions: newTransitions } ); expect(result.success).toBe(true); if (result.success) { - expect((result.workflow.steps as WorkflowStep[]).length).toBe(2); + expect((result.automation.steps as WorkflowStep[]).length).toBe(2); expect( - (result.workflow.transitions as WorkflowTransition[]).length + (result.automation.transitions as WorkflowTransition[]).length ).toBe(1); } }); @@ -632,7 +634,7 @@ describe("Workflows Server Actions", () => { } const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { name: "" } ); @@ -664,7 +666,7 @@ describe("Workflows Server Actions", () => { ]; const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { steps: invalidSteps } ); @@ -703,7 +705,7 @@ describe("Workflows Server Actions", () => { ]; const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { steps, transitions: invalidTransitions } ); @@ -743,12 +745,12 @@ describe("Workflows Server Actions", () => { } // Add required config including awsAccountId, defaultFrom, and real template - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { triggerConfig: { eventName: "signup" }, awsAccountId: testAwsAccount.id, defaultFrom: "test@example.com", steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "action-1", type: "send_email", @@ -760,20 +762,20 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "action-1", }, ], }); const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.status).toBe("enabled"); + expect(result.automation.status).toBe("enabled"); } }); @@ -788,7 +790,7 @@ describe("Workflows Server Actions", () => { // Try to enable without awsAccountId const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -808,12 +810,12 @@ describe("Workflows Server Actions", () => { } // Set AWS account but no eventName or action step - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, }); const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -833,13 +835,13 @@ describe("Workflows Server Actions", () => { } // Add awsAccountId and event name but no action step - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, triggerConfig: { eventName: "signup" }, }); const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -859,10 +861,10 @@ describe("Workflows Server Actions", () => { } // Add awsAccountId and action step but no eventName - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "action-1", type: "send_email", @@ -874,14 +876,14 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "action-1", }, ], }); const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -919,11 +921,11 @@ describe("Workflows Server Actions", () => { } // Set up and enable with awsAccountId - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, triggerConfig: { eventName: "signup" }, steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "action-1", type: "send_email", @@ -935,22 +937,22 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "action-1", }, ], }); - await enableWorkflow(createResult.workflow.id, testOrganization.id); + await enableWorkflow(createResult.automation.id, testOrganization.id); // Disable const result = await disableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.status).toBe("paused"); + expect(result.automation.status).toBe("paused"); } }); @@ -982,7 +984,7 @@ describe("Workflows Server Actions", () => { } const result = await deleteWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -990,7 +992,7 @@ describe("Workflows Server Actions", () => { // Verify deleted const getResult = await getWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(getResult.success).toBe(false); @@ -1024,7 +1026,7 @@ describe("Workflows Server Actions", () => { // Create an active execution await db.insert(workflowExecution).values({ id: "test-execution-1", - workflowId: createResult.workflow.id, + workflowId: createResult.automation.id, organizationId: testOrganization.id, contactId: testContactId, status: "active", @@ -1034,7 +1036,7 @@ describe("Workflows Server Actions", () => { }); const result = await deleteWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -1079,10 +1081,10 @@ describe("Workflows Server Actions", () => { } // Add some steps - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { triggerConfig: { eventName: "signup" }, steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "delay-1", type: "delay", @@ -1094,30 +1096,30 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "delay-1", }, ], }); const result = await duplicateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Original Workflow (copy)"); - expect(result.workflow.description).toBe("Original description"); - expect(result.workflow.status).toBe("draft"); - expect(result.workflow.id).not.toBe(createResult.workflow.id); + expect(result.automation.name).toBe("Original Workflow (copy)"); + expect(result.automation.description).toBe("Original description"); + expect(result.automation.status).toBe("draft"); + expect(result.automation.id).not.toBe(createResult.automation.id); // Steps should have new IDs const originalStepIds = ( - createResult.workflow.steps as WorkflowStep[] + createResult.automation.steps as WorkflowStep[] + ).map((s) => s.id); + const duplicateStepIds = ( + result.automation.steps as WorkflowStep[] ).map((s) => s.id); - const duplicateStepIds = (result.workflow.steps as WorkflowStep[]).map( - (s) => s.id - ); expect( originalStepIds.some((id) => duplicateStepIds.includes(id)) ).toBe(false); @@ -1146,11 +1148,11 @@ describe("Workflows Server Actions", () => { } // Enable the original with awsAccountId - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, triggerConfig: { eventName: "signup" }, steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "action-1", type: "send_email", @@ -1162,21 +1164,21 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "action-1", }, ], }); - await enableWorkflow(createResult.workflow.id, testOrganization.id); + await enableWorkflow(createResult.automation.id, testOrganization.id); const result = await duplicateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.status).toBe("draft"); + expect(result.automation.status).toBe("draft"); } }); }); @@ -1196,7 +1198,7 @@ describe("Workflows Server Actions", () => { } const result = await getWorkflowStats( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -1252,7 +1254,7 @@ describe("Workflows Server Actions", () => { currentMockUserId = testMemberUser.id; const result = await getWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -1281,14 +1283,14 @@ describe("Workflows Server Actions", () => { currentMockUserId = testMemberUser.id; const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { name: "Updated by Member" } ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Updated by Member"); + expect(result.automation.name).toBe("Updated by Member"); } }); }); diff --git a/apps/web/src/actions/automation-readiness.ts b/apps/web/src/actions/automation-readiness.ts new file mode 100644 index 000000000..28ea091c1 --- /dev/null +++ b/apps/web/src/actions/automation-readiness.ts @@ -0,0 +1,198 @@ +"use server"; + +import { auth } from "@wraps/auth"; +import { db, template } from "@wraps/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { headers } from "next/headers"; +import { createActionLogger, serializeError } from "@/lib/logger"; + +// ═══════════════════════════════════════════════════════════════════════════ +// TYPES +// ═══════════════════════════════════════════════════════════════════════════ + +export type ReadinessCheck = { + id: string; + label: string; + status: "pass" | "fail" | "warn"; + severity: "critical" | "warning"; + details?: string; +}; + +export type ReadinessResult = + | { success: true; checks: ReadinessCheck[] } + | { success: false; error: string }; + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +async function verifyOrgAccess( + organizationId: string +): Promise<{ userId: string; userEmail: string; role: string } | null> { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + return null; + } + + const membership = await db.query.member.findFirst({ + where: (m, ops) => + ops.and( + ops.eq(m.organizationId, organizationId), + ops.eq(m.userId, session.user.id) + ), + }); + + if (!membership) { + return null; + } + + return { + userId: session.user.id, + userEmail: session.user.email, + role: membership.role, + }; +} + +/** + * Known first-class contact fields (from packages/db/src/schema/contacts.ts). + * Fields accessed via `properties.*` are custom and always pass. + */ +const KNOWN_CONTACT_FIELDS = new Set([ + "email", + "emailStatus", + "phone", + "smsStatus", + "firstName", + "lastName", + "company", + "jobTitle", + "preferredChannel", + "status", + "emailsSent", + "emailsOpened", + "emailsClicked", + "smsSent", + "smsClicked", +]); + +async function checkTemplates( + organizationId: string, + templateIds: string[] +): Promise { + const uniqueIds = [...new Set(templateIds.filter(Boolean))]; + if (uniqueIds.length === 0) { + return []; + } + + const foundTemplates = await db + .select({ id: template.id, status: template.status }) + .from(template) + .where( + and( + eq(template.organizationId, organizationId), + inArray(template.id, uniqueIds) + ) + ); + + const foundIds = new Set(foundTemplates.map((t) => t.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + const unpublished = foundTemplates.filter((t) => t.status !== "PUBLISHED"); + + const checks: ReadinessCheck[] = []; + + checks.push({ + id: "templates_exist", + label: "All email templates exist", + status: missingIds.length > 0 ? "fail" : "pass", + severity: "critical", + details: + missingIds.length > 0 + ? `${missingIds.length} template${missingIds.length > 1 ? "s" : ""} not found` + : undefined, + }); + + checks.push({ + id: "templates_published", + label: "All templates are published", + status: unpublished.length > 0 ? "fail" : "pass", + severity: "warning", + details: + unpublished.length > 0 + ? `${unpublished.length} template${unpublished.length > 1 ? "s are" : " is"} still in ${unpublished.map((t) => t.status.toLowerCase()).join(", ")} status` + : undefined, + }); + + return checks; +} + +function checkConditionFields(conditionFields: string[]): ReadinessCheck[] { + const uniqueFields = [...new Set(conditionFields.filter(Boolean))]; + if (uniqueFields.length === 0) { + return []; + } + + const unknownFields = uniqueFields.filter( + (field) => + !(KNOWN_CONTACT_FIELDS.has(field) || field.startsWith("properties.")) + ); + + return [ + { + id: "condition_fields_valid", + label: "All condition fields are valid", + status: unknownFields.length > 0 ? "fail" : "pass", + severity: "warning", + details: + unknownFields.length > 0 + ? `Unknown field${unknownFields.length > 1 ? "s" : ""}: ${unknownFields.join(", ")}` + : undefined, + }, + ]; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ACTION +// ═══════════════════════════════════════════════════════════════════════════ + +export async function checkAutomationReadiness( + automationId: string, + organizationId: string, + payload: { + templateIds: string[]; + conditionFields: string[]; + } +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + const templateChecks = await checkTemplates( + organizationId, + payload.templateIds + ); + const fieldChecks = checkConditionFields(payload.conditionFields); + + return { success: true, checks: [...templateChecks, ...fieldChecks] }; + } catch (error) { + const log = createActionLogger("checkAutomationReadiness", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to check automation readiness" + ); + return { success: false, error: "Failed to check automation readiness" }; + } +} + +// Backward-compat alias +/** @deprecated Use checkAutomationReadiness */ +export const checkWorkflowReadiness = checkAutomationReadiness; diff --git a/apps/web/src/actions/automations.ts b/apps/web/src/actions/automations.ts new file mode 100644 index 000000000..fb08b80ca --- /dev/null +++ b/apps/web/src/actions/automations.ts @@ -0,0 +1,1352 @@ +"use server"; + +import { auth } from "@wraps/auth"; +import { + type Automation, + type AutomationStep, + type AutomationTransition, + type AutomationTriggerType, + automation, + automationExecution, + type CanvasViewport, + db, + type TriggerConfig, + template, +} from "@wraps/db"; +import { and, count, desc, eq, ilike, inArray, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { trackWorkflowCreated } from "@/lib/activation-tracking"; +import { createActionLogger, serializeError } from "@/lib/logger"; +import { checkFeatureAccess, checkWorkflowLimit } from "@/lib/plan-limits"; + +// ═══════════════════════════════════════════════════════════════════════════ +// TYPES +// ═══════════════════════════════════════════════════════════════════════════ + +export type AutomationWithMeta = Automation & { + createdByUser?: { + id: string; + name: string | null; + email: string; + } | null; +}; + +export type ListAutomationsResult = + | { + success: true; + automations: AutomationWithMeta[]; + total: number; + page: number; + pageSize: number; + } + | { success: false; error: string }; + +export type GetAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +export type CreateAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +export type UpdateAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +export type DeleteAutomationResult = + | { success: true } + | { success: false; error: string }; + +export type EnableAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +export type DuplicateAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Verify user has access to organization + */ +async function verifyOrgAccess( + organizationId: string +): Promise<{ userId: string; userEmail: string; role: string } | null> { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + return null; + } + + const membership = await db.query.member.findFirst({ + where: (m, { and, eq }) => + and(eq(m.organizationId, organizationId), eq(m.userId, session.user.id)), + }); + + if (!membership) { + return null; + } + + return { + userId: session.user.id, + userEmail: session.user.email, + role: membership.role, + }; +} + +/** + * Validate automation definition for common issues + */ +function validateAutomationDefinition( + steps: AutomationStep[], + transitions: AutomationTransition[] +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Check for trigger node + const triggerSteps = steps.filter((s) => s.type === "trigger"); + if (triggerSteps.length === 0) { + errors.push("Automation must have a trigger node"); + } else if (triggerSteps.length > 1) { + errors.push("Automation can only have one trigger node"); + } + + // Check all steps have IDs + for (const step of steps) { + if (!step.id) { + errors.push("All steps must have an ID"); + break; + } + } + + // Check transitions reference valid step IDs + const stepIds = new Set(steps.map((s) => s.id)); + for (const transition of transitions) { + if (!stepIds.has(transition.fromStepId)) { + errors.push( + `Transition references unknown step: ${transition.fromStepId}` + ); + } + if (!stepIds.has(transition.toStepId)) { + errors.push(`Transition references unknown step: ${transition.toStepId}`); + } + } + + return { valid: errors.length === 0, errors }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// AUTOMATION SCHEDULE API HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Call the automation schedule API to manage EventBridge schedules. + * Follows the same pattern as batch.ts for auth + org headers. + */ +async function callAutomationScheduleApi( + automationId: string, + organizationId: string, + action: "enable" | "disable" | "update", + body?: { cronExpression: string; timezone?: string } +): Promise<{ success: boolean; error?: string }> { + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + if (!apiUrl) { + console.error("[automation-schedule] NEXT_PUBLIC_API_URL not configured"); + return { success: false, error: "API URL not configured" }; + } + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.session?.token) { + return { success: false, error: "Not authenticated" }; + } + + const baseHeaders: Record = { + Authorization: `Bearer ${session.session.token}`, + "X-Organization-Id": organizationId, + }; + + let url: string; + let method: string; + let fetchBody: string | undefined; + + switch (action) { + case "enable": + url = `${apiUrl}/v1/automation-schedules/${automationId}/enable`; + method = "POST"; + fetchBody = JSON.stringify(body); + baseHeaders["Content-Type"] = "application/json"; + break; + case "disable": + url = `${apiUrl}/v1/automation-schedules/${automationId}/disable`; + method = "POST"; + break; + case "update": + url = `${apiUrl}/v1/automation-schedules/${automationId}`; + method = "PUT"; + fetchBody = JSON.stringify(body); + baseHeaders["Content-Type"] = "application/json"; + break; + } + + try { + const response = await fetch(url, { + method, + headers: baseHeaders, + body: fetchBody, + }); + + if (!response.ok) { + const text = await response.text(); + console.error( + `[automation-schedule] API ${action} failed for ${automationId}: ${response.status} ${text}` + ); + return { success: false, error: text }; + } + + return { success: true }; + } catch (error) { + console.error( + `[automation-schedule] API ${action} error for ${automationId}:`, + error + ); + return { + success: false, + error: error instanceof Error ? error.message : "API call failed", + }; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ACTIONS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * List automations for an organization with pagination + */ +export async function listAutomations( + organizationId: string, + options: { + page?: number; + pageSize?: number; + search?: string; + status?: Automation["status"]; + } = {} +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + const { page = 1, pageSize = 50, search, status } = options; + const offset = (page - 1) * pageSize; + + // Build where conditions + const conditions = [eq(automation.organizationId, organizationId)]; + + if (search) { + conditions.push(ilike(automation.name, `%${search}%`)); + } + + if (status) { + conditions.push(eq(automation.status, status)); + } + + // Get total count + const [totalResult] = await db + .select({ count: count() }) + .from(automation) + .where(and(...conditions)); + + const total = totalResult?.count ?? 0; + + // Get automations with pagination + const automations = await db.query.automation.findMany({ + where: and(...conditions), + with: { + createdByUser: { + columns: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: [desc(automation.updatedAt)], + limit: pageSize, + offset, + }); + + return { + success: true, + automations: automations as AutomationWithMeta[], + total, + page, + pageSize, + }; + } catch (error) { + const log = createActionLogger("listAutomations", { + orgSlug: organizationId, + }); + log.error({ err: serializeError(error) }, "Failed to list automations"); + return { success: false, error: "Failed to fetch automations" }; + } +} + +/** + * Get a single automation by ID + */ +export async function getAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + const a = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + with: { + createdByUser: { + columns: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + if (!a) { + return { success: false, error: "Automation not found" }; + } + + return { success: true, automation: a as AutomationWithMeta }; + } catch (error) { + const log = createActionLogger("getAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to get automation" + ); + return { success: false, error: "Failed to fetch automation" }; + } +} + +/** + * Create a new automation + */ +export async function createAutomation( + organizationId: string, + data: { + name: string; + description?: string; + awsAccountId?: string; + topicId?: string; + } +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess( + organizationId, + "automations" + ); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Check if organization has reached their automation limit + const limitCheck = await checkWorkflowLimit(organizationId); + if (!limitCheck.allowed) { + return { + success: false, + error: limitCheck.message ?? "You have reached your automation limit.", + }; + } + + if (!data.name?.trim()) { + return { success: false, error: "Automation name is required" }; + } + + // Create default trigger step + const triggerId = crypto.randomUUID(); + const defaultSteps: AutomationStep[] = [ + { + id: triggerId, + type: "trigger", + name: "Trigger", + position: { x: 400, y: 50 }, + config: { + type: "trigger", + triggerType: "event", + }, + }, + ]; + + const [newAutomation] = await db + .insert(automation) + .values({ + organizationId, + name: data.name.trim(), + description: data.description?.trim() || null, + awsAccountId: data.awsAccountId || null, + topicId: data.topicId || null, + status: "draft", + triggerType: "event", + triggerConfig: {}, + steps: defaultSteps, + transitions: [], + createdBy: access.userId, + }) + .returning(); + + if (!newAutomation) { + return { success: false, error: "Failed to create automation" }; + } + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + + // Track activation event + await trackWorkflowCreated(access.userEmail, organizationId).catch( + (err) => { + const log = createActionLogger("createAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(err) }, + "Failed to track automation created" + ); + } + ); + + return await getAutomation(newAutomation.id, organizationId); + } catch (error) { + const log = createActionLogger("createAutomation", { + orgSlug: organizationId, + }); + log.error({ err: serializeError(error) }, "Failed to create automation"); + return { success: false, error: "Failed to create automation" }; + } +} + +/** + * Update an automation + */ +export async function updateAutomation( + automationId: string, + organizationId: string, + data: { + name?: string; + description?: string; + awsAccountId?: string | null; + topicId?: string | null; + triggerType?: AutomationTriggerType; + triggerConfig?: TriggerConfig; + steps?: AutomationStep[]; + transitions?: AutomationTransition[]; + canvasViewport?: CanvasViewport; + allowReentry?: boolean; + reentryDelaySeconds?: number | null; + maxConcurrentExecutions?: number; + contactCooldownSeconds?: number | null; + // Sender defaults + defaultFrom?: string | null; + defaultFromName?: string | null; + defaultReplyTo?: string | null; + defaultSenderId?: string | null; + } +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess( + organizationId, + "automations" + ); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Verify automation exists + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + // Validate steps/transitions if provided + if (data.steps !== undefined || data.transitions !== undefined) { + const steps = data.steps ?? (existing.steps as AutomationStep[]); + const transitions = + data.transitions ?? (existing.transitions as AutomationTransition[]); + + const validation = validateAutomationDefinition(steps, transitions); + if (!validation.valid) { + return { + success: false, + error: `Invalid automation: ${validation.errors.join(", ")}`, + }; + } + } + + // Build update data + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (data.name !== undefined) { + if (!data.name.trim()) { + return { success: false, error: "Automation name is required" }; + } + updateData.name = data.name.trim(); + } + + if (data.description !== undefined) { + updateData.description = data.description?.trim() || null; + } + + if (data.awsAccountId !== undefined) { + updateData.awsAccountId = data.awsAccountId; + } + + if (data.topicId !== undefined) { + updateData.topicId = data.topicId; + } + + if (data.triggerType !== undefined) { + updateData.triggerType = data.triggerType; + } + + if (data.triggerConfig !== undefined) { + updateData.triggerConfig = data.triggerConfig; + } + + if (data.steps !== undefined) { + updateData.steps = data.steps; + } + + if (data.transitions !== undefined) { + updateData.transitions = data.transitions; + } + + // Bump version when the definition (steps or transitions) changes + // so new executions get a fresh snapshot and existing snapshots stay valid + if (data.steps !== undefined || data.transitions !== undefined) { + (updateData as Record).version = + sql`${automation.version} + 1`; + } + + if (data.canvasViewport !== undefined) { + updateData.canvasViewport = data.canvasViewport; + } + + if (data.allowReentry !== undefined) { + updateData.allowReentry = data.allowReentry; + } + + if (data.reentryDelaySeconds !== undefined) { + updateData.reentryDelaySeconds = data.reentryDelaySeconds; + } + + if (data.maxConcurrentExecutions !== undefined) { + updateData.maxConcurrentExecutions = data.maxConcurrentExecutions; + } + + if (data.contactCooldownSeconds !== undefined) { + updateData.contactCooldownSeconds = data.contactCooldownSeconds; + } + + // Sender defaults + if (data.defaultFrom !== undefined) { + updateData.defaultFrom = data.defaultFrom; + } + + if (data.defaultFromName !== undefined) { + updateData.defaultFromName = data.defaultFromName; + } + + if (data.defaultReplyTo !== undefined) { + updateData.defaultReplyTo = data.defaultReplyTo; + } + + if (data.defaultSenderId !== undefined) { + updateData.defaultSenderId = data.defaultSenderId; + } + + // Update automation + await db + .update(automation) + .set(updateData) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ); + + // Handle schedule changes for enabled automations + if (existing.status === "enabled") { + const oldTriggerType = existing.triggerType; + const newTriggerType = data.triggerType ?? oldTriggerType; + const oldConfig = existing.triggerConfig as TriggerConfig; + const newConfig = data.triggerConfig ?? oldConfig; + + // TriggerType changed FROM schedule → delete old schedule + if (oldTriggerType === "schedule" && newTriggerType !== "schedule") { + await callAutomationScheduleApi( + automationId, + organizationId, + "disable" + ); + } + + // TriggerType changed TO schedule → create new schedule + if ( + oldTriggerType !== "schedule" && + newTriggerType === "schedule" && + newConfig.schedule + ) { + await callAutomationScheduleApi( + automationId, + organizationId, + "enable", + { + cronExpression: newConfig.schedule, + timezone: newConfig.timezone, + } + ); + } + + // TriggerType stayed schedule but cron/timezone changed → reschedule + if ( + oldTriggerType === "schedule" && + newTriggerType === "schedule" && + data.triggerConfig !== undefined && + newConfig.schedule && + (oldConfig.schedule !== newConfig.schedule || + oldConfig.timezone !== newConfig.timezone) + ) { + await callAutomationScheduleApi( + automationId, + organizationId, + "update", + { + cronExpression: newConfig.schedule, + timezone: newConfig.timezone, + } + ); + } + } + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + revalidatePath(`/[orgSlug]/automations/${automationId}`, "page"); + + return await getAutomation(automationId, organizationId); + } catch (error) { + const log = createActionLogger("updateAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to update automation" + ); + return { success: false, error: "Failed to update automation" }; + } +} + +/** + * Delete an automation + */ +export async function deleteAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess( + organizationId, + "automations" + ); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Verify automation exists + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + // Check for active executions + const [activeCount] = await db + .select({ count: count() }) + .from(automationExecution) + .where( + and( + eq(automationExecution.workflowId, automationId), + inArray(automationExecution.status, [ + "pending", + "active", + "paused", + "waiting", + ]) + ) + ); + + if ((activeCount?.count ?? 0) > 0) { + return { + success: false, + error: `Cannot delete automation with ${activeCount?.count} active execution(s). Disable the automation first and wait for executions to complete.`, + }; + } + + // Clean up pending schedule before delete (best effort) + if (existing.triggerType === "schedule") { + await callAutomationScheduleApi(automationId, organizationId, "disable"); + } + + // Delete automation (cascades to executions) + await db + .delete(automation) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ); + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + + return { success: true }; + } catch (error) { + const log = createActionLogger("deleteAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to delete automation" + ); + return { success: false, error: "Failed to delete automation" }; + } +} + +/** + * Enable an automation (make it active and start accepting triggers) + */ +export async function enableAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess( + organizationId, + "automations" + ); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Get automation + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + // Require AWS account to be configured + if (!existing.awsAccountId) { + return { + success: false, + error: + "Please select an AWS account in automation settings before enabling", + }; + } + + // Validate automation has required configuration + const steps = existing.steps as AutomationStep[]; + const transitions = existing.transitions as AutomationTransition[]; + + const validation = validateAutomationDefinition(steps, transitions); + if (!validation.valid) { + return { + success: false, + error: `Cannot enable automation: ${validation.errors.join(", ")}`, + }; + } + + // Check trigger is configured + const triggerStep = steps.find((s) => s.type === "trigger"); + if (!triggerStep) { + return { + success: false, + error: "Automation must have a trigger configured", + }; + } + + // Validate trigger configuration based on type + const triggerConfig = existing.triggerConfig as TriggerConfig; + + switch (existing.triggerType) { + case "event": + // Custom event triggers require eventName + if (!triggerConfig?.eventName) { + return { + success: false, + error: "Custom event trigger must have an event name configured", + }; + } + break; + + case "schedule": + // Schedule triggers require a cron expression + if (!triggerConfig?.schedule) { + return { + success: false, + error: "Schedule trigger must have a cron expression configured", + }; + } + break; + + case "segment_entry": + case "segment_exit": + // Segment triggers require a segmentId + if (!triggerConfig?.segmentId) { + return { + success: false, + error: "Segment trigger must have a segment selected", + }; + } + break; + + case "topic_subscribed": + case "topic_unsubscribed": + // Topic triggers require a topicId + if (!triggerConfig?.topicId) { + return { + success: false, + error: "Topic trigger must have a topic selected", + }; + } + break; + + case "api": + case "contact_created": + case "contact_updated": + // These triggers don't require additional configuration + break; + } + + // Check automation has at least one action step + const actionSteps = steps.filter( + (s) => s.type !== "trigger" && s.type !== "exit" + ); + if (actionSteps.length === 0) { + return { + success: false, + error: "Automation must have at least one action step", + }; + } + + // Defense-in-depth: verify referenced templates exist + const emailSteps = steps.filter((s) => s.type === "send_email"); + const templateIds = emailSteps + .map((s) => (s.config.type === "send_email" ? s.config.templateId : "")) + .filter(Boolean); + const uniqueTemplateIds = [...new Set(templateIds)]; + + if (uniqueTemplateIds.length > 0) { + const foundTemplates = await db + .select({ id: template.id }) + .from(template) + .where( + and( + eq(template.organizationId, organizationId), + inArray(template.id, uniqueTemplateIds) + ) + ); + + const foundIds = new Set(foundTemplates.map((t) => t.id)); + const missingCount = uniqueTemplateIds.filter( + (id) => !foundIds.has(id) + ).length; + + if (missingCount > 0) { + return { + success: false, + error: `Cannot enable: ${missingCount} referenced template${missingCount > 1 ? "s do" : " does"} not exist`, + }; + } + } + + // Defense-in-depth: require sender email when email steps exist + if (emailSteps.length > 0 && !existing.defaultFrom) { + return { + success: false, + error: + "Please configure a sender email in automation settings before enabling", + }; + } + + // If schedule trigger, create EventBridge schedule BEFORE setting status + // to avoid a window where the automation is "enabled" without a valid schedule + if (existing.triggerType === "schedule" && triggerConfig.schedule) { + const scheduleResult = await callAutomationScheduleApi( + automationId, + organizationId, + "enable", + { + cronExpression: triggerConfig.schedule, + timezone: triggerConfig.timezone, + } + ); + + if (!scheduleResult.success) { + return { + success: false, + error: `Failed to create schedule: ${scheduleResult.error}`, + }; + } + } + + // Enable automation (schedule already created if needed) + await db + .update(automation) + .set({ + status: "enabled", + updatedAt: new Date(), + }) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ); + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + revalidatePath(`/[orgSlug]/automations/${automationId}`, "page"); + + return await getAutomation(automationId, organizationId); + } catch (error) { + const log = createActionLogger("enableAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to enable automation" + ); + return { success: false, error: "Failed to enable automation" }; + } +} + +/** + * Disable an automation (stop accepting new triggers, existing executions continue) + */ +export async function disableAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess( + organizationId, + "automations" + ); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Verify automation exists + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + // Pause automation and mark as edited from dashboard (for CLI conflict detection) + await db + .update(automation) + .set({ + status: "paused", + lastEditedFrom: "dashboard", + updatedAt: new Date(), + }) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ); + + // If schedule trigger, delete pending EventBridge schedule (best effort) + if (existing.triggerType === "schedule") { + await callAutomationScheduleApi(automationId, organizationId, "disable"); + } + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + revalidatePath(`/[orgSlug]/automations/${automationId}`, "page"); + + return await getAutomation(automationId, organizationId); + } catch (error) { + const log = createActionLogger("disableAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to disable automation" + ); + return { success: false, error: "Failed to disable automation" }; + } +} + +/** + * Duplicate an automation + */ +export async function duplicateAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess( + organizationId, + "automations" + ); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Check if organization has reached their automation limit + const limitCheck = await checkWorkflowLimit(organizationId); + if (!limitCheck.allowed) { + return { + success: false, + error: limitCheck.message ?? "You have reached your automation limit.", + }; + } + + // Get original automation + const original = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!original) { + return { success: false, error: "Automation not found" }; + } + + // Generate new IDs for steps and update transitions + const oldToNewIdMap = new Map(); + const originalSteps = original.steps as AutomationStep[]; + const originalTransitions = original.transitions as AutomationTransition[]; + + // Map old step IDs to new ones + for (const step of originalSteps) { + oldToNewIdMap.set(step.id, crypto.randomUUID()); + } + + // Create new steps with updated IDs + const newSteps: AutomationStep[] = originalSteps.map((step) => ({ + ...step, + id: oldToNewIdMap.get(step.id)!, + })); + + // Create new transitions with updated IDs + const newTransitions: AutomationTransition[] = originalTransitions.map( + (transition) => ({ + ...transition, + id: crypto.randomUUID(), + fromStepId: + oldToNewIdMap.get(transition.fromStepId) || transition.fromStepId, + toStepId: oldToNewIdMap.get(transition.toStepId) || transition.toStepId, + }) + ); + + // Create duplicate automation + const [newAutomation] = await db + .insert(automation) + .values({ + organizationId, + name: `${original.name} (copy)`, + description: original.description, + awsAccountId: original.awsAccountId, + topicId: original.topicId, + status: "draft", // Always start as draft + triggerType: original.triggerType, + triggerConfig: original.triggerConfig, + steps: newSteps, + transitions: newTransitions, + canvasViewport: original.canvasViewport, + allowReentry: original.allowReentry, + reentryDelaySeconds: original.reentryDelaySeconds, + maxConcurrentExecutions: original.maxConcurrentExecutions, + contactCooldownSeconds: original.contactCooldownSeconds, + createdBy: access.userId, + }) + .returning(); + + if (!newAutomation) { + return { success: false, error: "Failed to duplicate automation" }; + } + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + + return await getAutomation(newAutomation.id, organizationId); + } catch (error) { + const log = createActionLogger("duplicateAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to duplicate automation" + ); + return { success: false, error: "Failed to duplicate automation" }; + } +} + +/** + * Get automation execution statistics + */ +export async function getAutomationStats( + automationId: string, + organizationId: string +): Promise< + | { + success: true; + stats: { + total: number; + active: number; + completed: number; + failed: number; + }; + } + | { success: false; error: string } +> { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Verify automation exists + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + columns: { + totalExecutions: true, + activeExecutions: true, + completedExecutions: true, + failedExecutions: true, + }, + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + return { + success: true, + stats: { + total: existing.totalExecutions, + active: existing.activeExecutions, + completed: existing.completedExecutions, + failed: existing.failedExecutions, + }, + }; + } catch (error) { + const log = createActionLogger("getAutomationStats", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to get automation stats" + ); + return { success: false, error: "Failed to get automation stats" }; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// BACKWARD-COMPAT ALIASES +// ═══════════════════════════════════════════════════════════════════════════ + +/** @deprecated Use AutomationWithMeta */ +export type WorkflowWithMeta = AutomationWithMeta; + +/** @deprecated Use ListAutomationsResult */ +export type ListWorkflowsResult = ListAutomationsResult; + +/** @deprecated Use GetAutomationResult */ +export type GetWorkflowResult = GetAutomationResult; + +/** @deprecated Use CreateAutomationResult */ +export type CreateWorkflowResult = CreateAutomationResult; + +/** @deprecated Use UpdateAutomationResult */ +export type UpdateWorkflowResult = UpdateAutomationResult; + +/** @deprecated Use DeleteAutomationResult */ +export type DeleteWorkflowResult = DeleteAutomationResult; + +/** @deprecated Use EnableAutomationResult */ +export type EnableWorkflowResult = EnableAutomationResult; + +/** @deprecated Use DuplicateAutomationResult */ +export type DuplicateWorkflowResult = DuplicateAutomationResult; + +/** @deprecated Use listAutomations */ +export const listWorkflows = listAutomations as ( + organizationId: string, + options?: { + page?: number; + pageSize?: number; + search?: string; + status?: Automation["status"]; + } +) => Promise; + +/** @deprecated Use getAutomation */ +export const getWorkflow = getAutomation; + +/** @deprecated Use createAutomation */ +export const createWorkflow = createAutomation; + +/** @deprecated Use updateAutomation */ +export const updateWorkflow = updateAutomation; + +/** @deprecated Use deleteAutomation */ +export const deleteWorkflow = deleteAutomation; + +/** @deprecated Use enableAutomation */ +export const enableWorkflow = enableAutomation; + +/** @deprecated Use disableAutomation */ +export const disableWorkflow = disableAutomation; + +/** @deprecated Use duplicateAutomation */ +export const duplicateWorkflow = duplicateAutomation; + +/** @deprecated Use getAutomationStats */ +export const getWorkflowStats = getAutomationStats; diff --git a/apps/web/src/actions/search.ts b/apps/web/src/actions/search.ts index 9d68dfb80..2c2ecb943 100644 --- a/apps/web/src/actions/search.ts +++ b/apps/web/src/actions/search.ts @@ -101,7 +101,7 @@ export async function universalSearch( // Check feature access for gated entities in parallel const [workflowAccess, segmentAccess, topicAccess] = await Promise.all([ - checkFeatureAccess(organizationId, "workflows"), + checkFeatureAccess(organizationId, "automations"), checkFeatureAccess(organizationId, "segments"), checkFeatureAccess(organizationId, "topics"), ]); diff --git a/apps/web/src/actions/workflow-readiness.ts b/apps/web/src/actions/workflow-readiness.ts index e77bf9a17..f22376b43 100644 --- a/apps/web/src/actions/workflow-readiness.ts +++ b/apps/web/src/actions/workflow-readiness.ts @@ -1,194 +1,5 @@ -"use server"; - -import { auth } from "@wraps/auth"; -import { db, template } from "@wraps/db"; -import { and, eq, inArray } from "drizzle-orm"; -import { headers } from "next/headers"; -import { createActionLogger, serializeError } from "@/lib/logger"; - -// ═══════════════════════════════════════════════════════════════════════════ -// TYPES -// ═══════════════════════════════════════════════════════════════════════════ - -export type ReadinessCheck = { - id: string; - label: string; - status: "pass" | "fail" | "warn"; - severity: "critical" | "warning"; - details?: string; -}; - -export type ReadinessResult = - | { success: true; checks: ReadinessCheck[] } - | { success: false; error: string }; - -// ═══════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - -async function verifyOrgAccess( - organizationId: string -): Promise<{ userId: string; userEmail: string; role: string } | null> { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - return null; - } - - const membership = await db.query.member.findFirst({ - where: (m, ops) => - ops.and( - ops.eq(m.organizationId, organizationId), - ops.eq(m.userId, session.user.id) - ), - }); - - if (!membership) { - return null; - } - - return { - userId: session.user.id, - userEmail: session.user.email, - role: membership.role, - }; -} - /** - * Known first-class contact fields (from packages/db/src/schema/contacts.ts). - * Fields accessed via `properties.*` are custom and always pass. + * @deprecated Import from `./automation-readiness` instead. + * This file is a backward-compatibility shim. */ -const KNOWN_CONTACT_FIELDS = new Set([ - "email", - "emailStatus", - "phone", - "smsStatus", - "firstName", - "lastName", - "company", - "jobTitle", - "preferredChannel", - "status", - "emailsSent", - "emailsOpened", - "emailsClicked", - "smsSent", - "smsClicked", -]); - -async function checkTemplates( - organizationId: string, - templateIds: string[] -): Promise { - const uniqueIds = [...new Set(templateIds.filter(Boolean))]; - if (uniqueIds.length === 0) { - return []; - } - - const foundTemplates = await db - .select({ id: template.id, status: template.status }) - .from(template) - .where( - and( - eq(template.organizationId, organizationId), - inArray(template.id, uniqueIds) - ) - ); - - const foundIds = new Set(foundTemplates.map((t) => t.id)); - const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); - const unpublished = foundTemplates.filter((t) => t.status !== "PUBLISHED"); - - const checks: ReadinessCheck[] = []; - - checks.push({ - id: "templates_exist", - label: "All email templates exist", - status: missingIds.length > 0 ? "fail" : "pass", - severity: "critical", - details: - missingIds.length > 0 - ? `${missingIds.length} template${missingIds.length > 1 ? "s" : ""} not found` - : undefined, - }); - - checks.push({ - id: "templates_published", - label: "All templates are published", - status: unpublished.length > 0 ? "fail" : "pass", - severity: "warning", - details: - unpublished.length > 0 - ? `${unpublished.length} template${unpublished.length > 1 ? "s are" : " is"} still in ${unpublished.map((t) => t.status.toLowerCase()).join(", ")} status` - : undefined, - }); - - return checks; -} - -function checkConditionFields(conditionFields: string[]): ReadinessCheck[] { - const uniqueFields = [...new Set(conditionFields.filter(Boolean))]; - if (uniqueFields.length === 0) { - return []; - } - - const unknownFields = uniqueFields.filter( - (field) => - !(KNOWN_CONTACT_FIELDS.has(field) || field.startsWith("properties.")) - ); - - return [ - { - id: "condition_fields_valid", - label: "All condition fields are valid", - status: unknownFields.length > 0 ? "fail" : "pass", - severity: "warning", - details: - unknownFields.length > 0 - ? `Unknown field${unknownFields.length > 1 ? "s" : ""}: ${unknownFields.join(", ")}` - : undefined, - }, - ]; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// ACTION -// ═══════════════════════════════════════════════════════════════════════════ - -export async function checkWorkflowReadiness( - workflowId: string, - organizationId: string, - payload: { - templateIds: string[]; - conditionFields: string[]; - } -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - const templateChecks = await checkTemplates( - organizationId, - payload.templateIds - ); - const fieldChecks = checkConditionFields(payload.conditionFields); - - return { success: true, checks: [...templateChecks, ...fieldChecks] }; - } catch (error) { - const log = createActionLogger("checkWorkflowReadiness", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to check workflow readiness" - ); - return { success: false, error: "Failed to check workflow readiness" }; - } -} +export * from "./automation-readiness"; diff --git a/apps/web/src/actions/workflows.ts b/apps/web/src/actions/workflows.ts index c3a1d27c4..f68400522 100644 --- a/apps/web/src/actions/workflows.ts +++ b/apps/web/src/actions/workflows.ts @@ -1,1255 +1,5 @@ -"use server"; - -import { auth } from "@wraps/auth"; -import { - type CanvasViewport, - db, - type TriggerConfig, - template, - type Workflow, - type WorkflowStep, - type WorkflowTransition, - type WorkflowTriggerType, - workflow, - workflowExecution, -} from "@wraps/db"; -import { and, count, desc, eq, ilike, inArray, sql } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; -import { headers } from "next/headers"; -import { trackWorkflowCreated } from "@/lib/activation-tracking"; -import { createActionLogger, serializeError } from "@/lib/logger"; -import { checkFeatureAccess, checkWorkflowLimit } from "@/lib/plan-limits"; - -// ═══════════════════════════════════════════════════════════════════════════ -// TYPES -// ═══════════════════════════════════════════════════════════════════════════ - -export type WorkflowWithMeta = Workflow & { - createdByUser?: { - id: string; - name: string | null; - email: string; - } | null; -}; - -export type ListWorkflowsResult = - | { - success: true; - workflows: WorkflowWithMeta[]; - total: number; - page: number; - pageSize: number; - } - | { success: false; error: string }; - -export type GetWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -export type CreateWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -export type UpdateWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -export type DeleteWorkflowResult = - | { success: true } - | { success: false; error: string }; - -export type EnableWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -export type DuplicateWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -// ═══════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - /** - * Verify user has access to organization + * @deprecated Import from `./automations` instead. + * This file is a backward-compatibility shim. */ -async function verifyOrgAccess( - organizationId: string -): Promise<{ userId: string; userEmail: string; role: string } | null> { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - return null; - } - - const membership = await db.query.member.findFirst({ - where: (m, { and, eq }) => - and(eq(m.organizationId, organizationId), eq(m.userId, session.user.id)), - }); - - if (!membership) { - return null; - } - - return { - userId: session.user.id, - userEmail: session.user.email, - role: membership.role, - }; -} - -/** - * Validate workflow definition for common issues - */ -function validateWorkflowDefinition( - steps: WorkflowStep[], - transitions: WorkflowTransition[] -): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - // Check for trigger node - const triggerSteps = steps.filter((s) => s.type === "trigger"); - if (triggerSteps.length === 0) { - errors.push("Workflow must have a trigger node"); - } else if (triggerSteps.length > 1) { - errors.push("Workflow can only have one trigger node"); - } - - // Check all steps have IDs - for (const step of steps) { - if (!step.id) { - errors.push("All steps must have an ID"); - break; - } - } - - // Check transitions reference valid step IDs - const stepIds = new Set(steps.map((s) => s.id)); - for (const transition of transitions) { - if (!stepIds.has(transition.fromStepId)) { - errors.push( - `Transition references unknown step: ${transition.fromStepId}` - ); - } - if (!stepIds.has(transition.toStepId)) { - errors.push(`Transition references unknown step: ${transition.toStepId}`); - } - } - - return { valid: errors.length === 0, errors }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// WORKFLOW SCHEDULE API HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Call the workflow schedule API to manage EventBridge schedules. - * Follows the same pattern as batch.ts for auth + org headers. - */ -async function callWorkflowScheduleApi( - workflowId: string, - organizationId: string, - action: "enable" | "disable" | "update", - body?: { cronExpression: string; timezone?: string } -): Promise<{ success: boolean; error?: string }> { - const apiUrl = process.env.NEXT_PUBLIC_API_URL; - if (!apiUrl) { - console.error("[workflow-schedule] NEXT_PUBLIC_API_URL not configured"); - return { success: false, error: "API URL not configured" }; - } - - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.session?.token) { - return { success: false, error: "Not authenticated" }; - } - - const baseHeaders: Record = { - Authorization: `Bearer ${session.session.token}`, - "X-Organization-Id": organizationId, - }; - - let url: string; - let method: string; - let fetchBody: string | undefined; - - switch (action) { - case "enable": - url = `${apiUrl}/v1/workflow-schedules/${workflowId}/enable`; - method = "POST"; - fetchBody = JSON.stringify(body); - baseHeaders["Content-Type"] = "application/json"; - break; - case "disable": - url = `${apiUrl}/v1/workflow-schedules/${workflowId}/disable`; - method = "POST"; - break; - case "update": - url = `${apiUrl}/v1/workflow-schedules/${workflowId}`; - method = "PUT"; - fetchBody = JSON.stringify(body); - baseHeaders["Content-Type"] = "application/json"; - break; - } - - try { - const response = await fetch(url, { - method, - headers: baseHeaders, - body: fetchBody, - }); - - if (!response.ok) { - const text = await response.text(); - console.error( - `[workflow-schedule] API ${action} failed for ${workflowId}: ${response.status} ${text}` - ); - return { success: false, error: text }; - } - - return { success: true }; - } catch (error) { - console.error( - `[workflow-schedule] API ${action} error for ${workflowId}:`, - error - ); - return { - success: false, - error: error instanceof Error ? error.message : "API call failed", - }; - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// ACTIONS -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * List workflows for an organization with pagination - */ -export async function listWorkflows( - organizationId: string, - options: { - page?: number; - pageSize?: number; - search?: string; - status?: Workflow["status"]; - } = {} -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - const { page = 1, pageSize = 50, search, status } = options; - const offset = (page - 1) * pageSize; - - // Build where conditions - const conditions = [eq(workflow.organizationId, organizationId)]; - - if (search) { - conditions.push(ilike(workflow.name, `%${search}%`)); - } - - if (status) { - conditions.push(eq(workflow.status, status)); - } - - // Get total count - const [totalResult] = await db - .select({ count: count() }) - .from(workflow) - .where(and(...conditions)); - - const total = totalResult?.count ?? 0; - - // Get workflows with pagination - const workflows = await db.query.workflow.findMany({ - where: and(...conditions), - with: { - createdByUser: { - columns: { - id: true, - name: true, - email: true, - }, - }, - }, - orderBy: [desc(workflow.updatedAt)], - limit: pageSize, - offset, - }); - - return { - success: true, - workflows: workflows as WorkflowWithMeta[], - total, - page, - pageSize, - }; - } catch (error) { - const log = createActionLogger("listWorkflows", { - orgSlug: organizationId, - }); - log.error({ err: serializeError(error) }, "Failed to list workflows"); - return { success: false, error: "Failed to fetch workflows" }; - } -} - -/** - * Get a single workflow by ID - */ -export async function getWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - const w = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - with: { - createdByUser: { - columns: { - id: true, - name: true, - email: true, - }, - }, - }, - }); - - if (!w) { - return { success: false, error: "Workflow not found" }; - } - - return { success: true, workflow: w as WorkflowWithMeta }; - } catch (error) { - const log = createActionLogger("getWorkflow", { orgSlug: organizationId }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to get workflow" - ); - return { success: false, error: "Failed to fetch workflow" }; - } -} - -/** - * Create a new workflow - */ -export async function createWorkflow( - organizationId: string, - data: { - name: string; - description?: string; - awsAccountId?: string; - topicId?: string; - } -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Check if organization has reached their workflow limit - const limitCheck = await checkWorkflowLimit(organizationId); - if (!limitCheck.allowed) { - return { - success: false, - error: limitCheck.message ?? "You have reached your workflow limit.", - }; - } - - if (!data.name?.trim()) { - return { success: false, error: "Workflow name is required" }; - } - - // Create default trigger step - const triggerId = crypto.randomUUID(); - const defaultSteps: WorkflowStep[] = [ - { - id: triggerId, - type: "trigger", - name: "Trigger", - position: { x: 400, y: 50 }, - config: { - type: "trigger", - triggerType: "event", - }, - }, - ]; - - const [newWorkflow] = await db - .insert(workflow) - .values({ - organizationId, - name: data.name.trim(), - description: data.description?.trim() || null, - awsAccountId: data.awsAccountId || null, - topicId: data.topicId || null, - status: "draft", - triggerType: "event", - triggerConfig: {}, - steps: defaultSteps, - transitions: [], - createdBy: access.userId, - }) - .returning(); - - if (!newWorkflow) { - return { success: false, error: "Failed to create workflow" }; - } - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - - // Track activation event - await trackWorkflowCreated(access.userEmail, organizationId).catch( - (err) => { - const log = createActionLogger("createWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(err) }, - "Failed to track workflow created" - ); - } - ); - - return await getWorkflow(newWorkflow.id, organizationId); - } catch (error) { - const log = createActionLogger("createWorkflow", { - orgSlug: organizationId, - }); - log.error({ err: serializeError(error) }, "Failed to create workflow"); - return { success: false, error: "Failed to create workflow" }; - } -} - -/** - * Update a workflow - */ -export async function updateWorkflow( - workflowId: string, - organizationId: string, - data: { - name?: string; - description?: string; - awsAccountId?: string | null; - topicId?: string | null; - triggerType?: WorkflowTriggerType; - triggerConfig?: TriggerConfig; - steps?: WorkflowStep[]; - transitions?: WorkflowTransition[]; - canvasViewport?: CanvasViewport; - allowReentry?: boolean; - reentryDelaySeconds?: number | null; - maxConcurrentExecutions?: number; - contactCooldownSeconds?: number | null; - // Sender defaults - defaultFrom?: string | null; - defaultFromName?: string | null; - defaultReplyTo?: string | null; - defaultSenderId?: string | null; - } -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Verify workflow exists - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - // Validate steps/transitions if provided - if (data.steps !== undefined || data.transitions !== undefined) { - const steps = data.steps ?? (existing.steps as WorkflowStep[]); - const transitions = - data.transitions ?? (existing.transitions as WorkflowTransition[]); - - const validation = validateWorkflowDefinition(steps, transitions); - if (!validation.valid) { - return { - success: false, - error: `Invalid workflow: ${validation.errors.join(", ")}`, - }; - } - } - - // Build update data - const updateData: Partial = { - updatedAt: new Date(), - }; - - if (data.name !== undefined) { - if (!data.name.trim()) { - return { success: false, error: "Workflow name is required" }; - } - updateData.name = data.name.trim(); - } - - if (data.description !== undefined) { - updateData.description = data.description?.trim() || null; - } - - if (data.awsAccountId !== undefined) { - updateData.awsAccountId = data.awsAccountId; - } - - if (data.topicId !== undefined) { - updateData.topicId = data.topicId; - } - - if (data.triggerType !== undefined) { - updateData.triggerType = data.triggerType; - } - - if (data.triggerConfig !== undefined) { - updateData.triggerConfig = data.triggerConfig; - } - - if (data.steps !== undefined) { - updateData.steps = data.steps; - } - - if (data.transitions !== undefined) { - updateData.transitions = data.transitions; - } - - // Bump version when the definition (steps or transitions) changes - // so new executions get a fresh snapshot and existing snapshots stay valid - if (data.steps !== undefined || data.transitions !== undefined) { - (updateData as Record).version = - sql`${workflow.version} + 1`; - } - - if (data.canvasViewport !== undefined) { - updateData.canvasViewport = data.canvasViewport; - } - - if (data.allowReentry !== undefined) { - updateData.allowReentry = data.allowReentry; - } - - if (data.reentryDelaySeconds !== undefined) { - updateData.reentryDelaySeconds = data.reentryDelaySeconds; - } - - if (data.maxConcurrentExecutions !== undefined) { - updateData.maxConcurrentExecutions = data.maxConcurrentExecutions; - } - - if (data.contactCooldownSeconds !== undefined) { - updateData.contactCooldownSeconds = data.contactCooldownSeconds; - } - - // Sender defaults - if (data.defaultFrom !== undefined) { - updateData.defaultFrom = data.defaultFrom; - } - - if (data.defaultFromName !== undefined) { - updateData.defaultFromName = data.defaultFromName; - } - - if (data.defaultReplyTo !== undefined) { - updateData.defaultReplyTo = data.defaultReplyTo; - } - - if (data.defaultSenderId !== undefined) { - updateData.defaultSenderId = data.defaultSenderId; - } - - // Update workflow - await db - .update(workflow) - .set(updateData) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ); - - // Handle schedule changes for enabled workflows - if (existing.status === "enabled") { - const oldTriggerType = existing.triggerType; - const newTriggerType = data.triggerType ?? oldTriggerType; - const oldConfig = existing.triggerConfig as TriggerConfig; - const newConfig = data.triggerConfig ?? oldConfig; - - // TriggerType changed FROM schedule → delete old schedule - if (oldTriggerType === "schedule" && newTriggerType !== "schedule") { - await callWorkflowScheduleApi(workflowId, organizationId, "disable"); - } - - // TriggerType changed TO schedule → create new schedule - if ( - oldTriggerType !== "schedule" && - newTriggerType === "schedule" && - newConfig.schedule - ) { - await callWorkflowScheduleApi(workflowId, organizationId, "enable", { - cronExpression: newConfig.schedule, - timezone: newConfig.timezone, - }); - } - - // TriggerType stayed schedule but cron/timezone changed → reschedule - if ( - oldTriggerType === "schedule" && - newTriggerType === "schedule" && - data.triggerConfig !== undefined && - newConfig.schedule && - (oldConfig.schedule !== newConfig.schedule || - oldConfig.timezone !== newConfig.timezone) - ) { - await callWorkflowScheduleApi(workflowId, organizationId, "update", { - cronExpression: newConfig.schedule, - timezone: newConfig.timezone, - }); - } - } - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - revalidatePath(`/[orgSlug]/automations/${workflowId}`, "page"); - - return await getWorkflow(workflowId, organizationId); - } catch (error) { - const log = createActionLogger("updateWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to update workflow" - ); - return { success: false, error: "Failed to update workflow" }; - } -} - -/** - * Delete a workflow - */ -export async function deleteWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Verify workflow exists - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - // Check for active executions - const [activeCount] = await db - .select({ count: count() }) - .from(workflowExecution) - .where( - and( - eq(workflowExecution.workflowId, workflowId), - inArray(workflowExecution.status, [ - "pending", - "active", - "paused", - "waiting", - ]) - ) - ); - - if ((activeCount?.count ?? 0) > 0) { - return { - success: false, - error: `Cannot delete workflow with ${activeCount?.count} active execution(s). Disable the workflow first and wait for executions to complete.`, - }; - } - - // Clean up pending schedule before delete (best effort) - if (existing.triggerType === "schedule") { - await callWorkflowScheduleApi(workflowId, organizationId, "disable"); - } - - // Delete workflow (cascades to executions) - await db - .delete(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ); - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - - return { success: true }; - } catch (error) { - const log = createActionLogger("deleteWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to delete workflow" - ); - return { success: false, error: "Failed to delete workflow" }; - } -} - -/** - * Enable a workflow (make it active and start accepting triggers) - */ -export async function enableWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Get workflow - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - // Require AWS account to be configured - if (!existing.awsAccountId) { - return { - success: false, - error: - "Please select an AWS account in workflow settings before enabling", - }; - } - - // Validate workflow has required configuration - const steps = existing.steps as WorkflowStep[]; - const transitions = existing.transitions as WorkflowTransition[]; - - const validation = validateWorkflowDefinition(steps, transitions); - if (!validation.valid) { - return { - success: false, - error: `Cannot enable workflow: ${validation.errors.join(", ")}`, - }; - } - - // Check trigger is configured - const triggerStep = steps.find((s) => s.type === "trigger"); - if (!triggerStep) { - return { - success: false, - error: "Workflow must have a trigger configured", - }; - } - - // Validate trigger configuration based on type - const triggerConfig = existing.triggerConfig as TriggerConfig; - - switch (existing.triggerType) { - case "event": - // Custom event triggers require eventName - if (!triggerConfig?.eventName) { - return { - success: false, - error: "Custom event trigger must have an event name configured", - }; - } - break; - - case "schedule": - // Schedule triggers require a cron expression - if (!triggerConfig?.schedule) { - return { - success: false, - error: "Schedule trigger must have a cron expression configured", - }; - } - break; - - case "segment_entry": - case "segment_exit": - // Segment triggers require a segmentId - if (!triggerConfig?.segmentId) { - return { - success: false, - error: "Segment trigger must have a segment selected", - }; - } - break; - - case "topic_subscribed": - case "topic_unsubscribed": - // Topic triggers require a topicId - if (!triggerConfig?.topicId) { - return { - success: false, - error: "Topic trigger must have a topic selected", - }; - } - break; - - case "api": - case "contact_created": - case "contact_updated": - // These triggers don't require additional configuration - break; - } - - // Check workflow has at least one action step - const actionSteps = steps.filter( - (s) => s.type !== "trigger" && s.type !== "exit" - ); - if (actionSteps.length === 0) { - return { - success: false, - error: "Workflow must have at least one action step", - }; - } - - // Defense-in-depth: verify referenced templates exist - const emailSteps = steps.filter((s) => s.type === "send_email"); - const templateIds = emailSteps - .map((s) => (s.config.type === "send_email" ? s.config.templateId : "")) - .filter(Boolean); - const uniqueTemplateIds = [...new Set(templateIds)]; - - if (uniqueTemplateIds.length > 0) { - const foundTemplates = await db - .select({ id: template.id }) - .from(template) - .where( - and( - eq(template.organizationId, organizationId), - inArray(template.id, uniqueTemplateIds) - ) - ); - - const foundIds = new Set(foundTemplates.map((t) => t.id)); - const missingCount = uniqueTemplateIds.filter( - (id) => !foundIds.has(id) - ).length; - - if (missingCount > 0) { - return { - success: false, - error: `Cannot enable: ${missingCount} referenced template${missingCount > 1 ? "s do" : " does"} not exist`, - }; - } - } - - // Defense-in-depth: require sender email when email steps exist - if (emailSteps.length > 0 && !existing.defaultFrom) { - return { - success: false, - error: - "Please configure a sender email in workflow settings before enabling", - }; - } - - // If schedule trigger, create EventBridge schedule BEFORE setting status - // to avoid a window where the workflow is "enabled" without a valid schedule - if (existing.triggerType === "schedule" && triggerConfig.schedule) { - const scheduleResult = await callWorkflowScheduleApi( - workflowId, - organizationId, - "enable", - { - cronExpression: triggerConfig.schedule, - timezone: triggerConfig.timezone, - } - ); - - if (!scheduleResult.success) { - return { - success: false, - error: `Failed to create schedule: ${scheduleResult.error}`, - }; - } - } - - // Enable workflow (schedule already created if needed) - await db - .update(workflow) - .set({ - status: "enabled", - updatedAt: new Date(), - }) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ); - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - revalidatePath(`/[orgSlug]/automations/${workflowId}`, "page"); - - return await getWorkflow(workflowId, organizationId); - } catch (error) { - const log = createActionLogger("enableWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to enable workflow" - ); - return { success: false, error: "Failed to enable workflow" }; - } -} - -/** - * Disable a workflow (stop accepting new triggers, existing executions continue) - */ -export async function disableWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Verify workflow exists - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - // Pause workflow and mark as edited from dashboard (for CLI conflict detection) - await db - .update(workflow) - .set({ - status: "paused", - lastEditedFrom: "dashboard", - updatedAt: new Date(), - }) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ); - - // If schedule trigger, delete pending EventBridge schedule (best effort) - if (existing.triggerType === "schedule") { - await callWorkflowScheduleApi(workflowId, organizationId, "disable"); - } - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - revalidatePath(`/[orgSlug]/automations/${workflowId}`, "page"); - - return await getWorkflow(workflowId, organizationId); - } catch (error) { - const log = createActionLogger("disableWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to disable workflow" - ); - return { success: false, error: "Failed to disable workflow" }; - } -} - -/** - * Duplicate a workflow - */ -export async function duplicateWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Check if organization has reached their workflow limit - const limitCheck = await checkWorkflowLimit(organizationId); - if (!limitCheck.allowed) { - return { - success: false, - error: limitCheck.message ?? "You have reached your workflow limit.", - }; - } - - // Get original workflow - const original = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!original) { - return { success: false, error: "Workflow not found" }; - } - - // Generate new IDs for steps and update transitions - const oldToNewIdMap = new Map(); - const originalSteps = original.steps as WorkflowStep[]; - const originalTransitions = original.transitions as WorkflowTransition[]; - - // Map old step IDs to new ones - for (const step of originalSteps) { - oldToNewIdMap.set(step.id, crypto.randomUUID()); - } - - // Create new steps with updated IDs - const newSteps: WorkflowStep[] = originalSteps.map((step) => ({ - ...step, - id: oldToNewIdMap.get(step.id)!, - })); - - // Create new transitions with updated IDs - const newTransitions: WorkflowTransition[] = originalTransitions.map( - (transition) => ({ - ...transition, - id: crypto.randomUUID(), - fromStepId: - oldToNewIdMap.get(transition.fromStepId) || transition.fromStepId, - toStepId: oldToNewIdMap.get(transition.toStepId) || transition.toStepId, - }) - ); - - // Create duplicate workflow - const [newWorkflow] = await db - .insert(workflow) - .values({ - organizationId, - name: `${original.name} (copy)`, - description: original.description, - awsAccountId: original.awsAccountId, - topicId: original.topicId, - status: "draft", // Always start as draft - triggerType: original.triggerType, - triggerConfig: original.triggerConfig, - steps: newSteps, - transitions: newTransitions, - canvasViewport: original.canvasViewport, - allowReentry: original.allowReentry, - reentryDelaySeconds: original.reentryDelaySeconds, - maxConcurrentExecutions: original.maxConcurrentExecutions, - contactCooldownSeconds: original.contactCooldownSeconds, - createdBy: access.userId, - }) - .returning(); - - if (!newWorkflow) { - return { success: false, error: "Failed to duplicate workflow" }; - } - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - - return await getWorkflow(newWorkflow.id, organizationId); - } catch (error) { - const log = createActionLogger("duplicateWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to duplicate workflow" - ); - return { success: false, error: "Failed to duplicate workflow" }; - } -} - -/** - * Get workflow execution statistics - */ -export async function getWorkflowStats( - workflowId: string, - organizationId: string -): Promise< - | { - success: true; - stats: { - total: number; - active: number; - completed: number; - failed: number; - }; - } - | { success: false; error: string } -> { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Verify workflow exists - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - columns: { - totalExecutions: true, - activeExecutions: true, - completedExecutions: true, - failedExecutions: true, - }, - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - return { - success: true, - stats: { - total: existing.totalExecutions, - active: existing.activeExecutions, - completed: existing.completedExecutions, - failed: existing.failedExecutions, - }, - }; - } catch (error) { - const log = createActionLogger("getWorkflowStats", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to get workflow stats" - ); - return { success: false, error: "Failed to get workflow stats" }; - } -} +export * from "./automations"; diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[workflowId]/page.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[automationId]/page.tsx similarity index 80% rename from apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[workflowId]/page.tsx rename to apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[automationId]/page.tsx index 26edc16d7..43c3b32b3 100644 --- a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[workflowId]/page.tsx +++ b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[automationId]/page.tsx @@ -8,21 +8,21 @@ import { } from "@wraps/db"; import { eq } from "drizzle-orm"; import { redirect } from "next/navigation"; -import { getWorkflow } from "@/actions/workflows"; -import { WorkflowBuilder } from "@/components/(ee)/workflow-builder/workflow-builder"; +import { getAutomation } from "@/actions/automations"; +import { AutomationBuilder } from "@/components/(ee)/automation-builder/automation-builder"; import { getOrganizationWithMembership } from "@/lib/organization"; -type WorkflowBuilderPageProps = { +type AutomationBuilderPageProps = { params: Promise<{ orgSlug: string; - workflowId: string; + automationId: string; }>; }; -export default async function WorkflowBuilderPage({ +export default async function AutomationBuilderPage({ params, -}: WorkflowBuilderPageProps) { - const { orgSlug, workflowId } = await params; +}: AutomationBuilderPageProps) { + const { orgSlug, automationId } = await params; const session = await auth.api.getSession({ headers: await import("next/headers").then((mod) => mod.headers()), @@ -41,10 +41,13 @@ export default async function WorkflowBuilderPage({ redirect("/"); } - // Fetch workflow - const workflowResult = await getWorkflow(workflowId, orgWithMembership.id); + // Fetch automation + const automationResult = await getAutomation( + automationId, + orgWithMembership.id + ); - if (!workflowResult.success) { + if (!automationResult.success) { redirect(`/${orgSlug}/automations`); } @@ -90,7 +93,8 @@ export default async function WorkflowBuilderPage({ // Negative margins cancel out the dashboard layout padding return (
-
); diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/automations-table.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/automations-table.tsx new file mode 100644 index 000000000..78ed05fc9 --- /dev/null +++ b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/automations-table.tsx @@ -0,0 +1,614 @@ +"use client"; + +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type SortingState, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table"; +import { formatDistanceToNow } from "date-fns"; +import { + ArrowUpDown, + CheckCircle, + Copy, + MoreHorizontal, + Pause, + Pencil, + Play, + Plus, + Search, + Trash2, + Workflow, + Zap, +} from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition, +} from "react"; +import { toast } from "sonner"; +import { + type AutomationWithMeta, + deleteAutomation, + disableAutomation, + duplicateAutomation, + enableAutomation, +} from "@/actions/automations"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Kbd } from "@/components/ui/kbd"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + AUTOMATION_STATUS_COLORS, + AUTOMATION_STATUS_LABELS, + getStepCount, + getTriggerDescription, +} from "@/lib/automations"; +import { CreateAutomationDialog } from "./create-automation-dialog"; + +type AutomationsTableProps = { + workflows: AutomationWithMeta[]; + total: number; + organizationId: string; + orgSlug: string; + userRole: "owner" | "admin" | "member"; +}; + +export function AutomationsTable({ + workflows, + total, + organizationId, + orgSlug, + userRole, +}: AutomationsTableProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + // Table state + const [sorting, setSorting] = useState([ + { id: "updatedAt", desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [globalFilter, setGlobalFilter] = useState( + searchParams.get("search") || "" + ); + + // Ref for search input to enable keyboard shortcut + const searchInputRef = useRef(null); + + // Keyboard shortcut: Cmd+F to focus search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "f") { + e.preventDefault(); + searchInputRef.current?.focus(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + // Dialog state + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [automationToDelete, setAutomationToDelete] = + useState(null); + + const canManage = userRole === "owner" || userRole === "admin"; + + const handleEnable = async (automationId: string) => { + startTransition(async () => { + const result = await enableAutomation(automationId, organizationId); + if (result.success) { + toast.success("Automation enabled"); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }; + + const handleDisable = async (automationId: string) => { + startTransition(async () => { + const result = await disableAutomation(automationId, organizationId); + if (result.success) { + toast.success("Automation paused"); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }; + + const handleDuplicate = async (automationId: string) => { + startTransition(async () => { + const result = await duplicateAutomation(automationId, organizationId); + if (result.success) { + toast.success("Automation duplicated"); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }; + + const handleDelete = async () => { + if (!automationToDelete) { + return; + } + + startTransition(async () => { + const result = await deleteAutomation( + automationToDelete.id, + organizationId + ); + if (result.success) { + toast.success("Automation deleted"); + setDeleteDialogOpen(false); + setAutomationToDelete(null); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }; + + const updateSearchParams = useCallback( + (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + if (value === undefined || value === "") { + params.delete(key); + } else { + params.set(key, value); + } + } + router.push(`/${orgSlug}/automations?${params.toString()}`); + }, + [router, orgSlug, searchParams] + ); + + const handleSearch = useCallback( + (value: string) => { + setGlobalFilter(value); + updateSearchParams({ search: value || undefined }); + }, + [updateSearchParams] + ); + + const columns = useMemo( + () => [ + { + accessorKey: "name", + header: ({ + column, + }: { + column: { + toggleSorting: (desc: boolean) => void; + getIsSorted: () => string | false; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { + const wf = row.original; + const stepCount = getStepCount(wf); + return ( +
+
+ + {wf.name} +
+

+ {stepCount} step{stepCount !== 1 ? "s" : ""} + {wf.description ? ` • ${wf.description}` : ""} +

+
+ ); + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { + const wf = row.original; + const status = wf.status; + return ( + + {status === "enabled" && } + {status === "paused" && } + {AUTOMATION_STATUS_LABELS[status]} + + ); + }, + }, + { + accessorKey: "trigger", + header: "Trigger", + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { + const wf = row.original; + return ( +
+ + + {getTriggerDescription(wf)} + +
+ ); + }, + }, + { + accessorKey: "stats", + header: "Executions", + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { + const wf = row.original; + + if (wf.totalExecutions === 0) { + return -; + } + + return ( +
+ {wf.totalExecutions.toLocaleString()} total + {wf.activeExecutions > 0 && ( + + {wf.activeExecutions} active + + )} + {wf.failedExecutions > 0 && ( + + {wf.failedExecutions} failed + + )} +
+ ); + }, + }, + { + accessorKey: "updatedAt", + header: ({ + column, + }: { + column: { + toggleSorting: (desc: boolean) => void; + getIsSorted: () => string | false; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { + const date = new Date(row.original.updatedAt); + return ( +
+ {formatDistanceToNow(date, { addSuffix: true })} +
+ ); + }, + }, + { + id: "actions", + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { + const wf = row.original; + const canEnable = wf.status === "draft" || wf.status === "paused"; + const canDisable = wf.status === "enabled"; + + return ( + + + + + + { + e.stopPropagation(); + router.push(`/${orgSlug}/automations/${wf.id}`); + }} + > + + Edit automation + + { + e.stopPropagation(); + handleDuplicate(wf.id); + }} + > + + Duplicate + + {canManage && canEnable && ( + { + e.stopPropagation(); + handleEnable(wf.id); + }} + > + + Enable + + )} + {canManage && canDisable && ( + { + e.stopPropagation(); + handleDisable(wf.id); + }} + > + + Pause + + )} + {canManage && ( + <> + + { + e.stopPropagation(); + setAutomationToDelete(wf); + setDeleteDialogOpen(true); + }} + > + + Delete + + + )} + + + ); + }, + }, + ], + [ + canManage, + isPending, + orgSlug, + router, + handleDisable, + handleDuplicate, + handleEnable, + ] + ); + + const table = useReactTable({ + data: workflows, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + sorting, + columnFilters, + columnVisibility, + globalFilter, + }, + getRowId: (row) => row.id, + }); + + return ( + <> +
+ {/* Filters Bar */} +
+
+
+ + handleSearch(event.target.value)} + placeholder="Search automations..." + ref={searchInputRef} + value={globalFilter} + /> + + ⌘F + +
+
+
+ {canManage && ( + + )} +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {workflows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + router.push(`/${orgSlug}/automations/${row.original.id}`) + } + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + +
+ +

+ No automations yet +

+ {canManage && ( + + )} +
+
+
+ )} +
+
+
+ + {/* Pagination */} + {workflows.length > 0 && ( +
+
+ {total} automation{total !== 1 ? "s" : ""} +
+
+ + +
+
+ )} +
+ + {/* Create Automation Dialog */} + + + {/* Delete Confirmation Dialog */} + + + + Delete automation? + + This will permanently delete "{automationToDelete?.name}". This + action cannot be undone. + + + + Cancel + + Delete + + + + + + ); +} diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/create-automation-dialog.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/create-automation-dialog.tsx new file mode 100644 index 000000000..36e675026 --- /dev/null +++ b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/create-automation-dialog.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { createAutomation } from "@/actions/automations"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +type CreateAutomationDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + orgSlug: string; +}; + +export function CreateAutomationDialog({ + open, + onOpenChange, + organizationId, + orgSlug, +}: CreateAutomationDialogProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + toast.error("Please enter a name for the automation"); + return; + } + + startTransition(async () => { + const result = await createAutomation(organizationId, { + name: name.trim(), + description: description.trim() || undefined, + }); + + if (result.success) { + toast.success("Automation created"); + onOpenChange(false); + setName(""); + setDescription(""); + // Navigate to the automation builder + router.push(`/${orgSlug}/automations/${result.automation.id}`); + } else { + toast.error(result.error); + } + }); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + setName(""); + setDescription(""); + } + onOpenChange(open); + }; + + return ( + + +
+ + Create automation + + Create a new automation workflow. You can configure the trigger + and steps in the builder. + + +
+
+ + setName(e.target.value)} + placeholder="e.g., Welcome Series" + value={name} + /> +
+
+ +