From 695da728b5af95097c85a9dcb82118ab698fd2de Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Thu, 2 Jul 2026 00:41:31 +0000 Subject: [PATCH 01/10] Email Automation Implementation (Draft) --- .../migration.sql | 20 + .../tests/automation-rule-execution-state.ts | 163 +++ apps/backend/prisma/schema.prisma | 26 + apps/backend/scripts/run-cron-jobs.ts | 1 + .../rules/[rule_id]/dry-run/route.test.ts | 281 ++++ .../rules/[rule_id]/dry-run/route.ts | 126 ++ .../rules/[rule_id]/run/route.test.ts | 625 +++++++++ .../automations/rules/[rule_id]/run/route.ts | 115 ++ .../internal/automations/schedule/route.ts | 87 ++ .../automations/scheduled-run/route.ts | 81 ++ .../src/lib/automations/actions/send-email.ts | 104 ++ apps/backend/src/lib/automations/cooldown.ts | 46 + .../src/lib/automations/dry-run-route.ts | 184 +++ .../lib/automations/execution-state-store.ts | 241 ++++ .../quota-email-automation.test.ts | 791 ++++++++++++ .../src/lib/automations/rule-evaluator.ts | 137 ++ apps/backend/src/lib/automations/rules.ts | 124 ++ apps/backend/src/lib/automations/run-route.ts | 243 ++++ .../src/lib/automations/scheduler.test.ts | 287 +++++ apps/backend/src/lib/automations/scheduler.ts | 372 ++++++ .../sources/payments-item-quota.ts | 263 ++++ apps/backend/vercel.json | 4 + .../email-automations/page-client.test.ts | 348 +++++ .../email-automations/page-client.tsx | 1139 +++++++++++++++++ .../[projectId]/email-automations/page.tsx | 11 + apps/dashboard/src/lib/apps-frontend.tsx | 1 + .../src/lib/hexclave-app-internals.ts | 28 + .../shared/src/config/schema-fuzzer.test.ts | 32 + packages/shared/src/config/schema.ts | 224 ++++ 29 files changed, 6104 insertions(+) create mode 100644 apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/migration.sql create mode 100644 apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts create mode 100644 apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts create mode 100644 apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts create mode 100644 apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/automations/schedule/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts create mode 100644 apps/backend/src/lib/automations/actions/send-email.ts create mode 100644 apps/backend/src/lib/automations/cooldown.ts create mode 100644 apps/backend/src/lib/automations/dry-run-route.ts create mode 100644 apps/backend/src/lib/automations/execution-state-store.ts create mode 100644 apps/backend/src/lib/automations/quota-email-automation.test.ts create mode 100644 apps/backend/src/lib/automations/rule-evaluator.ts create mode 100644 apps/backend/src/lib/automations/rules.ts create mode 100644 apps/backend/src/lib/automations/run-route.ts create mode 100644 apps/backend/src/lib/automations/scheduler.test.ts create mode 100644 apps/backend/src/lib/automations/scheduler.ts create mode 100644 apps/backend/src/lib/automations/sources/payments-item-quota.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page.tsx diff --git a/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/migration.sql b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/migration.sql new file mode 100644 index 000000000..9fa6efc15 --- /dev/null +++ b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/migration.sql @@ -0,0 +1,20 @@ +CREATE TABLE "AutomationRuleExecutionState" ( + "tenancyId" UUID NOT NULL, + "ruleId" TEXT NOT NULL, + "sourceType" TEXT NOT NULL, + "actionType" TEXT NOT NULL, + "subjectType" TEXT NOT NULL, + "subjectId" TEXT NOT NULL, + "signalKey" TEXT NOT NULL, + "lastTriggeredAt" TIMESTAMP(3) NOT NULL, + "lastActionAt" TIMESTAMP(3), + "lastEmailOutboxId" UUID, + "lastSourceSnapshot" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutomationRuleExecutionState_pkey" PRIMARY KEY ("tenancyId", "ruleId", "subjectType", "subjectId", "signalKey"), + CONSTRAINT "AutomationRuleExecutionState_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "AutomationRuleExecutionState_tenancy_rule_triggered_idx" ON "AutomationRuleExecutionState"("tenancyId", "ruleId", "lastTriggeredAt"); diff --git a/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts new file mode 100644 index 000000000..95a219969 --- /dev/null +++ b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const tenancyId = randomUUID(); + + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Automation Rule Execution State Migration Test', '', false) + `; + await sql` + INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") + VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue") + `; + + return { tenancyId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const projectUserId = randomUUID(); + const emailOutboxId = randomUUID(); + const initialSnapshot = { + itemId: "credits", + currentQuantity: 5, + entitlementQuantity: 100, + thresholdKind: "near", + ownedProductIds: ["pro"], + activeSubscriptionIds: ["sub_123"], + }; + + await sql` + INSERT INTO "AutomationRuleExecutionState" ( + "tenancyId", + "ruleId", + "sourceType", + "actionType", + "subjectType", + "subjectId", + "signalKey", + "lastTriggeredAt", + "lastSourceSnapshot", + "createdAt", + "updatedAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + 'lowCreditsUpgradeEmail', + 'payments-item-quota', + 'send-email', + 'user', + ${projectUserId}, + 'credits:near', + NOW(), + ${JSON.stringify(initialSnapshot)}::jsonb, + NOW(), + NOW() + ) + `; + + const inserted = await sql` + SELECT + "sourceType", + "actionType", + "subjectType", + "subjectId", + "signalKey", + "lastActionAt", + "lastEmailOutboxId", + "lastSourceSnapshot" + FROM "AutomationRuleExecutionState" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "ruleId" = 'lowCreditsUpgradeEmail' + AND "subjectType" = 'user' + AND "subjectId" = ${projectUserId} + AND "signalKey" = 'credits:near' + `; + expect(Array.from(inserted)).toMatchObject([{ + sourceType: "payments-item-quota", + actionType: "send-email", + subjectType: "user", + subjectId: projectUserId, + signalKey: "credits:near", + lastActionAt: null, + lastEmailOutboxId: null, + }]); + expect(JSON.parse(inserted[0].lastSourceSnapshot)).toMatchObject(initialSnapshot); + + const updatedSnapshot = { + ...initialSnapshot, + currentQuantity: 0, + thresholdKind: "over", + }; + await sql` + UPDATE "AutomationRuleExecutionState" + SET + "lastActionAt" = NOW(), + "lastEmailOutboxId" = ${emailOutboxId}::uuid, + "lastSourceSnapshot" = ${JSON.stringify(updatedSnapshot)}::jsonb, + "updatedAt" = NOW() + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "ruleId" = 'lowCreditsUpgradeEmail' + AND "subjectType" = 'user' + AND "subjectId" = ${projectUserId} + AND "signalKey" = 'credits:near' + `; + + const updated = await sql` + SELECT "lastActionAt", "lastEmailOutboxId", "lastSourceSnapshot" + FROM "AutomationRuleExecutionState" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + AND "ruleId" = 'lowCreditsUpgradeEmail' + AND "subjectType" = 'user' + AND "subjectId" = ${projectUserId} + AND "signalKey" = 'credits:near' + `; + expect(updated).toHaveLength(1); + expect(updated[0].lastActionAt).toBeInstanceOf(Date); + expect(updated[0].lastEmailOutboxId).toBe(emailOutboxId); + expect(JSON.parse(updated[0].lastSourceSnapshot)).toMatchObject(updatedSnapshot); + + await expect(sql` + INSERT INTO "AutomationRuleExecutionState" ( + "tenancyId", + "ruleId", + "sourceType", + "actionType", + "subjectType", + "subjectId", + "signalKey", + "lastTriggeredAt", + "lastSourceSnapshot", + "createdAt", + "updatedAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + 'lowCreditsUpgradeEmail', + 'payments-item-quota', + 'send-email', + 'user', + ${projectUserId}, + 'credits:near', + NOW(), + ${JSON.stringify(initialSnapshot)}::jsonb, + NOW(), + NOW() + ) + `).rejects.toThrow(/AutomationRuleExecutionState_pkey/); + + await sql` + DELETE FROM "Tenancy" + WHERE "id" = ${ctx.tenancyId}::uuid + `; + + const remaining = await sql` + SELECT COUNT(*)::int AS count + FROM "AutomationRuleExecutionState" + WHERE "tenancyId" = ${ctx.tenancyId}::uuid + `; + expect(remaining[0].count).toBe(0); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 24afecea6..b0977bc20 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -83,6 +83,7 @@ model Tenancy { conversations Conversation[] conversationEntryPoints ConversationEntryPoint[] conversationMessages ConversationMessage[] + automationRuleExecutionStates AutomationRuleExecutionState[] // Email capacity boost - when set and in the future, email capacity is multiplied by 4 emailCapacityBoostExpiresAt DateTime? @@ -1143,6 +1144,31 @@ model UserNotificationPreference { @@index([tenancyId, sequenceId], name: "UserNotificationPreference_tenancyId_sequenceId_idx") } +model AutomationRuleExecutionState { + tenancyId String @db.Uuid + + ruleId String + sourceType String + actionType String + + subjectType String + subjectId String + + signalKey String + lastTriggeredAt DateTime + lastActionAt DateTime? + lastEmailOutboxId String? @db.Uuid + lastSourceSnapshot Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + + @@id([tenancyId, ruleId, subjectType, subjectId, signalKey]) + @@index([tenancyId, ruleId, lastTriggeredAt], name: "AutomationRuleExecutionState_tenancy_rule_triggered_idx") +} + model Conversation { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid diff --git a/apps/backend/scripts/run-cron-jobs.ts b/apps/backend/scripts/run-cron-jobs.ts index ff2723b7b..c6a3cf4ce 100644 --- a/apps/backend/scripts/run-cron-jobs.ts +++ b/apps/backend/scripts/run-cron-jobs.ts @@ -4,6 +4,7 @@ import { runAsynchronously, wait } from "@hexclave/shared/dist/utils/promises"; import { Result } from "@hexclave/shared/dist/utils/results"; const endpoints = [ + "/api/latest/internal/automations/schedule", "/api/latest/internal/external-db-sync/sequencer", "/api/latest/internal/external-db-sync/poller", ]; diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts new file mode 100644 index 000000000..cecff7944 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from "vitest"; +import { AutomationActionAdapter, AutomationSourceAdapter } from "@/lib/automations/rule-evaluator"; +import { evaluateAutomationRuleDryRunForRoute } from "@/lib/automations/dry-run-route"; +import { createPrismaAutomationRuleExecutionStateReader } from "@/lib/automations/execution-state-store"; + +const ruleId = "low-api-credits"; +const now = new Date("2026-07-01T00:00:00.000Z"); + +function createTenancy() { + return { + id: "tenancy-1", + project: { + display_name: "Acme App", + }, + config: { + automations: { + rules: { + [ruleId]: { + enabled: true, + source: { + type: "payments-item-quota", + itemId: "api_credits", + customerType: "user", + thresholds: { + nearRemainingQuantity: 10, + }, + }, + action: { + type: "send-email", + templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + notificationCategoryName: "Marketing", + }, + cooldown: { + days: 7, + }, + }, + }, + }, + }, + }; +} + +function createSourceAdapter(): AutomationSourceAdapter { + const evaluate: AutomationSourceAdapter["evaluate"] = async () => ({ + evaluatedCount: 2, + nextCursor: "cursor-2", + decisions: [{ + subject: { + type: "user", + id: "user-1", + }, + signal: { + key: "api_credits:near", + kind: "near", + }, + sourceSnapshot: { + sourceType: "payments-item-quota", + itemId: "api_credits", + itemDisplayName: "API credits", + currentQuantity: 7, + entitlementQuantity: 100, + thresholdKind: "near", + ownedProductIds: ["pro"], + activeSubscriptionIds: ["sub_1"], + }, + }], + }); + return { + evaluate: vi.fn(evaluate), + }; +} + +function createActionAdapter(): AutomationActionAdapter { + const buildPlan: AutomationActionAdapter["buildPlan"] = async (options) => ({ + type: "send-email", + recipient: { + type: "user-primary-email", + userId: options.decision.subject.id, + }, + tsxSource: "export function EmailTemplate() { return null; }", + templateId: options.rule.action.templateId, + themeId: null, + notificationCategoryName: "Marketing", + notificationCategoryId: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", + createdWith: { + type: "programmatic-call", + templateId: options.rule.action.templateId, + }, + isHighPriority: false, + shouldSkipDeliverabilityCheck: false, + variables: { + automationRuleId: options.ruleId, + }, + }); + return { + buildPlan: vi.fn(buildPlan), + }; +} + +function createFakePrisma(options: { + lastActionAt?: Date | null, +} = {}) { + return { + automationRuleExecutionState: { + findUnique: vi.fn(async () => options.lastActionAt === undefined ? null : { + lastActionAt: options.lastActionAt, + }), + upsert: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + emailOutbox: { + createMany: vi.fn(), + }, + }; +} + +describe("automation dry-run route helpers", () => { + it("returns the dry-run API response without writing automation state or email outbox rows", async () => { + const fakePrisma = createFakePrisma(); + const sourceAdapter = createSourceAdapter(); + const actionAdapter = createActionAdapter(); + const recipientStatusReader = vi.fn(async () => new Map([["user-1", { + userExists: true, + hasPrimaryEmail: true, + }]])); + + await expect(evaluateAutomationRuleDryRunForRoute({ + tenancy: createTenancy(), + ruleId, + limit: 25, + cursor: "cursor-1", + prisma: fakePrisma, + sourceAdapter, + actionAdapter, + recipientStatusReader, + executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), + now, + })).resolves.toMatchInlineSnapshot(` + { + "decisions": [ + { + "action": { + "notification_category_name": "Marketing", + "template_id": "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + "type": "send-email", + }, + "cooldown": { + "blocked": false, + }, + "recipient": { + "has_primary_email": true, + "user_exists": true, + }, + "source": { + "active_subscription_ids": [ + "sub_1", + ], + "current_quantity": 7, + "entitlement_quantity": 100, + "item_id": "api_credits", + "owned_product_ids": [ + "pro", + ], + "threshold_kind": "near", + "type": "payments-item-quota", + }, + "subject_id": "user-1", + "subject_type": "user", + }, + ], + "eligible_count": 1, + "evaluated_count": 2, + "mode": "dry-run", + "next_cursor": "cursor-2", + "rule_id": "low-api-credits", + "suppressed_count": 0, + } + `); + + expect(sourceAdapter.evaluate).toHaveBeenCalledWith(expect.objectContaining({ + limit: 25, + cursor: "cursor-1", + })); + expect(recipientStatusReader).toHaveBeenCalledWith({ + prisma: fakePrisma, + tenancyId: "tenancy-1", + userIds: ["user-1"], + }); + expect(fakePrisma.automationRuleExecutionState.upsert).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.create).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.update).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.updateMany).not.toHaveBeenCalled(); + expect(fakePrisma.emailOutbox.createMany).not.toHaveBeenCalled(); + }); + + it("reports active cooldown state without writing automation state", async () => { + const lastActionAt = new Date("2026-06-30T00:00:00.000Z"); + const fakePrisma = createFakePrisma({ lastActionAt }); + + await expect(evaluateAutomationRuleDryRunForRoute({ + tenancy: createTenancy(), + ruleId, + prisma: fakePrisma, + sourceAdapter: createSourceAdapter(), + actionAdapter: createActionAdapter(), + recipientStatusReader: async () => new Map(), + executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), + now, + })).resolves.toMatchObject({ + eligible_count: 0, + suppressed_count: 1, + decisions: [{ + cooldown: { + blocked: true, + last_action_at_millis: lastActionAt.getTime(), + next_eligible_at_millis: new Date("2026-07-07T00:00:00.000Z").getTime(), + }, + }], + }); + + expect(fakePrisma.automationRuleExecutionState.findUnique).toHaveBeenCalledOnce(); + expect(fakePrisma.automationRuleExecutionState.create).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.update).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.updateMany).not.toHaveBeenCalled(); + expect(fakePrisma.emailOutbox.createMany).not.toHaveBeenCalled(); + }); + + it("reports expired cooldown state as not blocked", async () => { + const fakePrisma = createFakePrisma({ + lastActionAt: new Date("2026-06-20T00:00:00.000Z"), + }); + + await expect(evaluateAutomationRuleDryRunForRoute({ + tenancy: createTenancy(), + ruleId, + prisma: fakePrisma, + sourceAdapter: createSourceAdapter(), + actionAdapter: createActionAdapter(), + recipientStatusReader: async () => new Map(), + executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), + now, + })).resolves.toMatchObject({ + eligible_count: 1, + suppressed_count: 0, + decisions: [{ + cooldown: { + blocked: false, + }, + }], + }); + + expect(fakePrisma.automationRuleExecutionState.create).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.update).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.updateMany).not.toHaveBeenCalled(); + expect(fakePrisma.emailOutbox.createMany).not.toHaveBeenCalled(); + }); + + it("marks missing recipient records as not currently emailable", async () => { + const fakePrisma = createFakePrisma(); + + await expect(evaluateAutomationRuleDryRunForRoute({ + tenancy: createTenancy(), + ruleId, + prisma: fakePrisma, + sourceAdapter: createSourceAdapter(), + actionAdapter: createActionAdapter(), + recipientStatusReader: async () => new Map(), + executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), + now, + })).resolves.toMatchObject({ + decisions: [{ + recipient: { + user_exists: false, + has_primary_email: false, + }, + }], + }); + }); +}); diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.ts new file mode 100644 index 000000000..900eeff74 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.ts @@ -0,0 +1,126 @@ +import { createSendEmailActionAdapter } from "@/lib/automations/actions/send-email"; +import { + AutomationDryRunRecipientStatusReader, + evaluateAutomationRuleDryRunForRoute, +} from "@/lib/automations/dry-run-route"; +import { createPrismaAutomationRuleExecutionStateReader } from "@/lib/automations/execution-state-store"; +import { + createPaymentsItemQuotaSourceAdapter, + paymentsItemQuotaCustomerDataReaders, + prismaPaymentsItemQuotaProjectUserReader, +} from "@/lib/automations/sources/payments-item-quota"; +import { getPrismaClientForTenancy, PrismaClientTransaction } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +export const prismaAutomationDryRunRecipientStatusReader: AutomationDryRunRecipientStatusReader = async (options) => { + const users = await options.prisma.projectUser.findMany({ + where: { + tenancyId: options.tenancyId, + projectUserId: { + in: options.userIds, + }, + }, + select: { + projectUserId: true, + contactChannels: { + where: { + type: "EMAIL", + isPrimary: "TRUE", + }, + select: { + id: true, + }, + take: 1, + }, + }, + }); + + return new Map(users.map((user) => [user.projectUserId, { + userExists: true, + hasPrimaryEmail: user.contactChannels.length > 0, + }])); +}; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + rule_id: yupString().defined(), + }).defined(), + body: yupObject({ + limit: yupNumber().integer().min(1).max(1000).optional(), + cursor: yupString().nullable().optional(), + }).optional().default({}), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + rule_id: yupString().defined(), + mode: yupString().oneOf(["dry-run"]).defined(), + evaluated_count: yupNumber().integer().defined(), + eligible_count: yupNumber().integer().defined(), + suppressed_count: yupNumber().integer().defined(), + next_cursor: yupString().nullable().defined(), + decisions: yupArray(yupObject({ + subject_type: yupString().oneOf(["user"]).defined(), + subject_id: yupString().defined(), + source: yupObject({ + type: yupString().oneOf(["payments-item-quota"]).defined(), + item_id: yupString().defined(), + current_quantity: yupNumber().defined(), + entitlement_quantity: yupNumber().nullable().defined(), + threshold_kind: yupString().oneOf(["near", "over"]).defined(), + owned_product_ids: yupArray(yupString().defined()).defined(), + active_subscription_ids: yupArray(yupString().defined()).defined(), + }).defined(), + action: yupObject({ + type: yupString().oneOf(["send-email"]).defined(), + template_id: yupString().defined(), + notification_category_name: yupString().oneOf(["Marketing"]).defined(), + }).defined(), + cooldown: yupObject({ + blocked: yupBoolean().defined(), + last_action_at_millis: yupNumber().optional(), + next_eligible_at_millis: yupNumber().optional(), + }).defined(), + recipient: yupObject({ + user_exists: yupBoolean().defined(), + has_primary_email: yupBoolean().defined(), + }).defined(), + skip_reason: yupString().optional(), + })).defined(), + }).defined(), + }), + async handler({ auth, params, body }) { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const sourceAdapter = createPaymentsItemQuotaSourceAdapter({ + prisma, + projectUserReader: prismaPaymentsItemQuotaProjectUserReader, + customerDataReaders: paymentsItemQuotaCustomerDataReaders, + }); + return { + statusCode: 200, + bodyType: "json", + body: await evaluateAutomationRuleDryRunForRoute({ + tenancy: auth.tenancy, + ruleId: params.rule_id, + limit: body.limit, + cursor: body.cursor, + prisma, + sourceAdapter, + actionAdapter: createSendEmailActionAdapter(), + recipientStatusReader: prismaAutomationDryRunRecipientStatusReader, + executionStateReader: createPrismaAutomationRuleExecutionStateReader(prisma), + now: new Date(), + }), + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts new file mode 100644 index 000000000..445e86538 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts @@ -0,0 +1,625 @@ +import { describe, expect, it, vi } from "vitest"; +import { Prisma } from "@/generated/prisma/client"; +import { + AutomationRuleExecutionStatePrisma, + createPrismaAutomationRuleExecutionStateStore, +} from "@/lib/automations/execution-state-store"; +import { AutomationActionAdapter, AutomationSourceAdapter, AutomationSourceDecision } from "@/lib/automations/rule-evaluator"; +import { + AutomationRuleExecutionStateStore, + automationRunResultToApiBody, + runAutomationRuleForRoute, +} from "@/lib/automations/run-route"; + +const ruleId = "low-api-credits"; +const scheduledAt = new Date("2026-07-01T12:00:00.000Z"); + +function createTenancy() { + return { + id: "tenancy-1", + project: { + display_name: "Acme App", + }, + config: { + automations: { + rules: { + [ruleId]: { + enabled: true, + source: { + type: "payments-item-quota", + itemId: "api_credits", + customerType: "user", + thresholds: { + nearRemainingQuantity: 10, + }, + }, + action: { + type: "send-email", + templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + notificationCategoryName: "Marketing", + }, + cooldown: { + days: 7, + }, + }, + }, + }, + }, + }; +} + +function createDecision(options: { + userId?: string, + kind?: "near" | "over", +} = {}): AutomationSourceDecision { + const kind = options.kind ?? "near"; + return { + subject: { + type: "user", + id: options.userId ?? "user-1", + }, + signal: { + key: `api_credits:${kind}`, + kind, + }, + sourceSnapshot: { + sourceType: "payments-item-quota", + itemId: "api_credits", + itemDisplayName: "API credits", + currentQuantity: kind === "over" ? 0 : 7, + entitlementQuantity: 100, + thresholdKind: kind, + ownedProductIds: ["pro"], + activeSubscriptionIds: ["sub_1"], + }, + }; +} + +function createSourceAdapter(decisionFactory: () => AutomationSourceDecision = createDecision): AutomationSourceAdapter { + const evaluate: AutomationSourceAdapter["evaluate"] = async () => ({ + evaluatedCount: 1, + nextCursor: "cursor-2", + decisions: [decisionFactory()], + }); + return { + evaluate: vi.fn(evaluate), + }; +} + +function createActionAdapter(): AutomationActionAdapter { + const buildPlan: AutomationActionAdapter["buildPlan"] = async (options) => ({ + type: "send-email", + recipient: { + type: "user-primary-email", + userId: options.decision.subject.id, + }, + tsxSource: "export function EmailTemplate() { return null; }", + templateId: options.rule.action.templateId, + themeId: null, + notificationCategoryName: "Marketing", + notificationCategoryId: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", + createdWith: { + type: "programmatic-call", + templateId: options.rule.action.templateId, + }, + isHighPriority: false, + shouldSkipDeliverabilityCheck: false, + variables: { + automationRuleId: options.ruleId, + ...options.decision.sourceSnapshot, + projectDisplayName: "Acme App", + }, + }); + return { + buildPlan: vi.fn(buildPlan), + }; +} + +type InMemoryExecutionState = { + lastTriggeredAt: Date, + lastActionAt: Date | null, + lastEmailOutboxId: string | null, + lastSourceSnapshot: Record, +}; + +function createInMemoryStateStore() { + const states = new Map(); + const keyFor = (options: { + tenancyId: string, + ruleId: string, + subjectType: string, + subjectId: string, + signalKey: string, + }) => `${options.tenancyId}:${options.ruleId}:${options.subjectType}:${options.subjectId}:${options.signalKey}`; + + const stateStore: AutomationRuleExecutionStateStore = { + claimExecution: vi.fn(async (options) => { + const key = keyFor(options); + const existing = states.get(key); + const cooldownCutoff = new Date(options.lastTriggeredAt.getTime() - options.cooldownDays * 24 * 60 * 60 * 1000); + const staleClaimCutoff = new Date(options.lastTriggeredAt.getTime() - 15 * 60 * 1000); + if (existing?.lastActionAt === null && existing.lastTriggeredAt >= staleClaimCutoff) { + return { + claimed: false, + lastActionAt: null, + }; + } + if (existing?.lastActionAt != null && existing.lastActionAt >= cooldownCutoff) { + return { + claimed: false, + lastActionAt: existing.lastActionAt, + }; + } + + states.set(key, { + lastTriggeredAt: options.lastTriggeredAt, + lastActionAt: existing?.lastActionAt ?? null, + lastEmailOutboxId: existing?.lastEmailOutboxId ?? null, + lastSourceSnapshot: options.sourceSnapshot, + }); + return { + claimed: true, + lastActionAt: existing?.lastActionAt ?? null, + }; + }), + markActionCompleted: vi.fn(async (options) => { + const key = keyFor(options); + const existing = states.get(key); + if (existing === undefined) { + throw new Error("Expected state to be claimed before marking action completed."); + } + states.set(key, { + ...existing, + lastActionAt: options.lastActionAt, + lastEmailOutboxId: options.lastEmailOutboxId, + }); + }), + }; + + return { + stateStore, + states, + }; +} + +async function runWithFakes(options: { + stateStore?: AutomationRuleExecutionStateStore, + decisionFactory?: () => AutomationSourceDecision, + now?: Date, + limit?: number, + cursor?: string | null, +} = {}) { + const sourceAdapter = createSourceAdapter(options.decisionFactory); + const actionAdapter = createActionAdapter(); + const emailSender = vi.fn(async () => {}); + let createdStore: ReturnType | undefined; + let stateStore = options.stateStore; + if (stateStore === undefined) { + createdStore = createInMemoryStateStore(); + stateStore = createdStore.stateStore; + } + + const result = await runAutomationRuleForRoute({ + tenancy: createTenancy(), + ruleId, + limit: options.limit, + cursor: options.cursor, + scheduledAt, + now: options.now ?? new Date("2026-07-01T12:00:00.000Z"), + sourceAdapter, + actionAdapter, + stateStore, + emailSender, + }); + + return { + result, + sourceAdapter, + actionAdapter, + emailSender, + states: createdStore?.states, + }; +} + +describe("automation real-send route helpers", () => { + it("claims state, enqueues email, and marks action completed", async () => { + const { result, emailSender, states } = await runWithFakes(); + + expect(result.sentCount).toBe(1); + expect(emailSender).toHaveBeenCalledWith(expect.objectContaining({ + scheduledAt, + action: expect.objectContaining({ + recipient: { + type: "user-primary-email", + userId: "user-1", + }, + variables: expect.objectContaining({ + automationRuleId: ruleId, + currentQuantity: 7, + }), + }), + })); + expect([...states!.values()]).toMatchObject([{ + lastActionAt: new Date("2026-07-01T12:00:00.000Z"), + lastEmailOutboxId: null, + lastSourceSnapshot: expect.objectContaining({ + itemId: "api_credits", + thresholdKind: "near", + }), + }]); + }); + + it("suppresses repeated same-signal sends during cooldown", async () => { + const { stateStore } = createInMemoryStateStore(); + + const first = await runWithFakes({ stateStore }); + const second = await runWithFakes({ + stateStore, + now: new Date("2026-07-02T12:00:00.000Z"), + }); + + expect(first.result.sentCount).toBe(1); + expect(second.result.sentCount).toBe(0); + expect(second.result.suppressedCount).toBe(1); + expect(second.emailSender).not.toHaveBeenCalled(); + expect(automationRunResultToApiBody(second.result)).toMatchObject({ + decisions: [{ + sent: false, + cooldown: { + blocked: true, + last_action_at_millis: new Date("2026-07-01T12:00:00.000Z").getTime(), + next_eligible_at_millis: new Date("2026-07-08T12:00:00.000Z").getTime(), + }, + skip_reason: "cooldown", + }], + }); + }); + + it("suppresses a second run while the first claim is still in flight", async () => { + const { stateStore, states } = createInMemoryStateStore(); + states.set("tenancy-1:low-api-credits:user:user-1:api_credits:near", { + lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt: null, + lastEmailOutboxId: null, + lastSourceSnapshot: createDecision().sourceSnapshot, + }); + + const second = await runWithFakes({ + stateStore, + now: new Date("2026-07-01T12:01:00.000Z"), + }); + + expect(second.result.sentCount).toBe(0); + expect(second.result.suppressedCount).toBe(1); + expect(second.emailSender).not.toHaveBeenCalled(); + expect(automationRunResultToApiBody(second.result)).toMatchObject({ + decisions: [{ + sent: false, + cooldown: { + blocked: true, + }, + skip_reason: "cooldown", + }], + }); + }); + + it("allows over signal after near signal because signal keys differ", async () => { + const { stateStore } = createInMemoryStateStore(); + + const near = await runWithFakes({ stateStore }); + const over = await runWithFakes({ + stateStore, + decisionFactory: () => createDecision({ kind: "over" }), + now: new Date("2026-07-02T12:00:00.000Z"), + }); + + expect(near.result.sentCount).toBe(1); + expect(over.result.sentCount).toBe(1); + expect(over.emailSender).toHaveBeenCalledTimes(1); + }); + + it("allows same signal again after cooldown expires", async () => { + const { stateStore } = createInMemoryStateStore(); + + await runWithFakes({ stateStore }); + const afterCooldown = await runWithFakes({ + stateStore, + now: new Date("2026-07-09T12:00:00.000Z"), + }); + + expect(afterCooldown.result.sentCount).toBe(1); + expect(afterCooldown.emailSender).toHaveBeenCalledTimes(1); + }); + + it("passes pagination through and does not duplicate sends for the same cursor result", async () => { + const { stateStore } = createInMemoryStateStore(); + + const first = await runWithFakes({ + stateStore, + limit: 25, + cursor: "cursor-1", + }); + const second = await runWithFakes({ + stateStore, + limit: 25, + cursor: "cursor-1", + now: new Date("2026-07-01T12:01:00.000Z"), + }); + + expect(first.sourceAdapter.evaluate).toHaveBeenCalledWith(expect.objectContaining({ + limit: 25, + cursor: "cursor-1", + })); + expect(first.result.nextCursor).toBe("cursor-2"); + expect(first.result.sentCount).toBe(1); + expect(second.result.sentCount).toBe(0); + }); +}); + +type PrismaStoreState = { + tenancyId: string, + ruleId: string, + sourceType: string, + actionType: string, + subjectType: "user", + subjectId: string, + signalKey: string, + lastTriggeredAt: Date, + lastActionAt: Date | null, + lastEmailOutboxId: string | null, + lastSourceSnapshot: Prisma.InputJsonObject, +}; + +function createPrismaUniqueConstraintError() { + return new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the automation execution state primary key.", { + code: "P2002", + clientVersion: "test", + }); +} + +function createMockExecutionStatePrisma(initialRows: PrismaStoreState[] = []) { + const rows = new Map(); + const keyFor = (options: { + tenancyId: string, + ruleId: string, + subjectType: "user", + subjectId: string, + signalKey: string, + }) => `${options.tenancyId}:${options.ruleId}:${options.subjectType}:${options.subjectId}:${options.signalKey}`; + + for (const row of initialRows) { + rows.set(keyFor(row), row); + } + + const prisma: AutomationRuleExecutionStatePrisma = { + automationRuleExecutionState: { + create: vi.fn(async (options) => { + const key = keyFor(options.data); + if (rows.has(key)) { + throw createPrismaUniqueConstraintError(); + } + rows.set(key, options.data); + return options.data; + }), + findUnique: vi.fn(async (options) => { + const row = rows.get(keyFor(options.where.tenancyId_ruleId_subjectType_subjectId_signalKey)); + return row === undefined ? null : { + lastTriggeredAt: row.lastTriggeredAt, + lastActionAt: row.lastActionAt, + }; + }), + updateMany: vi.fn(async (options) => { + const row = rows.get(keyFor(options.where)); + if (row === undefined) { + return { count: 0 }; + } + + const canClaim = options.where.OR.some((clause) => { + if ("lastTriggeredAt" in clause) { + return row.lastActionAt === null && row.lastTriggeredAt < clause.lastTriggeredAt.lt; + } + return row.lastActionAt !== null && row.lastActionAt < clause.lastActionAt.lt; + }); + if (!canClaim) { + return { count: 0 }; + } + + rows.set(keyFor(options.where), { + ...row, + ...options.data, + }); + return { count: 1 }; + }), + update: vi.fn(async (options) => { + const key = keyFor(options.where.tenancyId_ruleId_subjectType_subjectId_signalKey); + const row = rows.get(key); + if (row === undefined) { + throw new Error("Expected automation execution state row to exist before update."); + } + rows.set(key, { + ...row, + ...options.data, + }); + return rows.get(key); + }), + }, + }; + + return { + prisma, + rows, + }; +} + +function createClaimOptions(options: { + lastTriggeredAt?: Date, +} = {}) { + return { + tenancyId: "tenancy-1", + ruleId, + sourceType: "payments-item-quota" as const, + actionType: "send-email" as const, + subjectType: "user" as const, + subjectId: "user-1", + signalKey: "api_credits:near", + lastTriggeredAt: options.lastTriggeredAt ?? new Date("2026-07-01T12:00:00.000Z"), + cooldownDays: 7, + sourceSnapshot: createDecision().sourceSnapshot, + }; +} + +describe("Prisma automation execution state store", () => { + it("claims a new execution state row with Prisma create", async () => { + const { prisma, rows } = createMockExecutionStatePrisma(); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + const result = await store.claimExecution(createClaimOptions()); + + expect(result).toEqual({ + claimed: true, + lastActionAt: null, + }); + expect(prisma.automationRuleExecutionState.create).toHaveBeenCalledOnce(); + expect([...rows.values()]).toMatchObject([{ + lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt: null, + lastEmailOutboxId: null, + lastSourceSnapshot: expect.objectContaining({ + itemId: "api_credits", + }), + }]); + }); + + it("updates an existing cooled-down execution state row with Prisma updateMany", async () => { + const oldActionAt = new Date("2026-06-01T12:00:00.000Z"); + const { prisma } = createMockExecutionStatePrisma([{ + ...createClaimOptions(), + sourceType: "payments-item-quota", + actionType: "send-email", + lastTriggeredAt: new Date("2026-06-01T12:00:00.000Z"), + lastActionAt: oldActionAt, + lastEmailOutboxId: null, + lastSourceSnapshot: { + itemId: "old", + }, + }]); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + const result = await store.claimExecution(createClaimOptions()); + + expect(result).toEqual({ + claimed: true, + lastActionAt: oldActionAt, + }); + expect(prisma.automationRuleExecutionState.updateMany).toHaveBeenCalledOnce(); + }); + + it("does not claim an execution state row inside the cooldown window", async () => { + const lastActionAt = new Date("2026-06-30T12:00:00.000Z"); + const { prisma } = createMockExecutionStatePrisma([{ + ...createClaimOptions(), + sourceType: "payments-item-quota", + actionType: "send-email", + lastTriggeredAt: new Date("2026-06-30T12:00:00.000Z"), + lastActionAt, + lastEmailOutboxId: null, + lastSourceSnapshot: { + itemId: "api_credits", + }, + }]); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + const result = await store.claimExecution(createClaimOptions()); + + expect(result).toEqual({ + claimed: false, + lastActionAt, + }); + expect(prisma.automationRuleExecutionState.updateMany).toHaveBeenCalledOnce(); + }); + + it("allows only one concurrent claim while the first claim is in flight", async () => { + const { prisma } = createMockExecutionStatePrisma(); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + const first = await store.claimExecution(createClaimOptions({ + lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + })); + const second = await store.claimExecution(createClaimOptions({ + lastTriggeredAt: new Date("2026-07-01T12:01:00.000Z"), + })); + + expect(first).toEqual({ + claimed: true, + lastActionAt: null, + }); + expect(second).toEqual({ + claimed: false, + lastActionAt: null, + }); + expect(prisma.automationRuleExecutionState.create).toHaveBeenCalledTimes(2); + expect(prisma.automationRuleExecutionState.updateMany).toHaveBeenCalledOnce(); + }); + + it("continues to apply cooldown and retry rules after a claim completes", async () => { + const { prisma } = createMockExecutionStatePrisma(); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + await store.claimExecution(createClaimOptions({ + lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + })); + await store.markActionCompleted({ + tenancyId: "tenancy-1", + ruleId, + subjectType: "user", + subjectId: "user-1", + signalKey: "api_credits:near", + lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + lastEmailOutboxId: null, + }); + + await expect(store.claimExecution(createClaimOptions({ + lastTriggeredAt: new Date("2026-07-02T12:00:00.000Z"), + }))).resolves.toEqual({ + claimed: false, + lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + }); + await expect(store.claimExecution(createClaimOptions({ + lastTriggeredAt: new Date("2026-07-09T12:06:00.000Z"), + }))).resolves.toEqual({ + claimed: true, + lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + }); + }); + + it("marks a claimed action completed with Prisma update", async () => { + const { prisma, rows } = createMockExecutionStatePrisma([{ + ...createClaimOptions(), + sourceType: "payments-item-quota", + actionType: "send-email", + lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt: null, + lastEmailOutboxId: null, + lastSourceSnapshot: { + itemId: "api_credits", + }, + }]); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + await store.markActionCompleted({ + tenancyId: "tenancy-1", + ruleId, + subjectType: "user", + subjectId: "user-1", + signalKey: "api_credits:near", + lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + lastEmailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", + }); + + expect(prisma.automationRuleExecutionState.update).toHaveBeenCalledOnce(); + expect([...rows.values()]).toMatchObject([{ + lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + lastEmailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", + }]); + }); +}); diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts new file mode 100644 index 000000000..f1fabe302 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts @@ -0,0 +1,115 @@ +import { createSendEmailActionAdapter } from "@/lib/automations/actions/send-email"; +import { createPrismaAutomationRuleExecutionStateStore } from "@/lib/automations/execution-state-store"; +import { + automationRunResultToApiBody, + runAutomationRuleForRoute, +} from "@/lib/automations/run-route"; +import { + createPaymentsItemQuotaSourceAdapter, + paymentsItemQuotaCustomerDataReaders, + prismaPaymentsItemQuotaProjectUserReader, +} from "@/lib/automations/sources/payments-item-quota"; +import { sendEmailToMany } from "@/lib/emails"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, serverOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + rule_id: yupString().defined(), + }).defined(), + body: yupObject({ + limit: yupNumber().integer().min(1).max(1000).optional(), + cursor: yupString().nullable().optional(), + scheduled_at_millis: yupNumber().integer().optional(), + }).optional().default({}), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + rule_id: yupString().defined(), + mode: yupString().oneOf(["run"]).defined(), + evaluated_count: yupNumber().integer().defined(), + eligible_count: yupNumber().integer().defined(), + suppressed_count: yupNumber().integer().defined(), + sent_count: yupNumber().integer().defined(), + next_cursor: yupString().nullable().defined(), + decisions: yupArray(yupObject({ + subject_type: yupString().oneOf(["user"]).defined(), + subject_id: yupString().defined(), + signal_key: yupString().defined(), + sent: yupBoolean().defined(), + source: yupObject({ + type: yupString().oneOf(["payments-item-quota"]).defined(), + item_id: yupString().defined(), + current_quantity: yupNumber().defined(), + entitlement_quantity: yupNumber().nullable().defined(), + threshold_kind: yupString().oneOf(["near", "over"]).defined(), + owned_product_ids: yupArray(yupString().defined()).defined(), + active_subscription_ids: yupArray(yupString().defined()).defined(), + }).defined(), + action: yupObject({ + type: yupString().oneOf(["send-email"]).defined(), + template_id: yupString().defined(), + notification_category_name: yupString().oneOf(["Marketing"]).defined(), + }).defined(), + cooldown: yupObject({ + blocked: yupBoolean().defined(), + last_action_at_millis: yupNumber().optional(), + next_eligible_at_millis: yupNumber().optional(), + }).defined(), + skip_reason: yupString().oneOf(["cooldown"]).optional(), + })).defined(), + }).defined(), + }), + async handler({ auth, params, body }) { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const sourceAdapter = createPaymentsItemQuotaSourceAdapter({ + prisma, + projectUserReader: prismaPaymentsItemQuotaProjectUserReader, + customerDataReaders: paymentsItemQuotaCustomerDataReaders, + }); + const scheduledAt = body.scheduled_at_millis === undefined ? new Date() : new Date(body.scheduled_at_millis); + const result = await runAutomationRuleForRoute({ + tenancy: auth.tenancy, + ruleId: params.rule_id, + limit: body.limit, + cursor: body.cursor, + scheduledAt, + now: new Date(), + sourceAdapter, + actionAdapter: createSendEmailActionAdapter(), + stateStore: createPrismaAutomationRuleExecutionStateStore(prisma), + emailSender: async ({ action, scheduledAt }) => { + await sendEmailToMany({ + tenancy: auth.tenancy, + recipients: [action.recipient], + tsxSource: action.tsxSource, + extraVariables: action.variables, + themeId: action.themeId ?? null, + isHighPriority: action.isHighPriority, + shouldSkipDeliverabilityCheck: action.shouldSkipDeliverabilityCheck, + scheduledAt, + createdWith: action.createdWith, + overrideSubject: action.subject, + overrideNotificationCategoryId: action.notificationCategoryId, + }); + }, + }); + + return { + statusCode: 200, + bodyType: "json", + body: automationRunResultToApiBody(result), + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/automations/schedule/route.ts b/apps/backend/src/app/api/latest/internal/automations/schedule/route.ts new file mode 100644 index 000000000..585e4d4d1 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/schedule/route.ts @@ -0,0 +1,87 @@ +import { + discoverEnabledScheduledAutomationRules, + enqueueScheduledAutomationRuns, + normalizeScheduledAutomationDiscoveryLimit, + normalizeScheduledAutomationRunPageLimit, +} from "@/lib/automations/scheduler"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@hexclave/shared/dist/schema-fields"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { StatusError } from "@hexclave/shared/dist/utils/errors"; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Schedule automation rule runs", + description: "Internal endpoint invoked by cron to enqueue bounded scheduled automation rule runs.", + tags: ["Automations"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({}).nullable().optional(), + method: yupString().oneOf(["GET"]).defined(), + headers: yupObject({ + authorization: yupTuple([yupString().defined()]).optional(), + }).defined(), + query: yupObject({ + cursor: yupString().optional(), + max_tenancies: yupString().optional(), + limit: yupString().optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + ok: yupBoolean().defined(), + tenancies_scanned: yupNumber().integer().defined(), + rules_found: yupNumber().integer().defined(), + runs_enqueued: yupNumber().integer().defined(), + next_cursor: yupString().nullable().defined(), + }).defined(), + }), + handler: async ({ auth, headers, query }) => { + const isAdmin = auth?.type === "admin" && auth.project.id === "internal"; + const authHeader = headers.authorization?.[0]; + if (!isAdmin && authHeader !== `Bearer ${getEnvVariable("CRON_SECRET")}`) { + throw new StatusError(401, "Unauthorized"); + } + + const discovery = await discoverEnabledScheduledAutomationRules({ + limit: parseOptionalPositiveInteger(query.max_tenancies, "max_tenancies"), + cursor: query.cursor, + }); + const enqueueResult = await enqueueScheduledAutomationRuns({ + targets: discovery.targets, + limit: parseOptionalPositiveInteger(query.limit, "limit"), + scheduledAt: new Date(), + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + ok: true, + tenancies_scanned: discovery.scannedTenancyCount, + rules_found: discovery.targets.length, + runs_enqueued: enqueueResult.enqueuedCount, + next_cursor: discovery.nextCursor, + }, + }; + }, +}); + +function parseOptionalPositiveInteger(value: string | null | undefined, label: "max_tenancies" | "limit") { + if (value == null || value === "") { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1 || String(parsed) !== value) { + throw new StatusError(400, `${label} must be a positive integer`); + } + return label === "max_tenancies" + ? normalizeScheduledAutomationDiscoveryLimit(parsed) + : normalizeScheduledAutomationRunPageLimit(parsed); +} diff --git a/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts b/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts new file mode 100644 index 000000000..aa69b4ac4 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts @@ -0,0 +1,81 @@ +import { + normalizeScheduledAutomationRunPageLimit, + runScheduledAutomationRulePage, +} from "@/lib/automations/scheduler"; +import { ensureUpstashSignature } from "@/lib/upstash"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@hexclave/shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Run one scheduled automation rule page", + description: "Receives QStash work for a single tenancy/rule automation run page.", + tags: ["Automations"], + hidden: true, + }, + request: yupObject({ + headers: yupObject({ + "upstash-signature": yupTuple([yupString().defined()]).defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + body: yupObject({ + tenancyId: yupString().defined(), + ruleId: yupString().defined(), + cursor: yupString().nullable().optional(), + limit: yupNumber().integer().min(1).max(100).optional(), + scheduledAtMillis: yupNumber().integer().optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + ok: yupBoolean().defined(), + status: yupString().oneOf(["ran", "skipped"]).defined(), + skipped_reason: yupString().oneOf(["tenancy-not-found", "rule-not-found", "rule-disabled"]).optional(), + evaluated_count: yupNumber().integer().optional(), + sent_count: yupNumber().integer().optional(), + suppressed_count: yupNumber().integer().optional(), + next_cursor: yupString().nullable().optional(), + enqueued_continuation: yupBoolean().optional(), + }).defined(), + }), + handler: async ({ body }, fullReq) => { + await ensureUpstashSignature(fullReq); + const scheduledAt = body.scheduledAtMillis === undefined ? new Date() : new Date(body.scheduledAtMillis); + const result = await runScheduledAutomationRulePage({ + tenancyId: body.tenancyId, + ruleId: body.ruleId, + cursor: body.cursor, + limit: normalizeScheduledAutomationRunPageLimit(body.limit), + scheduledAt, + now: new Date(), + }); + + if (result.status === "skipped") { + return { + statusCode: 200, + bodyType: "json", + body: { + ok: true, + status: result.status, + skipped_reason: result.reason, + }, + }; + } + + return { + statusCode: 200, + bodyType: "json", + body: { + ok: true, + status: result.status, + evaluated_count: result.result.evaluatedCount, + sent_count: result.result.sentCount, + suppressed_count: result.result.suppressedCount, + next_cursor: result.result.nextCursor, + enqueued_continuation: result.enqueuedContinuation, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/automations/actions/send-email.ts b/apps/backend/src/lib/automations/actions/send-email.ts new file mode 100644 index 000000000..6066c555d --- /dev/null +++ b/apps/backend/src/lib/automations/actions/send-email.ts @@ -0,0 +1,104 @@ +import { AutomationActionAdapter, AutomationActionPlan, AutomationSourceDecision } from "../rule-evaluator"; +import { AutomationRuleTenancy, PaymentsItemQuotaAutomationRule, sendEmailActionType } from "../rules"; + +export type SendEmailNotificationCategory = { + id: string, + name: string, +}; + +export type SendEmailNotificationCategoryReader = (name: string) => Promise; + +export function createSendEmailActionAdapter(options: { + getNotificationCategoryByName?: SendEmailNotificationCategoryReader, +} = {}): AutomationActionAdapter { + return { + buildPlan: async (buildOptions) => await buildSendEmailActionPlan({ + ...buildOptions, + getNotificationCategoryByName: options.getNotificationCategoryByName, + }), + }; +} + +export const sendEmailActionAdapter = createSendEmailActionAdapter(); + +async function getNotificationCategoryByNameFromExistingEmailSystem(name: string) { + const { getNotificationCategoryByName } = await import("@/lib/notification-categories"); + return getNotificationCategoryByName(name); +} + +export async function buildSendEmailActionPlan(options: { + tenancy: AutomationRuleTenancy, + ruleId: string, + rule: PaymentsItemQuotaAutomationRule, + decision: AutomationSourceDecision, + getNotificationCategoryByName?: SendEmailNotificationCategoryReader, +}): Promise { + const template = options.tenancy.config.emails?.templates?.[options.rule.action.templateId]; + if (template === undefined) { + throw new Error(`Automation rule "${options.ruleId}" references email template "${options.rule.action.templateId}", but that template does not exist.`); + } + if (template.tsxSource === undefined) { + throw new Error(`Automation rule "${options.ruleId}" references email template "${options.rule.action.templateId}", but that template is missing tsxSource.`); + } + + const notificationCategoryName: string = options.rule.action.notificationCategoryName ?? "Marketing"; + if (notificationCategoryName !== "Marketing") { + throw new Error(`Automation rule "${options.ruleId}" has unsupported action.notificationCategoryName "${notificationCategoryName}". V1 supports only "Marketing".`); + } + const notificationCategoryReader = options.getNotificationCategoryByName ?? getNotificationCategoryByNameFromExistingEmailSystem; + const notificationCategory = await notificationCategoryReader(notificationCategoryName); + if (notificationCategory === undefined) { + throw new Error(`Automation rule "${options.ruleId}" references notification category "${notificationCategoryName}", but that category does not exist.`); + } + + const projectDisplayName = options.tenancy.project?.display_name; + if (projectDisplayName === undefined) { + throw new Error(`Automation rule "${options.ruleId}" cannot build email variables because tenancy.project.display_name is missing.`); + } + + return { + type: sendEmailActionType, + recipient: { + type: "user-primary-email", + userId: options.decision.subject.id, + }, + tsxSource: template.tsxSource, + templateId: options.rule.action.templateId, + themeId: resolveThemeId({ + actionThemeId: options.rule.action.themeId, + templateThemeId: template.themeId, + selectedThemeId: options.tenancy.config.emails?.selectedThemeId, + }), + ...(options.rule.action.subject === undefined ? {} : { subject: options.rule.action.subject }), + notificationCategoryName, + notificationCategoryId: notificationCategory.id, + createdWith: { + type: "programmatic-call", + templateId: options.rule.action.templateId, + }, + isHighPriority: false, + shouldSkipDeliverabilityCheck: false, + variables: { + automationRuleId: options.ruleId, + ...options.decision.sourceSnapshot, + projectDisplayName, + }, + }; +} + +function resolveThemeId(options: { + actionThemeId?: string | null, + templateThemeId?: string | null | false, + selectedThemeId?: string | null, +}) { + if (options.actionThemeId !== undefined) { + return options.actionThemeId; + } + if (options.templateThemeId === false) { + return null; + } + if (options.templateThemeId !== undefined) { + return options.templateThemeId; + } + return options.selectedThemeId ?? null; +} diff --git a/apps/backend/src/lib/automations/cooldown.ts b/apps/backend/src/lib/automations/cooldown.ts new file mode 100644 index 000000000..3b37c1800 --- /dev/null +++ b/apps/backend/src/lib/automations/cooldown.ts @@ -0,0 +1,46 @@ +export type AutomationCooldownStatus = + | { + blocked: false, + } + | { + blocked: true, + lastActionAt: Date, + nextEligibleAt: Date, + }; + +export function getAutomationCooldownStatus(options: { + lastActionAt: Date | null | undefined, + cooldownDays: number, + now: Date, +}): AutomationCooldownStatus { + if (options.lastActionAt == null) { + return { + blocked: false, + }; + } + + const nextEligibleAt = new Date(options.lastActionAt.getTime() + options.cooldownDays * 24 * 60 * 60 * 1000); + if (options.now.getTime() <= nextEligibleAt.getTime()) { + return { + blocked: true, + lastActionAt: options.lastActionAt, + nextEligibleAt, + }; + } + + return { + blocked: false, + }; +} + +export function automationCooldownStatusToApiBody(status: AutomationCooldownStatus) { + return status.blocked + ? { + blocked: true, + last_action_at_millis: status.lastActionAt.getTime(), + next_eligible_at_millis: status.nextEligibleAt.getTime(), + } + : { + blocked: false, + }; +} diff --git a/apps/backend/src/lib/automations/dry-run-route.ts b/apps/backend/src/lib/automations/dry-run-route.ts new file mode 100644 index 000000000..4de39c0c7 --- /dev/null +++ b/apps/backend/src/lib/automations/dry-run-route.ts @@ -0,0 +1,184 @@ +import { automationCooldownStatusToApiBody, type AutomationCooldownStatus } from "./cooldown"; +import { AutomationRuleExecutionStateReader } from "./execution-state-store"; +import { AutomationActionAdapter, AutomationEvaluationResult, AutomationSourceAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; +import { getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; + +export type AutomationDryRunRecipientStatus = { + userExists: boolean, + hasPrimaryEmail: boolean, +}; + +export type AutomationDryRunRecipientStatusReader = (options: { + prisma: TPrisma, + tenancyId: string, + userIds: string[], +}) => Promise>; + +type AutomationDryRunDecisionApiItem = { + subject_type: "user", + subject_id: string, + source: { + type: "payments-item-quota", + item_id: string, + current_quantity: number, + entitlement_quantity: number | null, + threshold_kind: "near" | "over", + owned_product_ids: string[], + active_subscription_ids: string[], + }, + action: { + type: "send-email", + template_id: string, + notification_category_name: "Marketing", + }, + cooldown: { + blocked: boolean, + last_action_at_millis?: number, + next_eligible_at_millis?: number, + }, + recipient: { + user_exists: boolean, + has_primary_email: boolean, + }, +}; + +type AutomationDryRunApiBody = { + rule_id: string, + mode: "dry-run", + evaluated_count: number, + eligible_count: number, + suppressed_count: number, + next_cursor: string | null, + decisions: AutomationDryRunDecisionApiItem[], +}; + +export async function evaluateAutomationRuleDryRunForRoute(options: { + tenancy: Parameters[0]["tenancy"], + ruleId: string, + limit?: number, + cursor?: string | null, + prisma: TPrisma, + sourceAdapter: AutomationSourceAdapter, + actionAdapter: AutomationActionAdapter, + recipientStatusReader: AutomationDryRunRecipientStatusReader, + executionStateReader: AutomationRuleExecutionStateReader, + now: Date, +}) { + const rule = getSupportedAutomationRule(options.tenancy, options.ruleId); + const result = await evaluateAutomationRule({ + tenancy: options.tenancy, + ruleId: options.ruleId, + mode: "dry-run", + limit: options.limit, + cursor: options.cursor, + adapters: { + sourceAdapters: { + [paymentsItemQuotaSourceType]: options.sourceAdapter, + }, + actionAdapters: { + [sendEmailActionType]: options.actionAdapter, + }, + }, + }); + + const recipientStatuses = await options.recipientStatusReader({ + prisma: options.prisma, + tenancyId: options.tenancy.id, + userIds: result.decisions.map((decision) => decision.subject.id), + }); + const cooldownStatuses = await Promise.all(result.decisions.map(async (decision) => await options.executionStateReader.getCooldownStatus({ + tenancyId: options.tenancy.id, + ruleId: options.ruleId, + subjectType: decision.subject.type, + subjectId: decision.subject.id, + signalKey: decision.signal.key, + cooldownDays: rule.cooldown.days, + now: options.now, + }))); + + return automationDryRunResultToApiBody(result, recipientStatuses, cooldownStatuses); +} + +export function automationDryRunResultToApiBody( + result: AutomationEvaluationResult, + recipientStatuses: Map, + cooldownStatuses: AutomationCooldownStatus[], +): AutomationDryRunApiBody { + const blockedCount = cooldownStatuses.filter((status) => status.blocked).length; + return { + rule_id: result.ruleId, + mode: "dry-run", + evaluated_count: result.evaluatedCount, + eligible_count: result.decisions.length - blockedCount, + suppressed_count: blockedCount, + next_cursor: result.nextCursor, + decisions: result.decisions.map((decision, index) => automationDryRunDecisionToApiItem(decision, recipientStatuses.get(decision.subject.id) ?? { + userExists: false, + hasPrimaryEmail: false, + }, cooldownStatuses[index] ?? { + blocked: false, + })), + }; +} + +function automationDryRunDecisionToApiItem( + decision: EvaluatedAutomationDecision, + recipientStatus: AutomationDryRunRecipientStatus, + cooldownStatus: AutomationCooldownStatus, +): AutomationDryRunDecisionApiItem { + return { + subject_type: decision.subject.type, + subject_id: decision.subject.id, + source: { + type: "payments-item-quota", + item_id: getStringSourceSnapshotValue(decision, "itemId"), + current_quantity: getNumberSourceSnapshotValue(decision, "currentQuantity"), + entitlement_quantity: getNullableNumberSourceSnapshotValue(decision, "entitlementQuantity"), + threshold_kind: decision.signal.kind, + owned_product_ids: getStringArraySourceSnapshotValue(decision, "ownedProductIds"), + active_subscription_ids: getStringArraySourceSnapshotValue(decision, "activeSubscriptionIds"), + }, + action: { + type: decision.action.type, + template_id: decision.action.templateId, + notification_category_name: decision.action.notificationCategoryName, + }, + cooldown: automationCooldownStatusToApiBody(cooldownStatus), + recipient: { + user_exists: recipientStatus.userExists, + has_primary_email: recipientStatus.hasPrimaryEmail, + }, + }; +} + +function getStringSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { + const value = decision.sourceSnapshot[key]; + if (typeof value !== "string") { + throw new Error(`Automation dry-run decision sourceSnapshot.${key} must be a string.`); + } + return value; +} + +function getNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { + const value = decision.sourceSnapshot[key]; + if (typeof value !== "number") { + throw new Error(`Automation dry-run decision sourceSnapshot.${key} must be a number.`); + } + return value; +} + +function getNullableNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { + const value = decision.sourceSnapshot[key]; + if (value !== null && typeof value !== "number") { + throw new Error(`Automation dry-run decision sourceSnapshot.${key} must be a number or null.`); + } + return value; +} + +function getStringArraySourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { + const value = decision.sourceSnapshot[key]; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { + throw new Error(`Automation dry-run decision sourceSnapshot.${key} must be an array of strings.`); + } + return value; +} diff --git a/apps/backend/src/lib/automations/execution-state-store.ts b/apps/backend/src/lib/automations/execution-state-store.ts new file mode 100644 index 000000000..c682a86f2 --- /dev/null +++ b/apps/backend/src/lib/automations/execution-state-store.ts @@ -0,0 +1,241 @@ +import { Prisma } from "@/generated/prisma/client"; +import { getAutomationCooldownStatus, type AutomationCooldownStatus } from "./cooldown"; +import { AutomationRuleExecutionStateStore } from "./run-route"; +import { AutomationJson } from "./rules"; + +type AutomationRuleExecutionStateKey = { + tenancyId: string, + ruleId: string, + subjectType: "user", + subjectId: string, + signalKey: string, +}; + +export type AutomationRuleExecutionStatePrisma = { + automationRuleExecutionState: { + create: (options: { + data: AutomationRuleExecutionStateKey & { + sourceType: string, + actionType: string, + lastTriggeredAt: Date, + lastActionAt: Date | null, + lastEmailOutboxId: string | null, + lastSourceSnapshot: Prisma.InputJsonObject, + }, + }) => Promise, + findUnique: (options: { + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: AutomationRuleExecutionStateKey, + }, + select: { + lastTriggeredAt: true, + lastActionAt: true, + }, + }) => Promise<{ lastTriggeredAt: Date, lastActionAt: Date | null } | null>, + updateMany: (options: { + where: AutomationRuleExecutionStateKey & { + OR: Array< + | { + lastActionAt: null, + lastTriggeredAt: { lt: Date }, + } + | { lastActionAt: { lt: Date } } + >, + }, + data: { + sourceType: string, + actionType: string, + lastTriggeredAt: Date, + lastSourceSnapshot: Prisma.InputJsonObject, + }, + }) => Promise<{ count: number }>, + update: (options: { + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: AutomationRuleExecutionStateKey, + }, + data: { + lastActionAt: Date, + lastEmailOutboxId: string | null, + }, + }) => Promise, + }, +}; + +export type AutomationRuleExecutionStateReaderPrisma = { + automationRuleExecutionState: { + findUnique: (options: { + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: AutomationRuleExecutionStateKey, + }, + select: { + lastActionAt: true, + }, + }) => Promise<{ lastActionAt: Date | null } | null>, + }, +}; + +export type AutomationRuleExecutionStateReader = { + getCooldownStatus: (options: AutomationRuleExecutionStateKey & { + cooldownDays: number, + now: Date, + }) => Promise, +}; + +export function createPrismaAutomationRuleExecutionStateReader(prisma: AutomationRuleExecutionStateReaderPrisma): AutomationRuleExecutionStateReader { + return { + async getCooldownStatus(options) { + const state = await prisma.automationRuleExecutionState.findUnique({ + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: getAutomationRuleExecutionStateKey(options), + }, + select: { + lastActionAt: true, + }, + }); + + return getAutomationCooldownStatus({ + lastActionAt: state?.lastActionAt, + cooldownDays: options.cooldownDays, + now: options.now, + }); + }, + }; +} + +export function createPrismaAutomationRuleExecutionStateStore(prisma: AutomationRuleExecutionStatePrisma): AutomationRuleExecutionStateStore { + return { + async claimExecution(options) { + const cooldownCutoff = new Date(options.lastTriggeredAt.getTime() - options.cooldownDays * 24 * 60 * 60 * 1000); + // A row with lastActionAt=null means another run has claimed this signal but has not + // finished enqueueing email yet. Treat recent claims as in-flight idempotency locks. + const staleClaimCutoff = new Date(options.lastTriggeredAt.getTime() - 15 * 60 * 1000); + const stateKey = getAutomationRuleExecutionStateKey(options); + const lastSourceSnapshot = getPrismaJsonObject(options.sourceSnapshot); + + try { + await prisma.automationRuleExecutionState.create({ + data: { + tenancyId: options.tenancyId, + ruleId: options.ruleId, + sourceType: options.sourceType, + actionType: options.actionType, + subjectType: options.subjectType, + subjectId: options.subjectId, + signalKey: options.signalKey, + lastTriggeredAt: options.lastTriggeredAt, + lastActionAt: null, + lastEmailOutboxId: null, + lastSourceSnapshot, + }, + }); + + return { + claimed: true, + lastActionAt: null, + }; + } catch (error) { + if (!isPrismaUniqueConstraintError(error)) { + throw error; + } + } + + const existing = await prisma.automationRuleExecutionState.findUnique({ + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: stateKey, + }, + select: { + lastTriggeredAt: true, + lastActionAt: true, + }, + }); + if (existing === null) { + throw new Error("Automation rule execution state disappeared after a unique constraint conflict."); + } + + const updateResult = await prisma.automationRuleExecutionState.updateMany({ + where: { + tenancyId: options.tenancyId, + ruleId: options.ruleId, + subjectType: options.subjectType, + subjectId: options.subjectId, + signalKey: options.signalKey, + OR: [ + { + lastActionAt: null, + lastTriggeredAt: { + lt: staleClaimCutoff, + }, + }, + { + lastActionAt: { + lt: cooldownCutoff, + }, + }, + ], + }, + data: { + sourceType: options.sourceType, + actionType: options.actionType, + lastTriggeredAt: options.lastTriggeredAt, + lastSourceSnapshot, + }, + }); + if (updateResult.count === 1) { + return { + claimed: true, + lastActionAt: existing.lastActionAt, + }; + } + if (updateResult.count !== 0) { + throw new Error(`Expected at most one automation execution claim row to update, received ${updateResult.count}.`); + } + + const current = await prisma.automationRuleExecutionState.findUnique({ + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: stateKey, + }, + select: { + lastTriggeredAt: true, + lastActionAt: true, + }, + }); + if (current === null) { + throw new Error("Automation rule execution state disappeared while evaluating cooldown."); + } + + return { + claimed: false, + lastActionAt: current.lastActionAt, + }; + }, + async markActionCompleted(options) { + await prisma.automationRuleExecutionState.update({ + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: getAutomationRuleExecutionStateKey(options), + }, + data: { + lastActionAt: options.lastActionAt, + lastEmailOutboxId: options.lastEmailOutboxId, + }, + }); + }, + }; +} + +function getAutomationRuleExecutionStateKey(options: AutomationRuleExecutionStateKey) { + return { + tenancyId: options.tenancyId, + ruleId: options.ruleId, + subjectType: options.subjectType, + subjectId: options.subjectId, + signalKey: options.signalKey, + }; +} + +function getPrismaJsonObject(sourceSnapshot: Record): Prisma.InputJsonObject { + return sourceSnapshot; +} + +function isPrismaUniqueConstraintError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"; +} diff --git a/apps/backend/src/lib/automations/quota-email-automation.test.ts b/apps/backend/src/lib/automations/quota-email-automation.test.ts new file mode 100644 index 000000000..270b1b306 --- /dev/null +++ b/apps/backend/src/lib/automations/quota-email-automation.test.ts @@ -0,0 +1,791 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildSendEmailActionPlan } from "./actions/send-email"; +import { AutomationActionAdapter, AutomationSourceAdapter, AutomationSourceDecision, evaluateAutomationRule } from "./rule-evaluator"; +import { + createPaymentsItemQuotaSourceAdapter, + PaymentsItemQuotaCustomerDataReaders, + PaymentsItemQuotaOwnedProduct, + PaymentsItemQuotaProjectUserReader, + PaymentsItemQuotaSubscription, +} from "./sources/payments-item-quota"; +import { AutomationRuleConfig, AutomationRuleTenancy, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; + +const ruleId = "low-api-credits"; + +function createRule(overrides: { + sourceType?: string, + customerType?: string, + actionType?: string, + templateId?: string, + themeId?: string | null, + subject?: string, +} = {}): AutomationRuleConfig { + return { + displayName: "Low API credits", + enabled: true, + source: { + type: overrides.sourceType ?? paymentsItemQuotaSourceType, + itemId: "api_credits", + customerType: overrides.customerType ?? "user", + thresholds: { + nearRemainingQuantity: 10, + }, + }, + action: { + type: overrides.actionType ?? sendEmailActionType, + templateId: overrides.templateId ?? "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + themeId: overrides.themeId, + subject: overrides.subject, + notificationCategoryName: "Marketing", + }, + cooldown: { + days: 7, + }, + }; +} + +function createTenancy(rule: AutomationRuleConfig | null = createRule()): AutomationRuleTenancy { + return { + id: "tenancy-1", + config: { + automations: { + rules: rule === null ? {} : { + [ruleId]: rule, + }, + }, + }, + }; +} + +function createAdapters() { + const evaluateSource: AutomationSourceAdapter["evaluate"] = async () => ({ + evaluatedCount: 2, + nextCursor: "cursor-2", + decisions: [{ + subject: { + type: "user", + id: "user-1", + }, + signal: { + key: "api_credits:near", + kind: "near", + }, + sourceSnapshot: { + itemId: "api_credits", + currentQuantity: 7, + entitlementQuantity: 100, + }, + }], + }); + const buildActionPlan: AutomationActionAdapter["buildPlan"] = async (options) => ({ + type: sendEmailActionType, + recipient: { + type: "user-primary-email", + userId: options.decision.subject.id, + }, + templateId: options.rule.action.templateId, + tsxSource: "export function EmailTemplate() { return null; }", + themeId: null, + notificationCategoryName: "Marketing", + notificationCategoryId: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", + createdWith: { + type: "programmatic-call", + templateId: options.rule.action.templateId, + }, + isHighPriority: false, + shouldSkipDeliverabilityCheck: false, + variables: { + automationRuleId: options.ruleId, + thresholdKind: options.decision.signal.kind, + }, + }); + const sourceAdapter: AutomationSourceAdapter = { + evaluate: vi.fn(evaluateSource), + }; + const actionAdapter: AutomationActionAdapter = { + buildPlan: vi.fn(buildActionPlan), + }; + + return { + sourceAdapter, + actionAdapter, + adapters: { + sourceAdapters: { + [paymentsItemQuotaSourceType]: sourceAdapter, + }, + actionAdapters: { + [sendEmailActionType]: actionAdapter, + }, + }, + }; +} + +describe("automation evaluator skeleton", () => { + it("dispatches a valid V1 rule to the payments item quota source adapter", async () => { + const { sourceAdapter, adapters } = createAdapters(); + + await evaluateAutomationRule({ + tenancy: createTenancy(), + ruleId, + mode: "dry-run", + limit: 25, + cursor: "cursor-1", + adapters, + }); + + expect(sourceAdapter.evaluate).toHaveBeenCalledTimes(1); + expect(sourceAdapter.evaluate).toHaveBeenCalledWith(expect.objectContaining({ + ruleId, + limit: 25, + cursor: "cursor-1", + rule: expect.objectContaining({ + source: expect.objectContaining({ + type: paymentsItemQuotaSourceType, + }), + }), + })); + }); + + it("dispatches source decisions to the send-email action adapter", async () => { + const { actionAdapter, adapters } = createAdapters(); + + await evaluateAutomationRule({ + tenancy: createTenancy(), + ruleId, + mode: "dry-run", + adapters, + }); + + expect(actionAdapter.buildPlan).toHaveBeenCalledTimes(1); + expect(actionAdapter.buildPlan).toHaveBeenCalledWith(expect.objectContaining({ + ruleId, + decision: expect.objectContaining({ + subject: { + type: "user", + id: "user-1", + }, + }), + })); + }); + + it("returns combined dry-run decisions with source snapshot and action plan", async () => { + const { adapters } = createAdapters(); + + await expect(evaluateAutomationRule({ + tenancy: createTenancy(), + ruleId, + mode: "dry-run", + adapters, + })).resolves.toMatchInlineSnapshot(` + { + "decisions": [ + { + "action": { + "createdWith": { + "templateId": "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + "type": "programmatic-call", + }, + "isHighPriority": false, + "notificationCategoryId": "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", + "notificationCategoryName": "Marketing", + "recipient": { + "type": "user-primary-email", + "userId": "user-1", + }, + "shouldSkipDeliverabilityCheck": false, + "templateId": "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + "themeId": null, + "tsxSource": "export function EmailTemplate() { return null; }", + "type": "send-email", + "variables": { + "automationRuleId": "low-api-credits", + "thresholdKind": "near", + }, + }, + "signal": { + "key": "api_credits:near", + "kind": "near", + }, + "sourceSnapshot": { + "currentQuantity": 7, + "entitlementQuantity": 100, + "itemId": "api_credits", + }, + "subject": { + "id": "user-1", + "type": "user", + }, + }, + ], + "eligibleCount": 1, + "evaluatedCount": 2, + "mode": "dry-run", + "nextCursor": "cursor-2", + "ruleId": "low-api-credits", + "suppressedCount": 0, + } + `); + }); + + it("fails loudly for a missing rule", async () => { + const { adapters } = createAdapters(); + + await expect(evaluateAutomationRule({ + tenancy: createTenancy(null), + ruleId, + mode: "dry-run", + adapters, + })).rejects.toThrow('Automation rule "low-api-credits" was not found for tenancy "tenancy-1".'); + }); + + it("fails loudly for unsupported source.type", async () => { + const { adapters } = createAdapters(); + + await expect(evaluateAutomationRule({ + tenancy: createTenancy(createRule({ sourceType: "client-push-quota" })), + ruleId, + mode: "dry-run", + adapters, + })).rejects.toThrow('Automation rule "low-api-credits" has unsupported source.type "client-push-quota". V1 supports only "payments-item-quota".'); + }); + + it("fails loudly for unsupported source.customerType", async () => { + const { adapters } = createAdapters(); + + await expect(evaluateAutomationRule({ + tenancy: createTenancy(createRule({ customerType: "team" })), + ruleId, + mode: "dry-run", + adapters, + })).rejects.toThrow('Automation rule "low-api-credits" has unsupported source.customerType "team". V1 supports only "user".'); + }); + + it("fails loudly for unsupported action.type", async () => { + const { adapters } = createAdapters(); + + await expect(evaluateAutomationRule({ + tenancy: createTenancy(createRule({ actionType: "webhook" })), + ruleId, + mode: "dry-run", + adapters, + })).rejects.toThrow('Automation rule "low-api-credits" has unsupported action.type "webhook". V1 supports only "send-email".'); + }); + + it("uses the same evaluator dispatch path for real-send mode", async () => { + const { sourceAdapter, actionAdapter, adapters } = createAdapters(); + + await expect(evaluateAutomationRule({ + tenancy: createTenancy(), + ruleId, + mode: "run", + adapters, + })).resolves.toMatchObject({ + mode: "run", + eligibleCount: 1, + }); + + expect(sourceAdapter.evaluate).toHaveBeenCalledTimes(1); + expect(actionAdapter.buildPlan).toHaveBeenCalledTimes(1); + }); + + it("keeps dry-run evaluation independent from Prisma state", async () => { + const { adapters } = createAdapters(); + + const result = await evaluateAutomationRule({ + tenancy: createTenancy(), + ruleId, + mode: "dry-run", + adapters, + }); + + expect(result.suppressedCount).toBe(0); + expect(result.decisions).toHaveLength(1); + }); +}); + +type FakePrisma = { + label: "fake-prisma", +}; + +type SourceTestState = { + projectUserIds: string[], + nextCursor?: string | null, + currentQuantities?: Record, + ownedProducts?: Record>, + subscriptions?: Record>, +}; + +const fakePrisma: FakePrisma = { + label: "fake-prisma", +}; + +function createSourceTenancy(options: { + itemCustomerType?: string, + thresholds?: NonNullable, + itemExists?: boolean, +} = {}): AutomationRuleTenancy { + return { + id: "tenancy-1", + config: { + automations: { + rules: { + [ruleId]: createRule(), + }, + }, + payments: { + items: options.itemExists === false ? {} : { + api_credits: { + displayName: "API credits", + customerType: options.itemCustomerType ?? "user", + }, + }, + }, + }, + }; +} + +function createSourceRule(tenancy: AutomationRuleTenancy, thresholds: NonNullable) { + const rule = getSupportedAutomationRule(tenancy, ruleId); + return { + ...rule, + source: { + ...rule.source, + thresholds, + }, + }; +} + +function createOwnedProduct(options: { + quantity?: number, + includedQuantity?: number, +} = {}): PaymentsItemQuotaOwnedProduct { + return { + quantity: options.quantity ?? 1, + productLineId: "line-1", + product: { + includedItems: { + api_credits: { + quantity: options.includedQuantity ?? 100, + }, + }, + }, + }; +} + +function createSourceAdapterFixture(state: SourceTestState) { + const projectUserReader: PaymentsItemQuotaProjectUserReader = { + listCandidateUserIds: vi.fn(async () => ({ + projectUserIds: state.projectUserIds, + nextCursor: state.nextCursor ?? null, + })), + }; + const customerDataReaders: PaymentsItemQuotaCustomerDataReaders = { + getItemQuantityForCustomer: vi.fn(async (options) => state.currentQuantities?.[options.customerId] ?? 50), + getOwnedProductsForCustomer: vi.fn(async (options) => state.ownedProducts?.[options.customerId] ?? { + pro: createOwnedProduct(), + }), + getSubscriptionMapForCustomer: vi.fn(async (options) => state.subscriptions?.[options.customerId] ?? {}), + }; + const adapter = createPaymentsItemQuotaSourceAdapter({ + prisma: fakePrisma, + projectUserReader, + customerDataReaders, + }); + return { + adapter, + projectUserReader, + customerDataReaders, + }; +} + +describe("payments item quota source adapter", () => { + it("rejects a rule whose configured item does not exist", async () => { + const tenancy = createSourceTenancy({ itemExists: false }); + const { adapter } = createSourceAdapterFixture({ projectUserIds: ["user-1"] }); + + await expect(adapter.evaluate({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + })).rejects.toThrow('Automation rule "low-api-credits" references payments item "api_credits", but that item does not exist.'); + }); + + it("rejects a payments item with a non-user customer type", async () => { + const tenancy = createSourceTenancy({ itemCustomerType: "team" }); + const { adapter } = createSourceAdapterFixture({ projectUserIds: ["user-1"] }); + + await expect(adapter.evaluate({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + })).rejects.toThrow('Automation rule "low-api-credits" references payments item "api_credits" with customerType "team"; V1 supports only user items.'); + }); + + it("detects near threshold by absolute remaining quantity", async () => { + const tenancy = createSourceTenancy(); + const rule = createSourceRule(tenancy, { nearRemainingQuantity: 10 }); + const { adapter } = createSourceAdapterFixture({ + projectUserIds: ["user-1", "user-2"], + currentQuantities: { + "user-1": 9, + "user-2": 11, + }, + }); + + await expect(adapter.evaluate({ tenancy, ruleId, rule })).resolves.toMatchObject({ + evaluatedCount: 2, + decisions: [{ + subject: { + type: "user", + id: "user-1", + }, + signal: { + key: "api_credits:near", + kind: "near", + }, + }], + }); + }); + + it("detects near threshold by remaining ratio", async () => { + const tenancy = createSourceTenancy(); + const rule = createSourceRule(tenancy, { nearRemainingRatio: 0.2 }); + const { adapter } = createSourceAdapterFixture({ + projectUserIds: ["user-1"], + currentQuantities: { + "user-1": 15, + }, + ownedProducts: { + "user-1": { + pro: createOwnedProduct({ quantity: 1, includedQuantity: 100 }), + }, + }, + }); + + await expect(adapter.evaluate({ tenancy, ruleId, rule })).resolves.toMatchObject({ + decisions: [{ + signal: { + key: "api_credits:near", + kind: "near", + }, + }], + }); + }); + + it("detects over threshold", async () => { + const tenancy = createSourceTenancy(); + const rule = createSourceRule(tenancy, { overLimitQuantity: 0 }); + const { adapter } = createSourceAdapterFixture({ + projectUserIds: ["user-1"], + currentQuantities: { + "user-1": 0, + }, + }); + + await expect(adapter.evaluate({ tenancy, ruleId, rule })).resolves.toMatchObject({ + decisions: [{ + signal: { + key: "api_credits:over", + kind: "over", + }, + }], + }); + }); + + it("lets over threshold win over near threshold", async () => { + const tenancy = createSourceTenancy(); + const rule = createSourceRule(tenancy, { + overLimitQuantity: 0, + nearRemainingQuantity: 10, + nearRemainingRatio: 0.2, + }); + const { adapter } = createSourceAdapterFixture({ + projectUserIds: ["user-1"], + currentQuantities: { + "user-1": 0, + }, + }); + + await expect(adapter.evaluate({ tenancy, ruleId, rule })).resolves.toMatchObject({ + decisions: [{ + signal: { + key: "api_credits:over", + kind: "over", + }, + }], + }); + }); + + it("ignores ratio threshold when entitlement quantity is missing or zero", async () => { + const tenancy = createSourceTenancy(); + const rule = createSourceRule(tenancy, { nearRemainingRatio: 0.2 }); + const { adapter } = createSourceAdapterFixture({ + projectUserIds: ["missing-entitlement", "zero-entitlement"], + currentQuantities: { + "missing-entitlement": 1, + "zero-entitlement": 1, + }, + ownedProducts: { + "missing-entitlement": { + pro: { + quantity: 1, + productLineId: "line-1", + product: { + includedItems: {}, + }, + }, + }, + "zero-entitlement": { + pro: createOwnedProduct({ includedQuantity: 0 }), + }, + }, + }); + + await expect(adapter.evaluate({ tenancy, ruleId, rule })).resolves.toMatchObject({ + evaluatedCount: 2, + decisions: [], + }); + }); + + it("includes item, quantity, product, and subscription context in the source snapshot", async () => { + const tenancy = createSourceTenancy(); + const rule = createSourceRule(tenancy, { nearRemainingRatio: 0.2 }); + const { adapter } = createSourceAdapterFixture({ + projectUserIds: ["user-1"], + currentQuantities: { + "user-1": 10, + }, + ownedProducts: { + "user-1": { + pro: createOwnedProduct({ quantity: 2, includedQuantity: 50 }), + __null__: createOwnedProduct({ quantity: 1, includedQuantity: 1 }), + }, + }, + subscriptions: { + "user-1": { + sub_active: { status: "active" }, + sub_trial: { status: "trialing" }, + sub_canceled: { status: "canceled" }, + }, + }, + }); + + await expect(adapter.evaluate({ tenancy, ruleId, rule, limit: 1, cursor: "previous-user" })).resolves.toMatchInlineSnapshot(` + { + "decisions": [ + { + "signal": { + "key": "api_credits:near", + "kind": "near", + }, + "sourceSnapshot": { + "activeSubscriptionIds": [ + "sub_active", + "sub_trial", + ], + "currentQuantity": 10, + "entitlementQuantity": 101, + "itemDisplayName": "API credits", + "itemId": "api_credits", + "ownedProductIds": [ + "pro", + ], + "sourceType": "payments-item-quota", + "thresholdKind": "near", + }, + "subject": { + "id": "user-1", + "type": "user", + }, + }, + ], + "evaluatedCount": 1, + "nextCursor": null, + } + `); + }); +}); + +function createActionTenancy(rule: AutomationRuleConfig = createRule()): AutomationRuleTenancy { + return { + id: "tenancy-1", + project: { + display_name: "Acme App", + }, + config: { + automations: { + rules: { + [ruleId]: rule, + }, + }, + emails: { + selectedThemeId: "active-theme-id", + templates: { + "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1": { + displayName: "Upgrade email", + tsxSource: "export function EmailTemplate() { return null; }", + themeId: "template-theme-id", + }, + }, + }, + }, + }; +} + +function createActionDecision(): AutomationSourceDecision { + return { + subject: { + type: "user", + id: "user-1", + }, + signal: { + key: "api_credits:near", + kind: "near", + }, + sourceSnapshot: { + sourceType: "payments-item-quota", + itemId: "api_credits", + itemDisplayName: "API credits", + currentQuantity: 7, + entitlementQuantity: 100, + thresholdKind: "near", + ownedProductIds: ["pro"], + activeSubscriptionIds: ["sub_1"], + }, + }; +} + +function buildTestSendEmailActionPlan(options: Omit[0], "getNotificationCategoryByName">) { + return buildSendEmailActionPlan({ + ...options, + getNotificationCategoryByName: async (name) => name === "Marketing" ? { + id: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", + name: "Marketing", + } : undefined, + }); +} + +describe("send-email action adapter", () => { + it("builds a user-primary-email recipient", async () => { + const tenancy = createActionTenancy(); + + await expect(buildTestSendEmailActionPlan({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + decision: createActionDecision(), + })).resolves.toMatchObject({ + recipient: { + type: "user-primary-email", + userId: "user-1", + }, + }); + }); + + it("uses the configured template, theme, and subject", async () => { + const tenancy = createActionTenancy(createRule({ + themeId: "rule-theme-id", + subject: "Upgrade your API credits", + })); + + await expect(buildTestSendEmailActionPlan({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + decision: createActionDecision(), + })).resolves.toMatchObject({ + templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + tsxSource: "export function EmailTemplate() { return null; }", + themeId: "rule-theme-id", + subject: "Upgrade your API credits", + createdWith: { + type: "programmatic-call", + templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + }, + }); + }); + + it("falls back to the template theme when the rule has no theme override", async () => { + const tenancy = createActionTenancy(); + + await expect(buildTestSendEmailActionPlan({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + decision: createActionDecision(), + })).resolves.toMatchObject({ + themeId: "template-theme-id", + }); + }); + + it("applies the Marketing notification category and outbox safety flags", async () => { + const tenancy = createActionTenancy(); + + await expect(buildTestSendEmailActionPlan({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + decision: createActionDecision(), + })).resolves.toMatchObject({ + notificationCategoryName: "Marketing", + notificationCategoryId: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", + isHighPriority: false, + shouldSkipDeliverabilityCheck: false, + }); + }); + + it("includes source snapshot variables and project display name", async () => { + const tenancy = createActionTenancy(); + + await expect(buildTestSendEmailActionPlan({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + decision: createActionDecision(), + })).resolves.toMatchObject({ + variables: { + automationRuleId: "low-api-credits", + sourceType: "payments-item-quota", + itemId: "api_credits", + itemDisplayName: "API credits", + currentQuantity: 7, + entitlementQuantity: 100, + thresholdKind: "near", + ownedProductIds: ["pro"], + activeSubscriptionIds: ["sub_1"], + projectDisplayName: "Acme App", + }, + }); + }); + + it("does not snapshot a recipient email address", async () => { + const tenancy = createActionTenancy(); + const plan = await buildTestSendEmailActionPlan({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + decision: createActionDecision(), + }); + + expect(plan.recipient).toEqual({ + type: "user-primary-email", + userId: "user-1", + }); + expect(plan.variables).not.toHaveProperty("email"); + expect(plan.variables).not.toHaveProperty("primaryEmail"); + expect(plan.variables).not.toHaveProperty("recipientEmail"); + }); + + it("fails loudly when the configured email template is missing", async () => { + const tenancy = createActionTenancy(createRule({ + templateId: "1b477fe1-7479-4d90-ac47-23d0a5048bc8", + })); + + await expect(buildTestSendEmailActionPlan({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + decision: createActionDecision(), + })).rejects.toThrow('Automation rule "low-api-credits" references email template "1b477fe1-7479-4d90-ac47-23d0a5048bc8", but that template does not exist.'); + }); +}); diff --git a/apps/backend/src/lib/automations/rule-evaluator.ts b/apps/backend/src/lib/automations/rule-evaluator.ts new file mode 100644 index 000000000..848151dc5 --- /dev/null +++ b/apps/backend/src/lib/automations/rule-evaluator.ts @@ -0,0 +1,137 @@ +import { + AutomationJson, + AutomationRuleTenancy, + SupportedAutomationRule, + getSupportedAutomationRule, + paymentsItemQuotaSourceType, + sendEmailActionType, +} from "./rules"; + +export type AutomationEvaluationMode = "dry-run" | "run"; + +export type AutomationSourceDecision = { + subject: { + type: "user", + id: string, + }, + signal: { + key: string, + kind: "near" | "over", + }, + sourceSnapshot: Record, +}; + +export type AutomationSourceEvaluationResult = { + evaluatedCount: number, + nextCursor: string | null, + decisions: AutomationSourceDecision[], +}; + +export type AutomationActionPlan = { + type: typeof sendEmailActionType, + recipient: { + type: "user-primary-email", + userId: string, + }, + tsxSource: string, + templateId: string, + themeId?: string | null, + subject?: string, + notificationCategoryName: "Marketing", + notificationCategoryId: string, + createdWith: { + type: "programmatic-call", + templateId: string, + }, + isHighPriority: false, + shouldSkipDeliverabilityCheck: false, + variables: Record, +}; + +export type EvaluatedAutomationDecision = AutomationSourceDecision & { + action: AutomationActionPlan, +}; + +export type AutomationEvaluationResult = { + ruleId: string, + mode: AutomationEvaluationMode, + evaluatedCount: number, + eligibleCount: number, + suppressedCount: number, + nextCursor: string | null, + decisions: EvaluatedAutomationDecision[], +}; + +export type AutomationSourceAdapter = { + evaluate: (options: { + tenancy: AutomationRuleTenancy, + ruleId: string, + rule: SupportedAutomationRule, + limit?: number, + cursor?: string | null, + }) => Promise, +}; + +export type AutomationActionAdapter = { + buildPlan: (options: { + tenancy: AutomationRuleTenancy, + ruleId: string, + rule: SupportedAutomationRule, + decision: AutomationSourceDecision, + }) => Promise, +}; + +export type AutomationEvaluatorAdapters = { + sourceAdapters?: Partial>, + actionAdapters?: Partial>, +}; + +export async function evaluateAutomationRule(options: { + tenancy: AutomationRuleTenancy, + ruleId: string, + mode: AutomationEvaluationMode, + limit?: number, + cursor?: string | null, + adapters?: AutomationEvaluatorAdapters, +}): Promise { + const rule = getSupportedAutomationRule(options.tenancy, options.ruleId); + const sourceAdapter = options.adapters?.sourceAdapters?.[rule.source.type]; + if (sourceAdapter === undefined) { + throw new Error(`No automation source adapter is registered for "${rule.source.type}". Register the payments item quota source adapter before evaluating this rule.`); + } + const actionAdapter = options.adapters?.actionAdapters?.[rule.action.type]; + if (actionAdapter === undefined) { + throw new Error(`No automation action adapter is registered for "${rule.action.type}". The "${sendEmailActionType}" implementation belongs to Commit 5.`); + } + + const sourceResult = await sourceAdapter.evaluate({ + tenancy: options.tenancy, + ruleId: options.ruleId, + rule, + limit: options.limit, + cursor: options.cursor, + }); + + const decisions = []; + for (const decision of sourceResult.decisions) { + decisions.push({ + ...decision, + action: await actionAdapter.buildPlan({ + tenancy: options.tenancy, + ruleId: options.ruleId, + rule, + decision, + }), + }); + } + + return { + ruleId: options.ruleId, + mode: options.mode, + evaluatedCount: sourceResult.evaluatedCount, + eligibleCount: decisions.length, + suppressedCount: 0, + nextCursor: sourceResult.nextCursor, + decisions, + }; +} diff --git a/apps/backend/src/lib/automations/rules.ts b/apps/backend/src/lib/automations/rules.ts new file mode 100644 index 000000000..656e71a31 --- /dev/null +++ b/apps/backend/src/lib/automations/rules.ts @@ -0,0 +1,124 @@ +export const paymentsItemQuotaSourceType = "payments-item-quota"; +export const sendEmailActionType = "send-email"; +export const userCustomerType = "user"; + +export type AutomationJson = string | number | boolean | null | AutomationJson[] | { [key: string]: AutomationJson }; + +export type AutomationRuleThresholds = { + nearRemainingRatio?: number, + nearRemainingQuantity?: number, + overLimitQuantity?: number, +}; + +export type AutomationRuleConfig = { + displayName?: string, + enabled: boolean, + source: { + type: string, + itemId?: string, + customerType?: string, + thresholds?: AutomationRuleThresholds, + }, + action: { + type: string, + templateId?: string, + themeId?: string | null, + subject?: string, + notificationCategoryName?: string, + }, + cooldown: { + days?: number, + }, +}; + +export type PaymentsItemQuotaAutomationRule = AutomationRuleConfig & { + source: { + type: typeof paymentsItemQuotaSourceType, + itemId: string, + customerType: typeof userCustomerType, + thresholds: AutomationRuleThresholds, + }, + action: { + type: typeof sendEmailActionType, + templateId: string, + themeId?: string | null, + subject?: string, + notificationCategoryName?: "Marketing", + }, + cooldown: { + days: number, + }, +}; + +export type SupportedAutomationRule = PaymentsItemQuotaAutomationRule; + +export type AutomationRulesConfig = { + automations?: { + rules?: Record, + }, + emails?: { + selectedThemeId?: string | null, + templates?: Record, + }, + payments?: { + items?: Record, + }, +}; + +export type AutomationRuleTenancy = { + id: string, + project?: { + display_name?: string, + }, + config: AutomationRulesConfig, +}; + +export function listAutomationRules(tenancy: AutomationRuleTenancy) { + return Object.entries(tenancy.config.automations?.rules ?? {}) + .filter((entry): entry is [string, AutomationRuleConfig] => entry[1] !== undefined) + .map(([ruleId, rule]) => ({ ruleId, rule })); +} + +export function getAutomationRule(tenancy: AutomationRuleTenancy, ruleId: string) { + return tenancy.config.automations?.rules?.[ruleId]; +} + +export function getSupportedAutomationRule(tenancy: AutomationRuleTenancy, ruleId: string) { + const rule = getAutomationRule(tenancy, ruleId); + if (rule === undefined) { + throw new Error(`Automation rule "${ruleId}" was not found for tenancy "${tenancy.id}".`); + } + assertSupportedAutomationRule(ruleId, rule); + return rule; +} + +export function assertSupportedAutomationRule(ruleId: string, rule: AutomationRuleConfig): asserts rule is SupportedAutomationRule { + if (rule.source.type !== paymentsItemQuotaSourceType) { + throw new Error(`Automation rule "${ruleId}" has unsupported source.type "${rule.source.type}". V1 supports only "${paymentsItemQuotaSourceType}".`); + } + if (rule.source.customerType !== userCustomerType) { + throw new Error(`Automation rule "${ruleId}" has unsupported source.customerType "${rule.source.customerType ?? ""}". V1 supports only "${userCustomerType}".`); + } + if (rule.action.type !== sendEmailActionType) { + throw new Error(`Automation rule "${ruleId}" has unsupported action.type "${rule.action.type}". V1 supports only "${sendEmailActionType}".`); + } + if (rule.source.itemId === undefined) { + throw new Error(`Automation rule "${ruleId}" is missing source.itemId.`); + } + if (rule.source.thresholds === undefined) { + throw new Error(`Automation rule "${ruleId}" is missing source.thresholds.`); + } + if (rule.action.templateId === undefined) { + throw new Error(`Automation rule "${ruleId}" is missing action.templateId.`); + } + if (rule.cooldown.days === undefined) { + throw new Error(`Automation rule "${ruleId}" is missing cooldown.days.`); + } +} diff --git a/apps/backend/src/lib/automations/run-route.ts b/apps/backend/src/lib/automations/run-route.ts new file mode 100644 index 000000000..b3aabc169 --- /dev/null +++ b/apps/backend/src/lib/automations/run-route.ts @@ -0,0 +1,243 @@ +import { AutomationJson, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; +import { AutomationActionPlan, AutomationEvaluationResult, AutomationSourceAdapter, AutomationActionAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; + +export type AutomationRuleExecutionClaimResult = + | { + claimed: true, + lastActionAt: Date | null, + } + | { + claimed: false, + lastActionAt: Date | null, + }; + +export type AutomationRuleExecutionStateStore = { + claimExecution: (options: { + tenancyId: string, + ruleId: string, + sourceType: typeof paymentsItemQuotaSourceType, + actionType: typeof sendEmailActionType, + subjectType: "user", + subjectId: string, + signalKey: string, + lastTriggeredAt: Date, + cooldownDays: number, + sourceSnapshot: Record, + }) => Promise, + markActionCompleted: (options: { + tenancyId: string, + ruleId: string, + subjectType: "user", + subjectId: string, + signalKey: string, + lastActionAt: Date, + lastEmailOutboxId: string | null, + }) => Promise, +}; + +export type AutomationEmailSender = (options: { + action: AutomationActionPlan, + scheduledAt: Date, +}) => Promise; + +export type AutomationRunDecisionResult = { + decision: EvaluatedAutomationDecision, + sent: boolean, + cooldown: { + blocked: boolean, + lastActionAtMillis?: number, + nextEligibleAtMillis?: number, + }, + skipReason?: "cooldown", +}; + +export type AutomationRunResult = { + ruleId: string, + mode: "run", + evaluatedCount: number, + eligibleCount: number, + suppressedCount: number, + sentCount: number, + nextCursor: string | null, + decisions: AutomationRunDecisionResult[], +}; + +export async function runAutomationRuleForRoute(options: { + tenancy: Parameters[0]["tenancy"], + ruleId: string, + limit?: number, + cursor?: string | null, + scheduledAt: Date, + now: Date, + sourceAdapter: AutomationSourceAdapter, + actionAdapter: AutomationActionAdapter, + stateStore: AutomationRuleExecutionStateStore, + emailSender: AutomationEmailSender, +}): Promise { + const rule = getSupportedAutomationRule(options.tenancy, options.ruleId); + const evaluation = await evaluateAutomationRule({ + tenancy: options.tenancy, + ruleId: options.ruleId, + mode: "run", + limit: options.limit, + cursor: options.cursor, + adapters: { + sourceAdapters: { + [paymentsItemQuotaSourceType]: options.sourceAdapter, + }, + actionAdapters: { + [sendEmailActionType]: options.actionAdapter, + }, + }, + }); + + const decisions: AutomationRunDecisionResult[] = []; + for (const decision of evaluation.decisions) { + const claim = await options.stateStore.claimExecution({ + tenancyId: options.tenancy.id, + ruleId: options.ruleId, + sourceType: rule.source.type, + actionType: rule.action.type, + subjectType: decision.subject.type, + subjectId: decision.subject.id, + signalKey: decision.signal.key, + lastTriggeredAt: options.now, + cooldownDays: rule.cooldown.days, + sourceSnapshot: decision.sourceSnapshot, + }); + + if (!claim.claimed) { + decisions.push({ + decision, + sent: false, + cooldown: getBlockedCooldownDetails(claim.lastActionAt, rule.cooldown.days), + skipReason: "cooldown", + }); + continue; + } + + await options.emailSender({ + action: decision.action, + scheduledAt: options.scheduledAt, + }); + await options.stateStore.markActionCompleted({ + tenancyId: options.tenancy.id, + ruleId: options.ruleId, + subjectType: decision.subject.type, + subjectId: decision.subject.id, + signalKey: decision.signal.key, + lastActionAt: options.now, + lastEmailOutboxId: null, + }); + + decisions.push({ + decision, + sent: true, + cooldown: { + blocked: false, + }, + }); + } + + const sentCount = decisions.filter((decision) => decision.sent).length; + const suppressedCount = decisions.length - sentCount; + + return { + ruleId: options.ruleId, + mode: "run", + evaluatedCount: evaluation.evaluatedCount, + eligibleCount: sentCount, + suppressedCount, + sentCount, + nextCursor: evaluation.nextCursor, + decisions, + }; +} + +export function automationRunResultToApiBody(result: AutomationRunResult) { + return { + rule_id: result.ruleId, + mode: result.mode, + evaluated_count: result.evaluatedCount, + eligible_count: result.eligibleCount, + suppressed_count: result.suppressedCount, + sent_count: result.sentCount, + next_cursor: result.nextCursor, + decisions: result.decisions.map((resultDecision) => ({ + subject_type: resultDecision.decision.subject.type, + subject_id: resultDecision.decision.subject.id, + signal_key: resultDecision.decision.signal.key, + sent: resultDecision.sent, + source: { + type: "payments-item-quota", + item_id: getStringSourceSnapshotValue(resultDecision.decision, "itemId"), + current_quantity: getNumberSourceSnapshotValue(resultDecision.decision, "currentQuantity"), + entitlement_quantity: getNullableNumberSourceSnapshotValue(resultDecision.decision, "entitlementQuantity"), + threshold_kind: resultDecision.decision.signal.kind, + owned_product_ids: getStringArraySourceSnapshotValue(resultDecision.decision, "ownedProductIds"), + active_subscription_ids: getStringArraySourceSnapshotValue(resultDecision.decision, "activeSubscriptionIds"), + }, + action: { + type: resultDecision.decision.action.type, + template_id: resultDecision.decision.action.templateId, + notification_category_name: resultDecision.decision.action.notificationCategoryName, + }, + cooldown: { + blocked: resultDecision.cooldown.blocked, + ...(resultDecision.cooldown.lastActionAtMillis === undefined ? {} : { + last_action_at_millis: resultDecision.cooldown.lastActionAtMillis, + }), + ...(resultDecision.cooldown.nextEligibleAtMillis === undefined ? {} : { + next_eligible_at_millis: resultDecision.cooldown.nextEligibleAtMillis, + }), + }, + ...(resultDecision.skipReason === undefined ? {} : { skip_reason: resultDecision.skipReason }), + })), + }; +} + +function getBlockedCooldownDetails(lastActionAt: Date | null, cooldownDays: number) { + if (lastActionAt === null) { + return { + blocked: true, + }; + } + const nextEligibleAtMillis = lastActionAt.getTime() + cooldownDays * 24 * 60 * 60 * 1000; + return { + blocked: true, + lastActionAtMillis: lastActionAt.getTime(), + nextEligibleAtMillis, + }; +} + +function getStringSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { + const value = decision.sourceSnapshot[key]; + if (typeof value !== "string") { + throw new Error(`Automation run decision sourceSnapshot.${key} must be a string.`); + } + return value; +} + +function getNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { + const value = decision.sourceSnapshot[key]; + if (typeof value !== "number") { + throw new Error(`Automation run decision sourceSnapshot.${key} must be a number.`); + } + return value; +} + +function getNullableNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { + const value = decision.sourceSnapshot[key]; + if (value !== null && typeof value !== "number") { + throw new Error(`Automation run decision sourceSnapshot.${key} must be a number or null.`); + } + return value; +} + +function getStringArraySourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { + const value = decision.sourceSnapshot[key]; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { + throw new Error(`Automation run decision sourceSnapshot.${key} must be an array of strings.`); + } + return value; +} diff --git a/apps/backend/src/lib/automations/scheduler.test.ts b/apps/backend/src/lib/automations/scheduler.test.ts new file mode 100644 index 000000000..d54309f93 --- /dev/null +++ b/apps/backend/src/lib/automations/scheduler.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/automations/actions/send-email", () => ({ + createSendEmailActionAdapter: () => ({}), +})); + +vi.mock("@/lib/automations/execution-state-store", () => ({ + createPrismaAutomationRuleExecutionStateStore: () => ({}), +})); + +vi.mock("@/lib/automations/sources/payments-item-quota", () => ({ + createPaymentsItemQuotaSourceAdapter: () => ({}), + paymentsItemQuotaCustomerDataReaders: {}, + prismaPaymentsItemQuotaProjectUserReader: {}, +})); + +vi.mock("@/lib/emails", () => ({ + sendEmailToMany: async () => {}, +})); + +vi.mock("@/lib/tenancies", () => ({ + getTenancy: async () => null, +})); + +vi.mock("@/prisma-client", () => ({ + getPrismaClientForTenancy: async () => ({}), + globalPrismaClient: { + tenancy: { + findMany: async () => [], + }, + outgoingRequest: { + createMany: async () => ({ count: 0 }), + }, + }, +})); + +import { + discoverEnabledScheduledAutomationRules, + enqueueScheduledAutomationRuns, + getScheduledAutomationDeduplicationKey, + runScheduledAutomationRulePage, + scheduledAutomationRunRoutePath, +} from "./scheduler"; +import { AutomationRunResult } from "./run-route"; + +const enabledRuleId = "low-api-credits"; + +function createTenancy(options: { + enabled?: boolean, + sourceType?: string, +} = {}) { + return { + id: "tenancy-1", + branchId: "main", + organization: null, + project: { + id: "project-1", + display_name: "Acme App", + description: "", + logo_url: null, + logo_full_url: null, + logo_dark_mode_url: null, + logo_full_dark_mode_url: null, + created_at_millis: 0, + is_production_mode: false, + is_development_environment: false, + owner_team_id: null, + onboarding_status: "completed", + onboarding_state: undefined, + pushed_config_error: null, + config_warnings: [], + }, + config: { + automations: { + rules: { + [enabledRuleId]: { + enabled: options.enabled ?? true, + source: { + type: options.sourceType ?? "payments-item-quota", + itemId: "api_credits", + customerType: "user", + thresholds: { + nearRemainingQuantity: 10, + }, + }, + action: { + type: "send-email", + templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + notificationCategoryName: "Marketing", + }, + cooldown: { + days: 7, + }, + }, + }, + }, + }, + }; +} + +function createRunResult(nextCursor: string | null): AutomationRunResult { + return { + ruleId: enabledRuleId, + mode: "run", + evaluatedCount: 100, + eligibleCount: 1, + suppressedCount: 0, + sentCount: 1, + nextCursor, + decisions: [], + }; +} + +describe("scheduled automation discovery", () => { + it("finds enabled V1 rules from bounded tenancy pages", async () => { + const prisma = { + tenancy: { + findMany: vi.fn(async () => [{ id: "tenancy-1" }, { id: "tenancy-2" }]), + }, + }; + const getTenancyById = vi.fn(async (tenancyId: string) => tenancyId === "tenancy-1" + ? createTenancy() + : createTenancy({ enabled: false })); + + await expect(discoverEnabledScheduledAutomationRules({ + prisma, + getTenancyById, + limit: 2, + cursor: "previous-tenancy", + })).resolves.toMatchInlineSnapshot(` + { + "nextCursor": "tenancy-2", + "scannedTenancyCount": 2, + "targets": [ + { + "ruleId": "low-api-credits", + "tenancyId": "tenancy-1", + }, + ], + } + `); + expect(prisma.tenancy.findMany).toHaveBeenCalledWith({ + where: { + id: { + gt: "previous-tenancy", + }, + }, + orderBy: { + id: "asc", + }, + take: 2, + select: { + id: true, + }, + }); + }); + + it("fails loudly for enabled non-V1 rules", async () => { + const prisma = { + tenancy: { + findMany: vi.fn(async () => [{ id: "tenancy-1" }]), + }, + }; + + await expect(discoverEnabledScheduledAutomationRules({ + prisma, + getTenancyById: async () => createTenancy({ sourceType: "client-push-quota" }), + })).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Automation rule "low-api-credits" has unsupported source.type "client-push-quota". V1 supports only "payments-item-quota".]`, + ); + }); +}); + +describe("scheduled automation queueing", () => { + it("enqueues deduped QStash work through OutgoingRequest", async () => { + const prisma = { + outgoingRequest: { + createMany: vi.fn(async () => ({ count: 1 })), + }, + }; + + await expect(enqueueScheduledAutomationRuns({ + prisma, + targets: [{ + tenancyId: "tenancy-1", + ruleId: enabledRuleId, + }], + scheduledAt: new Date("2026-07-01T12:00:00.000Z"), + limit: 75, + })).resolves.toEqual({ enqueuedCount: 1 }); + + expect(prisma.outgoingRequest.createMany).toHaveBeenCalledWith({ + data: [{ + deduplicationKey: "automation-rule-run:tenancy-1:low-api-credits:start", + qstashOptions: { + body: { + cursor: null, + limit: 75, + ruleId: "low-api-credits", + scheduledAtMillis: 1782907200000, + tenancyId: "tenancy-1", + }, + flowControl: { + key: "automation-rule-run:tenancy-1", + parallelism: 1, + }, + url: scheduledAutomationRunRoutePath, + }, + }], + skipDuplicates: true, + }); + }); + + it("uses cursor-specific dedupe keys for continuation pages", () => { + expect(getScheduledAutomationDeduplicationKey({ + tenancyId: "tenancy-1", + ruleId: enabledRuleId, + cursor: "user-100", + })).toBe("automation-rule-run:tenancy-1:low-api-credits:user-100"); + }); +}); + +describe("scheduled automation worker orchestration", () => { + it("runs a page and enqueues continuation when the evaluator returns a next cursor", async () => { + const runRule = vi.fn(async () => createRunResult("user-100")); + const enqueueContinuation = vi.fn(async () => ({ enqueuedCount: 1 })); + + await expect(runScheduledAutomationRulePage({ + tenancyId: "tenancy-1", + ruleId: enabledRuleId, + limit: 100, + scheduledAt: new Date("2026-07-01T12:00:00.000Z"), + now: new Date("2026-07-01T12:01:00.000Z"), + getTenancyById: async () => createTenancy(), + runRule, + enqueueContinuation, + })).resolves.toMatchInlineSnapshot(` + { + "enqueuedContinuation": true, + "result": { + "decisions": [], + "eligibleCount": 1, + "evaluatedCount": 100, + "mode": "run", + "nextCursor": "user-100", + "ruleId": "low-api-credits", + "sentCount": 1, + "suppressedCount": 0, + }, + "status": "ran", + } + `); + + expect(runRule).toHaveBeenCalledWith(expect.objectContaining({ + cursor: null, + limit: 100, + ruleId: enabledRuleId, + })); + expect(enqueueContinuation).toHaveBeenCalledWith({ + tenancyId: "tenancy-1", + ruleId: enabledRuleId, + cursor: "user-100", + limit: 100, + scheduledAt: new Date("2026-07-01T12:00:00.000Z"), + }); + }); + + it("skips stale queued work when the rule was disabled after enqueue", async () => { + const runRule = vi.fn(async () => createRunResult(null)); + + await expect(runScheduledAutomationRulePage({ + tenancyId: "tenancy-1", + ruleId: enabledRuleId, + scheduledAt: new Date("2026-07-01T12:00:00.000Z"), + now: new Date("2026-07-01T12:01:00.000Z"), + getTenancyById: async () => createTenancy({ enabled: false }), + runRule, + })).resolves.toMatchInlineSnapshot(` + { + "reason": "rule-disabled", + "status": "skipped", + } + `); + + expect(runRule).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/lib/automations/scheduler.ts b/apps/backend/src/lib/automations/scheduler.ts new file mode 100644 index 000000000..66f444b7a --- /dev/null +++ b/apps/backend/src/lib/automations/scheduler.ts @@ -0,0 +1,372 @@ +import { createSendEmailActionAdapter } from "@/lib/automations/actions/send-email"; +import { createPrismaAutomationRuleExecutionStateStore } from "@/lib/automations/execution-state-store"; +import { + AutomationRunResult, + runAutomationRuleForRoute, +} from "@/lib/automations/run-route"; +import { + createPaymentsItemQuotaSourceAdapter, + paymentsItemQuotaCustomerDataReaders, + prismaPaymentsItemQuotaProjectUserReader, +} from "@/lib/automations/sources/payments-item-quota"; +import { sendEmailToMany } from "@/lib/emails"; +import { getTenancy, type Tenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import { assertSupportedAutomationRule, AutomationRuleTenancy, getAutomationRule, listAutomationRules } from "./rules"; + +export const scheduledAutomationDiscoveryLimit = 500; +export const scheduledAutomationRunPageLimit = 100; +export const scheduledAutomationRunRoutePath = "/api/latest/internal/automations/scheduled-run"; + +type AutomationScheduleTarget = { + tenancyId: string, + ruleId: string, +}; + +export type AutomationScheduleDiscoveryResult = { + scannedTenancyCount: number, + targets: AutomationScheduleTarget[], + nextCursor: string | null, +}; + +export type AutomationScheduledRunPayload = { + tenancyId: string, + ruleId: string, + cursor: string | null, + limit: number, + scheduledAtMillis: number, +}; + +export type AutomationScheduledRunResult = + | { + status: "skipped", + reason: "tenancy-not-found" | "rule-not-found" | "rule-disabled", + } + | { + status: "ran", + result: AutomationRunResult, + enqueuedContinuation: boolean, + }; + +type AutomationDiscoveryPrisma = { + tenancy: { + findMany: (options: { + where: { + id?: { + gt: string, + }, + }, + orderBy: { + id: "asc", + }, + take: number, + select: { + id: true, + }, + }) => Promise>, + }, +}; + +type AutomationQueuePrisma = { + outgoingRequest: { + createMany: (options: { + data: Array<{ + qstashOptions: { + url: string, + body: AutomationScheduledRunPayload, + flowControl: { + key: string, + parallelism: number, + }, + }, + deduplicationKey: string, + }>, + skipDuplicates: true, + }) => Promise<{ count: number }>, + }, +}; + +type ScheduledAutomationRunner = (options: { + tenancy: AutomationRuleTenancy, + ruleId: string, + cursor: string | null, + limit: number, + scheduledAt: Date, + now: Date, +}) => Promise; + +export function normalizeScheduledAutomationDiscoveryLimit(limit: number | undefined) { + if (limit === undefined) return scheduledAutomationDiscoveryLimit; + return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationDiscoveryLimit)); +} + +export function normalizeScheduledAutomationRunPageLimit(limit: number | undefined) { + if (limit === undefined) return scheduledAutomationRunPageLimit; + return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationRunPageLimit)); +} + +export async function discoverEnabledScheduledAutomationRules(options: { + prisma?: AutomationDiscoveryPrisma, + getTenancyById?: (tenancyId: string) => Promise, + limit?: number, + cursor?: string | null, +} = {}): Promise { + const prisma = options.prisma ?? globalPrismaClient; + const limit = normalizeScheduledAutomationDiscoveryLimit(options.limit); + const tenancyRows = await prisma.tenancy.findMany({ + where: { + ...(options.cursor == null ? {} : { + id: { + gt: options.cursor, + }, + }), + }, + orderBy: { + id: "asc", + }, + take: limit, + select: { + id: true, + }, + }); + const getTenancyById = options.getTenancyById ?? getTenancy; + const targets: AutomationScheduleTarget[] = []; + + for (const row of tenancyRows) { + const tenancy = await getTenancyById(row.id); + if (tenancy === null) { + continue; + } + + for (const { ruleId, rule } of listAutomationRules(tenancy)) { + if (!rule.enabled) { + continue; + } + assertSupportedAutomationRule(ruleId, rule); + targets.push({ + tenancyId: tenancy.id, + ruleId, + }); + } + } + + return { + scannedTenancyCount: tenancyRows.length, + targets, + nextCursor: tenancyRows.length === limit ? tenancyRows[tenancyRows.length - 1]?.id ?? null : null, + }; +} + +export async function enqueueScheduledAutomationRuns(options: { + prisma?: AutomationQueuePrisma, + targets: AutomationScheduleTarget[], + limit?: number, + cursor?: string | null, + scheduledAt: Date, +}): Promise<{ enqueuedCount: number }> { + if (options.targets.length === 0) { + return { enqueuedCount: 0 }; + } + + const prisma = options.prisma ?? globalPrismaClient; + const limit = normalizeScheduledAutomationRunPageLimit(options.limit); + const cursor = options.cursor ?? null; + const createResult = await prisma.outgoingRequest.createMany({ + data: options.targets.map((target) => ({ + qstashOptions: { + url: scheduledAutomationRunRoutePath, + body: { + tenancyId: target.tenancyId, + ruleId: target.ruleId, + cursor, + limit, + scheduledAtMillis: options.scheduledAt.getTime(), + }, + flowControl: { + key: getScheduledAutomationFlowControlKey(target.tenancyId), + parallelism: 1, + }, + }, + deduplicationKey: getScheduledAutomationDeduplicationKey({ + ...target, + cursor, + }), + })), + skipDuplicates: true, + }); + + return { + enqueuedCount: createResult.count, + }; +} + +export async function enqueueScheduledAutomationContinuation(options: { + tenancyId: string, + ruleId: string, + cursor: string, + limit: number, + scheduledAt: Date, + prisma?: AutomationQueuePrisma, +}): Promise<{ enqueuedCount: number }> { + return await enqueueScheduledAutomationRuns({ + prisma: options.prisma, + targets: [{ + tenancyId: options.tenancyId, + ruleId: options.ruleId, + }], + cursor: options.cursor, + limit: options.limit, + scheduledAt: options.scheduledAt, + }); +} + +export async function runScheduledAutomationRulePage(options: { + tenancyId: string, + ruleId: string, + cursor?: string | null, + limit?: number, + scheduledAt: Date, + now: Date, + getTenancyById?: (tenancyId: string) => Promise, + runRule?: ScheduledAutomationRunner, + enqueueContinuation?: typeof enqueueScheduledAutomationContinuation, +}): Promise { + const getTenancyById = options.getTenancyById ?? getTenancy; + const tenancy = await getTenancyById(options.tenancyId); + if (tenancy === null) { + return { + status: "skipped", + reason: "tenancy-not-found", + }; + } + + const rule = getAutomationRule(tenancy, options.ruleId); + if (rule === undefined) { + return { + status: "skipped", + reason: "rule-not-found", + }; + } + if (!rule.enabled) { + return { + status: "skipped", + reason: "rule-disabled", + }; + } + assertSupportedAutomationRule(options.ruleId, rule); + + const limit = normalizeScheduledAutomationRunPageLimit(options.limit); + const result = options.runRule === undefined + ? await runProductionScheduledAutomationRulePage({ + tenancyId: options.tenancyId, + ruleId: options.ruleId, + cursor: options.cursor ?? null, + limit, + scheduledAt: options.scheduledAt, + now: options.now, + }) + : await options.runRule({ + tenancy, + ruleId: options.ruleId, + cursor: options.cursor ?? null, + limit, + scheduledAt: options.scheduledAt, + now: options.now, + }); + + let enqueuedContinuation = false; + if (result.nextCursor !== null) { + const enqueueContinuation = options.enqueueContinuation ?? enqueueScheduledAutomationContinuation; + const enqueueResult = await enqueueContinuation({ + tenancyId: options.tenancyId, + ruleId: options.ruleId, + cursor: result.nextCursor, + limit, + scheduledAt: options.scheduledAt, + }); + enqueuedContinuation = enqueueResult.enqueuedCount > 0; + } + + return { + status: "ran", + result, + enqueuedContinuation, + }; +} + +async function runProductionScheduledAutomationRulePage(options: { + tenancyId: string, + ruleId: string, + cursor: string | null, + limit: number, + scheduledAt: Date, + now: Date, +}) { + const tenancy = await getTenancy(options.tenancyId); + if (tenancy === null) { + throw new Error(`Tenancy "${options.tenancyId}" disappeared between scheduled automation validation and execution.`); + } + return await runAutomationRuleForScheduledWorker({ + tenancy, + ruleId: options.ruleId, + cursor: options.cursor, + limit: options.limit, + scheduledAt: options.scheduledAt, + now: options.now, + }); +} + +async function runAutomationRuleForScheduledWorker(options: { + tenancy: Tenancy, + ruleId: string, + cursor: string | null, + limit: number, + scheduledAt: Date, + now: Date, +}): Promise { + const prisma = await getPrismaClientForTenancy(options.tenancy); + const sourceAdapter = createPaymentsItemQuotaSourceAdapter({ + prisma, + projectUserReader: prismaPaymentsItemQuotaProjectUserReader, + customerDataReaders: paymentsItemQuotaCustomerDataReaders, + }); + + return await runAutomationRuleForRoute({ + tenancy: options.tenancy, + ruleId: options.ruleId, + limit: options.limit, + cursor: options.cursor, + scheduledAt: options.scheduledAt, + now: options.now, + sourceAdapter, + actionAdapter: createSendEmailActionAdapter(), + stateStore: createPrismaAutomationRuleExecutionStateStore(prisma), + emailSender: async ({ action, scheduledAt }) => { + await sendEmailToMany({ + tenancy: options.tenancy, + recipients: [action.recipient], + tsxSource: action.tsxSource, + extraVariables: action.variables, + themeId: action.themeId ?? null, + isHighPriority: action.isHighPriority, + shouldSkipDeliverabilityCheck: action.shouldSkipDeliverabilityCheck, + scheduledAt, + createdWith: action.createdWith, + overrideSubject: action.subject, + overrideNotificationCategoryId: action.notificationCategoryId, + }); + }, + }); +} + +function getScheduledAutomationFlowControlKey(tenancyId: string) { + return `automation-rule-run:${tenancyId}`; +} + +export function getScheduledAutomationDeduplicationKey(options: { + tenancyId: string, + ruleId: string, + cursor: string | null, +}) { + return `automation-rule-run:${options.tenancyId}:${options.ruleId}:${options.cursor ?? "start"}`; +} diff --git a/apps/backend/src/lib/automations/sources/payments-item-quota.ts b/apps/backend/src/lib/automations/sources/payments-item-quota.ts new file mode 100644 index 000000000..906eba652 --- /dev/null +++ b/apps/backend/src/lib/automations/sources/payments-item-quota.ts @@ -0,0 +1,263 @@ +import { PrismaClientTransaction } from "@/prisma-client"; +import { + getItemQuantityForCustomer, + getOwnedProductsForCustomer, + getSubscriptionMapForCustomer, +} from "@/lib/payments/customer-data"; +import { AutomationSourceAdapter, AutomationSourceDecision, AutomationSourceEvaluationResult } from "../rule-evaluator"; +import { AutomationRuleTenancy, PaymentsItemQuotaAutomationRule, paymentsItemQuotaSourceType } from "../rules"; + +export type PaymentsItemQuotaProjectUserPage = { + projectUserIds: string[], + nextCursor: string | null, +}; + +export type PaymentsItemQuotaProjectUserReader = { + listCandidateUserIds: (options: { + prisma: TPrisma, + tenancyId: string, + limit: number, + cursor?: string | null, + }) => Promise, +}; + +export type PaymentsItemQuotaProjectUserPrisma = { + projectUser: { + findMany: (options: { + where: { + tenancyId: string, + projectUserId?: { + gt: string, + }, + }, + orderBy: { + projectUserId: "asc", + }, + take: number, + select: { + projectUserId: true, + }, + }) => Promise>, + }, +}; + +export const prismaPaymentsItemQuotaProjectUserReader: PaymentsItemQuotaProjectUserReader = { + async listCandidateUserIds(options) { + const rows = await options.prisma.projectUser.findMany({ + where: { + tenancyId: options.tenancyId, + ...(options.cursor == null ? {} : { + projectUserId: { + gt: options.cursor, + }, + }), + }, + orderBy: { + projectUserId: "asc", + }, + take: options.limit, + select: { + projectUserId: true, + }, + }); + + return { + projectUserIds: rows.map((row) => row.projectUserId), + nextCursor: rows.length === options.limit ? rows[rows.length - 1]?.projectUserId ?? null : null, + }; + }, +}; + +export type PaymentsItemQuotaOwnedProduct = { + quantity: number, + product: { + includedItems?: Record, + }, + productLineId?: string | null, +}; + +export type PaymentsItemQuotaSubscription = { + status: string, +}; + +export type PaymentsItemQuotaCustomerDataReaders = { + getItemQuantityForCustomer: (options: { + prisma: TPrisma, + tenancyId: string, + itemId: string, + customerId: string, + customerType: "user", + }) => Promise, + getOwnedProductsForCustomer: (options: { + prisma: TPrisma, + tenancyId: string, + customerType: "user", + customerId: string, + }) => Promise>, + getSubscriptionMapForCustomer: (options: { + prisma: TPrisma, + tenancyId: string, + customerType: "user", + customerId: string, + }) => Promise>, +}; + +export const paymentsItemQuotaCustomerDataReaders: PaymentsItemQuotaCustomerDataReaders = { + getItemQuantityForCustomer, + getOwnedProductsForCustomer, + getSubscriptionMapForCustomer, +}; + +export function createPaymentsItemQuotaSourceAdapter(options: { + prisma: TPrisma, + projectUserReader: PaymentsItemQuotaProjectUserReader, + customerDataReaders: PaymentsItemQuotaCustomerDataReaders, +}): AutomationSourceAdapter { + return { + evaluate: async (evaluateOptions) => await evaluatePaymentsItemQuotaSource({ + ...evaluateOptions, + prisma: options.prisma, + projectUserReader: options.projectUserReader, + customerDataReaders: options.customerDataReaders, + }), + }; +} + +async function evaluatePaymentsItemQuotaSource(options: { + prisma: TPrisma, + projectUserReader: PaymentsItemQuotaProjectUserReader, + customerDataReaders: PaymentsItemQuotaCustomerDataReaders, + tenancy: AutomationRuleTenancy, + ruleId: string, + rule: PaymentsItemQuotaAutomationRule, + limit?: number, + cursor?: string | null, +}): Promise { + const itemId = options.rule.source.itemId; + const item = options.tenancy.config.payments?.items?.[itemId]; + if (item === undefined) { + throw new Error(`Automation rule "${options.ruleId}" references payments item "${itemId}", but that item does not exist.`); + } + if (item.customerType !== "user") { + throw new Error(`Automation rule "${options.ruleId}" references payments item "${itemId}" with customerType "${item.customerType ?? ""}"; V1 supports only user items.`); + } + + const limit = normalizeLimit(options.limit); + const page = await options.projectUserReader.listCandidateUserIds({ + prisma: options.prisma, + tenancyId: options.tenancy.id, + limit, + cursor: options.cursor, + }); + + const decisions: AutomationSourceDecision[] = []; + for (const projectUserId of page.projectUserIds) { + const [currentQuantity, ownedProducts, subscriptionMap] = await Promise.all([ + options.customerDataReaders.getItemQuantityForCustomer({ + prisma: options.prisma, + tenancyId: options.tenancy.id, + itemId, + customerId: projectUserId, + customerType: "user", + }), + options.customerDataReaders.getOwnedProductsForCustomer({ + prisma: options.prisma, + tenancyId: options.tenancy.id, + customerType: "user", + customerId: projectUserId, + }), + options.customerDataReaders.getSubscriptionMapForCustomer({ + prisma: options.prisma, + tenancyId: options.tenancy.id, + customerType: "user", + customerId: projectUserId, + }), + ]); + + const entitlementQuantity = getEntitlementQuantity(ownedProducts, itemId); + const thresholdKind = getThresholdKind({ + currentQuantity, + entitlementQuantity, + rule: options.rule, + }); + if (thresholdKind === null) { + continue; + } + + const ownedProductIds = getOwnedProductIds(ownedProducts); + const activeSubscriptionIds = getActiveSubscriptionIds(subscriptionMap); + decisions.push({ + subject: { + type: "user", + id: projectUserId, + }, + signal: { + key: `${itemId}:${thresholdKind}`, + kind: thresholdKind, + }, + sourceSnapshot: { + sourceType: paymentsItemQuotaSourceType, + itemId, + itemDisplayName: item.displayName ?? itemId, + currentQuantity, + entitlementQuantity: entitlementQuantity > 0 ? entitlementQuantity : null, + thresholdKind, + ownedProductIds, + activeSubscriptionIds, + }, + }); + } + + return { + evaluatedCount: page.projectUserIds.length, + nextCursor: page.nextCursor, + decisions, + }; +} + +function normalizeLimit(limit: number | undefined) { + if (limit === undefined) return 100; + return Math.max(1, Math.min(Math.floor(limit), 1000)); +} + +function getEntitlementQuantity(ownedProducts: Record, itemId: string) { + return Object.values(ownedProducts).reduce((sum, ownedProduct) => { + const includedQuantity = ownedProduct.product.includedItems?.[itemId]?.quantity ?? 0; + return sum + includedQuantity * ownedProduct.quantity; + }, 0); +} + +function getThresholdKind(options: { + currentQuantity: number, + entitlementQuantity: number, + rule: PaymentsItemQuotaAutomationRule, +}): "near" | "over" | null { + const thresholds = options.rule.source.thresholds; + const overLimitQuantity = thresholds.overLimitQuantity ?? 0; + if (options.currentQuantity <= overLimitQuantity) { + return "over"; + } + if (thresholds.nearRemainingQuantity !== undefined && options.currentQuantity <= thresholds.nearRemainingQuantity) { + return "near"; + } + if ( + thresholds.nearRemainingRatio !== undefined && + options.entitlementQuantity > 0 && + options.currentQuantity / options.entitlementQuantity <= thresholds.nearRemainingRatio + ) { + return "near"; + } + return null; +} + +function getOwnedProductIds(ownedProducts: Record) { + return Object.keys(ownedProducts).filter((productId) => productId !== "__null__"); +} + +function getActiveSubscriptionIds(subscriptionMap: Record) { + return Object.entries(subscriptionMap) + .filter(([, subscription]) => subscription.status === "active" || subscription.status === "trialing") + .map(([subscriptionId]) => subscriptionId); +} diff --git a/apps/backend/vercel.json b/apps/backend/vercel.json index f40117b3e..7ec889706 100644 --- a/apps/backend/vercel.json +++ b/apps/backend/vercel.json @@ -12,6 +12,10 @@ { "path": "/api/latest/internal/external-db-sync/sequencer", "schedule": "* * * * *" + }, + { + "path": "/api/latest/internal/automations/schedule", + "schedule": "* * * * *" } ], "github": { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts new file mode 100644 index 000000000..0ee002952 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildRuleFromDraft, + getMissingPrerequisites, + parseAutomationRouteResult, + readRules, + readUserItemOptions, +} from "./page-client"; + +vi.mock("@/components/design-components", () => ({ + DesignAlert: () => null, + DesignBadge: () => null, + DesignButton: () => null, + DesignCard: () => null, + DesignDialog: () => null, + DesignDialogClose: () => null, + DesignEmptyState: () => null, + DesignInput: () => null, + DesignListItemRow: () => null, + DesignPillToggle: () => null, + DesignSelectorDropdown: () => null, +})); + +vi.mock("@/components/ui", () => ({ + Label: () => null, + Typography: () => null, +})); + +vi.mock("@/lib/config-update", () => ({ + useUpdateConfig: () => async () => {}, +})); + +vi.mock("@/lib/hexclave-app-internals", () => ({ + sendAdminInternalRequestOrThrow: async () => new Response("{}"), +})); + +vi.mock("@/lib/utils", () => ({ + cn: (...classes: (string | undefined | false)[]) => classes.filter(Boolean).join(" "), +})); + +vi.mock("@hexclave/dashboard-ui-components", () => ({ + createDefaultDataGridState: () => ({ + sorting: [], + quickSearch: "", + pagination: { + pageIndex: 0, + pageSize: 25, + }, + }), + DataGrid: () => null, + useDataSource: (options: { data?: unknown[] }) => ({ + rows: options.data ?? [], + totalRowCount: options.data?.length ?? 0, + isLoading: false, + }), +})); + +vi.mock("../app-enabled-guard", () => ({ + AppEnabledGuard: (props: { children: unknown }) => props.children, +})); + +vi.mock("../page-layout", () => ({ + PageLayout: (props: { children: unknown }) => props.children, +})); + +vi.mock("../use-admin-app", () => ({ + useAdminApp: () => { + throw new Error("useAdminApp should not be called by usage email helper tests"); + }, +})); + +describe("usage email automation dashboard helpers", () => { + it("reads V1 rules from automations.rules only", () => { + const rules = readRules({ + automations: { + rules: { + usageUpgrade: { + displayName: "Usage upgrade", + enabled: true, + source: { + type: "payments-item-quota", + itemId: "credits", + customerType: "user", + thresholds: { + nearRemainingRatio: 0.2, + }, + }, + action: { + type: "send-email", + templateId: "template-id", + notificationCategoryName: "Marketing", + }, + cooldown: { + days: 7, + }, + }, + unsupportedFutureRule: { + enabled: true, + source: { + type: "client-push-quota", + customerType: "user", + thresholds: { + nearRemainingRatio: 0.2, + }, + }, + action: { + type: "send-email", + templateId: "template-id", + }, + cooldown: { + days: 7, + }, + }, + }, + }, + payments: { + quotaEmailAutomations: { + wrongPlace: {}, + }, + }, + emails: { + quotaEmailAutomations: { + wrongPlace: {}, + }, + }, + }); + + expect(rules).toMatchInlineSnapshot(` + [ + { + "rule": { + "action": { + "notificationCategoryName": "Marketing", + "templateId": "template-id", + "type": "send-email", + }, + "cooldown": { + "days": 7, + }, + "displayName": "Usage upgrade", + "enabled": true, + "source": { + "customerType": "user", + "itemId": "credits", + "thresholds": { + "nearRemainingRatio": 0.2, + }, + "type": "payments-item-quota", + }, + }, + "ruleId": "usageUpgrade", + }, + ] + `); + }); + + it("lists only user-scoped Payments items", () => { + expect(readUserItemOptions({ + payments: { + items: { + credits: { + displayName: "Credits", + customerType: "user", + }, + seats: { + displayName: "Seats", + customerType: "team", + }, + }, + }, + })).toMatchInlineSnapshot(` + [ + { + "label": "Credits", + "value": "credits", + }, + ] + `); + }); + + it("detects missing usage email automation prerequisites", () => { + expect(getMissingPrerequisites([], [])).toMatchInlineSnapshot(` + [ + "paymentsItem", + "emailTemplate", + ] + `); + expect(getMissingPrerequisites([{ value: "credits", label: "Credits" }], [])).toMatchInlineSnapshot(` + [ + "emailTemplate", + ] + `); + expect(getMissingPrerequisites([], [{ value: "template-id", label: "Upgrade email" }])).toMatchInlineSnapshot(` + [ + "paymentsItem", + ] + `); + expect(getMissingPrerequisites([{ value: "credits", label: "Credits" }], [{ value: "template-id", label: "Upgrade email" }])).toMatchInlineSnapshot(`[]`); + }); + + it("builds a valid V1 rule from the editor draft", () => { + expect(buildRuleFromDraft({ + ruleId: "usage-upgrade", + displayName: "Usage upgrade", + enabled: true, + itemId: "credits", + nearRemainingRatio: "0.25", + nearRemainingQuantity: "10", + overLimitQuantity: "0", + templateId: "00000000-0000-0000-0000-000000000001", + themeId: "__project_default__", + subject: "Upgrade your plan", + cooldownDays: "14", + })).toMatchInlineSnapshot(` + { + "action": { + "notificationCategoryName": "Marketing", + "subject": "Upgrade your plan", + "templateId": "00000000-0000-0000-0000-000000000001", + "type": "send-email", + }, + "cooldown": { + "days": 14, + }, + "displayName": "Usage upgrade", + "enabled": true, + "source": { + "customerType": "user", + "itemId": "credits", + "thresholds": { + "nearRemainingQuantity": 10, + "nearRemainingRatio": 0.25, + "overLimitQuantity": 0, + }, + "type": "payments-item-quota", + }, + } + `); + }); + + it("rejects drafts without thresholds", () => { + expect(() => buildRuleFromDraft({ + ruleId: "usage-upgrade", + displayName: "Usage upgrade", + enabled: true, + itemId: "credits", + nearRemainingRatio: "", + nearRemainingQuantity: "", + overLimitQuantity: "", + templateId: "00000000-0000-0000-0000-000000000001", + themeId: "__project_default__", + subject: "", + cooldownDays: "7", + })).toThrowErrorMatchingInlineSnapshot(`[Error: At least one threshold is required]`); + }); + + it("rejects drafts without a Payments item", () => { + expect(() => buildRuleFromDraft({ + ruleId: "usage-upgrade", + displayName: "Usage upgrade", + enabled: true, + itemId: "", + nearRemainingRatio: "0.25", + nearRemainingQuantity: "", + overLimitQuantity: "0", + templateId: "00000000-0000-0000-0000-000000000001", + themeId: "__project_default__", + subject: "", + cooldownDays: "7", + })).toThrowErrorMatchingInlineSnapshot(`[Error: A Payments item is required]`); + }); + + it("rejects drafts without an email template", () => { + expect(() => buildRuleFromDraft({ + ruleId: "usage-upgrade", + displayName: "Usage upgrade", + enabled: true, + itemId: "credits", + nearRemainingRatio: "0.25", + nearRemainingQuantity: "", + overLimitQuantity: "0", + templateId: "", + themeId: "__project_default__", + subject: "", + cooldownDays: "7", + })).toThrowErrorMatchingInlineSnapshot(`[Error: An email template is required]`); + }); + + it("parses dry-run and run route responses", () => { + expect(parseAutomationRouteResult({ + rule_id: "usage-upgrade", + mode: "run", + evaluated_count: 2, + eligible_count: 1, + suppressed_count: 1, + sent_count: 1, + next_cursor: null, + decisions: [ + { + subject_type: "user", + subject_id: "user-1", + signal_key: "credits:over", + sent: true, + source: { + type: "payments-item-quota", + item_id: "credits", + current_quantity: 12, + entitlement_quantity: 10, + threshold_kind: "over", + owned_product_ids: ["pro"], + active_subscription_ids: ["sub-1"], + }, + action: { + type: "send-email", + template_id: "template-id", + notification_category_name: "Marketing", + }, + cooldown: { + blocked: false, + }, + }, + ], + })).toMatchInlineSnapshot(` + { + "decisions": [ + { + "blocked": false, + "currentQuantity": 12, + "entitlementQuantity": 10, + "hasPrimaryEmail": undefined, + "sent": true, + "skipReason": undefined, + "subjectId": "user-1", + "subjectType": "user", + "thresholdKind": "over", + }, + ], + "eligibleCount": 1, + "evaluatedCount": 2, + "mode": "run", + "nextCursor": null, + "ruleId": "usage-upgrade", + "sentCount": 1, + "suppressedCount": 1, + } + `); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx new file mode 100644 index 000000000..583fd3c0f --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx @@ -0,0 +1,1139 @@ +"use client"; + +import { + DesignAlert, + DesignBadge, + DesignButton, + DesignCard, + DesignDialog, + DesignDialogClose, + DesignEmptyState, + DesignInput, + DesignListItemRow, + DesignPillToggle, + DesignSelectorDropdown, +} from "@/components/design-components"; +import { StyledLink } from "@/components/link"; +import { Label, Typography } from "@/components/ui"; +import { useUpdateConfig } from "@/lib/config-update"; +import { sendAdminInternalRequestOrThrow } from "@/lib/hexclave-app-internals"; +import { cn } from "@/lib/utils"; +import { + createDefaultDataGridState, + DataGrid, + useDataSource, + type DataGridColumnDef, + type DataGridState, +} from "@hexclave/dashboard-ui-components"; +import { + CheckCircleIcon, + EnvelopeSimpleIcon, + LightningIcon, + PaperPlaneTiltIcon, + PencilSimpleIcon, + PlayIcon, + PlusIcon, + TrashIcon, + WarningCircleIcon, + XCircleIcon, +} from "@phosphor-icons/react"; +import { getUserSpecifiedIdErrorMessage, isValidUserSpecifiedId, sanitizeUserSpecifiedId } from "@hexclave/shared/dist/schema-fields"; +import { urlString } from "@hexclave/shared/dist/utils/urls"; +import { useMemo, useState } from "react"; +import { AppEnabledGuard } from "../app-enabled-guard"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; + +const THEME_DEFAULT_VALUE = "__project_default__"; +const DEFAULT_LIMIT = 100; + +type UsageEmailAutomationRule = { + displayName?: string, + enabled: boolean, + source: { + type: "payments-item-quota", + itemId: string, + customerType: "user", + thresholds: { + nearRemainingRatio?: number, + nearRemainingQuantity?: number, + overLimitQuantity?: number, + }, + }, + action: { + type: "send-email", + templateId: string, + themeId?: string | null, + subject?: string, + notificationCategoryName?: "Marketing", + }, + cooldown: { + days: number, + }, +}; + +type RuleEntry = { + ruleId: string, + rule: UsageEmailAutomationRule, +}; + +type SelectorOption = { + value: string, + label: string, +}; + +type RuleEditorDraft = { + ruleId: string, + displayName: string, + enabled: boolean, + itemId: string, + nearRemainingRatio: string, + nearRemainingQuantity: string, + overLimitQuantity: string, + templateId: string, + themeId: string, + subject: string, + cooldownDays: string, +}; + +type MissingPrerequisite = "paymentsItem" | "emailTemplate"; + +type AutomationDecision = { + subjectType: "user", + subjectId: string, + thresholdKind: "near" | "over", + currentQuantity: number, + entitlementQuantity: number | null, + blocked: boolean, + sent?: boolean, + skipReason?: string, + hasPrimaryEmail?: boolean, +}; + +type AutomationRouteResult = { + ruleId: string, + mode: "dry-run" | "run", + evaluatedCount: number, + eligibleCount: number, + suppressedCount: number, + sentCount?: number, + nextCursor: string | null, + decisions: AutomationDecision[], +}; + +type DialogMode = + | { type: "create" } + | { type: "edit", entry: RuleEntry }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +export function readRules(config: unknown): RuleEntry[] { + if (!isRecord(config)) return []; + const automations = isRecord(config.automations) ? config.automations : undefined; + const rules = isRecord(automations?.rules) ? automations.rules : undefined; + if (rules === undefined) return []; + + return Object.entries(rules).flatMap(([ruleId, rawRule]) => { + const rule = parseRule(rawRule); + return rule === undefined ? [] : [{ ruleId, rule }]; + }); +} + +function parseRule(rawRule: unknown): UsageEmailAutomationRule | undefined { + if (!isRecord(rawRule)) return undefined; + const source = isRecord(rawRule.source) ? rawRule.source : undefined; + const action = isRecord(rawRule.action) ? rawRule.action : undefined; + const cooldown = isRecord(rawRule.cooldown) ? rawRule.cooldown : undefined; + const thresholds = isRecord(source?.thresholds) ? source.thresholds : undefined; + if ( + source === undefined + || action === undefined + || cooldown === undefined + || thresholds === undefined + || readString(source.type) !== "payments-item-quota" + || readString(source.customerType) !== "user" + || readString(action.type) !== "send-email" + ) { + return undefined; + } + + const itemId = readString(source.itemId); + const templateId = readString(action.templateId); + const cooldownDays = readNumber(cooldown.days); + if (itemId === undefined || templateId === undefined || cooldownDays === undefined) { + return undefined; + } + + return { + ...(readString(rawRule.displayName) === undefined ? {} : { displayName: readString(rawRule.displayName) }), + enabled: readBoolean(rawRule.enabled) ?? false, + source: { + type: "payments-item-quota", + itemId, + customerType: "user", + thresholds: { + ...(readNumber(thresholds.nearRemainingRatio) === undefined ? {} : { nearRemainingRatio: readNumber(thresholds.nearRemainingRatio) }), + ...(readNumber(thresholds.nearRemainingQuantity) === undefined ? {} : { nearRemainingQuantity: readNumber(thresholds.nearRemainingQuantity) }), + ...(readNumber(thresholds.overLimitQuantity) === undefined ? {} : { overLimitQuantity: readNumber(thresholds.overLimitQuantity) }), + }, + }, + action: { + type: "send-email", + templateId, + ...(readString(action.themeId) === undefined && action.themeId !== null ? {} : { themeId: action.themeId === null ? null : readString(action.themeId) }), + ...(readString(action.subject) === undefined ? {} : { subject: readString(action.subject) }), + notificationCategoryName: "Marketing", + }, + cooldown: { + days: cooldownDays, + }, + }; +} + +export function readUserItemOptions(config: unknown): SelectorOption[] { + if (!isRecord(config)) return []; + const payments = isRecord(config.payments) ? config.payments : undefined; + const items = isRecord(payments?.items) ? payments.items : undefined; + if (items === undefined) return []; + + return Object.entries(items).flatMap(([itemId, rawItem]) => { + if (!isRecord(rawItem) || readString(rawItem.customerType) !== "user") { + return []; + } + return [{ + value: itemId, + label: readString(rawItem.displayName) ?? itemId, + }]; + }); +} + +function readTemplateOptions(rawTemplates: unknown): SelectorOption[] { + if (!Array.isArray(rawTemplates)) return []; + return rawTemplates.flatMap((rawTemplate) => { + if (!isRecord(rawTemplate)) return []; + const id = readString(rawTemplate.id); + if (id === undefined) return []; + return [{ + value: id, + label: readString(rawTemplate.displayName) ?? id, + }]; + }); +} + +function readThemeOptions(rawThemes: unknown): SelectorOption[] { + if (!Array.isArray(rawThemes)) return []; + return rawThemes.flatMap((rawTheme) => { + if (!isRecord(rawTheme)) return []; + const id = readString(rawTheme.id); + if (id === undefined) return []; + return [{ + value: id, + label: readString(rawTheme.displayName) ?? id, + }]; + }); +} + +function createDraft(mode: DialogMode, existingRuleIds: string[], itemOptions: SelectorOption[], templateOptions: SelectorOption[], themeOptions: SelectorOption[]): RuleEditorDraft { + if (mode.type === "edit") { + const { ruleId, rule } = mode.entry; + return { + ruleId, + displayName: rule.displayName ?? ruleId, + enabled: rule.enabled, + itemId: rule.source.itemId, + nearRemainingRatio: rule.source.thresholds.nearRemainingRatio === undefined ? "" : String(rule.source.thresholds.nearRemainingRatio), + nearRemainingQuantity: rule.source.thresholds.nearRemainingQuantity === undefined ? "" : String(rule.source.thresholds.nearRemainingQuantity), + overLimitQuantity: rule.source.thresholds.overLimitQuantity === undefined ? "" : String(rule.source.thresholds.overLimitQuantity), + templateId: rule.action.templateId, + themeId: rule.action.themeId ?? THEME_DEFAULT_VALUE, + subject: rule.action.subject ?? "", + cooldownDays: String(rule.cooldown.days), + }; + } + + return { + ruleId: nextRuleId(existingRuleIds), + displayName: "Usage upgrade email", + enabled: true, + itemId: itemOptions[0]?.value ?? "", + nearRemainingRatio: "0.2", + nearRemainingQuantity: "", + overLimitQuantity: "0", + templateId: templateOptions[0]?.value ?? "", + themeId: themeOptions[0]?.value ?? THEME_DEFAULT_VALUE, + subject: "", + cooldownDays: "7", + }; +} + +function nextRuleId(existingRuleIds: string[]) { + const base = "usage-upgrade-email"; + if (!existingRuleIds.includes(base)) return base; + for (let index = 2; index < 1000; index += 1) { + const candidate = `${base}-${index}`; + if (!existingRuleIds.includes(candidate)) return candidate; + } + throw new Error("Could not find an available usage email automation rule ID"); +} + +function parseOptionalNonNegativeNumber(value: string, label: string): number | undefined { + const trimmed = value.trim(); + if (trimmed === "") return undefined; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`${label} must be a non-negative number`); + } + return parsed; +} + +export function buildRuleFromDraft(draft: RuleEditorDraft): UsageEmailAutomationRule { + const nearRemainingRatio = parseOptionalNonNegativeNumber(draft.nearRemainingRatio, "Near remaining ratio"); + if (nearRemainingRatio !== undefined && (nearRemainingRatio <= 0 || nearRemainingRatio > 1)) { + throw new Error("Near remaining ratio must be greater than 0 and less than or equal to 1"); + } + const nearRemainingQuantity = parseOptionalNonNegativeNumber(draft.nearRemainingQuantity, "Near remaining quantity"); + const overLimitQuantity = parseOptionalNonNegativeNumber(draft.overLimitQuantity, "Over-limit quantity"); + if (nearRemainingRatio === undefined && nearRemainingQuantity === undefined && overLimitQuantity === undefined) { + throw new Error("At least one threshold is required"); + } + + const cooldownDays = Number(draft.cooldownDays); + if (!Number.isInteger(cooldownDays) || cooldownDays < 1) { + throw new Error("Cooldown days must be a positive integer"); + } + if (draft.itemId.trim() === "") { + throw new Error("A Payments item is required"); + } + if (draft.templateId.trim() === "") { + throw new Error("An email template is required"); + } + + return { + ...(draft.displayName.trim() === "" ? {} : { displayName: draft.displayName.trim() }), + enabled: draft.enabled, + source: { + type: "payments-item-quota", + itemId: draft.itemId, + customerType: "user", + thresholds: { + ...(nearRemainingRatio === undefined ? {} : { nearRemainingRatio }), + ...(nearRemainingQuantity === undefined ? {} : { nearRemainingQuantity }), + ...(overLimitQuantity === undefined ? {} : { overLimitQuantity }), + }, + }, + action: { + type: "send-email", + templateId: draft.templateId, + ...(draft.themeId === THEME_DEFAULT_VALUE ? {} : { themeId: draft.themeId }), + ...(draft.subject.trim() === "" ? {} : { subject: draft.subject.trim() }), + notificationCategoryName: "Marketing", + }, + cooldown: { + days: cooldownDays, + }, + }; +} + +export function getMissingPrerequisites(itemOptions: SelectorOption[], templateOptions: SelectorOption[]): MissingPrerequisite[] { + const missingPrerequisites: MissingPrerequisite[] = []; + if (itemOptions.length === 0) { + missingPrerequisites.push("paymentsItem"); + } + if (templateOptions.length === 0) { + missingPrerequisites.push("emailTemplate"); + } + return missingPrerequisites; +} + +function formatThresholds(rule: UsageEmailAutomationRule) { + const thresholds = rule.source.thresholds; + const parts = [ + thresholds.nearRemainingRatio === undefined ? undefined : `remaining <= ${Math.round(thresholds.nearRemainingRatio * 100)}%`, + thresholds.nearRemainingQuantity === undefined ? undefined : `remaining <= ${thresholds.nearRemainingQuantity}`, + thresholds.overLimitQuantity === undefined ? undefined : `over cutoff <= ${thresholds.overLimitQuantity}`, + ].filter((part) => part !== undefined); + return parts.join(" or "); +} + +function formatItem(itemOptions: SelectorOption[], itemId: string) { + return itemOptions.find((item) => item.value === itemId)?.label ?? itemId; +} + +function formatTemplate(templateOptions: SelectorOption[], templateId: string) { + return templateOptions.find((template) => template.value === templateId)?.label ?? templateId; +} + +function requireStringFromRecord(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string") { + throw new Error(`Automation response is missing string field "${key}"`); + } + return value; +} + +function requireNumberFromRecord(record: Record, key: string): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Automation response is missing number field "${key}"`); + } + return value; +} + +function requireBooleanFromRecord(record: Record, key: string): boolean { + const value = record[key]; + if (typeof value !== "boolean") { + throw new Error(`Automation response is missing boolean field "${key}"`); + } + return value; +} + +export function parseAutomationRouteResult(value: unknown): AutomationRouteResult { + if (!isRecord(value) || !Array.isArray(value.decisions)) { + throw new Error("Automation response did not match the expected shape"); + } + const mode = requireStringFromRecord(value, "mode"); + if (mode !== "dry-run" && mode !== "run") { + throw new Error(`Automation response mode "${mode}" is unsupported`); + } + + return { + ruleId: requireStringFromRecord(value, "rule_id"), + mode, + evaluatedCount: requireNumberFromRecord(value, "evaluated_count"), + eligibleCount: requireNumberFromRecord(value, "eligible_count"), + suppressedCount: requireNumberFromRecord(value, "suppressed_count"), + sentCount: readNumber(value.sent_count), + nextCursor: value.next_cursor === null ? null : requireStringFromRecord(value, "next_cursor"), + decisions: value.decisions.flatMap((rawDecision) => parseDecision(rawDecision)), + }; +} + +function parseDecision(rawDecision: unknown): AutomationDecision[] { + if (!isRecord(rawDecision)) return []; + const source = isRecord(rawDecision.source) ? rawDecision.source : undefined; + const cooldown = isRecord(rawDecision.cooldown) ? rawDecision.cooldown : undefined; + const recipient = isRecord(rawDecision.recipient) ? rawDecision.recipient : undefined; + if (source === undefined || cooldown === undefined) return []; + const thresholdKind = requireStringFromRecord(source, "threshold_kind"); + if (thresholdKind !== "near" && thresholdKind !== "over") { + return []; + } + + return [{ + subjectType: "user", + subjectId: requireStringFromRecord(rawDecision, "subject_id"), + thresholdKind, + currentQuantity: requireNumberFromRecord(source, "current_quantity"), + entitlementQuantity: source.entitlement_quantity === null ? null : requireNumberFromRecord(source, "entitlement_quantity"), + blocked: requireBooleanFromRecord(cooldown, "blocked"), + sent: readBoolean(rawDecision.sent), + skipReason: readString(rawDecision.skip_reason), + hasPrimaryEmail: recipient === undefined ? undefined : readBoolean(recipient.has_primary_email), + }]; +} + +async function requestAutomationRun(adminApp: object, ruleId: string, mode: "dry-run" | "run"): Promise { + const response = await sendAdminInternalRequestOrThrow( + adminApp, + `/internal/automations/rules/${encodeURIComponent(ruleId)}/${mode}`, + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ limit: DEFAULT_LIMIT }), + }, + ); + if (!response.ok) { + throw new Error(`Automation ${mode} failed with status ${response.status}`); + } + return parseAutomationRouteResult(await response.json()); +} + +export default function PageClient() { + const hexclaveAdminApp = useAdminApp(); + const project = hexclaveAdminApp.useProject(); + const config = project.useConfig(); + const emailTemplates = hexclaveAdminApp.useEmailTemplates(); + const emailThemes = hexclaveAdminApp.useEmailThemes(); + const updateConfig = useUpdateConfig(); + const rules = useMemo(() => readRules(config), [config]); + const itemOptions = useMemo(() => readUserItemOptions(config), [config]); + const templateOptions = useMemo(() => readTemplateOptions(emailTemplates), [emailTemplates]); + const themeOptions = useMemo(() => [ + { value: THEME_DEFAULT_VALUE, label: "Project default" }, + ...readThemeOptions(emailThemes), + ], [emailThemes]); + const [editorMode, setEditorMode] = useState(null); + const [deleteEntry, setDeleteEntry] = useState(null); + const [dryRunEntry, setDryRunEntry] = useState(null); + const [sendEntry, setSendEntry] = useState(null); + const paymentsItemsHref = urlString`/projects/${hexclaveAdminApp.projectId}/payments/products`; + + const saveRule = async (ruleId: string, rule: UsageEmailAutomationRule) => { + await updateConfig({ + adminApp: hexclaveAdminApp, + configUpdate: { [`automations.rules.${ruleId}`]: rule }, + pushable: true, + }); + }; + + const deleteRule = async (ruleId: string) => { + await updateConfig({ + adminApp: hexclaveAdminApp, + configUpdate: { [`automations.rules.${ruleId}`]: null }, + pushable: true, + }); + }; + + return ( + + setEditorMode({ type: "create" })}> + + New usage email + + )} + > +
+ + + + {rules.length === 0 ? ( + + setEditorMode({ type: "create" })}> + + New usage email + + + ) : ( +
+ {rules.map((entry) => ( + , + onClick: () => setDryRunEntry(entry), + }, + { + id: "send", + label: "Send", + icon: , + onClick: () => setSendEntry(entry), + }, + { + id: "more", + label: "More", + display: "icon", + onClick: [ + { + id: "edit", + label: "Edit", + icon: , + onClick: () => setEditorMode({ type: "edit", entry }), + }, + { + id: "delete", + label: "Delete", + icon: , + itemVariant: "destructive", + onClick: () => setDeleteEntry(entry), + }, + ], + }, + ]} + /> + ))} +
+ )} +
+ + {itemOptions.length === 0 ? ( + + ) : null} + + {templateOptions.length === 0 ? ( + + ) : null} +
+ + {editorMode !== null ? ( + rule.ruleId)} + itemOptions={itemOptions} + templateOptions={templateOptions} + themeOptions={themeOptions} + paymentsItemsHref={paymentsItemsHref} + onSave={saveRule} + onOpenChange={(open) => { + if (!open) setEditorMode(null); + }} + /> + ) : null} + + {deleteEntry !== null ? ( + { + if (!open) setDeleteEntry(null); + }} + /> + ) : null} + + {dryRunEntry !== null ? ( + { + if (!open) setDryRunEntry(null); + }} + /> + ) : null} + + {sendEntry !== null ? ( + { + if (!open) setSendEntry(null); + }} + /> + ) : null} +
+
+ ); +} + +function RuleEditorDialog(props: { + mode: DialogMode, + existingRuleIds: string[], + itemOptions: SelectorOption[], + templateOptions: SelectorOption[], + themeOptions: SelectorOption[], + paymentsItemsHref: string, + onSave: (ruleId: string, rule: UsageEmailAutomationRule) => Promise, + onOpenChange: (open: boolean) => void, +}) { + const [draft, setDraft] = useState(() => createDraft(props.mode, props.existingRuleIds, props.itemOptions, props.templateOptions, props.themeOptions)); + const [error, setError] = useState(null); + const isEditing = props.mode.type === "edit"; + const missingPrerequisites = getMissingPrerequisites(props.itemOptions, props.templateOptions); + const hasMissingPrerequisites = missingPrerequisites.length > 0; + + const save = async () => { + setError(null); + const ruleId = draft.ruleId.trim(); + if (!isValidUserSpecifiedId(ruleId)) { + setError(getUserSpecifiedIdErrorMessage("automationRuleId")); + return; + } + if (!isEditing && props.existingRuleIds.includes(ruleId)) { + setError("A usage email rule with this ID already exists"); + return; + } + + try { + await props.onSave(ruleId, buildRuleFromDraft(draft)); + props.onOpenChange(false); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Could not save usage email automation"); + } + }; + + const setDraftField = (field: keyof RuleEditorDraft, value: string | boolean) => { + setDraft((previous) => ({ + ...previous, + [field]: value, + })); + }; + + return ( + + + Cancel + + + {isEditing ? "Save changes" : "Create rule"} + + + )} + > +
+ {error !== null ? ( + + ) : null} + +
+ + setDraftField("ruleId", sanitizeUserSpecifiedId(event.target.value))} + size="md" + className="font-mono text-sm" + /> + + + setDraftField("displayName", event.target.value)} + size="md" + /> + +
+ +
+
+
+ Trigger + Payments item quota for user customers +
+ +
+
+ + setDraftField("itemId", value)} + options={props.itemOptions} + placeholder="Select item" + disabled={props.itemOptions.length === 0} + size="md" + /> + {props.itemOptions.length === 0 ? ( +
+ + Usage Emails require a Payments item with customerType=user. Configure one in{" "} + + Payments products + + , then return here to create this rule. + + )} + /> +
+ ) : null} +
+ + + +
+
+ +
+ Thresholds +
+ + setDraftField("nearRemainingRatio", event.target.value)} + size="md" + /> + + + setDraftField("nearRemainingQuantity", event.target.value)} + size="md" + /> + + + setDraftField("overLimitQuantity", event.target.value)} + size="md" + /> + +
+
+ +
+
+
+ Action + Send one Marketing email through the existing email pipeline +
+ +
+
+ + setDraftField("templateId", value)} + options={props.templateOptions} + placeholder="Select template" + disabled={props.templateOptions.length === 0} + size="md" + /> + {props.templateOptions.length === 0 ? ( +
+ +
+ ) : null} +
+ + setDraftField("themeId", value)} + options={props.themeOptions} + size="md" + /> + + + setDraftField("subject", event.target.value)} + size="md" + /> + + + + +
+
+ +
+ + setDraftField("cooldownDays", event.target.value)} + size="md" + /> + +
+
+ Enabled + Disabled rules can be saved and dry-run later. +
+ setDraftField("enabled", id === "enabled")} + size="sm" + /> +
+
+
+
+ ); +} + +function DeleteRuleDialog(props: { + entry: RuleEntry, + onDelete: (ruleId: string) => Promise, + onOpenChange: (open: boolean) => void, +}) { + const [error, setError] = useState(null); + return ( + + + Cancel + + { + setError(null); + try { + await props.onDelete(props.entry.ruleId); + props.onOpenChange(false); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Could not delete usage email automation"); + } + }} + > + Delete + + + )} + > + {error !== null ? ( + + ) : ( + + This removes the rule configuration only. Existing EmailOutbox delivery history is not changed. + + )} + + ); +} + +function RunPreviewDialog(props: { + entry: RuleEntry, + mode: "dry-run" | "run", + adminApp: object, + onOpenChange: (open: boolean) => void, +}) { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const title = props.mode === "dry-run" ? "Dry run usage email" : "Send usage email"; + const description = props.mode === "dry-run" + ? "Preview eligible users without writing execution state or enqueueing email." + : "Run the rule now and enqueue eligible emails through EmailOutbox."; + + const execute = async () => { + setError(null); + setResult(null); + try { + setResult(await requestAutomationRun(props.adminApp, props.entry.ruleId, props.mode)); + } catch (caughtError) { + setError(caughtError instanceof Error ? caughtError.message : "Automation request failed"); + } + }; + + return ( + + + Close + + + {props.mode === "dry-run" ? "Run preview" : "Send now"} + + + )} + > +
+ {props.mode === "run" ? ( + + ) : null} + {error !== null ? ( + + ) : null} + {result === null ? ( + + ) : ( + + )} +
+
+ ); +} + +function AutomationResult(props: { result: AutomationRouteResult }) { + return ( +
+
+ + + + +
+ {props.result.nextCursor !== null ? ( + + ) : null} + +
+ ); +} + +function AutomationDecisionGrid(props: { decisions: AutomationDecision[] }) { + const columns = useMemo[]>(() => [ + { + id: "subjectId", + header: "User ID", + accessor: "subjectId", + width: 220, + flex: 1, + type: "string", + renderCell: ({ value }) => {String(value)}, + }, + { + id: "thresholdKind", + header: "Threshold", + accessor: "thresholdKind", + width: 110, + type: "string", + renderCell: ({ row }) => , + }, + { + id: "quantity", + header: "Quantity", + width: 140, + sortable: false, + renderCell: ({ row }) => ( + + {row.currentQuantity} / {row.entitlementQuantity ?? "no limit"} + + ), + }, + { + id: "recipient", + header: "Recipient", + width: 130, + sortable: false, + renderCell: ({ row }) => { + if (row.hasPrimaryEmail === undefined) return not checked; + return row.hasPrimaryEmail + ? + : ; + }, + }, + { + id: "status", + header: "Status", + width: 130, + sortable: false, + renderCell: ({ row }) => { + if (row.sent === true) return ; + if (row.blocked) return ; + if (row.skipReason !== undefined) return ; + return ; + }, + }, + ], []); + const [gridState, setGridState] = useState(() => createDefaultDataGridState(columns)); + const gridData = useDataSource({ + data: props.decisions, + columns, + getRowId: (row) => `${row.subjectType}:${row.subjectId}:${row.thresholdKind}`, + sorting: gridState.sorting, + quickSearch: gridState.quickSearch, + pagination: gridState.pagination, + paginationMode: "client", + }); + + return ( + `${row.subjectType}:${row.subjectId}:${row.thresholdKind}`} + totalRowCount={gridData.totalRowCount} + isLoading={gridData.isLoading} + state={gridState} + onChange={setGridState} + paginationMode="paginated" + footer={false} + fillHeight={false} + maxHeight={360} + emptyState={} + /> + ); +} + +function Metric(props: { label: string, value: number }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ); +} + +function Field(props: { label: string, helper?: string, children: React.ReactNode }) { + const id = `usage-email-field-${props.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; + return ( +
+ +
{props.children}
+ {props.helper !== undefined ? ( + {props.helper} + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page.tsx new file mode 100644 index 000000000..96bc4d11e --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page.tsx @@ -0,0 +1,11 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Usage Emails", +}; + +export default function Page() { + return ( + + ); +} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 7f592e49d..ab43cc313 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -218,6 +218,7 @@ export const ALL_APPS_FRONTEND = { { displayName: "Sent", href: "." }, { displayName: "Drafts", href: "../email-drafts", getBreadcrumbItems: getEmailDraftBreadcrumbItems }, { displayName: "Templates", href: "../email-templates", getBreadcrumbItems: getEmailTemplatesBreadcrumbItems }, + { displayName: "Automations", href: "../email-automations" }, { displayName: "Email Settings", href: "../email-settings" }, ], screenshots: getScreenshots('emails', 8), diff --git a/apps/dashboard/src/lib/hexclave-app-internals.ts b/apps/dashboard/src/lib/hexclave-app-internals.ts index c1be230d2..d2ff6bd27 100644 --- a/apps/dashboard/src/lib/hexclave-app-internals.ts +++ b/apps/dashboard/src/lib/hexclave-app-internals.ts @@ -60,6 +60,12 @@ type AdminAppInternalsHooks = { useMetricsUserCounts: () => MetricsUserCounts, }; +type AdminAppInternalsRequestType = "client" | "server" | "admin"; + +type AdminAppInternalsRequest = { + sendRequest: (path: string, requestOptions: RequestInit, requestType?: AdminAppInternalsRequestType) => Promise, +}; + function getInternalsHookOrThrow(adminApp: object, hookName: K): AdminAppInternalsHooks[K] { const internals = Reflect.get(adminApp, hexclaveAppInternalsSymbol); if (typeof internals !== "object" || internals == null || !(hookName in internals)) { @@ -95,3 +101,25 @@ export function useUserActivityOrThrow(adminApp: object, userId: string): UserAc export function useMetricsUserCountsOrThrow(adminApp: object): MetricsUserCounts { return getInternalsHookOrThrow(adminApp, "useMetricsUserCounts")(); } + +function getInternalsSendRequestOrThrow(adminApp: object): AdminAppInternalsRequest["sendRequest"] { + const internals = Reflect.get(adminApp, hexclaveAppInternalsSymbol); + if (typeof internals !== "object" || internals == null || !("sendRequest" in internals)) { + throw new HexclaveAssertionError("Admin app internals are unavailable: missing sendRequest"); + } + + const sendRequest = (internals as Record).sendRequest; + if (typeof sendRequest !== "function") { + throw new HexclaveAssertionError("Admin app internals are unavailable: sendRequest is not callable"); + } + + return sendRequest as AdminAppInternalsRequest["sendRequest"]; +} + +export async function sendAdminInternalRequestOrThrow( + adminApp: object, + path: string, + requestOptions: RequestInit, +): Promise { + return await getInternalsSendRequestOrThrow(adminApp)(path, requestOptions, "admin"); +} diff --git a/packages/shared/src/config/schema-fuzzer.test.ts b/packages/shared/src/config/schema-fuzzer.test.ts index 531f1c5f8..a5fe5bb0c 100644 --- a/packages/shared/src/config/schema-fuzzer.test.ts +++ b/packages/shared/src/config/schema-fuzzer.test.ts @@ -6,6 +6,10 @@ import { nicify } from "../utils/strings"; import { normalize, override } from "./format"; import { BranchConfigNormalizedOverride, EnvironmentConfigNormalizedOverride, OrganizationConfigNormalizedOverride, ProjectConfigNormalizedOverride, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "./schema"; +const paymentsItemQuotaAutomationSourceType = "payments-item-quota"; +const sendEmailAutomationActionType = "send-email"; +const marketingAutomationNotificationCategoryName = "Marketing"; + type FuzzerConfig = ReadonlyArray } : Required<{ [K in keyof T]: FuzzerConfig; }> & Record>) : T>; @@ -61,6 +65,34 @@ const branchSchemaFuzzerConfig = [{ }], signUpRulesDefaultAction: ["allow", "reject"], }], + automations: [{ + rules: [{ + "some-rule-id": [{ + displayName: ["Low credits upgrade email", "Usage limit email"], + enabled: [true, false], + source: [{ + type: [paymentsItemQuotaAutomationSourceType], + itemId: ["some-item-id", "some-other-item-id"], + customerType: ["user"], + thresholds: [{ + nearRemainingRatio: [0.2, 0.5, 1], + nearRemainingQuantity: [0, 10, 100], + overLimitQuantity: [0, 1], + }], + }], + action: [{ + type: [sendEmailAutomationActionType], + templateId: ["12345678-1234-4234-9234-123456789012"], + themeId: [null, "12345678-1234-4234-9234-123456789012"], + subject: ["You're running low on credits", "Upgrade your plan"], + notificationCategoryName: [marketingAutomationNotificationCategoryName], + }], + cooldown: [{ + days: [1, 7, 30], + }], + }], + }], + }], dbSync: [{ externalDatabases: [{ "some-external-db-id": [{ diff --git a/packages/shared/src/config/schema.ts b/packages/shared/src/config/schema.ts index 46a49b3cd..fc7d4aeec 100644 --- a/packages/shared/src/config/schema.ts +++ b/packages/shared/src/config/schema.ts @@ -24,6 +24,9 @@ export type ConfigLevel = typeof configLevels[number]; const permissionRegex = /^\$?[a-z0-9_:]+$/; const customPermissionRegex = /^[a-z0-9_:]+$/; const providerIdRegex = /^[a-z0-9_-]+$/; +const paymentsItemQuotaAutomationSourceType = "payments-item-quota"; +const sendEmailAutomationActionType = "send-email"; +const marketingAutomationNotificationCategoryName = "Marketing"; declare module "yup" { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions @@ -143,6 +146,198 @@ const branchAuthSchema = yupObject({ signUpRulesDefaultAction: yupString().oneOf(['allow', 'reject']), }); +const branchAutomationThresholdsSchema = yupObject({ + nearRemainingRatio: yupNumber().moreThan(0).max(1).optional(), + nearRemainingQuantity: yupNumber().min(0).optional(), + overLimitQuantity: yupNumber().min(0).optional(), +}).test( + 'at-least-one-threshold', + 'At least one quota threshold is required', + function(this: yup.TestContext, value) { + if (!isObjectLike(value)) return true; + if ( + value.nearRemainingRatio === undefined + && value.nearRemainingQuantity === undefined + && value.overLimitQuantity === undefined + ) { + return this.createError({ + message: 'At least one quota threshold is required', + path: this.path, + }); + } + return true; + }, +); + +const branchAutomationsSchema = yupObject({ + rules: yupRecord( + userSpecifiedIdSchema("automationRuleId"), + yupObject({ + displayName: yupString().optional(), + enabled: yupBoolean(), + source: yupObject({ + type: yupString().oneOf([paymentsItemQuotaAutomationSourceType]).defined(), + itemId: userSpecifiedIdSchema("itemId"), + customerType: yupString().oneOf(["user"]), + thresholds: branchAutomationThresholdsSchema, + }), + action: yupObject({ + type: yupString().oneOf([sendEmailAutomationActionType]).defined(), + templateId: yupString().uuid(), + themeId: yupString().uuid().nullable().optional(), + subject: yupString().optional(), + notificationCategoryName: yupString().oneOf([marketingAutomationNotificationCategoryName]).optional(), + }), + cooldown: yupObject({ + days: yupNumber().integer().min(1), + }), + }), + ), +}); + +const validQuotaAutomationRule = { + automations: { + rules: { + lowCreditsUpgradeEmail: { + displayName: "Low credits upgrade email", + enabled: true, + source: { + type: paymentsItemQuotaAutomationSourceType, + itemId: "credits", + customerType: "user", + thresholds: { + nearRemainingRatio: 0.2, + nearRemainingQuantity: 10, + overLimitQuantity: 0, + }, + }, + action: { + type: sendEmailAutomationActionType, + templateId: "12345678-1234-4234-9234-123456789012", + themeId: null, + subject: "You're running low on credits", + notificationCategoryName: marketingAutomationNotificationCategoryName, + }, + cooldown: { + days: 7, + }, + }, + }, + }, +}; + +import.meta.vitest?.test("branchAutomationsSchema accepts a valid payments item quota email rule", async ({ expect }) => { + await expect(branchConfigSchema.validate(validQuotaAutomationRule, { abortEarly: false })).resolves.toMatchObject(validQuotaAutomationRule); + await expect(assertNoConfigOverrideErrors(branchConfigSchema, validQuotaAutomationRule)).resolves.toBeUndefined(); +}); + +import.meta.vitest?.test("branchAutomationsSchema rejects unsupported source types", async ({ expect }) => { + await expect(branchConfigSchema.validate({ + automations: { + rules: { + lowCreditsUpgradeEmail: { + source: { + type: "custom-usage", + }, + }, + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: automations.rules.source.type must be one of the following values: payments-item-quota]`); +}); + +import.meta.vitest?.test("branchAutomationsSchema rejects unsupported action types", async ({ expect }) => { + await expect(branchConfigSchema.validate({ + automations: { + rules: { + lowCreditsUpgradeEmail: { + action: { + type: "webhook", + }, + }, + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: automations.rules.action.type must be one of the following values: send-email]`); +}); + +import.meta.vitest?.test("branchAutomationsSchema rejects unsupported source customer types", async ({ expect }) => { + await expect(branchConfigSchema.validate({ + automations: { + rules: { + lowCreditsUpgradeEmail: { + source: { + type: paymentsItemQuotaAutomationSourceType, + customerType: "team", + }, + }, + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: automations.rules.source.customerType must be one of the following values: user]`); +}); + +import.meta.vitest?.test("branchAutomationsSchema rejects missing and invalid thresholds", async ({ expect }) => { + await expect(branchConfigSchema.validate({ + automations: { + rules: { + lowCreditsUpgradeEmail: { + source: { + type: paymentsItemQuotaAutomationSourceType, + thresholds: {}, + }, + }, + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: At least one quota threshold is required]`); + + await expect(branchConfigSchema.validate({ + automations: { + rules: { + lowCreditsUpgradeEmail: { + source: { + type: paymentsItemQuotaAutomationSourceType, + thresholds: { + nearRemainingRatio: 1.5, + nearRemainingQuantity: -1, + overLimitQuantity: -1, + }, + }, + }, + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(` + [ValidationError: 3 errors occurred] + `); +}); + +import.meta.vitest?.test("branchAutomationsSchema rejects invalid cooldowns", async ({ expect }) => { + await expect(branchConfigSchema.validate({ + automations: { + rules: { + lowCreditsUpgradeEmail: { + cooldown: { + days: 0, + }, + }, + }, + }, + }, { abortEarly: false })).rejects.toThrowErrorMatchingInlineSnapshot(`[ValidationError: automations.rules.cooldown.days must be greater than or equal to 1]`); +}); + +import.meta.vitest?.test("branchAutomationsSchema rejects payments quota email automation config", async ({ expect }) => { + await expect(assertNoConfigOverrideErrors(branchConfigSchema, { + payments: { + quotaEmailAutomations: {}, + }, + })).rejects.toThrow(/payments contains unknown properties: quotaEmailAutomations/); +}); + +import.meta.vitest?.test("branchAutomationsSchema rejects emails quota email automation config", async ({ expect }) => { + await expect(assertNoConfigOverrideErrors(branchConfigSchema, { + emails: { + quotaEmailAutomations: {}, + }, + })).rejects.toThrow(/emails contains unknown properties: quotaEmailAutomations/); +}); + export const branchPaymentsSchema = yupObject({ blockNewPurchases: yupBoolean(), autoPay: yupObject({ @@ -319,6 +514,8 @@ export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, [ auth: branchAuthSchema, + automations: branchAutomationsSchema, + emails: yupObject({ selectedThemeId: schemaFields.emailThemeSchema, themes: schemaFields.emailThemeListSchema, @@ -787,6 +984,33 @@ const organizationConfigDefaults = { signUpRulesDefaultAction: 'allow', }, + automations: { + rules: (key: string) => ({ + displayName: undefined, + enabled: false, + source: { + type: paymentsItemQuotaAutomationSourceType, + itemId: undefined, + customerType: "user", + thresholds: { + nearRemainingRatio: undefined, + nearRemainingQuantity: undefined, + overLimitQuantity: undefined, + }, + }, + action: { + type: sendEmailAutomationActionType, + templateId: undefined, + themeId: undefined, + subject: undefined, + notificationCategoryName: marketingAutomationNotificationCategoryName, + }, + cooldown: { + days: undefined, + }, + }), + }, + emails: { server: { isShared: true, From 82d92831432e302fb8e7e880ffd34e9fb24902a6 Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Thu, 2 Jul 2026 02:34:38 +0000 Subject: [PATCH 02/10] Email quota automation v1(Draft II) --- .../rules/[rule_id]/dry-run/route.test.ts | 47 ++++++++++++++++++- .../rules/[rule_id]/run/route.test.ts | 33 +++++++++++-- apps/backend/src/lib/automations/run-route.ts | 4 ++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts index cecff7944..8b815501c 100644 --- a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts @@ -6,7 +6,9 @@ import { createPrismaAutomationRuleExecutionStateReader } from "@/lib/automation const ruleId = "low-api-credits"; const now = new Date("2026-07-01T00:00:00.000Z"); -function createTenancy() { +function createTenancy(options: { + enabled?: boolean, +} = {}) { return { id: "tenancy-1", project: { @@ -16,7 +18,7 @@ function createTenancy() { automations: { rules: { [ruleId]: { - enabled: true, + enabled: options.enabled ?? true, source: { type: "payments-item-quota", itemId: "api_credits", @@ -117,6 +119,47 @@ function createFakePrisma(options: { } describe("automation dry-run route helpers", () => { + it("allows disabled rules to be previewed without writing automation state or email outbox rows", async () => { + const fakePrisma = createFakePrisma(); + const sourceAdapter = createSourceAdapter(); + const actionAdapter = createActionAdapter(); + + await expect(evaluateAutomationRuleDryRunForRoute({ + tenancy: createTenancy({ enabled: false }), + ruleId, + prisma: fakePrisma, + sourceAdapter, + actionAdapter, + recipientStatusReader: async () => new Map([["user-1", { + userExists: true, + hasPrimaryEmail: true, + }]]), + executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), + now, + })).resolves.toMatchObject({ + rule_id: ruleId, + mode: "dry-run", + eligible_count: 1, + suppressed_count: 0, + decisions: [{ + recipient: { + user_exists: true, + has_primary_email: true, + }, + cooldown: { + blocked: false, + }, + }], + }); + + expect(sourceAdapter.evaluate).toHaveBeenCalledOnce(); + expect(actionAdapter.buildPlan).toHaveBeenCalledOnce(); + expect(fakePrisma.automationRuleExecutionState.create).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.update).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.updateMany).not.toHaveBeenCalled(); + expect(fakePrisma.emailOutbox.createMany).not.toHaveBeenCalled(); + }); + it("returns the dry-run API response without writing automation state or email outbox rows", async () => { const fakePrisma = createFakePrisma(); const sourceAdapter = createSourceAdapter(); diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts index 445e86538..420345c0a 100644 --- a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts @@ -14,7 +14,9 @@ import { const ruleId = "low-api-credits"; const scheduledAt = new Date("2026-07-01T12:00:00.000Z"); -function createTenancy() { +function createTenancy(options: { + enabled?: boolean, +} = {}) { return { id: "tenancy-1", project: { @@ -24,7 +26,7 @@ function createTenancy() { automations: { rules: { [ruleId]: { - enabled: true, + enabled: options.enabled ?? true, source: { type: "payments-item-quota", itemId: "api_credits", @@ -188,6 +190,7 @@ async function runWithFakes(options: { now?: Date, limit?: number, cursor?: string | null, + ruleEnabled?: boolean, } = {}) { const sourceAdapter = createSourceAdapter(options.decisionFactory); const actionAdapter = createActionAdapter(); @@ -200,7 +203,7 @@ async function runWithFakes(options: { } const result = await runAutomationRuleForRoute({ - tenancy: createTenancy(), + tenancy: createTenancy(options.ruleEnabled === undefined ? {} : { enabled: options.ruleEnabled }), ruleId, limit: options.limit, cursor: options.cursor, @@ -222,6 +225,30 @@ async function runWithFakes(options: { } describe("automation real-send route helpers", () => { + it("refuses disabled rules before evaluating, claiming state, or sending email", async () => { + const sourceAdapter = createSourceAdapter(); + const actionAdapter = createActionAdapter(); + const emailSender = vi.fn(async () => {}); + const { stateStore } = createInMemoryStateStore(); + + await expect(runAutomationRuleForRoute({ + tenancy: createTenancy({ enabled: false }), + ruleId, + scheduledAt, + now: new Date("2026-07-01T12:00:00.000Z"), + sourceAdapter, + actionAdapter, + stateStore, + emailSender, + })).rejects.toThrowErrorMatchingInlineSnapshot(`[StatusError: Automation rule "low-api-credits" is disabled and cannot be manually sent.]`); + + expect(sourceAdapter.evaluate).not.toHaveBeenCalled(); + expect(actionAdapter.buildPlan).not.toHaveBeenCalled(); + expect(stateStore.claimExecution).not.toHaveBeenCalled(); + expect(stateStore.markActionCompleted).not.toHaveBeenCalled(); + expect(emailSender).not.toHaveBeenCalled(); + }); + it("claims state, enqueues email, and marks action completed", async () => { const { result, emailSender, states } = await runWithFakes(); diff --git a/apps/backend/src/lib/automations/run-route.ts b/apps/backend/src/lib/automations/run-route.ts index b3aabc169..6ea7971e2 100644 --- a/apps/backend/src/lib/automations/run-route.ts +++ b/apps/backend/src/lib/automations/run-route.ts @@ -1,3 +1,4 @@ +import { StatusError } from "@hexclave/shared/dist/utils/errors"; import { AutomationJson, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; import { AutomationActionPlan, AutomationEvaluationResult, AutomationSourceAdapter, AutomationActionAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; @@ -75,6 +76,9 @@ export async function runAutomationRuleForRoute(options: { emailSender: AutomationEmailSender, }): Promise { const rule = getSupportedAutomationRule(options.tenancy, options.ruleId); + if (!rule.enabled) { + throw new StatusError(StatusError.Conflict, `Automation rule "${options.ruleId}" is disabled and cannot be manually sent.`); + } const evaluation = await evaluateAutomationRule({ tenancy: options.tenancy, ruleId: options.ruleId, From d25c4b73d92a14228e8146f4f147bcef6c8c6359 Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Fri, 3 Jul 2026 16:56:32 +0000 Subject: [PATCH 03/10] Email Automation UI Updates --- .../email-automations/page-client.tsx | 559 ++++++++++-------- .../src/components/design-components/menu.tsx | 5 +- 2 files changed, 329 insertions(+), 235 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx index 583fd3c0f..431191b38 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx @@ -9,7 +9,7 @@ import { DesignDialogClose, DesignEmptyState, DesignInput, - DesignListItemRow, + DesignMenu, DesignPillToggle, DesignSelectorDropdown, } from "@/components/design-components"; @@ -32,7 +32,6 @@ import { PaperPlaneTiltIcon, PencilSimpleIcon, PlayIcon, - PlusIcon, TrashIcon, WarningCircleIcon, XCircleIcon, @@ -270,9 +269,9 @@ function createDraft(mode: DialogMode, existingRuleIds: string[], itemOptions: S displayName: "Usage upgrade email", enabled: true, itemId: itemOptions[0]?.value ?? "", - nearRemainingRatio: "0.2", + nearRemainingRatio: "", nearRemainingQuantity: "", - overLimitQuantity: "0", + overLimitQuantity: "", templateId: templateOptions[0]?.value ?? "", themeId: themeOptions[0]?.value ?? THEME_DEFAULT_VALUE, subject: "", @@ -361,12 +360,19 @@ export function getMissingPrerequisites(itemOptions: SelectorOption[], templateO function formatThresholds(rule: UsageEmailAutomationRule) { const thresholds = rule.source.thresholds; - const parts = [ - thresholds.nearRemainingRatio === undefined ? undefined : `remaining <= ${Math.round(thresholds.nearRemainingRatio * 100)}%`, - thresholds.nearRemainingQuantity === undefined ? undefined : `remaining <= ${thresholds.nearRemainingQuantity}`, - thresholds.overLimitQuantity === undefined ? undefined : `over cutoff <= ${thresholds.overLimitQuantity}`, + const limitsParts = [ + thresholds.nearRemainingRatio === undefined ? undefined : `${Math.round(thresholds.nearRemainingRatio * 100)}%`, + thresholds.nearRemainingQuantity === undefined ? undefined : `${thresholds.nearRemainingQuantity}`, ].filter((part) => part !== undefined); - return parts.join(" or "); + + const parts: string[] = []; + if (limitsParts.length > 0) { + parts.push(`Limits: ${limitsParts.join(" or ")}`); + } + if (thresholds.overLimitQuantity !== undefined) { + parts.push(`Cutoff: ${thresholds.overLimitQuantity}`); + } + return parts.join(", "); } function formatItem(itemOptions: SelectorOption[], itemId: string) { @@ -503,65 +509,103 @@ export default function PageClient() { return ( setEditorMode({ type: "create" })}> - - New usage email + New Email Rule )} >
{rules.length === 0 ? ( setEditorMode({ type: "create" })}> - - New usage email + New Email Rule ) : (
{rules.map((entry) => ( - , - onClick: () => setDryRunEntry(entry), - }, - { - id: "send", - label: "Send", - icon: , - onClick: () => setSendEntry(entry), - }, - { - id: "more", - label: "More", - display: "icon", - onClick: [ + className={cn( + "w-full group relative flex flex-col lg:flex-row lg:items-center justify-between p-4 rounded-2xl transition-all duration-150 hover:transition-none text-left gap-4", + "bg-white/90 dark:bg-background/60 backdrop-blur-xl ring-1 ring-black/[0.06] hover:ring-black/[0.1] dark:ring-white/[0.06] dark:hover:ring-white/[0.1]", + "shadow-sm hover:shadow-md" + )} + > +
+
+
+ +
+
+ {entry.rule.displayName ?? entry.ruleId} +
+ {entry.rule.enabled ? ( + + + Enabled + + ) : ( + + + Disabled + + )} + · + {formatItem(itemOptions, entry.rule.source.itemId)} + · + {formatThresholds(entry.rule)} + · + {entry.rule.cooldown.days}-Day Cooldown +
+
+
+ +
e.stopPropagation()}> + setDryRunEntry(entry)} + className="gap-1 h-7 px-2.5 text-[11px] rounded-lg font-medium" + > + + Preview + + setSendEntry(entry)} + className="gap-1 h-7 px-2.5 text-[11px] rounded-lg font-medium" + > + + Send Now + + setDeleteEntry(entry), }, - ], - }, - ]} - /> + ]} + /> +
+
))}
)} @@ -699,201 +743,246 @@ function RuleEditorDialog(props: { Cancel - {isEditing ? "Save changes" : "Create rule"} + {isEditing ? "Save Changes" : "Create Rule"} )} > -
+
{error !== null ? ( ) : null} -
- - setDraftField("ruleId", sanitizeUserSpecifiedId(event.target.value))} - size="md" - className="font-mono text-sm" - /> - - - setDraftField("displayName", event.target.value)} - size="md" - /> - + {props.itemOptions.length === 0 ? ( + + Usage Emails require a Payments item with customerType=user. Configure one in{" "} + + Payments products + + , then return here to create this rule. + + )} + /> + ) : null} + + {props.templateOptions.length === 0 ? ( + + ) : null} + + {/* SECTION 0: General Identity Block */} +
+
+ + setDraftField("ruleId", sanitizeUserSpecifiedId(event.target.value))} + size="md" + className="font-mono text-xs h-9" + /> + +
+
+ + setDraftField("displayName", event.target.value)} + size="md" + className="h-9" + /> + +
+
+
+ Rule Status + setDraftField("enabled", id === "enabled")} + size="sm" + className="w-full h-9" + /> +
+
-
-
-
- Trigger - Payments item quota for user customers + {/* SECTION 1: Stepper Flow */} +
+ + {/* STEP 1: MONITOR TRIGGER SOURCE */} +
+
+
- -
-
- - setDraftField("itemId", value)} - options={props.itemOptions} - placeholder="Select item" - disabled={props.itemOptions.length === 0} - size="md" - /> - {props.itemOptions.length === 0 ? ( -
- - Usage Emails require a Payments item with customerType=user. Configure one in{" "} - - Payments products - - , then return here to create this rule. - - )} + +
+
+ 1. Trigger Source + +
+ + Select the Payments item you want to monitor for account limits. + +
+ +
+
+ + setDraftField("itemId", value)} + options={props.itemOptions} + placeholder="Select item" + disabled={props.itemOptions.length === 0} + size="md" /> -
- ) : null} - - - - -
-
- -
- Thresholds -
- - setDraftField("nearRemainingRatio", event.target.value)} - size="md" - /> - - - setDraftField("nearRemainingQuantity", event.target.value)} - size="md" - /> - - - setDraftField("overLimitQuantity", event.target.value)} - size="md" - /> - -
-
- -
-
-
- Action - Send one Marketing email through the existing email pipeline -
- -
-
- - setDraftField("templateId", value)} - options={props.templateOptions} - placeholder="Select template" - disabled={props.templateOptions.length === 0} - size="md" - /> - {props.templateOptions.length === 0 ? ( -
- + + setDraftField("cooldownDays", event.target.value)} + size="md" + placeholder="e.g. 7" /> -
- ) : null} -
- - setDraftField("themeId", value)} - options={props.themeOptions} - size="md" - /> - - - setDraftField("subject", event.target.value)} - size="md" - /> - - - - -
-
- -
- - setDraftField("cooldownDays", event.target.value)} - size="md" - /> - -
-
- Enabled - Disabled rules can be saved and dry-run later. + +
- setDraftField("enabled", id === "enabled")} - size="sm" - />
+ + {/* STEP 2: THRESHOLD CONDITIONS */} +
+
+ +
+ +
+
+ 2. Define Thresholds +
+ + Specify the conditions under which notifications are fired. Leave a field blank to ignore it. + +
+ +
+
+ + setDraftField("nearRemainingRatio", event.target.value)} + size="md" + placeholder="Between 0.0 and 1.0" + /> + + + setDraftField("nearRemainingQuantity", event.target.value)} + size="md" + placeholder="e.g. 10" + /> + + + setDraftField("overLimitQuantity", event.target.value)} + size="md" + placeholder="e.g. 0" + /> + +
+
+
+ + {/* STEP 3: CONFIGURE ACTION */} +
+
+ +
+ +
+
+ 3. Email Notification Action +
+ + Design the custom email notification sent automatically to qualifying accounts. + +
+ +
+
+ + setDraftField("templateId", value)} + options={props.templateOptions} + placeholder="Select template" + disabled={props.templateOptions.length === 0} + size="md" + /> + + + setDraftField("themeId", value)} + options={props.themeOptions} + size="md" + /> + + + setDraftField("subject", event.target.value)} + size="md" + placeholder="e.g. Action Required: Your quota is low" + /> + + +
+ Category + +
+
+
+
+
+
@@ -956,10 +1045,10 @@ function RunPreviewDialog(props: { }) { const [result, setResult] = useState(null); const [error, setError] = useState(null); - const title = props.mode === "dry-run" ? "Dry run usage email" : "Send usage email"; + const title = props.mode === "dry-run" ? "Email Rule Preview" : "Send Email Rule"; const description = props.mode === "dry-run" - ? "Preview eligible users without writing execution state or enqueueing email." - : "Run the rule now and enqueue eligible emails through EmailOutbox."; + ? "Preview which users currently qualify for this rule without sending any emails or updating their cooldown tracking." + : "Trigger this email rule immediately. This will send real notification emails to qualifying users and update their cooldown tracking."; const execute = async () => { setError(null); @@ -985,7 +1074,7 @@ function RunPreviewDialog(props: { Close - {props.mode === "dry-run" ? "Run preview" : "Send now"} + {props.mode === "dry-run" ? "Run Preview" : "Send Now"} )} @@ -994,8 +1083,8 @@ function RunPreviewDialog(props: { {props.mode === "run" ? ( ) : null} {error !== null ? ( @@ -1004,8 +1093,8 @@ function RunPreviewDialog(props: { {result === null ? ( ) : ( @@ -1128,12 +1217,16 @@ function Metric(props: { label: string, value: number }) { function Field(props: { label: string, helper?: string, children: React.ReactNode }) { const id = `usage-email-field-${props.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; return ( -
- +
+
{props.children}
- {props.helper !== undefined ? ( - {props.helper} - ) : null}
); } diff --git a/apps/dashboard/src/components/design-components/menu.tsx b/apps/dashboard/src/components/design-components/menu.tsx index c9b006230..f4f0ace34 100644 --- a/apps/dashboard/src/components/design-components/menu.tsx +++ b/apps/dashboard/src/components/design-components/menu.tsx @@ -46,6 +46,7 @@ type DesignMenuBaseProps = { withIcons?: boolean, align?: DesignMenuAlign, contentClassName?: string, + triggerClassName?: string, }; type DesignMenuActionsProps = DesignMenuBaseProps & { @@ -84,14 +85,14 @@ export function DesignMenu(props: DesignMenuProps) { {trigger === "button" ? ( - + {triggerLabel} ) : ( {triggerIcon} From fd48c858867a11855e4046548555b688a7f578f9 Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Fri, 3 Jul 2026 18:51:55 +0000 Subject: [PATCH 04/10] Email Automation V1 Design (PR Commit) --- .../email-automations/page-client.test.ts | 2 +- .../email-automations/page-client.tsx | 3 +- email_auto_v1_design.md | 505 ++++++++++++++++++ 3 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 email_auto_v1_design.md diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts index 0ee002952..1d68d8142 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts @@ -26,7 +26,7 @@ vi.mock("@/components/ui", () => ({ Typography: () => null, })); -vi.mock("@/lib/config-update", () => ({ +vi.mock("@/components/config-update", () => ({ useUpdateConfig: () => async () => {}, })); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx index 431191b38..b7c0e83e5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx @@ -15,7 +15,7 @@ import { } from "@/components/design-components"; import { StyledLink } from "@/components/link"; import { Label, Typography } from "@/components/ui"; -import { useUpdateConfig } from "@/lib/config-update"; +import { useUpdateConfig } from "@/components/config-update"; import { sendAdminInternalRequestOrThrow } from "@/lib/hexclave-app-internals"; import { cn } from "@/lib/utils"; import { @@ -592,6 +592,7 @@ export default function PageClient() { setSendEntry(entry)} className="gap-1 h-7 px-2.5 text-[11px] rounded-lg font-medium" > diff --git a/email_auto_v1_design.md b/email_auto_v1_design.md new file mode 100644 index 000000000..ecf0e9ed8 --- /dev/null +++ b/email_auto_v1_design.md @@ -0,0 +1,505 @@ +# Quota Email Automation V1 Design + +## Goal + +Hexclave clients should be able to automate upgrade-oriented emails to their own product end users when those users are near or over Hexclave Payments item balances. + +V1 deliberately uses Hexclave-native Payments items, products, subscriptions, users, and the existing email outbox pipeline. It must not read arbitrary client product databases. + +## Confirmed Architecture Decision + +Persist automation configuration under a generic `automations.rules` namespace. + +Surface the V1 UI under: + +```txt +Emails -> Automations / Usage Emails +``` + +Do not build a full Automations product surface yet. + +V1 supports only: + +- `source.type = "payments-item-quota"` +- `action.type = "send-email"` +- `source.customerType = "user"` + +This separates the feature's product location from its data source: + +- Payments owns products, subscriptions, item definitions, and balances. +- Emails owns templates, themes, notification categories, outbox, unsubscribe, and delivery. +- Automations owns rules, thresholds, cooldowns, dry-runs, scheduling, and idempotency. + +## Relevant Existing Systems + +Payments customer-data readers already expose current customer state: + +- `apps/backend/src/lib/payments/customer-data.ts` + - `getOwnedProductsForCustomer` + - `getItemQuantitiesForCustomer` + - `getItemQuantityForCustomer` + - `getSubscriptionMapForCustomer` + +Payments item APIs already validate item/customer relationships: + +- `apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts` +- `apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts` + +Payments config already defines products, product lines, and items: + +- `packages/shared/src/config/schema.ts` +- `packages/shared/src/schema-fields.ts` + +Email infrastructure already supports durable async sending: + +- `apps/backend/src/lib/emails.tsx` +- `apps/backend/src/lib/email-queue-step.tsx` +- `apps/backend/src/app/api/latest/emails/README.md` +- `apps/backend/prisma/schema.prisma` `EmailOutbox` + +Background work patterns already exist: + +- Simple cron route: `apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx` +- Per-tenancy queued work: `apps/backend/src/lib/external-db-sync-queue.ts` +- Queue table: `apps/backend/prisma/schema.prisma` `OutgoingRequest` + +## 1. Config Schema + +Add a generic branch-level automation namespace: + +```ts +automations: { + rules: { + [ruleId: string]: { + displayName?: string, + enabled: boolean, + source: { + type: "payments-item-quota", + itemId: string, + customerType: "user", + thresholds: { + nearRemainingRatio?: number, + nearRemainingQuantity?: number, + overLimitQuantity?: number, + }, + }, + action: { + type: "send-email", + templateId: string, + themeId?: string | null, + subject?: string, + notificationCategoryName?: "Marketing", + }, + cooldown: { + days: number, + }, + } + } +} +``` + +V1 constraints: + +- Only `source.type: "payments-item-quota"`. +- Only `action.type: "send-email"`. +- Only `source.customerType: "user"`. +- `source.itemId` must use the same user-specified ID conventions as Payments item IDs. +- `nearRemainingRatio`, if present, should be greater than `0` and less than or equal to `1`. +- At least one of `nearRemainingRatio`, `nearRemainingQuantity`, or `overLimitQuantity` should be present. +- `overLimitQuantity` should default to `0` at evaluation time. +- `cooldown.days` should be a positive integer. +- V1 should default notification category to Marketing. + +This should live beside other branch config schemas in `packages/shared/src/config/schema.ts`, with reusable source/action schema pieces placed in `packages/shared/src/schema-fields.ts` only if that keeps the config file readable. + +Do not put canonical rule config under `payments.*` or `emails.*`. + +## 2. Service And File Names + +Use generic automation service names with a Payments quota source implementation. + +Add: + +```txt +apps/backend/src/lib/automations/rules.ts +apps/backend/src/lib/automations/rule-evaluator.ts +apps/backend/src/lib/automations/sources/payments-item-quota.ts +apps/backend/src/lib/automations/actions/send-email.ts +apps/backend/src/lib/automations/quota-email-automation.test.ts +``` + +Recommended responsibilities: + +- `rules.ts`: config parsing, rule lookup, V1 rule type guards. +- `rule-evaluator.ts`: source/action dispatch, dry-run vs send orchestration. +- `sources/payments-item-quota.ts`: reads Payments data and emits quota trigger decisions. +- `actions/send-email.ts`: converts decisions into `sendEmailToMany` calls. +- Tests can start in one focused file, then split as the module grows. + +Avoid naming the central service `payments/quota-email-automation.ts`; the Payments dependency should be isolated to the source adapter. + +## 3. State Table / Model Name + +Use a generic automation state model, not a Payments-specific state model. + +Recommended Prisma model name: + +```prisma +model AutomationRuleExecutionState { + tenancyId String @db.Uuid + + ruleId String + sourceType String + actionType String + + subjectType String + subjectId String + + signalKey String + lastTriggeredAt DateTime + lastActionAt DateTime? + lastEmailOutboxId String? @db.Uuid + + lastSourceSnapshot Json + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) + + @@id([tenancyId, ruleId, subjectType, subjectId, signalKey]) + @@index([tenancyId, ruleId, lastTriggeredAt]) +} +``` + +For V1: + +- `subjectType = "user"` +- `subjectId = projectUserId` +- `sourceType = "payments-item-quota"` +- `actionType = "send-email"` +- `signalKey = itemId + ":" + thresholdKind` +- `lastSourceSnapshot` contains current quantity, entitlement quantity, threshold kind, item ID, product IDs, and subscription IDs. + +Dry-runs must not write this table. + +## 4. Internal Dry-Run Route + +Add: + +```txt +apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.ts +``` + +Request: + +- admin/server internal auth initially +- `limit` +- `cursor` +- optional source debug filters, such as `customer_id`, only if useful for manual testing + +Response: + +```ts +{ + rule_id: string, + mode: "dry-run", + evaluated_count: number, + eligible_count: number, + suppressed_count: number, + next_cursor: string | null, + decisions: Array<{ + subject_type: "user", + subject_id: string, + source: { + type: "payments-item-quota", + item_id: string, + current_quantity: number, + entitlement_quantity: number | null, + threshold_kind: "near" | "over", + owned_product_ids: string[], + active_subscription_ids: string[], + }, + action: { + type: "send-email", + template_id: string, + notification_category_name: "Marketing", + }, + cooldown: { + blocked: boolean, + last_action_at_millis?: number, + next_eligible_at_millis?: number, + }, + recipient: { + user_exists: boolean, + has_primary_email: boolean, + }, + skip_reason?: string, + }> +} +``` + +The dry-run route must use the same evaluator path as real send mode, but must not enqueue outbox rows or write execution state. + +## 5. Real-Send Route + +Add: + +```txt +apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts +``` + +Request: + +- admin/server internal auth initially +- `limit` +- `cursor` +- optional `scheduled_at_millis` + +Behavior: + +1. Load tenancy and rule config. +2. Validate rule is a V1-supported source/action pair. +3. Evaluate candidates using the generic evaluator. +4. Claim/update `AutomationRuleExecutionState` for eligible decisions. +5. Enqueue emails with `sendEmailToMany`. +6. Store `lastEmailOutboxId` when possible, or store enough state to audit the action if batch create does not return IDs. + +If exact outbox IDs are needed for audit, use individual creates or a raw insert with `RETURNING`. If V1 can tolerate not linking exact IDs, keep `lastEmailOutboxId` nullable and rely on `EmailOutbox.extraRenderVariables` carrying `automationRuleId`. + +## 6. Evaluator Source / Action Separation + +The evaluator should explicitly separate source adapters from action adapters. + +Suggested shape: + +```ts +type AutomationSourceDecision = { + subject: { + type: "user", + id: string, + }, + signal: { + key: string, + kind: "near" | "over", + }, + sourceSnapshot: Record, +}; + +type AutomationActionPlan = { + type: "send-email", + recipient: { type: "user-primary-email", userId: string }, + templateId: string, + variables: Record, +}; +``` + +Flow: + +```txt +rule config + -> source adapter evaluates source-specific data + -> generic evaluator applies cooldown/idempotency + -> action adapter builds side-effect plan + -> dry-run returns plan + -> real run executes plan and writes state +``` + +For V1, there is one source adapter and one action adapter: + +```txt +payments-item-quota -> send-email +``` + +This keeps V1 small without baking Payments into the automation framework. + +## 7. Payments Item Quota Source + +Source adapter: + +```txt +apps/backend/src/lib/automations/sources/payments-item-quota.ts +``` + +For each candidate user: + +1. Validate `tenancy.config.payments.items[itemId]` exists. +2. Validate item customer type is `"user"`. +3. Read current item balance with `getItemQuantityForCustomer`. +4. Read owned products with `getOwnedProductsForCustomer`. +5. Read subscriptions with `getSubscriptionMapForCustomer`. +6. Compute entitlement context from owned product snapshots: + +```ts +ownedProduct.product.includedItems[itemId]?.quantity +``` + +Threshold evaluation: + +1. `over`: `currentQuantity <= overLimitQuantity`, default `0` +2. `near`: not over, and either: + - `currentQuantity <= nearRemainingQuantity` + - `currentQuantity / entitlementQuantity <= nearRemainingRatio` + +If `entitlementQuantity` is missing or `0`, only absolute quantity thresholds apply. + +Candidate selection: + +- V1 dry-run may page through `ProjectUser`. +- Scheduled/real sends should prefer bounded candidates from recent `ItemQuantityChange` rows for configured item IDs, with later catch-up scanning if needed. + +## 8. Email Outbox Integration + +Action adapter: + +```txt +apps/backend/src/lib/automations/actions/send-email.ts +``` + +Use `sendEmailToMany` from `apps/backend/src/lib/emails.tsx`. + +Recipient: + +```ts +{ type: "user-primary-email", userId: subject.id } +``` + +Do not store or snapshot email addresses in automation state. + +Set: + +- `createdWith: { type: "programmatic-call", templateId }` +- `overrideNotificationCategoryId` for Marketing +- `shouldSkipDeliverabilityCheck: false` +- `isHighPriority: false` +- `scheduledAt` from route input or `now` + +Extra render variables should include: + +```ts +{ + automationRuleId, + sourceType: "payments-item-quota", + itemId, + itemDisplayName, + currentQuantity, + entitlementQuantity, + thresholdKind, + ownedProductIds, + activeSubscriptionIds, + projectDisplayName, +} +``` + +The email worker should remain responsible for: + +- resolving the latest primary email, +- skipping deleted users, +- skipping users without primary email, +- honoring unsubscribe preferences, +- delivery retries and failures. + +## 9. UI Placement + +V1 UI should live under the Emails app navigation: + +```txt +Emails -> Automations +``` + +or: + +```txt +Emails -> Usage Emails +``` + +The UI edits `automations.rules`, not `emails.*`. + +Do not add a full Automations app to the app store or sidebar in V1. A future Automations app can reuse the same config namespace and backend services. + +The Emails UI should make the Payments source visible in the rule editor: + +```txt +Trigger: Payments item quota +Item: API credits +When: remaining balance <= 10 or <= 20% +Send: Upgrade email template +``` + +## 10. Tests + +Config/schema tests: + +- accepts a valid `automations.rules.*` V1 rule. +- rejects unsupported `source.type`. +- rejects unsupported `action.type`. +- rejects unsupported `source.customerType`. +- rejects invalid thresholds. +- rejects invalid cooldown. +- confirms rule config does not live under `payments.*` or `emails.*`. + +Source adapter tests: + +- item not found produces a skip/error decision. +- item customer type mismatch is rejected. +- detects near threshold by absolute remaining quantity. +- detects near threshold by ratio. +- detects over threshold. +- over wins over near. +- ratio threshold is ignored when entitlement quantity is missing or zero. +- source snapshot includes item, quantity, products, and subscriptions. + +Generic evaluator tests: + +- dry-run writes no `AutomationRuleExecutionState`. +- real run writes state. +- cooldown suppresses repeated same signal. +- over signal can send after near signal. +- cooldown expires after configured days. +- unsupported source/action pairs fail loud. + +Email action tests: + +- builds `user-primary-email` recipient. +- uses configured template ID and optional theme/subject. +- applies Marketing notification category. +- includes source snapshot variables. +- does not snapshot recipient email address. + +Route tests: + +- dry-run route returns decisions and writes nothing. +- run route requires privileged auth. +- run route enqueues EmailOutbox rows. +- pagination does not duplicate sends. + +Safety tests: + +- deleted users are skipped or safely handled. +- users without primary email are reported in dry-run and safely skipped by the email worker. +- no arbitrary database connection/config is read. + +## 11. Commit Plan + +Keep V1 small and reviewable: + +1. Add `automations.rules` config schema and schema tests. +2. Add `AutomationRuleExecutionState` Prisma model and migration tests. +3. Add generic evaluator skeleton with V1-only source/action dispatch. +4. Add `payments-item-quota` source adapter and tests. +5. Add `send-email` action adapter and tests. +6. Add internal dry-run route and route tests. +7. Add real-send route, state writes, EmailOutbox enqueue integration, and tests. +8. Add Emails -> Automations / Usage Emails dashboard page that edits `automations.rules`. +9. Add scheduled/queued worker only after manual dry-run and manual send paths are stable. + +## 12. Non-Goals For V1 + +- No arbitrary client DB reads. +- No generic SQL/query builder. +- No non-Payments sources. +- No non-email actions. +- No team/custom customer recipients. +- No full Automations product surface. +- No public SDK API until internal behavior is proven. + From 60148482aba813938733efd365202b1159d5b175 Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Fri, 3 Jul 2026 20:53:54 +0000 Subject: [PATCH 05/10] Email Automation V1 Design (PR Commit Cleanup) --- email_auto_v1_design.md | 505 ---------------------------------------- 1 file changed, 505 deletions(-) delete mode 100644 email_auto_v1_design.md diff --git a/email_auto_v1_design.md b/email_auto_v1_design.md deleted file mode 100644 index ecf0e9ed8..000000000 --- a/email_auto_v1_design.md +++ /dev/null @@ -1,505 +0,0 @@ -# Quota Email Automation V1 Design - -## Goal - -Hexclave clients should be able to automate upgrade-oriented emails to their own product end users when those users are near or over Hexclave Payments item balances. - -V1 deliberately uses Hexclave-native Payments items, products, subscriptions, users, and the existing email outbox pipeline. It must not read arbitrary client product databases. - -## Confirmed Architecture Decision - -Persist automation configuration under a generic `automations.rules` namespace. - -Surface the V1 UI under: - -```txt -Emails -> Automations / Usage Emails -``` - -Do not build a full Automations product surface yet. - -V1 supports only: - -- `source.type = "payments-item-quota"` -- `action.type = "send-email"` -- `source.customerType = "user"` - -This separates the feature's product location from its data source: - -- Payments owns products, subscriptions, item definitions, and balances. -- Emails owns templates, themes, notification categories, outbox, unsubscribe, and delivery. -- Automations owns rules, thresholds, cooldowns, dry-runs, scheduling, and idempotency. - -## Relevant Existing Systems - -Payments customer-data readers already expose current customer state: - -- `apps/backend/src/lib/payments/customer-data.ts` - - `getOwnedProductsForCustomer` - - `getItemQuantitiesForCustomer` - - `getItemQuantityForCustomer` - - `getSubscriptionMapForCustomer` - -Payments item APIs already validate item/customer relationships: - -- `apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts` -- `apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts` - -Payments config already defines products, product lines, and items: - -- `packages/shared/src/config/schema.ts` -- `packages/shared/src/schema-fields.ts` - -Email infrastructure already supports durable async sending: - -- `apps/backend/src/lib/emails.tsx` -- `apps/backend/src/lib/email-queue-step.tsx` -- `apps/backend/src/app/api/latest/emails/README.md` -- `apps/backend/prisma/schema.prisma` `EmailOutbox` - -Background work patterns already exist: - -- Simple cron route: `apps/backend/src/app/api/latest/internal/email-queue-step/route.tsx` -- Per-tenancy queued work: `apps/backend/src/lib/external-db-sync-queue.ts` -- Queue table: `apps/backend/prisma/schema.prisma` `OutgoingRequest` - -## 1. Config Schema - -Add a generic branch-level automation namespace: - -```ts -automations: { - rules: { - [ruleId: string]: { - displayName?: string, - enabled: boolean, - source: { - type: "payments-item-quota", - itemId: string, - customerType: "user", - thresholds: { - nearRemainingRatio?: number, - nearRemainingQuantity?: number, - overLimitQuantity?: number, - }, - }, - action: { - type: "send-email", - templateId: string, - themeId?: string | null, - subject?: string, - notificationCategoryName?: "Marketing", - }, - cooldown: { - days: number, - }, - } - } -} -``` - -V1 constraints: - -- Only `source.type: "payments-item-quota"`. -- Only `action.type: "send-email"`. -- Only `source.customerType: "user"`. -- `source.itemId` must use the same user-specified ID conventions as Payments item IDs. -- `nearRemainingRatio`, if present, should be greater than `0` and less than or equal to `1`. -- At least one of `nearRemainingRatio`, `nearRemainingQuantity`, or `overLimitQuantity` should be present. -- `overLimitQuantity` should default to `0` at evaluation time. -- `cooldown.days` should be a positive integer. -- V1 should default notification category to Marketing. - -This should live beside other branch config schemas in `packages/shared/src/config/schema.ts`, with reusable source/action schema pieces placed in `packages/shared/src/schema-fields.ts` only if that keeps the config file readable. - -Do not put canonical rule config under `payments.*` or `emails.*`. - -## 2. Service And File Names - -Use generic automation service names with a Payments quota source implementation. - -Add: - -```txt -apps/backend/src/lib/automations/rules.ts -apps/backend/src/lib/automations/rule-evaluator.ts -apps/backend/src/lib/automations/sources/payments-item-quota.ts -apps/backend/src/lib/automations/actions/send-email.ts -apps/backend/src/lib/automations/quota-email-automation.test.ts -``` - -Recommended responsibilities: - -- `rules.ts`: config parsing, rule lookup, V1 rule type guards. -- `rule-evaluator.ts`: source/action dispatch, dry-run vs send orchestration. -- `sources/payments-item-quota.ts`: reads Payments data and emits quota trigger decisions. -- `actions/send-email.ts`: converts decisions into `sendEmailToMany` calls. -- Tests can start in one focused file, then split as the module grows. - -Avoid naming the central service `payments/quota-email-automation.ts`; the Payments dependency should be isolated to the source adapter. - -## 3. State Table / Model Name - -Use a generic automation state model, not a Payments-specific state model. - -Recommended Prisma model name: - -```prisma -model AutomationRuleExecutionState { - tenancyId String @db.Uuid - - ruleId String - sourceType String - actionType String - - subjectType String - subjectId String - - signalKey String - lastTriggeredAt DateTime - lastActionAt DateTime? - lastEmailOutboxId String? @db.Uuid - - lastSourceSnapshot Json - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade) - - @@id([tenancyId, ruleId, subjectType, subjectId, signalKey]) - @@index([tenancyId, ruleId, lastTriggeredAt]) -} -``` - -For V1: - -- `subjectType = "user"` -- `subjectId = projectUserId` -- `sourceType = "payments-item-quota"` -- `actionType = "send-email"` -- `signalKey = itemId + ":" + thresholdKind` -- `lastSourceSnapshot` contains current quantity, entitlement quantity, threshold kind, item ID, product IDs, and subscription IDs. - -Dry-runs must not write this table. - -## 4. Internal Dry-Run Route - -Add: - -```txt -apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.ts -``` - -Request: - -- admin/server internal auth initially -- `limit` -- `cursor` -- optional source debug filters, such as `customer_id`, only if useful for manual testing - -Response: - -```ts -{ - rule_id: string, - mode: "dry-run", - evaluated_count: number, - eligible_count: number, - suppressed_count: number, - next_cursor: string | null, - decisions: Array<{ - subject_type: "user", - subject_id: string, - source: { - type: "payments-item-quota", - item_id: string, - current_quantity: number, - entitlement_quantity: number | null, - threshold_kind: "near" | "over", - owned_product_ids: string[], - active_subscription_ids: string[], - }, - action: { - type: "send-email", - template_id: string, - notification_category_name: "Marketing", - }, - cooldown: { - blocked: boolean, - last_action_at_millis?: number, - next_eligible_at_millis?: number, - }, - recipient: { - user_exists: boolean, - has_primary_email: boolean, - }, - skip_reason?: string, - }> -} -``` - -The dry-run route must use the same evaluator path as real send mode, but must not enqueue outbox rows or write execution state. - -## 5. Real-Send Route - -Add: - -```txt -apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts -``` - -Request: - -- admin/server internal auth initially -- `limit` -- `cursor` -- optional `scheduled_at_millis` - -Behavior: - -1. Load tenancy and rule config. -2. Validate rule is a V1-supported source/action pair. -3. Evaluate candidates using the generic evaluator. -4. Claim/update `AutomationRuleExecutionState` for eligible decisions. -5. Enqueue emails with `sendEmailToMany`. -6. Store `lastEmailOutboxId` when possible, or store enough state to audit the action if batch create does not return IDs. - -If exact outbox IDs are needed for audit, use individual creates or a raw insert with `RETURNING`. If V1 can tolerate not linking exact IDs, keep `lastEmailOutboxId` nullable and rely on `EmailOutbox.extraRenderVariables` carrying `automationRuleId`. - -## 6. Evaluator Source / Action Separation - -The evaluator should explicitly separate source adapters from action adapters. - -Suggested shape: - -```ts -type AutomationSourceDecision = { - subject: { - type: "user", - id: string, - }, - signal: { - key: string, - kind: "near" | "over", - }, - sourceSnapshot: Record, -}; - -type AutomationActionPlan = { - type: "send-email", - recipient: { type: "user-primary-email", userId: string }, - templateId: string, - variables: Record, -}; -``` - -Flow: - -```txt -rule config - -> source adapter evaluates source-specific data - -> generic evaluator applies cooldown/idempotency - -> action adapter builds side-effect plan - -> dry-run returns plan - -> real run executes plan and writes state -``` - -For V1, there is one source adapter and one action adapter: - -```txt -payments-item-quota -> send-email -``` - -This keeps V1 small without baking Payments into the automation framework. - -## 7. Payments Item Quota Source - -Source adapter: - -```txt -apps/backend/src/lib/automations/sources/payments-item-quota.ts -``` - -For each candidate user: - -1. Validate `tenancy.config.payments.items[itemId]` exists. -2. Validate item customer type is `"user"`. -3. Read current item balance with `getItemQuantityForCustomer`. -4. Read owned products with `getOwnedProductsForCustomer`. -5. Read subscriptions with `getSubscriptionMapForCustomer`. -6. Compute entitlement context from owned product snapshots: - -```ts -ownedProduct.product.includedItems[itemId]?.quantity -``` - -Threshold evaluation: - -1. `over`: `currentQuantity <= overLimitQuantity`, default `0` -2. `near`: not over, and either: - - `currentQuantity <= nearRemainingQuantity` - - `currentQuantity / entitlementQuantity <= nearRemainingRatio` - -If `entitlementQuantity` is missing or `0`, only absolute quantity thresholds apply. - -Candidate selection: - -- V1 dry-run may page through `ProjectUser`. -- Scheduled/real sends should prefer bounded candidates from recent `ItemQuantityChange` rows for configured item IDs, with later catch-up scanning if needed. - -## 8. Email Outbox Integration - -Action adapter: - -```txt -apps/backend/src/lib/automations/actions/send-email.ts -``` - -Use `sendEmailToMany` from `apps/backend/src/lib/emails.tsx`. - -Recipient: - -```ts -{ type: "user-primary-email", userId: subject.id } -``` - -Do not store or snapshot email addresses in automation state. - -Set: - -- `createdWith: { type: "programmatic-call", templateId }` -- `overrideNotificationCategoryId` for Marketing -- `shouldSkipDeliverabilityCheck: false` -- `isHighPriority: false` -- `scheduledAt` from route input or `now` - -Extra render variables should include: - -```ts -{ - automationRuleId, - sourceType: "payments-item-quota", - itemId, - itemDisplayName, - currentQuantity, - entitlementQuantity, - thresholdKind, - ownedProductIds, - activeSubscriptionIds, - projectDisplayName, -} -``` - -The email worker should remain responsible for: - -- resolving the latest primary email, -- skipping deleted users, -- skipping users without primary email, -- honoring unsubscribe preferences, -- delivery retries and failures. - -## 9. UI Placement - -V1 UI should live under the Emails app navigation: - -```txt -Emails -> Automations -``` - -or: - -```txt -Emails -> Usage Emails -``` - -The UI edits `automations.rules`, not `emails.*`. - -Do not add a full Automations app to the app store or sidebar in V1. A future Automations app can reuse the same config namespace and backend services. - -The Emails UI should make the Payments source visible in the rule editor: - -```txt -Trigger: Payments item quota -Item: API credits -When: remaining balance <= 10 or <= 20% -Send: Upgrade email template -``` - -## 10. Tests - -Config/schema tests: - -- accepts a valid `automations.rules.*` V1 rule. -- rejects unsupported `source.type`. -- rejects unsupported `action.type`. -- rejects unsupported `source.customerType`. -- rejects invalid thresholds. -- rejects invalid cooldown. -- confirms rule config does not live under `payments.*` or `emails.*`. - -Source adapter tests: - -- item not found produces a skip/error decision. -- item customer type mismatch is rejected. -- detects near threshold by absolute remaining quantity. -- detects near threshold by ratio. -- detects over threshold. -- over wins over near. -- ratio threshold is ignored when entitlement quantity is missing or zero. -- source snapshot includes item, quantity, products, and subscriptions. - -Generic evaluator tests: - -- dry-run writes no `AutomationRuleExecutionState`. -- real run writes state. -- cooldown suppresses repeated same signal. -- over signal can send after near signal. -- cooldown expires after configured days. -- unsupported source/action pairs fail loud. - -Email action tests: - -- builds `user-primary-email` recipient. -- uses configured template ID and optional theme/subject. -- applies Marketing notification category. -- includes source snapshot variables. -- does not snapshot recipient email address. - -Route tests: - -- dry-run route returns decisions and writes nothing. -- run route requires privileged auth. -- run route enqueues EmailOutbox rows. -- pagination does not duplicate sends. - -Safety tests: - -- deleted users are skipped or safely handled. -- users without primary email are reported in dry-run and safely skipped by the email worker. -- no arbitrary database connection/config is read. - -## 11. Commit Plan - -Keep V1 small and reviewable: - -1. Add `automations.rules` config schema and schema tests. -2. Add `AutomationRuleExecutionState` Prisma model and migration tests. -3. Add generic evaluator skeleton with V1-only source/action dispatch. -4. Add `payments-item-quota` source adapter and tests. -5. Add `send-email` action adapter and tests. -6. Add internal dry-run route and route tests. -7. Add real-send route, state writes, EmailOutbox enqueue integration, and tests. -8. Add Emails -> Automations / Usage Emails dashboard page that edits `automations.rules`. -9. Add scheduled/queued worker only after manual dry-run and manual send paths are stable. - -## 12. Non-Goals For V1 - -- No arbitrary client DB reads. -- No generic SQL/query builder. -- No non-Payments sources. -- No non-email actions. -- No team/custom customer recipients. -- No full Automations product surface. -- No public SDK API until internal behavior is proven. - From 3f25743cd14cd1ddd4232129c64ca64e7ef09dca Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Mon, 13 Jul 2026 20:40:21 +0000 Subject: [PATCH 06/10] Email Automation V1 Design (Latest App Version) --- apps/dashboard/src/lib/hexclave-app-internals.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/dashboard/src/lib/hexclave-app-internals.ts b/apps/dashboard/src/lib/hexclave-app-internals.ts index e6fa6139c..51d2bba94 100644 --- a/apps/dashboard/src/lib/hexclave-app-internals.ts +++ b/apps/dashboard/src/lib/hexclave-app-internals.ts @@ -125,6 +125,8 @@ export async function sendAdminInternalRequestOrThrow( requestOptions: RequestInit, ): Promise { return await getInternalsSendRequestOrThrow(adminApp)(path, requestOptions, "admin"); +} + function getMetricsQueryString(includeAnonymous: boolean, filters?: AnalyticsOverviewFilters): string { const params = new URLSearchParams(); if (includeAnonymous) { From b7e5c51ea29fb38951f0f82ac8b897f6876fe321 Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Mon, 13 Jul 2026 23:03:57 +0000 Subject: [PATCH 07/10] Email Automation V1 Bot Comment Fix Batch I --- .../rules/[rule_id]/dry-run/route.test.ts | 30 +++- .../rules/[rule_id]/run/route.test.ts | 160 +++++++++++++++++- .../automations/rules/[rule_id]/run/route.ts | 3 +- .../automations/scheduled-run/route.ts | 3 +- .../src/lib/automations/dry-run-route.ts | 16 +- .../lib/automations/execution-state-store.ts | 2 + .../quota-email-automation.test.ts | 63 +++++++ apps/backend/src/lib/automations/rules.ts | 16 +- apps/backend/src/lib/automations/run-route.ts | 15 +- .../src/lib/automations/scheduled-at.ts | 13 ++ .../src/lib/automations/scheduler.test.ts | 147 +++++++++++++--- apps/backend/src/lib/automations/scheduler.ts | 12 +- .../sources/payments-item-quota.ts | 3 +- .../email-automations/page-client.test.ts | 33 ++++ .../email-automations/page-client.tsx | 84 +++++---- 15 files changed, 527 insertions(+), 73 deletions(-) create mode 100644 apps/backend/src/lib/automations/scheduled-at.ts diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts index 8b815501c..babea6cf3 100644 --- a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts @@ -8,6 +8,7 @@ const now = new Date("2026-07-01T00:00:00.000Z"); function createTenancy(options: { enabled?: boolean, + ruleExists?: boolean, } = {}) { return { id: "tenancy-1", @@ -16,7 +17,7 @@ function createTenancy(options: { }, config: { automations: { - rules: { + rules: options.ruleExists === false ? {} : { [ruleId]: { enabled: options.enabled ?? true, source: { @@ -119,6 +120,33 @@ function createFakePrisma(options: { } describe("automation dry-run route helpers", () => { + it("returns 404 for missing rules before evaluating or writing state", async () => { + const fakePrisma = createFakePrisma(); + const sourceAdapter = createSourceAdapter(); + const actionAdapter = createActionAdapter(); + + const resultPromise = evaluateAutomationRuleDryRunForRoute({ + tenancy: createTenancy({ ruleExists: false }), + ruleId, + prisma: fakePrisma, + sourceAdapter, + actionAdapter, + recipientStatusReader: async () => new Map(), + executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), + now, + }); + await expect(resultPromise).rejects.toMatchObject({ statusCode: 404 }); + await expect(resultPromise).rejects.toThrowErrorMatchingInlineSnapshot(`[StatusError: Automation rule "low-api-credits" was not found for tenancy "tenancy-1".]`); + + expect(sourceAdapter.evaluate).not.toHaveBeenCalled(); + expect(actionAdapter.buildPlan).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.findUnique).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.create).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.update).not.toHaveBeenCalled(); + expect(fakePrisma.automationRuleExecutionState.updateMany).not.toHaveBeenCalled(); + expect(fakePrisma.emailOutbox.createMany).not.toHaveBeenCalled(); + }); + it("allows disabled rules to be previewed without writing automation state or email outbox rows", async () => { const fakePrisma = createFakePrisma(); const sourceAdapter = createSourceAdapter(); diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts index 420345c0a..be14e41a0 100644 --- a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts @@ -10,12 +10,30 @@ import { automationRunResultToApiBody, runAutomationRuleForRoute, } from "@/lib/automations/run-route"; +import { parseAutomationScheduledAtMillis } from "@/lib/automations/scheduled-at"; const ruleId = "low-api-credits"; const scheduledAt = new Date("2026-07-01T12:00:00.000Z"); +describe("automation scheduled timestamp parsing", () => { + it("accepts valid scheduled millis", () => { + expect(parseAutomationScheduledAtMillis(1782907200000, "scheduled_at_millis")).toEqual(new Date("2026-07-01T12:00:00.000Z")); + }); + + it("rejects out-of-range manual run scheduled millis", () => { + expect(() => parseAutomationScheduledAtMillis(8640000000000001, "scheduled_at_millis")) + .toThrowErrorMatchingInlineSnapshot(`[StatusError: scheduled_at_millis must be a valid JavaScript timestamp in milliseconds.]`); + }); + + it("rejects out-of-range scheduled worker millis", () => { + expect(() => parseAutomationScheduledAtMillis(-8640000000000001, "scheduledAtMillis")) + .toThrowErrorMatchingInlineSnapshot(`[StatusError: scheduledAtMillis must be a valid JavaScript timestamp in milliseconds.]`); + }); +}); + function createTenancy(options: { enabled?: boolean, + ruleExists?: boolean, } = {}) { return { id: "tenancy-1", @@ -24,7 +42,7 @@ function createTenancy(options: { }, config: { automations: { - rules: { + rules: options.ruleExists === false ? {} : { [ruleId]: { enabled: options.enabled ?? true, source: { @@ -155,8 +173,8 @@ function createInMemoryStateStore() { states.set(key, { lastTriggeredAt: options.lastTriggeredAt, - lastActionAt: existing?.lastActionAt ?? null, - lastEmailOutboxId: existing?.lastEmailOutboxId ?? null, + lastActionAt: null, + lastEmailOutboxId: null, lastSourceSnapshot: options.sourceSnapshot, }); return { @@ -187,6 +205,7 @@ function createInMemoryStateStore() { async function runWithFakes(options: { stateStore?: AutomationRuleExecutionStateStore, decisionFactory?: () => AutomationSourceDecision, + emailSender?: Parameters[0]["emailSender"], now?: Date, limit?: number, cursor?: string | null, @@ -194,7 +213,7 @@ async function runWithFakes(options: { } = {}) { const sourceAdapter = createSourceAdapter(options.decisionFactory); const actionAdapter = createActionAdapter(); - const emailSender = vi.fn(async () => {}); + const emailSender = vi.fn(options.emailSender ?? (async () => {})); let createdStore: ReturnType | undefined; let stateStore = options.stateStore; if (stateStore === undefined) { @@ -225,6 +244,32 @@ async function runWithFakes(options: { } describe("automation real-send route helpers", () => { + it("returns 404 for missing manual rules before evaluating, claiming state, or sending email", async () => { + const sourceAdapter = createSourceAdapter(); + const actionAdapter = createActionAdapter(); + const emailSender = vi.fn(async () => {}); + const { stateStore } = createInMemoryStateStore(); + + const resultPromise = runAutomationRuleForRoute({ + tenancy: createTenancy({ ruleExists: false }), + ruleId, + scheduledAt, + now: new Date("2026-07-01T12:00:00.000Z"), + sourceAdapter, + actionAdapter, + stateStore, + emailSender, + }); + await expect(resultPromise).rejects.toMatchObject({ statusCode: 404 }); + await expect(resultPromise).rejects.toThrowErrorMatchingInlineSnapshot(`[StatusError: Automation rule "low-api-credits" was not found for tenancy "tenancy-1".]`); + + expect(sourceAdapter.evaluate).not.toHaveBeenCalled(); + expect(actionAdapter.buildPlan).not.toHaveBeenCalled(); + expect(stateStore.claimExecution).not.toHaveBeenCalled(); + expect(stateStore.markActionCompleted).not.toHaveBeenCalled(); + expect(emailSender).not.toHaveBeenCalled(); + }); + it("refuses disabled rules before evaluating, claiming state, or sending email", async () => { const sourceAdapter = createSourceAdapter(); const actionAdapter = createActionAdapter(); @@ -381,6 +426,77 @@ describe("automation real-send route helpers", () => { expect(first.result.sentCount).toBe(1); expect(second.result.sentCount).toBe(0); }); + + it("leaves failed sends as temporary in-flight claims and does not mark completion", async () => { + const { stateStore, states } = createInMemoryStateStore(); + const failingEmailSender = vi.fn(async () => { + throw new Error("email provider unavailable"); + }); + + await expect(runWithFakes({ + stateStore, + emailSender: failingEmailSender, + })).rejects.toThrow("email provider unavailable"); + + expect(failingEmailSender).toHaveBeenCalledOnce(); + expect(stateStore.claimExecution).toHaveBeenCalledOnce(); + expect(stateStore.markActionCompleted).not.toHaveBeenCalled(); + expect([...states.values()]).toMatchObject([{ + lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt: null, + lastEmailOutboxId: null, + }]); + + const immediateRetry = await runWithFakes({ + stateStore, + now: new Date("2026-07-01T12:01:00.000Z"), + }); + + expect(immediateRetry.result.sentCount).toBe(0); + expect(immediateRetry.result.suppressedCount).toBe(1); + expect(immediateRetry.emailSender).not.toHaveBeenCalled(); + }); + + it("allows retry after a failed send claim becomes stale", async () => { + const { stateStore } = createInMemoryStateStore(); + + await expect(runWithFakes({ + stateStore, + emailSender: async () => { + throw new Error("email provider unavailable"); + }, + })).rejects.toThrow("email provider unavailable"); + + const retryAfterStaleClaim = await runWithFakes({ + stateStore, + now: new Date("2026-07-01T12:16:00.000Z"), + }); + + expect(retryAfterStaleClaim.result.sentCount).toBe(1); + expect(retryAfterStaleClaim.emailSender).toHaveBeenCalledOnce(); + }); + + it("suppresses a second run after an expired cooldown is reclaimed but not completed", async () => { + const { stateStore } = createInMemoryStateStore(); + + await runWithFakes({ stateStore }); + await expect(runWithFakes({ + stateStore, + now: new Date("2026-07-09T12:00:00.000Z"), + emailSender: async () => { + throw new Error("email provider unavailable"); + }, + })).rejects.toThrow("email provider unavailable"); + + const secondReclaimAttempt = await runWithFakes({ + stateStore, + now: new Date("2026-07-09T12:01:00.000Z"), + }); + + expect(secondReclaimAttempt.result.sentCount).toBe(0); + expect(secondReclaimAttempt.result.suppressedCount).toBe(1); + expect(secondReclaimAttempt.emailSender).not.toHaveBeenCalled(); + }); }); type PrismaStoreState = { @@ -619,6 +735,42 @@ describe("Prisma automation execution state store", () => { }); }); + it("allows only one expired cooldown reclaim before completion", async () => { + const lastActionAt = new Date("2026-07-01T12:05:00.000Z"); + const { prisma, rows } = createMockExecutionStatePrisma([{ + ...createClaimOptions(), + sourceType: "payments-item-quota", + actionType: "send-email", + lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt, + lastEmailOutboxId: null, + lastSourceSnapshot: { + itemId: "api_credits", + }, + }]); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + const first = await store.claimExecution(createClaimOptions({ + lastTriggeredAt: new Date("2026-07-09T12:06:00.000Z"), + })); + const second = await store.claimExecution(createClaimOptions({ + lastTriggeredAt: new Date("2026-07-09T12:07:00.000Z"), + })); + + expect(first).toEqual({ + claimed: true, + lastActionAt, + }); + expect(second).toEqual({ + claimed: false, + lastActionAt: null, + }); + expect([...rows.values()]).toMatchObject([{ + lastTriggeredAt: new Date("2026-07-09T12:06:00.000Z"), + lastActionAt: null, + }]); + }); + it("marks a claimed action completed with Prisma update", async () => { const { prisma, rows } = createMockExecutionStatePrisma([{ ...createClaimOptions(), diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts index f1fabe302..d26f90835 100644 --- a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts @@ -4,6 +4,7 @@ import { automationRunResultToApiBody, runAutomationRuleForRoute, } from "@/lib/automations/run-route"; +import { parseAutomationScheduledAtMillis } from "@/lib/automations/scheduled-at"; import { createPaymentsItemQuotaSourceAdapter, paymentsItemQuotaCustomerDataReaders, @@ -78,7 +79,7 @@ export const POST = createSmartRouteHandler({ projectUserReader: prismaPaymentsItemQuotaProjectUserReader, customerDataReaders: paymentsItemQuotaCustomerDataReaders, }); - const scheduledAt = body.scheduled_at_millis === undefined ? new Date() : new Date(body.scheduled_at_millis); + const scheduledAt = parseAutomationScheduledAtMillis(body.scheduled_at_millis, "scheduled_at_millis"); const result = await runAutomationRuleForRoute({ tenancy: auth.tenancy, ruleId: params.rule_id, diff --git a/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts b/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts index aa69b4ac4..b622c811b 100644 --- a/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts +++ b/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts @@ -2,6 +2,7 @@ import { normalizeScheduledAutomationRunPageLimit, runScheduledAutomationRulePage, } from "@/lib/automations/scheduler"; +import { parseAutomationScheduledAtMillis } from "@/lib/automations/scheduled-at"; import { ensureUpstashSignature } from "@/lib/upstash"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@hexclave/shared/dist/schema-fields"; @@ -42,7 +43,7 @@ export const POST = createSmartRouteHandler({ }), handler: async ({ body }, fullReq) => { await ensureUpstashSignature(fullReq); - const scheduledAt = body.scheduledAtMillis === undefined ? new Date() : new Date(body.scheduledAtMillis); + const scheduledAt = parseAutomationScheduledAtMillis(body.scheduledAtMillis, "scheduledAtMillis"); const result = await runScheduledAutomationRulePage({ tenancyId: body.tenancyId, ruleId: body.ruleId, diff --git a/apps/backend/src/lib/automations/dry-run-route.ts b/apps/backend/src/lib/automations/dry-run-route.ts index 4de39c0c7..39d686717 100644 --- a/apps/backend/src/lib/automations/dry-run-route.ts +++ b/apps/backend/src/lib/automations/dry-run-route.ts @@ -1,7 +1,8 @@ +import { StatusError } from "@hexclave/shared/dist/utils/errors"; import { automationCooldownStatusToApiBody, type AutomationCooldownStatus } from "./cooldown"; import { AutomationRuleExecutionStateReader } from "./execution-state-store"; import { AutomationActionAdapter, AutomationEvaluationResult, AutomationSourceAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; -import { getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; +import { AutomationRuleNotFoundError, AutomationRuleTenancy, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; export type AutomationDryRunRecipientStatus = { userExists: boolean, @@ -64,7 +65,7 @@ export async function evaluateAutomationRuleDryRunForRoute(options: { executionStateReader: AutomationRuleExecutionStateReader, now: Date, }) { - const rule = getSupportedAutomationRule(options.tenancy, options.ruleId); + const rule = getSupportedAutomationRuleForDryRunRoute(options.tenancy, options.ruleId); const result = await evaluateAutomationRule({ tenancy: options.tenancy, ruleId: options.ruleId, @@ -99,6 +100,17 @@ export async function evaluateAutomationRuleDryRunForRoute(options: { return automationDryRunResultToApiBody(result, recipientStatuses, cooldownStatuses); } +function getSupportedAutomationRuleForDryRunRoute(tenancy: AutomationRuleTenancy, ruleId: string) { + try { + return getSupportedAutomationRule(tenancy, ruleId); + } catch (error) { + if (error instanceof AutomationRuleNotFoundError) { + throw new StatusError(StatusError.NotFound, error.message); + } + throw error; + } +} + export function automationDryRunResultToApiBody( result: AutomationEvaluationResult, recipientStatuses: Map, diff --git a/apps/backend/src/lib/automations/execution-state-store.ts b/apps/backend/src/lib/automations/execution-state-store.ts index c682a86f2..0acb08b15 100644 --- a/apps/backend/src/lib/automations/execution-state-store.ts +++ b/apps/backend/src/lib/automations/execution-state-store.ts @@ -46,6 +46,7 @@ export type AutomationRuleExecutionStatePrisma = { sourceType: string, actionType: string, lastTriggeredAt: Date, + lastActionAt: Date | null, lastSourceSnapshot: Prisma.InputJsonObject, }, }) => Promise<{ count: number }>, @@ -177,6 +178,7 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation sourceType: options.sourceType, actionType: options.actionType, lastTriggeredAt: options.lastTriggeredAt, + lastActionAt: null, lastSourceSnapshot, }, }); diff --git a/apps/backend/src/lib/automations/quota-email-automation.test.ts b/apps/backend/src/lib/automations/quota-email-automation.test.ts index 270b1b306..c69625454 100644 --- a/apps/backend/src/lib/automations/quota-email-automation.test.ts +++ b/apps/backend/src/lib/automations/quota-email-automation.test.ts @@ -271,6 +271,24 @@ describe("automation evaluator skeleton", () => { })).rejects.toThrow('Automation rule "low-api-credits" has unsupported action.type "webhook". V1 supports only "send-email".'); }); + it("fails loudly when source.thresholds has no configured values", async () => { + const { adapters } = createAdapters(); + const rule = createRule(); + + await expect(evaluateAutomationRule({ + tenancy: createTenancy({ + ...rule, + source: { + ...rule.source, + thresholds: {}, + }, + }), + ruleId, + mode: "dry-run", + adapters, + })).rejects.toThrow('Automation rule "low-api-credits" must configure at least one source.thresholds value.'); + }); + it("uses the same evaluator dispatch path for real-send mode", async () => { const { sourceAdapter, actionAdapter, adapters } = createAdapters(); @@ -447,6 +465,26 @@ describe("payments item quota source adapter", () => { }); }); + it("does not emit an implicit over signal for near-only absolute thresholds at zero quantity", async () => { + const tenancy = createSourceTenancy(); + const rule = createSourceRule(tenancy, { nearRemainingQuantity: 10 }); + const { adapter } = createSourceAdapterFixture({ + projectUserIds: ["user-1"], + currentQuantities: { + "user-1": 0, + }, + }); + + await expect(adapter.evaluate({ tenancy, ruleId, rule })).resolves.toMatchObject({ + decisions: [{ + signal: { + key: "api_credits:near", + kind: "near", + }, + }], + }); + }); + it("detects near threshold by remaining ratio", async () => { const tenancy = createSourceTenancy(); const rule = createSourceRule(tenancy, { nearRemainingRatio: 0.2 }); @@ -472,6 +510,31 @@ describe("payments item quota source adapter", () => { }); }); + it("does not emit an implicit over signal for near-only ratio thresholds at zero quantity", async () => { + const tenancy = createSourceTenancy(); + const rule = createSourceRule(tenancy, { nearRemainingRatio: 0.2 }); + const { adapter } = createSourceAdapterFixture({ + projectUserIds: ["user-1"], + currentQuantities: { + "user-1": 0, + }, + ownedProducts: { + "user-1": { + pro: createOwnedProduct({ quantity: 1, includedQuantity: 100 }), + }, + }, + }); + + await expect(adapter.evaluate({ tenancy, ruleId, rule })).resolves.toMatchObject({ + decisions: [{ + signal: { + key: "api_credits:near", + kind: "near", + }, + }], + }); + }); + it("detects over threshold", async () => { const tenancy = createSourceTenancy(); const rule = createSourceRule(tenancy, { overLimitQuantity: 0 }); diff --git a/apps/backend/src/lib/automations/rules.ts b/apps/backend/src/lib/automations/rules.ts index 656e71a31..6669edf04 100644 --- a/apps/backend/src/lib/automations/rules.ts +++ b/apps/backend/src/lib/automations/rules.ts @@ -90,10 +90,17 @@ export function getAutomationRule(tenancy: AutomationRuleTenancy, ruleId: string return tenancy.config.automations?.rules?.[ruleId]; } +export class AutomationRuleNotFoundError extends Error { + constructor(tenancyId: string, ruleId: string) { + super(`Automation rule "${ruleId}" was not found for tenancy "${tenancyId}".`); + this.name = "AutomationRuleNotFoundError"; + } +} + export function getSupportedAutomationRule(tenancy: AutomationRuleTenancy, ruleId: string) { const rule = getAutomationRule(tenancy, ruleId); if (rule === undefined) { - throw new Error(`Automation rule "${ruleId}" was not found for tenancy "${tenancy.id}".`); + throw new AutomationRuleNotFoundError(tenancy.id, ruleId); } assertSupportedAutomationRule(ruleId, rule); return rule; @@ -115,6 +122,13 @@ export function assertSupportedAutomationRule(ruleId: string, rule: AutomationRu if (rule.source.thresholds === undefined) { throw new Error(`Automation rule "${ruleId}" is missing source.thresholds.`); } + if ( + rule.source.thresholds.nearRemainingRatio === undefined + && rule.source.thresholds.nearRemainingQuantity === undefined + && rule.source.thresholds.overLimitQuantity === undefined + ) { + throw new Error(`Automation rule "${ruleId}" must configure at least one source.thresholds value.`); + } if (rule.action.templateId === undefined) { throw new Error(`Automation rule "${ruleId}" is missing action.templateId.`); } diff --git a/apps/backend/src/lib/automations/run-route.ts b/apps/backend/src/lib/automations/run-route.ts index 6ea7971e2..70e6623ae 100644 --- a/apps/backend/src/lib/automations/run-route.ts +++ b/apps/backend/src/lib/automations/run-route.ts @@ -1,5 +1,5 @@ import { StatusError } from "@hexclave/shared/dist/utils/errors"; -import { AutomationJson, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; +import { AutomationJson, AutomationRuleNotFoundError, AutomationRuleTenancy, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; import { AutomationActionPlan, AutomationEvaluationResult, AutomationSourceAdapter, AutomationActionAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; export type AutomationRuleExecutionClaimResult = @@ -75,7 +75,7 @@ export async function runAutomationRuleForRoute(options: { stateStore: AutomationRuleExecutionStateStore, emailSender: AutomationEmailSender, }): Promise { - const rule = getSupportedAutomationRule(options.tenancy, options.ruleId); + const rule = getSupportedAutomationRuleForRunRoute(options.tenancy, options.ruleId); if (!rule.enabled) { throw new StatusError(StatusError.Conflict, `Automation rule "${options.ruleId}" is disabled and cannot be manually sent.`); } @@ -158,6 +158,17 @@ export async function runAutomationRuleForRoute(options: { }; } +function getSupportedAutomationRuleForRunRoute(tenancy: AutomationRuleTenancy, ruleId: string) { + try { + return getSupportedAutomationRule(tenancy, ruleId); + } catch (error) { + if (error instanceof AutomationRuleNotFoundError) { + throw new StatusError(StatusError.NotFound, error.message); + } + throw error; + } +} + export function automationRunResultToApiBody(result: AutomationRunResult) { return { rule_id: result.ruleId, diff --git a/apps/backend/src/lib/automations/scheduled-at.ts b/apps/backend/src/lib/automations/scheduled-at.ts new file mode 100644 index 000000000..be8d17502 --- /dev/null +++ b/apps/backend/src/lib/automations/scheduled-at.ts @@ -0,0 +1,13 @@ +import { StatusError } from "@hexclave/shared/dist/utils/errors"; + +export function parseAutomationScheduledAtMillis(value: number | undefined, label: "scheduled_at_millis" | "scheduledAtMillis") { + if (value === undefined) { + return new Date(); + } + + const scheduledAt = new Date(value); + if (!Number.isFinite(scheduledAt.getTime())) { + throw new StatusError(StatusError.BadRequest, `${label} must be a valid JavaScript timestamp in milliseconds.`); + } + return scheduledAt; +} diff --git a/apps/backend/src/lib/automations/scheduler.test.ts b/apps/backend/src/lib/automations/scheduler.test.ts index d54309f93..1b365f0e1 100644 --- a/apps/backend/src/lib/automations/scheduler.test.ts +++ b/apps/backend/src/lib/automations/scheduler.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +const captureErrorMock = vi.hoisted(() => vi.fn()); + vi.mock("@/lib/automations/actions/send-email", () => ({ createSendEmailActionAdapter: () => ({}), })); @@ -34,6 +36,14 @@ vi.mock("@/prisma-client", () => ({ }, })); +vi.mock("@hexclave/shared/dist/utils/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureError: captureErrorMock, + }; +}); + import { discoverEnabledScheduledAutomationRules, enqueueScheduledAutomationRuns, @@ -45,9 +55,35 @@ import { AutomationRunResult } from "./run-route"; const enabledRuleId = "low-api-credits"; +function createAutomationRule(options: { + enabled?: boolean, + sourceType?: string, +} = {}) { + return { + enabled: options.enabled ?? true, + source: { + type: options.sourceType ?? "payments-item-quota", + itemId: "api_credits", + customerType: "user", + thresholds: { + nearRemainingQuantity: 10, + }, + }, + action: { + type: "send-email", + templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + notificationCategoryName: "Marketing", + }, + cooldown: { + days: 7, + }, + }; +} + function createTenancy(options: { enabled?: boolean, sourceType?: string, + rules?: Record>, } = {}) { return { id: "tenancy-1", @@ -72,26 +108,8 @@ function createTenancy(options: { }, config: { automations: { - rules: { - [enabledRuleId]: { - enabled: options.enabled ?? true, - source: { - type: options.sourceType ?? "payments-item-quota", - itemId: "api_credits", - customerType: "user", - thresholds: { - nearRemainingQuantity: 10, - }, - }, - action: { - type: "send-email", - templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", - notificationCategoryName: "Marketing", - }, - cooldown: { - days: 7, - }, - }, + rules: options.rules ?? { + [enabledRuleId]: createAutomationRule(options), }, }, }, @@ -155,7 +173,8 @@ describe("scheduled automation discovery", () => { }); }); - it("fails loudly for enabled non-V1 rules", async () => { + it("reports malformed enabled rules and continues discovering later valid rules", async () => { + captureErrorMock.mockClear(); const prisma = { tenancy: { findMany: vi.fn(async () => [{ id: "tenancy-1" }]), @@ -164,10 +183,25 @@ describe("scheduled automation discovery", () => { await expect(discoverEnabledScheduledAutomationRules({ prisma, - getTenancyById: async () => createTenancy({ sourceType: "client-push-quota" }), - })).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Automation rule "low-api-credits" has unsupported source.type "client-push-quota". V1 supports only "payments-item-quota".]`, - ); + getTenancyById: async () => createTenancy({ + rules: { + invalidRule: createAutomationRule({ sourceType: "client-push-quota" }), + [enabledRuleId]: createAutomationRule(), + }, + }), + })).resolves.toMatchInlineSnapshot(` + { + "nextCursor": null, + "scannedTenancyCount": 1, + "targets": [ + { + "ruleId": "low-api-credits", + "tenancyId": "tenancy-1", + }, + ], + } + `); + expect(captureErrorMock).toHaveBeenCalledWith("automation-scheduler-invalid-rule", expect.any(Error)); }); }); @@ -211,6 +245,27 @@ describe("scheduled automation queueing", () => { }); }); + it("reports zero enqueues when a duplicate pending OutgoingRequest already exists", async () => { + const prisma = { + outgoingRequest: { + createMany: vi.fn(async () => ({ count: 0 })), + }, + }; + + await expect(enqueueScheduledAutomationRuns({ + prisma, + targets: [{ + tenancyId: "tenancy-1", + ruleId: enabledRuleId, + }], + scheduledAt: new Date("2026-07-01T12:00:00.000Z"), + })).resolves.toEqual({ enqueuedCount: 0 }); + + expect(prisma.outgoingRequest.createMany).toHaveBeenCalledWith(expect.objectContaining({ + skipDuplicates: true, + })); + }); + it("uses cursor-specific dedupe keys for continuation pages", () => { expect(getScheduledAutomationDeduplicationKey({ tenancyId: "tenancy-1", @@ -265,6 +320,48 @@ describe("scheduled automation worker orchestration", () => { }); }); + it("reports duplicate continuation work without failing the completed page", async () => { + const runRule = vi.fn(async () => createRunResult("user-100")); + const enqueueContinuation = vi.fn(async () => ({ enqueuedCount: 0 })); + + await expect(runScheduledAutomationRulePage({ + tenancyId: "tenancy-1", + ruleId: enabledRuleId, + limit: 100, + scheduledAt: new Date("2026-07-01T12:00:00.000Z"), + now: new Date("2026-07-01T12:01:00.000Z"), + getTenancyById: async () => createTenancy(), + runRule, + enqueueContinuation, + })).resolves.toMatchObject({ + status: "ran", + enqueuedContinuation: false, + result: { + nextCursor: "user-100", + }, + }); + }); + + it("propagates continuation enqueue failures so the queue can retry the page", async () => { + const runRule = vi.fn(async () => createRunResult("user-100")); + const enqueueContinuation = vi.fn(async () => { + throw new Error("queue unavailable"); + }); + + await expect(runScheduledAutomationRulePage({ + tenancyId: "tenancy-1", + ruleId: enabledRuleId, + limit: 100, + scheduledAt: new Date("2026-07-01T12:00:00.000Z"), + now: new Date("2026-07-01T12:01:00.000Z"), + getTenancyById: async () => createTenancy(), + runRule, + enqueueContinuation, + })).rejects.toThrow("queue unavailable"); + + expect(runRule).toHaveBeenCalledOnce(); + }); + it("skips stale queued work when the rule was disabled after enqueue", async () => { const runRule = vi.fn(async () => createRunResult(null)); diff --git a/apps/backend/src/lib/automations/scheduler.ts b/apps/backend/src/lib/automations/scheduler.ts index 66f444b7a..f2e4f0dee 100644 --- a/apps/backend/src/lib/automations/scheduler.ts +++ b/apps/backend/src/lib/automations/scheduler.ts @@ -12,6 +12,7 @@ import { import { sendEmailToMany } from "@/lib/emails"; import { getTenancy, type Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; import { assertSupportedAutomationRule, AutomationRuleTenancy, getAutomationRule, listAutomationRules } from "./rules"; export const scheduledAutomationDiscoveryLimit = 500; @@ -142,7 +143,16 @@ export async function discoverEnabledScheduledAutomationRules(options: { if (!rule.enabled) { continue; } - assertSupportedAutomationRule(ruleId, rule); + try { + assertSupportedAutomationRule(ruleId, rule); + } catch (error) { + captureError("automation-scheduler-invalid-rule", new HexclaveAssertionError(`Skipping invalid scheduled automation rule "${ruleId}" for tenancy "${tenancy.id}".`, { + cause: error, + tenancyId: tenancy.id, + ruleId, + })); + continue; + } targets.push({ tenancyId: tenancy.id, ruleId, diff --git a/apps/backend/src/lib/automations/sources/payments-item-quota.ts b/apps/backend/src/lib/automations/sources/payments-item-quota.ts index 906eba652..4e32886a4 100644 --- a/apps/backend/src/lib/automations/sources/payments-item-quota.ts +++ b/apps/backend/src/lib/automations/sources/payments-item-quota.ts @@ -235,8 +235,7 @@ function getThresholdKind(options: { rule: PaymentsItemQuotaAutomationRule, }): "near" | "over" | null { const thresholds = options.rule.source.thresholds; - const overLimitQuantity = thresholds.overLimitQuantity ?? 0; - if (options.currentQuantity <= overLimitQuantity) { + if (thresholds.overLimitQuantity !== undefined && options.currentQuantity <= thresholds.overLimitQuantity) { return "over"; } if (thresholds.nearRemainingQuantity !== undefined && options.currentQuantity <= thresholds.nearRemainingQuantity) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts index 1d68d8142..83249c1be 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts @@ -345,4 +345,37 @@ describe("usage email automation dashboard helpers", () => { } `); }); + + it("fails loudly for malformed automation decision rows", () => { + expect(() => parseAutomationRouteResult({ + rule_id: "usage-upgrade", + mode: "dry-run", + evaluated_count: 1, + eligible_count: 1, + suppressed_count: 0, + next_cursor: null, + decisions: [ + { + subject_type: "user", + subject_id: "user-1", + source: { + type: "payments-item-quota", + item_id: "credits", + current_quantity: 12, + entitlement_quantity: 10, + threshold_kind: "future-threshold", + owned_product_ids: [], + active_subscription_ids: [], + }, + cooldown: { + blocked: false, + }, + recipient: { + user_exists: true, + has_primary_email: true, + }, + }, + ], + })).toThrowErrorMatchingInlineSnapshot(`[Error: Automation response threshold_kind "future-threshold" is unsupported]`); + }); }); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx index b7c0e83e5..dd221ac2e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx @@ -424,23 +424,31 @@ export function parseAutomationRouteResult(value: unknown): AutomationRouteResul suppressedCount: requireNumberFromRecord(value, "suppressed_count"), sentCount: readNumber(value.sent_count), nextCursor: value.next_cursor === null ? null : requireStringFromRecord(value, "next_cursor"), - decisions: value.decisions.flatMap((rawDecision) => parseDecision(rawDecision)), + decisions: value.decisions.map((rawDecision) => parseDecision(rawDecision)), }; } -function parseDecision(rawDecision: unknown): AutomationDecision[] { - if (!isRecord(rawDecision)) return []; +function parseDecision(rawDecision: unknown): AutomationDecision { + if (!isRecord(rawDecision)) { + throw new Error("Automation response decision did not match the expected shape"); + } const source = isRecord(rawDecision.source) ? rawDecision.source : undefined; const cooldown = isRecord(rawDecision.cooldown) ? rawDecision.cooldown : undefined; const recipient = isRecord(rawDecision.recipient) ? rawDecision.recipient : undefined; - if (source === undefined || cooldown === undefined) return []; + if (source === undefined || cooldown === undefined) { + throw new Error("Automation response decision did not match the expected shape"); + } + const subjectType = requireStringFromRecord(rawDecision, "subject_type"); + if (subjectType !== "user") { + throw new Error(`Automation response subject_type "${subjectType}" is unsupported`); + } const thresholdKind = requireStringFromRecord(source, "threshold_kind"); if (thresholdKind !== "near" && thresholdKind !== "over") { - return []; + throw new Error(`Automation response threshold_kind "${thresholdKind}" is unsupported`); } - return [{ - subjectType: "user", + return { + subjectType, subjectId: requireStringFromRecord(rawDecision, "subject_id"), thresholdKind, currentQuantity: requireNumberFromRecord(source, "current_quantity"), @@ -449,7 +457,7 @@ function parseDecision(rawDecision: unknown): AutomationDecision[] { sent: readBoolean(rawDecision.sent), skipReason: readString(rawDecision.skip_reason), hasPrimaryEmail: recipient === undefined ? undefined : readBoolean(recipient.has_primary_email), - }]; + }; } async function requestAutomationRun(adminApp: object, ruleId: string, mode: "dry-run" | "run"): Promise { @@ -791,25 +799,27 @@ function RuleEditorDialog(props: { {/* SECTION 0: General Identity Block */}
- + {(fieldId) => ( setDraftField("ruleId", sanitizeUserSpecifiedId(event.target.value))} size="md" className="font-mono text-xs h-9" /> - + )}
- + {(fieldId) => ( setDraftField("displayName", event.target.value)} size="md" className="h-9" /> - + )}
@@ -849,8 +859,9 @@ function RuleEditorDialog(props: {
- + {(fieldId) => ( setDraftField("itemId", value)} options={props.itemOptions} @@ -858,9 +869,10 @@ function RuleEditorDialog(props: { disabled={props.itemOptions.length === 0} size="md" /> - - + )} + {(fieldId) => ( - + )}
@@ -892,8 +904,9 @@ function RuleEditorDialog(props: {
- + {(fieldId) => ( - - + )} + {(fieldId) => ( - - + )} + {(fieldId) => ( - + )}
@@ -948,8 +963,9 @@ function RuleEditorDialog(props: {
- + {(fieldId) => ( setDraftField("templateId", value)} options={props.templateOptions} @@ -957,29 +973,31 @@ function RuleEditorDialog(props: { disabled={props.templateOptions.length === 0} size="md" /> - - + )} + {(fieldId) => ( setDraftField("themeId", value)} options={props.themeOptions} size="md" /> - - + )} + {(fieldId) => ( setDraftField("subject", event.target.value)} size="md" placeholder="e.g. Action Required: Your quota is low" /> - - -
+ )} + {(fieldId) => ( +
Category
-
+ )}
@@ -1215,7 +1233,7 @@ function Metric(props: { label: string, value: number }) { ); } -function Field(props: { label: string, helper?: string, children: React.ReactNode }) { +function Field(props: { label: string, helper?: string, children: (id: string) => React.ReactNode }) { const id = `usage-email-field-${props.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; return (
@@ -1227,7 +1245,7 @@ function Field(props: { label: string, helper?: string, children: React.ReactNod ) : null} -
{props.children}
+
{props.children(id)}
); } From bf99da4c2e51f592f2d72e9b1d6d70438a2a5657 Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Tue, 14 Jul 2026 18:43:48 +0000 Subject: [PATCH 08/10] Email Automation V1 Bot Comment Fix Batch II --- .../rules/[rule_id]/dry-run/route.test.ts | 138 +++--------------- .../rules/[rule_id]/run/route.test.ts | 138 +++--------------- .../rules/[rule_id]/test-helpers.ts | 113 ++++++++++++++ .../src/lib/automations/dry-run-route.ts | 53 +------ apps/backend/src/lib/automations/run-route.ts | 43 +----- .../src/lib/automations/source-snapshot.ts | 58 ++++++++ .../email-automations/page-client.test.ts | 87 +++++++++++ .../email-automations/page-client.tsx | 54 ++++++- 8 files changed, 355 insertions(+), 329 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/test-helpers.ts create mode 100644 apps/backend/src/lib/automations/source-snapshot.ts diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts index babea6cf3..003f69da7 100644 --- a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts @@ -1,105 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import { AutomationActionAdapter, AutomationSourceAdapter } from "@/lib/automations/rule-evaluator"; import { evaluateAutomationRuleDryRunForRoute } from "@/lib/automations/dry-run-route"; import { createPrismaAutomationRuleExecutionStateReader } from "@/lib/automations/execution-state-store"; +import { + automationRouteTestRuleId as ruleId, + createAutomationRouteTestActionAdapter, + createAutomationRouteTestSourceAdapter, + createAutomationRouteTestTenancy, +} from "../test-helpers"; -const ruleId = "low-api-credits"; const now = new Date("2026-07-01T00:00:00.000Z"); -function createTenancy(options: { - enabled?: boolean, - ruleExists?: boolean, -} = {}) { - return { - id: "tenancy-1", - project: { - display_name: "Acme App", - }, - config: { - automations: { - rules: options.ruleExists === false ? {} : { - [ruleId]: { - enabled: options.enabled ?? true, - source: { - type: "payments-item-quota", - itemId: "api_credits", - customerType: "user", - thresholds: { - nearRemainingQuantity: 10, - }, - }, - action: { - type: "send-email", - templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", - notificationCategoryName: "Marketing", - }, - cooldown: { - days: 7, - }, - }, - }, - }, - }, - }; -} - -function createSourceAdapter(): AutomationSourceAdapter { - const evaluate: AutomationSourceAdapter["evaluate"] = async () => ({ - evaluatedCount: 2, - nextCursor: "cursor-2", - decisions: [{ - subject: { - type: "user", - id: "user-1", - }, - signal: { - key: "api_credits:near", - kind: "near", - }, - sourceSnapshot: { - sourceType: "payments-item-quota", - itemId: "api_credits", - itemDisplayName: "API credits", - currentQuantity: 7, - entitlementQuantity: 100, - thresholdKind: "near", - ownedProductIds: ["pro"], - activeSubscriptionIds: ["sub_1"], - }, - }], - }); - return { - evaluate: vi.fn(evaluate), - }; -} - -function createActionAdapter(): AutomationActionAdapter { - const buildPlan: AutomationActionAdapter["buildPlan"] = async (options) => ({ - type: "send-email", - recipient: { - type: "user-primary-email", - userId: options.decision.subject.id, - }, - tsxSource: "export function EmailTemplate() { return null; }", - templateId: options.rule.action.templateId, - themeId: null, - notificationCategoryName: "Marketing", - notificationCategoryId: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", - createdWith: { - type: "programmatic-call", - templateId: options.rule.action.templateId, - }, - isHighPriority: false, - shouldSkipDeliverabilityCheck: false, - variables: { - automationRuleId: options.ruleId, - }, - }); - return { - buildPlan: vi.fn(buildPlan), - }; -} - function createFakePrisma(options: { lastActionAt?: Date | null, } = {}) { @@ -122,11 +32,11 @@ function createFakePrisma(options: { describe("automation dry-run route helpers", () => { it("returns 404 for missing rules before evaluating or writing state", async () => { const fakePrisma = createFakePrisma(); - const sourceAdapter = createSourceAdapter(); - const actionAdapter = createActionAdapter(); + const sourceAdapter = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); const resultPromise = evaluateAutomationRuleDryRunForRoute({ - tenancy: createTenancy({ ruleExists: false }), + tenancy: createAutomationRouteTestTenancy({ ruleExists: false }), ruleId, prisma: fakePrisma, sourceAdapter, @@ -149,11 +59,11 @@ describe("automation dry-run route helpers", () => { it("allows disabled rules to be previewed without writing automation state or email outbox rows", async () => { const fakePrisma = createFakePrisma(); - const sourceAdapter = createSourceAdapter(); - const actionAdapter = createActionAdapter(); + const sourceAdapter = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); await expect(evaluateAutomationRuleDryRunForRoute({ - tenancy: createTenancy({ enabled: false }), + tenancy: createAutomationRouteTestTenancy({ enabled: false }), ruleId, prisma: fakePrisma, sourceAdapter, @@ -190,15 +100,15 @@ describe("automation dry-run route helpers", () => { it("returns the dry-run API response without writing automation state or email outbox rows", async () => { const fakePrisma = createFakePrisma(); - const sourceAdapter = createSourceAdapter(); - const actionAdapter = createActionAdapter(); + const sourceAdapter = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); const recipientStatusReader = vi.fn(async () => new Map([["user-1", { userExists: true, hasPrimaryEmail: true, }]])); await expect(evaluateAutomationRuleDryRunForRoute({ - tenancy: createTenancy(), + tenancy: createAutomationRouteTestTenancy(), ruleId, limit: 25, cursor: "cursor-1", @@ -271,11 +181,11 @@ describe("automation dry-run route helpers", () => { const fakePrisma = createFakePrisma({ lastActionAt }); await expect(evaluateAutomationRuleDryRunForRoute({ - tenancy: createTenancy(), + tenancy: createAutomationRouteTestTenancy(), ruleId, prisma: fakePrisma, - sourceAdapter: createSourceAdapter(), - actionAdapter: createActionAdapter(), + sourceAdapter: createAutomationRouteTestSourceAdapter(), + actionAdapter: createAutomationRouteTestActionAdapter(), recipientStatusReader: async () => new Map(), executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), now, @@ -304,11 +214,11 @@ describe("automation dry-run route helpers", () => { }); await expect(evaluateAutomationRuleDryRunForRoute({ - tenancy: createTenancy(), + tenancy: createAutomationRouteTestTenancy(), ruleId, prisma: fakePrisma, - sourceAdapter: createSourceAdapter(), - actionAdapter: createActionAdapter(), + sourceAdapter: createAutomationRouteTestSourceAdapter(), + actionAdapter: createAutomationRouteTestActionAdapter(), recipientStatusReader: async () => new Map(), executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), now, @@ -332,11 +242,11 @@ describe("automation dry-run route helpers", () => { const fakePrisma = createFakePrisma(); await expect(evaluateAutomationRuleDryRunForRoute({ - tenancy: createTenancy(), + tenancy: createAutomationRouteTestTenancy(), ruleId, prisma: fakePrisma, - sourceAdapter: createSourceAdapter(), - actionAdapter: createActionAdapter(), + sourceAdapter: createAutomationRouteTestSourceAdapter(), + actionAdapter: createAutomationRouteTestActionAdapter(), recipientStatusReader: async () => new Map(), executionStateReader: createPrismaAutomationRuleExecutionStateReader(fakePrisma), now, diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts index be14e41a0..0c54eb52e 100644 --- a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts @@ -4,15 +4,21 @@ import { AutomationRuleExecutionStatePrisma, createPrismaAutomationRuleExecutionStateStore, } from "@/lib/automations/execution-state-store"; -import { AutomationActionAdapter, AutomationSourceAdapter, AutomationSourceDecision } from "@/lib/automations/rule-evaluator"; +import { AutomationSourceDecision } from "@/lib/automations/rule-evaluator"; import { AutomationRuleExecutionStateStore, automationRunResultToApiBody, runAutomationRuleForRoute, } from "@/lib/automations/run-route"; import { parseAutomationScheduledAtMillis } from "@/lib/automations/scheduled-at"; +import { + automationRouteTestRuleId as ruleId, + createAutomationRouteTestActionAdapter, + createAutomationRouteTestSourceAdapter, + createAutomationRouteTestSourceDecision, + createAutomationRouteTestTenancy, +} from "../test-helpers"; -const ruleId = "low-api-credits"; const scheduledAt = new Date("2026-07-01T12:00:00.000Z"); describe("automation scheduled timestamp parsing", () => { @@ -31,110 +37,6 @@ describe("automation scheduled timestamp parsing", () => { }); }); -function createTenancy(options: { - enabled?: boolean, - ruleExists?: boolean, -} = {}) { - return { - id: "tenancy-1", - project: { - display_name: "Acme App", - }, - config: { - automations: { - rules: options.ruleExists === false ? {} : { - [ruleId]: { - enabled: options.enabled ?? true, - source: { - type: "payments-item-quota", - itemId: "api_credits", - customerType: "user", - thresholds: { - nearRemainingQuantity: 10, - }, - }, - action: { - type: "send-email", - templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", - notificationCategoryName: "Marketing", - }, - cooldown: { - days: 7, - }, - }, - }, - }, - }, - }; -} - -function createDecision(options: { - userId?: string, - kind?: "near" | "over", -} = {}): AutomationSourceDecision { - const kind = options.kind ?? "near"; - return { - subject: { - type: "user", - id: options.userId ?? "user-1", - }, - signal: { - key: `api_credits:${kind}`, - kind, - }, - sourceSnapshot: { - sourceType: "payments-item-quota", - itemId: "api_credits", - itemDisplayName: "API credits", - currentQuantity: kind === "over" ? 0 : 7, - entitlementQuantity: 100, - thresholdKind: kind, - ownedProductIds: ["pro"], - activeSubscriptionIds: ["sub_1"], - }, - }; -} - -function createSourceAdapter(decisionFactory: () => AutomationSourceDecision = createDecision): AutomationSourceAdapter { - const evaluate: AutomationSourceAdapter["evaluate"] = async () => ({ - evaluatedCount: 1, - nextCursor: "cursor-2", - decisions: [decisionFactory()], - }); - return { - evaluate: vi.fn(evaluate), - }; -} - -function createActionAdapter(): AutomationActionAdapter { - const buildPlan: AutomationActionAdapter["buildPlan"] = async (options) => ({ - type: "send-email", - recipient: { - type: "user-primary-email", - userId: options.decision.subject.id, - }, - tsxSource: "export function EmailTemplate() { return null; }", - templateId: options.rule.action.templateId, - themeId: null, - notificationCategoryName: "Marketing", - notificationCategoryId: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", - createdWith: { - type: "programmatic-call", - templateId: options.rule.action.templateId, - }, - isHighPriority: false, - shouldSkipDeliverabilityCheck: false, - variables: { - automationRuleId: options.ruleId, - ...options.decision.sourceSnapshot, - projectDisplayName: "Acme App", - }, - }); - return { - buildPlan: vi.fn(buildPlan), - }; -} - type InMemoryExecutionState = { lastTriggeredAt: Date, lastActionAt: Date | null, @@ -211,8 +113,8 @@ async function runWithFakes(options: { cursor?: string | null, ruleEnabled?: boolean, } = {}) { - const sourceAdapter = createSourceAdapter(options.decisionFactory); - const actionAdapter = createActionAdapter(); + const sourceAdapter = createAutomationRouteTestSourceAdapter(options.decisionFactory, { evaluatedCount: 1 }); + const actionAdapter = createAutomationRouteTestActionAdapter(); const emailSender = vi.fn(options.emailSender ?? (async () => {})); let createdStore: ReturnType | undefined; let stateStore = options.stateStore; @@ -222,7 +124,7 @@ async function runWithFakes(options: { } const result = await runAutomationRuleForRoute({ - tenancy: createTenancy(options.ruleEnabled === undefined ? {} : { enabled: options.ruleEnabled }), + tenancy: createAutomationRouteTestTenancy(options.ruleEnabled === undefined ? {} : { enabled: options.ruleEnabled }), ruleId, limit: options.limit, cursor: options.cursor, @@ -245,13 +147,13 @@ async function runWithFakes(options: { describe("automation real-send route helpers", () => { it("returns 404 for missing manual rules before evaluating, claiming state, or sending email", async () => { - const sourceAdapter = createSourceAdapter(); - const actionAdapter = createActionAdapter(); + const sourceAdapter = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); const emailSender = vi.fn(async () => {}); const { stateStore } = createInMemoryStateStore(); const resultPromise = runAutomationRuleForRoute({ - tenancy: createTenancy({ ruleExists: false }), + tenancy: createAutomationRouteTestTenancy({ ruleExists: false }), ruleId, scheduledAt, now: new Date("2026-07-01T12:00:00.000Z"), @@ -271,13 +173,13 @@ describe("automation real-send route helpers", () => { }); it("refuses disabled rules before evaluating, claiming state, or sending email", async () => { - const sourceAdapter = createSourceAdapter(); - const actionAdapter = createActionAdapter(); + const sourceAdapter = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); const emailSender = vi.fn(async () => {}); const { stateStore } = createInMemoryStateStore(); await expect(runAutomationRuleForRoute({ - tenancy: createTenancy({ enabled: false }), + tenancy: createAutomationRouteTestTenancy({ enabled: false }), ruleId, scheduledAt, now: new Date("2026-07-01T12:00:00.000Z"), @@ -353,7 +255,7 @@ describe("automation real-send route helpers", () => { lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), lastActionAt: null, lastEmailOutboxId: null, - lastSourceSnapshot: createDecision().sourceSnapshot, + lastSourceSnapshot: createAutomationRouteTestSourceDecision().sourceSnapshot, }); const second = await runWithFakes({ @@ -381,7 +283,7 @@ describe("automation real-send route helpers", () => { const near = await runWithFakes({ stateStore }); const over = await runWithFakes({ stateStore, - decisionFactory: () => createDecision({ kind: "over" }), + decisionFactory: () => createAutomationRouteTestSourceDecision({ kind: "over" }), now: new Date("2026-07-02T12:00:00.000Z"), }); @@ -607,7 +509,7 @@ function createClaimOptions(options: { signalKey: "api_credits:near", lastTriggeredAt: options.lastTriggeredAt ?? new Date("2026-07-01T12:00:00.000Z"), cooldownDays: 7, - sourceSnapshot: createDecision().sourceSnapshot, + sourceSnapshot: createAutomationRouteTestSourceDecision().sourceSnapshot, }; } diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/test-helpers.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/test-helpers.ts new file mode 100644 index 000000000..dbfd012b0 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/test-helpers.ts @@ -0,0 +1,113 @@ +import { AutomationActionAdapter, AutomationSourceAdapter, AutomationSourceDecision } from "@/lib/automations/rule-evaluator"; +import { vi } from "vitest"; + +export const automationRouteTestRuleId = "low-api-credits"; + +export function createAutomationRouteTestTenancy(options: { + enabled?: boolean, + ruleExists?: boolean, +} = {}) { + return { + id: "tenancy-1", + project: { + display_name: "Acme App", + }, + config: { + automations: { + rules: options.ruleExists === false ? {} : { + [automationRouteTestRuleId]: { + enabled: options.enabled ?? true, + source: { + type: "payments-item-quota", + itemId: "api_credits", + customerType: "user", + thresholds: { + nearRemainingQuantity: 10, + }, + }, + action: { + type: "send-email", + templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + notificationCategoryName: "Marketing", + }, + cooldown: { + days: 7, + }, + }, + }, + }, + }, + }; +} + +export function createAutomationRouteTestSourceDecision(options: { + userId?: string, + kind?: "near" | "over", +} = {}): AutomationSourceDecision { + const kind = options.kind ?? "near"; + return { + subject: { + type: "user", + id: options.userId ?? "user-1", + }, + signal: { + key: `api_credits:${kind}`, + kind, + }, + sourceSnapshot: { + sourceType: "payments-item-quota", + itemId: "api_credits", + itemDisplayName: "API credits", + currentQuantity: kind === "over" ? 0 : 7, + entitlementQuantity: 100, + thresholdKind: kind, + ownedProductIds: ["pro"], + activeSubscriptionIds: ["sub_1"], + }, + }; +} + +export function createAutomationRouteTestSourceAdapter( + decisionFactory: () => AutomationSourceDecision = createAutomationRouteTestSourceDecision, + options: { + evaluatedCount?: number, + } = {}, +): AutomationSourceAdapter { + const evaluate: AutomationSourceAdapter["evaluate"] = async () => ({ + evaluatedCount: options.evaluatedCount ?? 2, + nextCursor: "cursor-2", + decisions: [decisionFactory()], + }); + return { + evaluate: vi.fn(evaluate), + }; +} + +export function createAutomationRouteTestActionAdapter(): AutomationActionAdapter { + const buildPlan: AutomationActionAdapter["buildPlan"] = async (options) => ({ + type: "send-email", + recipient: { + type: "user-primary-email", + userId: options.decision.subject.id, + }, + tsxSource: "export function EmailTemplate() { return null; }", + templateId: options.rule.action.templateId, + themeId: null, + notificationCategoryName: "Marketing", + notificationCategoryId: "4f6f8873-3d04-46bd-8bef-18338b1a1b4c", + createdWith: { + type: "programmatic-call", + templateId: options.rule.action.templateId, + }, + isHighPriority: false, + shouldSkipDeliverabilityCheck: false, + variables: { + automationRuleId: options.ruleId, + ...options.decision.sourceSnapshot, + projectDisplayName: "Acme App", + }, + }); + return { + buildPlan: vi.fn(buildPlan), + }; +} diff --git a/apps/backend/src/lib/automations/dry-run-route.ts b/apps/backend/src/lib/automations/dry-run-route.ts index 39d686717..fac366291 100644 --- a/apps/backend/src/lib/automations/dry-run-route.ts +++ b/apps/backend/src/lib/automations/dry-run-route.ts @@ -3,6 +3,7 @@ import { automationCooldownStatusToApiBody, type AutomationCooldownStatus } from import { AutomationRuleExecutionStateReader } from "./execution-state-store"; import { AutomationActionAdapter, AutomationEvaluationResult, AutomationSourceAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; import { AutomationRuleNotFoundError, AutomationRuleTenancy, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; +import { PaymentsItemQuotaSourceApiBody, paymentsItemQuotaSourceSnapshotToApiBody } from "./source-snapshot"; export type AutomationDryRunRecipientStatus = { userExists: boolean, @@ -18,15 +19,7 @@ export type AutomationDryRunRecipientStatusReader = (options: { type AutomationDryRunDecisionApiItem = { subject_type: "user", subject_id: string, - source: { - type: "payments-item-quota", - item_id: string, - current_quantity: number, - entitlement_quantity: number | null, - threshold_kind: "near" | "over", - owned_product_ids: string[], - active_subscription_ids: string[], - }, + source: PaymentsItemQuotaSourceApiBody, action: { type: "send-email", template_id: string, @@ -141,15 +134,7 @@ function automationDryRunDecisionToApiItem( return { subject_type: decision.subject.type, subject_id: decision.subject.id, - source: { - type: "payments-item-quota", - item_id: getStringSourceSnapshotValue(decision, "itemId"), - current_quantity: getNumberSourceSnapshotValue(decision, "currentQuantity"), - entitlement_quantity: getNullableNumberSourceSnapshotValue(decision, "entitlementQuantity"), - threshold_kind: decision.signal.kind, - owned_product_ids: getStringArraySourceSnapshotValue(decision, "ownedProductIds"), - active_subscription_ids: getStringArraySourceSnapshotValue(decision, "activeSubscriptionIds"), - }, + source: paymentsItemQuotaSourceSnapshotToApiBody(decision, "Automation dry-run decision"), action: { type: decision.action.type, template_id: decision.action.templateId, @@ -162,35 +147,3 @@ function automationDryRunDecisionToApiItem( }, }; } - -function getStringSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { - const value = decision.sourceSnapshot[key]; - if (typeof value !== "string") { - throw new Error(`Automation dry-run decision sourceSnapshot.${key} must be a string.`); - } - return value; -} - -function getNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { - const value = decision.sourceSnapshot[key]; - if (typeof value !== "number") { - throw new Error(`Automation dry-run decision sourceSnapshot.${key} must be a number.`); - } - return value; -} - -function getNullableNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { - const value = decision.sourceSnapshot[key]; - if (value !== null && typeof value !== "number") { - throw new Error(`Automation dry-run decision sourceSnapshot.${key} must be a number or null.`); - } - return value; -} - -function getStringArraySourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { - const value = decision.sourceSnapshot[key]; - if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { - throw new Error(`Automation dry-run decision sourceSnapshot.${key} must be an array of strings.`); - } - return value; -} diff --git a/apps/backend/src/lib/automations/run-route.ts b/apps/backend/src/lib/automations/run-route.ts index 70e6623ae..3d5842f9b 100644 --- a/apps/backend/src/lib/automations/run-route.ts +++ b/apps/backend/src/lib/automations/run-route.ts @@ -1,6 +1,7 @@ import { StatusError } from "@hexclave/shared/dist/utils/errors"; import { AutomationJson, AutomationRuleNotFoundError, AutomationRuleTenancy, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; import { AutomationActionPlan, AutomationEvaluationResult, AutomationSourceAdapter, AutomationActionAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; +import { paymentsItemQuotaSourceSnapshotToApiBody } from "./source-snapshot"; export type AutomationRuleExecutionClaimResult = | { @@ -183,15 +184,7 @@ export function automationRunResultToApiBody(result: AutomationRunResult) { subject_id: resultDecision.decision.subject.id, signal_key: resultDecision.decision.signal.key, sent: resultDecision.sent, - source: { - type: "payments-item-quota", - item_id: getStringSourceSnapshotValue(resultDecision.decision, "itemId"), - current_quantity: getNumberSourceSnapshotValue(resultDecision.decision, "currentQuantity"), - entitlement_quantity: getNullableNumberSourceSnapshotValue(resultDecision.decision, "entitlementQuantity"), - threshold_kind: resultDecision.decision.signal.kind, - owned_product_ids: getStringArraySourceSnapshotValue(resultDecision.decision, "ownedProductIds"), - active_subscription_ids: getStringArraySourceSnapshotValue(resultDecision.decision, "activeSubscriptionIds"), - }, + source: paymentsItemQuotaSourceSnapshotToApiBody(resultDecision.decision, "Automation run decision"), action: { type: resultDecision.decision.action.type, template_id: resultDecision.decision.action.templateId, @@ -224,35 +217,3 @@ function getBlockedCooldownDetails(lastActionAt: Date | null, cooldownDays: numb nextEligibleAtMillis, }; } - -function getStringSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { - const value = decision.sourceSnapshot[key]; - if (typeof value !== "string") { - throw new Error(`Automation run decision sourceSnapshot.${key} must be a string.`); - } - return value; -} - -function getNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { - const value = decision.sourceSnapshot[key]; - if (typeof value !== "number") { - throw new Error(`Automation run decision sourceSnapshot.${key} must be a number.`); - } - return value; -} - -function getNullableNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { - const value = decision.sourceSnapshot[key]; - if (value !== null && typeof value !== "number") { - throw new Error(`Automation run decision sourceSnapshot.${key} must be a number or null.`); - } - return value; -} - -function getStringArraySourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string) { - const value = decision.sourceSnapshot[key]; - if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { - throw new Error(`Automation run decision sourceSnapshot.${key} must be an array of strings.`); - } - return value; -} diff --git a/apps/backend/src/lib/automations/source-snapshot.ts b/apps/backend/src/lib/automations/source-snapshot.ts new file mode 100644 index 000000000..150e82d41 --- /dev/null +++ b/apps/backend/src/lib/automations/source-snapshot.ts @@ -0,0 +1,58 @@ +import { EvaluatedAutomationDecision } from "./rule-evaluator"; + +export type PaymentsItemQuotaSourceApiBody = { + type: "payments-item-quota", + item_id: string, + current_quantity: number, + entitlement_quantity: number | null, + threshold_kind: "near" | "over", + owned_product_ids: string[], + active_subscription_ids: string[], +}; + +export function paymentsItemQuotaSourceSnapshotToApiBody( + decision: EvaluatedAutomationDecision, + context: string, +): PaymentsItemQuotaSourceApiBody { + return { + type: "payments-item-quota", + item_id: getStringSourceSnapshotValue(decision, "itemId", context), + current_quantity: getNumberSourceSnapshotValue(decision, "currentQuantity", context), + entitlement_quantity: getNullableNumberSourceSnapshotValue(decision, "entitlementQuantity", context), + threshold_kind: decision.signal.kind, + owned_product_ids: getStringArraySourceSnapshotValue(decision, "ownedProductIds", context), + active_subscription_ids: getStringArraySourceSnapshotValue(decision, "activeSubscriptionIds", context), + }; +} + +function getStringSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string, context: string) { + const value = decision.sourceSnapshot[key]; + if (typeof value !== "string") { + throw new Error(`${context} sourceSnapshot.${key} must be a string.`); + } + return value; +} + +function getNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string, context: string) { + const value = decision.sourceSnapshot[key]; + if (typeof value !== "number") { + throw new Error(`${context} sourceSnapshot.${key} must be a number.`); + } + return value; +} + +function getNullableNumberSourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string, context: string) { + const value = decision.sourceSnapshot[key]; + if (value !== null && typeof value !== "number") { + throw new Error(`${context} sourceSnapshot.${key} must be a number or null.`); + } + return value; +} + +function getStringArraySourceSnapshotValue(decision: EvaluatedAutomationDecision, key: string, context: string) { + const value = decision.sourceSnapshot[key]; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { + throw new Error(`${context} sourceSnapshot.${key} must be an array of strings.`); + } + return value; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts index 83249c1be..f9a7fea74 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts @@ -5,6 +5,8 @@ import { parseAutomationRouteResult, readRules, readUserItemOptions, + deleteUsageEmailAutomationRule, + saveUsageEmailAutomationRule, } from "./page-client"; vi.mock("@/components/design-components", () => ({ @@ -378,4 +380,89 @@ describe("usage email automation dashboard helpers", () => { ], })).toThrowErrorMatchingInlineSnapshot(`[Error: Automation response threshold_kind "future-threshold" is unsupported]`); }); + + it("writes valid rule IDs to automations.rules config paths", async () => { + const updateConfig = vi.fn(async () => true); + const adminApp = { + getProject: async () => { + throw new Error("getProject should not be called by config path helper tests"); + }, + }; + const rule = buildRuleFromDraft({ + ruleId: "usage-upgrade", + displayName: "Usage upgrade", + enabled: true, + itemId: "credits", + nearRemainingRatio: "0.25", + nearRemainingQuantity: "", + overLimitQuantity: "", + templateId: "00000000-0000-0000-0000-000000000001", + themeId: "__project_default__", + subject: "", + cooldownDays: "7", + }); + + await saveUsageEmailAutomationRule({ + applyConfigUpdate: updateConfig, + adminApp, + ruleId: "usage-upgrade", + rule, + }); + await deleteUsageEmailAutomationRule({ + applyConfigUpdate: updateConfig, + adminApp, + ruleId: "usage-upgrade", + }); + + expect(updateConfig).toHaveBeenNthCalledWith(1, { + adminApp, + configUpdate: { + "automations.rules.usage-upgrade": rule, + }, + pushable: true, + }); + expect(updateConfig).toHaveBeenNthCalledWith(2, { + adminApp, + configUpdate: { + "automations.rules.usage-upgrade": null, + }, + pushable: true, + }); + }); + + it.each(["bad.path", "__proto__", "constructor", "prototype"])("rejects unsafe rule ID %s before updating config", async (ruleId) => { + const updateConfig = vi.fn(async () => true); + const adminApp = { + getProject: async () => { + throw new Error("getProject should not be called by config path helper tests"); + }, + }; + const rule = buildRuleFromDraft({ + ruleId: "usage-upgrade", + displayName: "Usage upgrade", + enabled: true, + itemId: "credits", + nearRemainingRatio: "0.25", + nearRemainingQuantity: "", + overLimitQuantity: "", + templateId: "00000000-0000-0000-0000-000000000001", + themeId: "__project_default__", + subject: "", + cooldownDays: "7", + }); + + await expect(saveUsageEmailAutomationRule({ + applyConfigUpdate: updateConfig, + adminApp, + ruleId, + rule, + })).rejects.toThrow(`Unsafe usage email rule ID "${ruleId}" cannot be used in a config path.`); + await expect(deleteUsageEmailAutomationRule({ + applyConfigUpdate: updateConfig, + adminApp, + ruleId, + })).rejects.toThrow(`Unsafe usage email rule ID "${ruleId}" cannot be used in a config path.`); + + expect(updateConfig).not.toHaveBeenCalled(); + }); }); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx index dd221ac2e..dc3f439da 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx @@ -71,6 +71,12 @@ type UsageEmailAutomationRule = { }, }; +type ConfigUpdater = (options: { + adminApp: TAdminApp, + configUpdate: Parameters>[0]["configUpdate"], + pushable: boolean, +}) => Promise; + type RuleEntry = { ruleId: string, rule: UsageEmailAutomationRule, @@ -383,6 +389,41 @@ function formatTemplate(templateOptions: SelectorOption[], templateId: string) { return templateOptions.find((template) => template.value === templateId)?.label ?? templateId; } +const unsafeAutomationRuleConfigPathKeys = new Set(["__proto__", "constructor", "prototype"]); + +function assertSafeAutomationRuleConfigPathId(ruleId: string) { + if (!isValidUserSpecifiedId(ruleId) || unsafeAutomationRuleConfigPathKeys.has(ruleId)) { + throw new Error(`Unsafe usage email rule ID "${ruleId}" cannot be used in a config path.`); + } +} + +export async function saveUsageEmailAutomationRule(options: { + applyConfigUpdate: ConfigUpdater, + adminApp: TAdminApp, + ruleId: string, + rule: UsageEmailAutomationRule, +}) { + assertSafeAutomationRuleConfigPathId(options.ruleId); + await options.applyConfigUpdate({ + adminApp: options.adminApp, + configUpdate: { [`automations.rules.${options.ruleId}`]: options.rule }, + pushable: true, + }); +} + +export async function deleteUsageEmailAutomationRule(options: { + applyConfigUpdate: ConfigUpdater, + adminApp: TAdminApp, + ruleId: string, +}) { + assertSafeAutomationRuleConfigPathId(options.ruleId); + await options.applyConfigUpdate({ + adminApp: options.adminApp, + configUpdate: { [`automations.rules.${options.ruleId}`]: null }, + pushable: true, + }); +} + function requireStringFromRecord(record: Record, key: string): string { const value = record[key]; if (typeof value !== "string") { @@ -499,18 +540,19 @@ export default function PageClient() { const paymentsItemsHref = urlString`/projects/${hexclaveAdminApp.projectId}/payments/products`; const saveRule = async (ruleId: string, rule: UsageEmailAutomationRule) => { - await updateConfig({ + await saveUsageEmailAutomationRule({ + applyConfigUpdate: updateConfig, adminApp: hexclaveAdminApp, - configUpdate: { [`automations.rules.${ruleId}`]: rule }, - pushable: true, + ruleId, + rule, }); }; const deleteRule = async (ruleId: string) => { - await updateConfig({ + await deleteUsageEmailAutomationRule({ + applyConfigUpdate: updateConfig, adminApp: hexclaveAdminApp, - configUpdate: { [`automations.rules.${ruleId}`]: null }, - pushable: true, + ruleId, }); }; From fa2dc67165d70c0483a3ecfdc0aef29e497585d5 Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Fri, 17 Jul 2026 01:28:21 +0000 Subject: [PATCH 09/10] Email Automation V1.5 (Fixed the Automated Email Schedule-Sender and the Email Schedule Fairness Problem) --- .../migration.sql | 17 + .../tests/scheduler-state-seed.ts | 20 + apps/backend/prisma/schema.prisma | 17 + .../automations/schedule/route.test.ts | 19 + .../internal/automations/schedule/route.ts | 62 +- .../automations/scheduled-run/route.ts | 82 --- .../lib/automations/scheduler-state.test.ts | 154 +++++ .../src/lib/automations/scheduler-state.ts | 176 +++++ .../src/lib/automations/scheduler.test.ts | 578 ++++++++--------- apps/backend/src/lib/automations/scheduler.ts | 601 ++++++++++-------- apps/backend/src/lib/email-rendering.test.tsx | 44 +- packages/shared/src/config/schema.ts | 7 + packages/shared/src/helpers/emails.ts | 7 + .../src/helpers/usage-email-template.ts | 48 ++ 14 files changed, 1146 insertions(+), 686 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260717000000_add_automation_scheduler_state/migration.sql create mode 100644 apps/backend/prisma/migrations/20260717000000_add_automation_scheduler_state/tests/scheduler-state-seed.ts create mode 100644 apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts delete mode 100644 apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts create mode 100644 apps/backend/src/lib/automations/scheduler-state.test.ts create mode 100644 apps/backend/src/lib/automations/scheduler-state.ts create mode 100644 packages/shared/src/helpers/usage-email-template.ts diff --git a/apps/backend/prisma/migrations/20260717000000_add_automation_scheduler_state/migration.sql b/apps/backend/prisma/migrations/20260717000000_add_automation_scheduler_state/migration.sql new file mode 100644 index 000000000..afc3841e7 --- /dev/null +++ b/apps/backend/prisma/migrations/20260717000000_add_automation_scheduler_state/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE "AutomationSchedulerState" ( + "key" TEXT NOT NULL, + "completedTenancyCursor" UUID, + "activeTenancyId" UUID, + "completedRuleCursor" TEXT, + "activeRuleId" TEXT, + "nextSubjectCursor" UUID, + "leaseOwner" UUID, + "leaseExpiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutomationSchedulerState_pkey" PRIMARY KEY ("key") +); + +INSERT INTO "AutomationSchedulerState" ("key", "updatedAt") +VALUES ('usage-email-v1', CURRENT_TIMESTAMP); diff --git a/apps/backend/prisma/migrations/20260717000000_add_automation_scheduler_state/tests/scheduler-state-seed.ts b/apps/backend/prisma/migrations/20260717000000_add_automation_scheduler_state/tests/scheduler-state-seed.ts new file mode 100644 index 000000000..26f86efa6 --- /dev/null +++ b/apps/backend/prisma/migrations/20260717000000_add_automation_scheduler_state/tests/scheduler-state-seed.ts @@ -0,0 +1,20 @@ +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const postMigration = async (sql: Sql) => { + const stateRows = await sql` + SELECT "key", "completedTenancyCursor", "activeTenancyId", "completedRuleCursor", + "activeRuleId", "nextSubjectCursor", "leaseOwner", "leaseExpiresAt" + FROM "AutomationSchedulerState" + `; + expect(Array.from(stateRows)).toEqual([{ + key: "usage-email-v1", + completedTenancyCursor: null, + activeTenancyId: null, + completedRuleCursor: null, + activeRuleId: null, + nextSubjectCursor: null, + leaseOwner: null, + leaseExpiresAt: null, + }]); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 08dfbccae..6465826c9 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1187,6 +1187,23 @@ model AutomationRuleExecutionState { @@index([tenancyId, ruleId, lastTriggeredAt], name: "AutomationRuleExecutionState_tenancy_rule_triggered_idx") } +model AutomationSchedulerState { + key String @id + + completedTenancyCursor String? @db.Uuid + activeTenancyId String? @db.Uuid + completedRuleCursor String? + activeRuleId String? + // Continuation cursor passed to the next source-adapter page. + nextSubjectCursor String? @db.Uuid + + leaseOwner String? @db.Uuid + leaseExpiresAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Conversation { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid diff --git a/apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts new file mode 100644 index 000000000..8b9724694 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { maxDuration, parseAutomationScheduleBound } from "./route"; + +describe("automation schedule route bounds", () => { + it("keeps the work budget below the route runtime limit", () => { + expect(maxDuration).toBe(60); + expect(parseAutomationScheduleBound("45000", "max_duration_ms", 45_000)).toBe(45_000); + }); + + it("allows omitted and lower positive integer bounds", () => { + expect(parseAutomationScheduleBound(undefined, "limit", 100)).toBeUndefined(); + expect(parseAutomationScheduleBound("25", "limit", 100)).toBe(25); + }); + + it.each(["0", "101", "1.5", "01", "not-a-number"])("rejects an invalid internal bound: %s", (value) => { + expect(() => parseAutomationScheduleBound(value, "limit", 100)) + .toThrowError("limit must be an integer between 1 and 100"); + }); +}); diff --git a/apps/backend/src/app/api/latest/internal/automations/schedule/route.ts b/apps/backend/src/app/api/latest/internal/automations/schedule/route.ts index 585e4d4d1..d583f8b09 100644 --- a/apps/backend/src/app/api/latest/internal/automations/schedule/route.ts +++ b/apps/backend/src/app/api/latest/internal/automations/schedule/route.ts @@ -1,8 +1,9 @@ import { - discoverEnabledScheduledAutomationRules, - enqueueScheduledAutomationRuns, - normalizeScheduledAutomationDiscoveryLimit, - normalizeScheduledAutomationRunPageLimit, + runScheduledAutomations, + scheduledAutomationDiscoveryLimit, + scheduledAutomationMaxPages, + scheduledAutomationRunPageLimit, + scheduledAutomationWorkBudgetMs, } from "@/lib/automations/scheduler"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@hexclave/shared/dist/schema-fields"; @@ -11,11 +12,12 @@ import { StatusError } from "@hexclave/shared/dist/utils/errors"; export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; +export const maxDuration = 60; export const GET = createSmartRouteHandler({ metadata: { - summary: "Schedule automation rule runs", - description: "Internal endpoint invoked by cron to enqueue bounded scheduled automation rule runs.", + summary: "Run scheduled automation rules", + description: "Internal endpoint invoked by cron to execute bounded scheduled automation work.", tags: ["Automations"], hidden: true, }, @@ -26,9 +28,10 @@ export const GET = createSmartRouteHandler({ authorization: yupTuple([yupString().defined()]).optional(), }).defined(), query: yupObject({ - cursor: yupString().optional(), max_tenancies: yupString().optional(), limit: yupString().optional(), + max_pages: yupString().optional(), + max_duration_ms: yupString().optional(), }).defined(), }), response: yupObject({ @@ -36,10 +39,14 @@ export const GET = createSmartRouteHandler({ bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ ok: yupBoolean().defined(), + status: yupString().oneOf(["ran", "lease-held"]).defined(), tenancies_scanned: yupNumber().integer().defined(), - rules_found: yupNumber().integer().defined(), - runs_enqueued: yupNumber().integer().defined(), - next_cursor: yupString().nullable().defined(), + rules_processed: yupNumber().integer().defined(), + pages_processed: yupNumber().integer().defined(), + evaluated_count: yupNumber().integer().defined(), + sent_count: yupNumber().integer().defined(), + suppressed_count: yupNumber().integer().defined(), + cycle_completed: yupBoolean().defined(), }).defined(), }), handler: async ({ auth, headers, query }) => { @@ -49,14 +56,11 @@ export const GET = createSmartRouteHandler({ throw new StatusError(401, "Unauthorized"); } - const discovery = await discoverEnabledScheduledAutomationRules({ - limit: parseOptionalPositiveInteger(query.max_tenancies, "max_tenancies"), - cursor: query.cursor, - }); - const enqueueResult = await enqueueScheduledAutomationRuns({ - targets: discovery.targets, - limit: parseOptionalPositiveInteger(query.limit, "limit"), - scheduledAt: new Date(), + const result = await runScheduledAutomations({ + maxTenancies: parseAutomationScheduleBound(query.max_tenancies, "max_tenancies", scheduledAutomationDiscoveryLimit), + pageLimit: parseAutomationScheduleBound(query.limit, "limit", scheduledAutomationRunPageLimit), + maxPages: parseAutomationScheduleBound(query.max_pages, "max_pages", scheduledAutomationMaxPages), + workBudgetMs: parseAutomationScheduleBound(query.max_duration_ms, "max_duration_ms", scheduledAutomationWorkBudgetMs), }); return { @@ -64,24 +68,26 @@ export const GET = createSmartRouteHandler({ bodyType: "json", body: { ok: true, - tenancies_scanned: discovery.scannedTenancyCount, - rules_found: discovery.targets.length, - runs_enqueued: enqueueResult.enqueuedCount, - next_cursor: discovery.nextCursor, + status: result.status, + tenancies_scanned: result.tenanciesScanned, + rules_processed: result.rulesProcessed, + pages_processed: result.pagesProcessed, + evaluated_count: result.evaluatedCount, + sent_count: result.sentCount, + suppressed_count: result.suppressedCount, + cycle_completed: result.cycleCompleted, }, }; }, }); -function parseOptionalPositiveInteger(value: string | null | undefined, label: "max_tenancies" | "limit") { +export function parseAutomationScheduleBound(value: string | null | undefined, label: string, maximum: number) { if (value == null || value === "") { return undefined; } const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed < 1 || String(parsed) !== value) { - throw new StatusError(400, `${label} must be a positive integer`); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > maximum || String(parsed) !== value) { + throw new StatusError(400, `${label} must be an integer between 1 and ${maximum}`); } - return label === "max_tenancies" - ? normalizeScheduledAutomationDiscoveryLimit(parsed) - : normalizeScheduledAutomationRunPageLimit(parsed); + return parsed; } diff --git a/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts b/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts deleted file mode 100644 index b622c811b..000000000 --- a/apps/backend/src/app/api/latest/internal/automations/scheduled-run/route.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - normalizeScheduledAutomationRunPageLimit, - runScheduledAutomationRulePage, -} from "@/lib/automations/scheduler"; -import { parseAutomationScheduledAtMillis } from "@/lib/automations/scheduled-at"; -import { ensureUpstashSignature } from "@/lib/upstash"; -import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@hexclave/shared/dist/schema-fields"; - -export const POST = createSmartRouteHandler({ - metadata: { - summary: "Run one scheduled automation rule page", - description: "Receives QStash work for a single tenancy/rule automation run page.", - tags: ["Automations"], - hidden: true, - }, - request: yupObject({ - headers: yupObject({ - "upstash-signature": yupTuple([yupString().defined()]).defined(), - }).defined(), - method: yupString().oneOf(["POST"]).defined(), - body: yupObject({ - tenancyId: yupString().defined(), - ruleId: yupString().defined(), - cursor: yupString().nullable().optional(), - limit: yupNumber().integer().min(1).max(100).optional(), - scheduledAtMillis: yupNumber().integer().optional(), - }).defined(), - }), - response: yupObject({ - statusCode: yupNumber().oneOf([200]).defined(), - bodyType: yupString().oneOf(["json"]).defined(), - body: yupObject({ - ok: yupBoolean().defined(), - status: yupString().oneOf(["ran", "skipped"]).defined(), - skipped_reason: yupString().oneOf(["tenancy-not-found", "rule-not-found", "rule-disabled"]).optional(), - evaluated_count: yupNumber().integer().optional(), - sent_count: yupNumber().integer().optional(), - suppressed_count: yupNumber().integer().optional(), - next_cursor: yupString().nullable().optional(), - enqueued_continuation: yupBoolean().optional(), - }).defined(), - }), - handler: async ({ body }, fullReq) => { - await ensureUpstashSignature(fullReq); - const scheduledAt = parseAutomationScheduledAtMillis(body.scheduledAtMillis, "scheduledAtMillis"); - const result = await runScheduledAutomationRulePage({ - tenancyId: body.tenancyId, - ruleId: body.ruleId, - cursor: body.cursor, - limit: normalizeScheduledAutomationRunPageLimit(body.limit), - scheduledAt, - now: new Date(), - }); - - if (result.status === "skipped") { - return { - statusCode: 200, - bodyType: "json", - body: { - ok: true, - status: result.status, - skipped_reason: result.reason, - }, - }; - } - - return { - statusCode: 200, - bodyType: "json", - body: { - ok: true, - status: result.status, - evaluated_count: result.result.evaluatedCount, - sent_count: result.result.sentCount, - suppressed_count: result.result.suppressedCount, - next_cursor: result.result.nextCursor, - enqueued_continuation: result.enqueuedContinuation, - }, - }; - }, -}); diff --git a/apps/backend/src/lib/automations/scheduler-state.test.ts b/apps/backend/src/lib/automations/scheduler-state.test.ts new file mode 100644 index 000000000..646ee0221 --- /dev/null +++ b/apps/backend/src/lib/automations/scheduler-state.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + automationSchedulerStateKey, + createPrismaAutomationSchedulerStateStore, + type AutomationSchedulerCheckpoint, +} from "./scheduler-state"; + +const emptyCheckpoint: AutomationSchedulerCheckpoint = { + completedTenancyCursor: null, + activeTenancyId: null, + completedRuleCursor: null, + activeRuleId: null, + nextSubjectCursor: null, +}; + +function createFakePrisma(options: { missing?: boolean } = {}) { + let state = options.missing ? null : { + key: automationSchedulerStateKey, + ...emptyCheckpoint, + leaseOwner: null as string | null, + leaseExpiresAt: null as Date | null, + }; + + const matchesWhere = (where: any) => { + const currentState = state; + if (currentState === null || where.key !== currentState.key) return false; + if (where.leaseOwner !== undefined && where.leaseOwner !== currentState.leaseOwner) return false; + if (where.leaseExpiresAt?.gt !== undefined && !(currentState.leaseExpiresAt !== null && currentState.leaseExpiresAt > where.leaseExpiresAt.gt)) return false; + if (where.OR !== undefined) { + return where.OR.some((condition: any) => { + if (condition.leaseOwner === null) return currentState.leaseOwner === null; + if (condition.leaseExpiresAt?.lte !== undefined) { + return currentState.leaseExpiresAt !== null && currentState.leaseExpiresAt <= condition.leaseExpiresAt.lte; + } + return false; + }); + } + return true; + }; + + return { + prisma: { + automationSchedulerState: { + async updateMany({ where, data }: any) { + if (!matchesWhere(where)) return { count: 0 }; + state = { ...state!, ...data }; + return { count: 1 }; + }, + async findUnique() { + return state === null ? null : { ...state }; + }, + }, + } as any, + getState: () => state, + }; +} + +function createClock(initial: string) { + let current = new Date(initial); + return { + now: () => new Date(current), + advance: (milliseconds: number) => { + current = new Date(current.getTime() + milliseconds); + }, + }; +} + +describe("Prisma automation scheduler state store", () => { + it("allows only one concurrent lease acquisition", async () => { + const fake = createFakePrisma(); + const clock = createClock("2026-07-17T12:00:00.000Z"); + let ownerSequence = 0; + const store = createPrismaAutomationSchedulerStateStore({ + prisma: fake.prisma, + now: clock.now, + ownerId: () => `00000000-0000-4000-8000-${String(++ownerSequence).padStart(12, "0")}`, + }); + + const [first, second] = await Promise.all([store.acquire(), store.acquire()]); + + expect([first, second].filter((lease) => lease !== null)).toHaveLength(1); + expect([first, second].filter((lease) => lease === null)).toHaveLength(1); + }); + + it("reclaims an expired lease and fences the stale owner", async () => { + const fake = createFakePrisma(); + const clock = createClock("2026-07-17T12:00:00.000Z"); + const firstStore = createPrismaAutomationSchedulerStateStore({ + prisma: fake.prisma, + now: clock.now, + ownerId: () => "00000000-0000-4000-8000-000000000001", + leaseDurationMs: 1_000, + renewalThresholdMs: 250, + }); + const firstLease = await firstStore.acquire() ?? undefined; + expect(firstLease).toBeDefined(); + + clock.advance(1_001); + const secondStore = createPrismaAutomationSchedulerStateStore({ + prisma: fake.prisma, + now: clock.now, + ownerId: () => "00000000-0000-4000-8000-000000000002", + leaseDurationMs: 1_000, + renewalThresholdMs: 250, + }); + const secondLease = await secondStore.acquire() ?? undefined; + expect(secondLease?.ownerId).toBe("00000000-0000-4000-8000-000000000002"); + + await expect(firstLease!.saveCheckpoint({ + ...emptyCheckpoint, + activeTenancyId: "00000000-0000-4000-8000-000000000010", + })).rejects.toThrow("lease was lost or expired"); + await expect(firstLease!.renewIfNeeded()).rejects.toThrow("lease was lost or expired"); + await expect(firstLease!.release()).rejects.toThrow("lease was lost or expired"); + + await secondLease!.saveCheckpoint({ + ...emptyCheckpoint, + activeTenancyId: "00000000-0000-4000-8000-000000000020", + }); + expect(fake.getState()?.activeTenancyId).toBe("00000000-0000-4000-8000-000000000020"); + await secondLease!.release(); + expect(fake.getState()?.leaseOwner).toBeNull(); + }); + + it("renews with injected duration values when the threshold is reached", async () => { + const fake = createFakePrisma(); + const clock = createClock("2026-07-17T12:00:00.000Z"); + const lease = await createPrismaAutomationSchedulerStateStore({ + prisma: fake.prisma, + now: clock.now, + ownerId: () => "00000000-0000-4000-8000-000000000001", + leaseDurationMs: 120_000, + renewalThresholdMs: 30_000, + }).acquire(); + + clock.advance(89_999); + await lease!.renewIfNeeded(); + expect(fake.getState()?.leaseExpiresAt).toEqual(new Date("2026-07-17T12:02:00.000Z")); + + clock.advance(2); + await lease!.renewIfNeeded(); + expect(fake.getState()?.leaseExpiresAt).toEqual(new Date("2026-07-17T12:03:30.001Z")); + }); + + it("fails loudly when the migration-seeded singleton is missing", async () => { + const fake = createFakePrisma({ missing: true }); + const store = createPrismaAutomationSchedulerStateStore({ + prisma: fake.prisma, + ownerId: () => "00000000-0000-4000-8000-000000000001", + }); + + await expect(store.acquire()).rejects.toThrow("Apply the scheduler-state migration"); + }); +}); diff --git a/apps/backend/src/lib/automations/scheduler-state.ts b/apps/backend/src/lib/automations/scheduler-state.ts new file mode 100644 index 000000000..54a1bfa7c --- /dev/null +++ b/apps/backend/src/lib/automations/scheduler-state.ts @@ -0,0 +1,176 @@ +import { randomUUID } from "crypto"; +import type { PrismaClientTransaction } from "@/prisma-client"; + +export const automationSchedulerStateKey = "usage-email-v1"; +export const automationSchedulerLeaseDurationMs = 120_000; +export const automationSchedulerLeaseRenewalThresholdMs = 30_000; + +export type AutomationSchedulerCheckpoint = { + completedTenancyCursor: string | null, + activeTenancyId: string | null, + completedRuleCursor: string | null, + activeRuleId: string | null, + nextSubjectCursor: string | null, +}; + +export type AutomationSchedulerLease = { + ownerId: string, + checkpoint: AutomationSchedulerCheckpoint, + renewIfNeeded: () => Promise, + saveCheckpoint: (checkpoint: AutomationSchedulerCheckpoint) => Promise, + release: () => Promise, +}; + +type AutomationSchedulerStatePrisma = Pick; + +export function createPrismaAutomationSchedulerStateStore(options: { + prisma: AutomationSchedulerStatePrisma, + now?: () => Date, + ownerId?: () => string, + leaseDurationMs?: number, + renewalThresholdMs?: number, +}) { + const now = options.now ?? (() => new Date()); + const ownerId = options.ownerId ?? randomUUID; + const leaseDurationMs = options.leaseDurationMs ?? automationSchedulerLeaseDurationMs; + const renewalThresholdMs = options.renewalThresholdMs ?? automationSchedulerLeaseRenewalThresholdMs; + validateLeaseDurations(leaseDurationMs, renewalThresholdMs); + + return { + async acquire(): Promise { + const acquiredAt = now(); + const owner = ownerId(); + const leaseExpiresAt = new Date(acquiredAt.getTime() + leaseDurationMs); + const acquired = await options.prisma.automationSchedulerState.updateMany({ + where: { + key: automationSchedulerStateKey, + OR: [ + { leaseOwner: null }, + { leaseExpiresAt: { lte: acquiredAt } }, + ], + }, + data: { + leaseOwner: owner, + leaseExpiresAt, + }, + }); + if (acquired.count === 0) { + const existing = await options.prisma.automationSchedulerState.findUnique({ + where: { key: automationSchedulerStateKey }, + select: { key: true }, + }); + if (existing === null) { + throw new Error(`Automation scheduler state "${automationSchedulerStateKey}" is missing. Apply the scheduler-state migration before running cron.`); + } + return null; + } + assertSingleMutation(acquired.count, "acquire automation scheduler lease"); + + const state = await options.prisma.automationSchedulerState.findUnique({ + where: { key: automationSchedulerStateKey }, + select: { + completedTenancyCursor: true, + activeTenancyId: true, + completedRuleCursor: true, + activeRuleId: true, + nextSubjectCursor: true, + leaseOwner: true, + leaseExpiresAt: true, + }, + }); + if (state === null || state.leaseOwner !== owner || state.leaseExpiresAt === null) { + throw new Error("Automation scheduler lease changed while it was being acquired."); + } + + let currentLeaseExpiresAt = state.leaseExpiresAt; + let checkpoint = readCheckpoint(state); + let released = false; + + const assertActive = () => { + if (released) { + throw new Error("Automation scheduler lease has already been released."); + } + }; + + const mutateOwnedUnexpiredLease = async ( + action: string, + data: Parameters[0]["data"], + ) => { + assertActive(); + const mutationNow = now(); + const result = await options.prisma.automationSchedulerState.updateMany({ + where: { + key: automationSchedulerStateKey, + leaseOwner: owner, + leaseExpiresAt: { gt: mutationNow }, + }, + data, + }); + assertSingleMutation(result.count, action); + }; + + return { + ownerId: owner, + get checkpoint() { + return checkpoint; + }, + async renewIfNeeded() { + assertActive(); + const renewalNow = now(); + if (currentLeaseExpiresAt.getTime() - renewalNow.getTime() >= renewalThresholdMs) { + return; + } + const renewedUntil = new Date(renewalNow.getTime() + leaseDurationMs); + await mutateOwnedUnexpiredLease("renew automation scheduler lease", { + leaseExpiresAt: renewedUntil, + }); + currentLeaseExpiresAt = renewedUntil; + }, + async saveCheckpoint(nextCheckpoint) { + await mutateOwnedUnexpiredLease("save automation scheduler checkpoint", nextCheckpoint); + checkpoint = nextCheckpoint; + }, + async release() { + assertActive(); + const result = await options.prisma.automationSchedulerState.updateMany({ + where: { + key: automationSchedulerStateKey, + leaseOwner: owner, + }, + data: { + leaseOwner: null, + leaseExpiresAt: null, + }, + }); + assertSingleMutation(result.count, "release automation scheduler lease"); + released = true; + }, + }; + }, + }; +} + +function readCheckpoint(state: AutomationSchedulerCheckpoint): AutomationSchedulerCheckpoint { + return { + completedTenancyCursor: state.completedTenancyCursor, + activeTenancyId: state.activeTenancyId, + completedRuleCursor: state.completedRuleCursor, + activeRuleId: state.activeRuleId, + nextSubjectCursor: state.nextSubjectCursor, + }; +} + +function validateLeaseDurations(leaseDurationMs: number, renewalThresholdMs: number) { + if (!Number.isFinite(leaseDurationMs) || leaseDurationMs <= 0) { + throw new Error("Automation scheduler lease duration must be positive."); + } + if (!Number.isFinite(renewalThresholdMs) || renewalThresholdMs <= 0 || renewalThresholdMs >= leaseDurationMs) { + throw new Error("Automation scheduler renewal threshold must be positive and shorter than the lease duration."); + } +} + +function assertSingleMutation(count: number, action: string) { + if (count !== 1) { + throw new Error(`Unable to ${action}; the scheduler lease was lost or expired.`); + } +} diff --git a/apps/backend/src/lib/automations/scheduler.test.ts b/apps/backend/src/lib/automations/scheduler.test.ts index 1b365f0e1..bbaa755d9 100644 --- a/apps/backend/src/lib/automations/scheduler.test.ts +++ b/apps/backend/src/lib/automations/scheduler.test.ts @@ -1,124 +1,120 @@ import { describe, expect, it, vi } from "vitest"; +import type { AutomationSchedulerCheckpoint, AutomationSchedulerLease } from "./scheduler-state"; const captureErrorMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/automations/actions/send-email", () => ({ createSendEmailActionAdapter: () => ({}), })); - vi.mock("@/lib/automations/execution-state-store", () => ({ createPrismaAutomationRuleExecutionStateStore: () => ({}), })); - vi.mock("@/lib/automations/sources/payments-item-quota", () => ({ createPaymentsItemQuotaSourceAdapter: () => ({}), paymentsItemQuotaCustomerDataReaders: {}, prismaPaymentsItemQuotaProjectUserReader: {}, })); - -vi.mock("@/lib/emails", () => ({ - sendEmailToMany: async () => {}, -})); - -vi.mock("@/lib/tenancies", () => ({ - getTenancy: async () => null, -})); - +vi.mock("@/lib/emails", () => ({ sendEmailToMany: async () => {} })); +vi.mock("@/lib/tenancies", () => ({ getTenancy: async () => null })); vi.mock("@/prisma-client", () => ({ getPrismaClientForTenancy: async () => ({}), globalPrismaClient: { - tenancy: { - findMany: async () => [], - }, - outgoingRequest: { - createMany: async () => ({ count: 0 }), - }, + tenancy: { findMany: async () => [] }, + automationSchedulerState: {}, }, })); - vi.mock("@hexclave/shared/dist/utils/errors", async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - captureError: captureErrorMock, - }; + return { ...actual, captureError: captureErrorMock }; }); import { - discoverEnabledScheduledAutomationRules, - enqueueScheduledAutomationRuns, - getScheduledAutomationDeduplicationKey, - runScheduledAutomationRulePage, - scheduledAutomationRunRoutePath, + normalizeScheduledAutomationDiscoveryLimit, + normalizeScheduledAutomationMaxPages, + normalizeScheduledAutomationRunPageLimit, + normalizeScheduledAutomationWorkBudgetMs, + runScheduledAutomations, } from "./scheduler"; -import { AutomationRunResult } from "./run-route"; +import type { AutomationRunResult } from "./run-route"; -const enabledRuleId = "low-api-credits"; +const ruleId = "low-api-credits"; -function createAutomationRule(options: { - enabled?: boolean, - sourceType?: string, -} = {}) { +function emptyCheckpoint(): AutomationSchedulerCheckpoint { + return { + completedTenancyCursor: null, + activeTenancyId: null, + completedRuleCursor: null, + activeRuleId: null, + nextSubjectCursor: null, + }; +} + +function createStateHarness(initial: AutomationSchedulerCheckpoint = emptyCheckpoint()) { + let checkpoint = initial; + const saveCheckpoint = vi.fn(async (next: AutomationSchedulerCheckpoint) => { + checkpoint = next; + }); + const release = vi.fn(async () => {}); + const renewIfNeeded = vi.fn(async () => {}); + const acquire = vi.fn(async (): Promise => ({ + ownerId: "00000000-0000-4000-8000-000000000001", + get checkpoint() { + return checkpoint; + }, + saveCheckpoint, + release, + renewIfNeeded, + })); + return { + stateStore: { acquire }, + getCheckpoint: () => checkpoint, + acquire, + saveCheckpoint, + release, + renewIfNeeded, + }; +} + +function createPrisma(tenancyIds: string[]) { + return { + tenancy: { + findMany: vi.fn(async (options: any) => tenancyIds + .filter((id) => options.where.id?.gt === undefined || id > options.where.id.gt) + .slice(0, options.take) + .map((id) => ({ id }))), + }, + }; +} + +function createRule(options: { enabled?: boolean, sourceType?: string } = {}) { return { enabled: options.enabled ?? true, source: { type: options.sourceType ?? "payments-item-quota", - itemId: "api_credits", + itemId: "api-credits", customerType: "user", - thresholds: { - nearRemainingQuantity: 10, - }, + thresholds: { nearRemainingQuantity: 10 }, }, action: { type: "send-email", templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", notificationCategoryName: "Marketing", }, - cooldown: { - days: 7, - }, + cooldown: { days: 7 }, }; } -function createTenancy(options: { - enabled?: boolean, - sourceType?: string, - rules?: Record>, -} = {}) { +function createTenancy(id: string, rules: Record> = { [ruleId]: createRule() }) { return { - id: "tenancy-1", - branchId: "main", - organization: null, - project: { - id: "project-1", - display_name: "Acme App", - description: "", - logo_url: null, - logo_full_url: null, - logo_dark_mode_url: null, - logo_full_dark_mode_url: null, - created_at_millis: 0, - is_production_mode: false, - is_development_environment: false, - owner_team_id: null, - onboarding_status: "completed", - onboarding_state: undefined, - pushed_config_error: null, - config_warnings: [], - }, - config: { - automations: { - rules: options.rules ?? { - [enabledRuleId]: createAutomationRule(options), - }, - }, - }, + id, + project: { display_name: "Acme App" }, + config: { automations: { rules } }, }; } -function createRunResult(nextCursor: string | null): AutomationRunResult { +function createRunResult(nextCursor: string | null, options: Partial = {}): AutomationRunResult { return { - ruleId: enabledRuleId, + ruleId, mode: "run", evaluatedCount: 100, eligibleCount: 1, @@ -126,258 +122,248 @@ function createRunResult(nextCursor: string | null): AutomationRunResult { sentCount: 1, nextCursor, decisions: [], + ...options, }; } -describe("scheduled automation discovery", () => { - it("finds enabled V1 rules from bounded tenancy pages", async () => { - const prisma = { - tenancy: { - findMany: vi.fn(async () => [{ id: "tenancy-1" }, { id: "tenancy-2" }]), - }, - }; - const getTenancyById = vi.fn(async (tenancyId: string) => tenancyId === "tenancy-1" - ? createTenancy() - : createTenancy({ enabled: false })); - - await expect(discoverEnabledScheduledAutomationRules({ - prisma, - getTenancyById, - limit: 2, - cursor: "previous-tenancy", - })).resolves.toMatchInlineSnapshot(` - { - "nextCursor": "tenancy-2", - "scannedTenancyCount": 2, - "targets": [ - { - "ruleId": "low-api-credits", - "tenancyId": "tenancy-1", - }, - ], - } - `); - expect(prisma.tenancy.findMany).toHaveBeenCalledWith({ - where: { - id: { - gt: "previous-tenancy", - }, - }, - orderBy: { - id: "asc", - }, - take: 2, - select: { - id: true, - }, - }); - }); - - it("reports malformed enabled rules and continues discovering later valid rules", async () => { - captureErrorMock.mockClear(); - const prisma = { - tenancy: { - findMany: vi.fn(async () => [{ id: "tenancy-1" }]), - }, - }; - - await expect(discoverEnabledScheduledAutomationRules({ - prisma, - getTenancyById: async () => createTenancy({ - rules: { - invalidRule: createAutomationRule({ sourceType: "client-push-quota" }), - [enabledRuleId]: createAutomationRule(), - }, - }), - })).resolves.toMatchInlineSnapshot(` - { - "nextCursor": null, - "scannedTenancyCount": 1, - "targets": [ - { - "ruleId": "low-api-credits", - "tenancyId": "tenancy-1", - }, - ], - } - `); - expect(captureErrorMock).toHaveBeenCalledWith("automation-scheduler-invalid-rule", expect.any(Error)); +describe("scheduled automation bounds", () => { + it("strictly normalizes internal limits", () => { + expect(normalizeScheduledAutomationDiscoveryLimit(900)).toBe(500); + expect(normalizeScheduledAutomationRunPageLimit(900)).toBe(100); + expect(normalizeScheduledAutomationMaxPages(900)).toBe(10); + expect(normalizeScheduledAutomationWorkBudgetMs(90_000)).toBe(45_000); }); }); -describe("scheduled automation queueing", () => { - it("enqueues deduped QStash work through OutgoingRequest", async () => { - const prisma = { - outgoingRequest: { - createMany: vi.fn(async () => ({ count: 1 })), - }, - }; - - await expect(enqueueScheduledAutomationRuns({ - prisma, - targets: [{ - tenancyId: "tenancy-1", - ruleId: enabledRuleId, - }], - scheduledAt: new Date("2026-07-01T12:00:00.000Z"), - limit: 75, - })).resolves.toEqual({ enqueuedCount: 1 }); - - expect(prisma.outgoingRequest.createMany).toHaveBeenCalledWith({ - data: [{ - deduplicationKey: "automation-rule-run:tenancy-1:low-api-credits:start", - qstashOptions: { - body: { - cursor: null, - limit: 75, - ruleId: "low-api-credits", - scheduledAtMillis: 1782907200000, - tenancyId: "tenancy-1", - }, - flowControl: { - key: "automation-rule-run:tenancy-1", - parallelism: 1, - }, - url: scheduledAutomationRunRoutePath, - }, - }], - skipDuplicates: true, +describe("native cron automation traversal", () => { + it("returns without work when another invocation holds the lease", async () => { + await expect(runScheduledAutomations({ + stateStore: { acquire: async () => null }, + })).resolves.toMatchObject({ + status: "lease-held", + pagesProcessed: 0, + sentCount: 0, }); }); - it("reports zero enqueues when a duplicate pending OutgoingRequest already exists", async () => { - const prisma = { - outgoingRequest: { - createMany: vi.fn(async () => ({ count: 0 })), - }, - }; + it("persists the next subject cursor only after a page succeeds", async () => { + const state = createStateHarness(); + const runRule = vi.fn(async () => createRunResult("00000000-0000-4000-8000-000000000100")); - await expect(enqueueScheduledAutomationRuns({ - prisma, - targets: [{ - tenancyId: "tenancy-1", - ruleId: enabledRuleId, - }], - scheduledAt: new Date("2026-07-01T12:00:00.000Z"), - })).resolves.toEqual({ enqueuedCount: 0 }); - - expect(prisma.outgoingRequest.createMany).toHaveBeenCalledWith(expect.objectContaining({ - skipDuplicates: true, - })); - }); - - it("uses cursor-specific dedupe keys for continuation pages", () => { - expect(getScheduledAutomationDeduplicationKey({ - tenancyId: "tenancy-1", - ruleId: enabledRuleId, - cursor: "user-100", - })).toBe("automation-rule-run:tenancy-1:low-api-credits:user-100"); - }); -}); - -describe("scheduled automation worker orchestration", () => { - it("runs a page and enqueues continuation when the evaluator returns a next cursor", async () => { - const runRule = vi.fn(async () => createRunResult("user-100")); - const enqueueContinuation = vi.fn(async () => ({ enqueuedCount: 1 })); - - await expect(runScheduledAutomationRulePage({ - tenancyId: "tenancy-1", - ruleId: enabledRuleId, - limit: 100, - scheduledAt: new Date("2026-07-01T12:00:00.000Z"), - now: new Date("2026-07-01T12:01:00.000Z"), - getTenancyById: async () => createTenancy(), + await expect(runScheduledAutomations({ + prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id), runRule, - enqueueContinuation, - })).resolves.toMatchInlineSnapshot(` - { - "enqueuedContinuation": true, - "result": { - "decisions": [], - "eligibleCount": 1, - "evaluatedCount": 100, - "mode": "run", - "nextCursor": "user-100", - "ruleId": "low-api-credits", - "sentCount": 1, - "suppressedCount": 0, - }, - "status": "ran", - } - `); + maxPages: 1, + })).resolves.toMatchObject({ + status: "ran", + pagesProcessed: 1, + evaluatedCount: 100, + sentCount: 1, + }); expect(runRule).toHaveBeenCalledWith(expect.objectContaining({ cursor: null, limit: 100, - ruleId: enabledRuleId, })); - expect(enqueueContinuation).toHaveBeenCalledWith({ - tenancyId: "tenancy-1", - ruleId: enabledRuleId, - cursor: "user-100", - limit: 100, - scheduledAt: new Date("2026-07-01T12:00:00.000Z"), + expect(state.getCheckpoint()).toMatchObject({ + activeRuleId: ruleId, + nextSubjectCursor: "00000000-0000-4000-8000-000000000100", }); + expect(state.renewIfNeeded).toHaveBeenCalledOnce(); + expect(state.release).toHaveBeenCalledOnce(); }); - it("reports duplicate continuation work without failing the completed page", async () => { - const runRule = vi.fn(async () => createRunResult("user-100")); - const enqueueContinuation = vi.fn(async () => ({ enqueuedCount: 0 })); + it("uses a monotonic clock for the work budget", async () => { + const state = createStateHarness(); + let elapsedMillis = 0; + let wallClockMillis = Date.UTC(2026, 0, 1); + const runRule = vi.fn(async () => { + elapsedMillis = 10; + return createRunResult("00000000-0000-4000-8000-000000000100"); + }); - await expect(runScheduledAutomationRulePage({ - tenancyId: "tenancy-1", - ruleId: enabledRuleId, - limit: 100, - scheduledAt: new Date("2026-07-01T12:00:00.000Z"), - now: new Date("2026-07-01T12:01:00.000Z"), - getTenancyById: async () => createTenancy(), + await expect(runScheduledAutomations({ + prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id), runRule, - enqueueContinuation, + now: () => new Date(wallClockMillis -= 60_000), + elapsedNow: () => elapsedMillis, + workBudgetMs: 10, })).resolves.toMatchObject({ - status: "ran", - enqueuedContinuation: false, - result: { - nextCursor: "user-100", - }, + pagesProcessed: 1, }); - }); - - it("propagates continuation enqueue failures so the queue can retry the page", async () => { - const runRule = vi.fn(async () => createRunResult("user-100")); - const enqueueContinuation = vi.fn(async () => { - throw new Error("queue unavailable"); - }); - - await expect(runScheduledAutomationRulePage({ - tenancyId: "tenancy-1", - ruleId: enabledRuleId, - limit: 100, - scheduledAt: new Date("2026-07-01T12:00:00.000Z"), - now: new Date("2026-07-01T12:01:00.000Z"), - getTenancyById: async () => createTenancy(), - runRule, - enqueueContinuation, - })).rejects.toThrow("queue unavailable"); expect(runRule).toHaveBeenCalledOnce(); + expect(state.getCheckpoint()).toMatchObject({ + activeRuleId: ruleId, + nextSubjectCursor: "00000000-0000-4000-8000-000000000100", + }); }); - it("skips stale queued work when the rule was disabled after enqueue", async () => { + it("preserves the current page checkpoint and fails on transient execution errors", async () => { + const initial = { + completedTenancyCursor: "00000000-0000-4000-8000-000000000001", + activeTenancyId: "00000000-0000-4000-8000-000000000010", + completedRuleCursor: null, + activeRuleId: ruleId, + nextSubjectCursor: "00000000-0000-4000-8000-000000000100", + }; + const state = createStateHarness(initial); + const runRule = vi.fn(async () => { + throw new Error("email enqueue failed"); + }); + + await expect(runScheduledAutomations({ + prisma: createPrisma([]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id), + runRule, + })).rejects.toThrow("email enqueue failed"); + + expect(runRule).toHaveBeenCalledWith(expect.objectContaining({ + cursor: initial.nextSubjectCursor, + })); + expect(state.getCheckpoint()).toEqual(initial); + expect(state.release).toHaveBeenCalledOnce(); + }); + + it("propagates discovery database failures without changing the checkpoint", async () => { + const state = createStateHarness(); + const prisma = { + tenancy: { + findMany: vi.fn(async () => { + throw new Error("database unavailable"); + }), + }, + }; + + await expect(runScheduledAutomations({ + prisma, + stateStore: state.stateStore, + })).rejects.toThrow("database unavailable"); + + expect(state.getCheckpoint()).toEqual(emptyCheckpoint()); + expect(state.release).toHaveBeenCalledOnce(); + }); + + it("advances a deleted tenancy without executing a rule", async () => { + const state = createStateHarness(); + const tenancyId = "00000000-0000-4000-8000-000000000010"; const runRule = vi.fn(async () => createRunResult(null)); - await expect(runScheduledAutomationRulePage({ - tenancyId: "tenancy-1", - ruleId: enabledRuleId, - scheduledAt: new Date("2026-07-01T12:00:00.000Z"), - now: new Date("2026-07-01T12:01:00.000Z"), - getTenancyById: async () => createTenancy({ enabled: false }), + await expect(runScheduledAutomations({ + prisma: createPrisma([tenancyId]), + stateStore: state.stateStore, + getTenancyById: async () => null, runRule, - })).resolves.toMatchInlineSnapshot(` - { - "reason": "rule-disabled", - "status": "skipped", - } - `); + maxTenancies: 1, + })).resolves.toMatchObject({ + tenanciesScanned: 1, + cycleCompleted: false, + }); + + expect(state.getCheckpoint()).toEqual({ + completedTenancyCursor: tenancyId, + activeTenancyId: null, + completedRuleCursor: null, + activeRuleId: null, + nextSubjectCursor: null, + }); + expect(runRule).not.toHaveBeenCalled(); + }); + + it("completes rule and tenancy boundaries without resetting the full cycle", async () => { + const state = createStateHarness(); + const tenancyId = "00000000-0000-4000-8000-000000000010"; + + const first = await runScheduledAutomations({ + prisma: createPrisma([tenancyId]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id), + runRule: async () => createRunResult(null), + maxPages: 1, + }); + expect(first.cycleCompleted).toBe(false); + expect(state.getCheckpoint()).toEqual({ + completedTenancyCursor: null, + activeTenancyId: tenancyId, + completedRuleCursor: ruleId, + activeRuleId: null, + nextSubjectCursor: null, + }); + + const second = await runScheduledAutomations({ + prisma: createPrisma([tenancyId]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id), + runRule: async () => createRunResult(null), + }); + expect(second.cycleCompleted).toBe(true); + expect(state.getCheckpoint()).toEqual(emptyCheckpoint()); + }); + + it("continues after a full discovery page and resets only at the actual end", async () => { + const tenancyIds = Array.from({ length: 501 }, (_, index) => ( + `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}` + )); + const state = createStateHarness(); + const prisma = createPrisma(tenancyIds); + + const first = await runScheduledAutomations({ + prisma, + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id, {}), + runRule: async () => createRunResult(null), + maxTenancies: 500, + }); + expect(first).toMatchObject({ tenanciesScanned: 500, cycleCompleted: false }); + expect(state.getCheckpoint().completedTenancyCursor).toBe(tenancyIds[499]); + + const second = await runScheduledAutomations({ + prisma, + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id, {}), + runRule: async () => createRunResult(null), + maxTenancies: 500, + }); + expect(second).toMatchObject({ tenanciesScanned: 1, cycleCompleted: true }); + expect(state.getCheckpoint()).toEqual(emptyCheckpoint()); + }); + + it("reports malformed rules, advances them, and runs the next valid rule", async () => { + captureErrorMock.mockClear(); + const state = createStateHarness(); + const validRuleId = "valid-rule"; + const runRule = vi.fn(async () => createRunResult(null, { ruleId: validRuleId })); + + await runScheduledAutomations({ + prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id, { + "invalid-rule": createRule({ sourceType: "client-push-quota" }), + [validRuleId]: createRule(), + }), + runRule, + maxPages: 1, + }); + + expect(captureErrorMock).toHaveBeenCalledWith("automation-scheduler-invalid-rule", expect.any(Error)); + expect(runRule).toHaveBeenCalledWith(expect.objectContaining({ ruleId: validRuleId })); + }); + + it("skips disabled rules without executing them", async () => { + const state = createStateHarness(); + const runRule = vi.fn(async () => createRunResult(null)); + + await runScheduledAutomations({ + prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id, { [ruleId]: createRule({ enabled: false }) }), + runRule, + }); expect(runRule).not.toHaveBeenCalled(); }); diff --git a/apps/backend/src/lib/automations/scheduler.ts b/apps/backend/src/lib/automations/scheduler.ts index f2e4f0dee..364bb7019 100644 --- a/apps/backend/src/lib/automations/scheduler.ts +++ b/apps/backend/src/lib/automations/scheduler.ts @@ -1,9 +1,10 @@ import { createSendEmailActionAdapter } from "@/lib/automations/actions/send-email"; import { createPrismaAutomationRuleExecutionStateStore } from "@/lib/automations/execution-state-store"; import { - AutomationRunResult, - runAutomationRuleForRoute, -} from "@/lib/automations/run-route"; + createPrismaAutomationSchedulerStateStore, + type AutomationSchedulerCheckpoint, + type AutomationSchedulerLease, +} from "@/lib/automations/scheduler-state"; import { createPaymentsItemQuotaSourceAdapter, paymentsItemQuotaCustomerDataReaders, @@ -13,82 +14,31 @@ import { sendEmailToMany } from "@/lib/emails"; import { getTenancy, type Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; -import { assertSupportedAutomationRule, AutomationRuleTenancy, getAutomationRule, listAutomationRules } from "./rules"; +import { stringCompare } from "@hexclave/shared/dist/utils/strings"; +import { runAutomationRuleForRoute, type AutomationRunResult } from "./run-route"; +import { assertSupportedAutomationRule, type AutomationRuleTenancy, listAutomationRules } from "./rules"; export const scheduledAutomationDiscoveryLimit = 500; export const scheduledAutomationRunPageLimit = 100; -export const scheduledAutomationRunRoutePath = "/api/latest/internal/automations/scheduled-run"; - -type AutomationScheduleTarget = { - tenancyId: string, - ruleId: string, -}; - -export type AutomationScheduleDiscoveryResult = { - scannedTenancyCount: number, - targets: AutomationScheduleTarget[], - nextCursor: string | null, -}; - -export type AutomationScheduledRunPayload = { - tenancyId: string, - ruleId: string, - cursor: string | null, - limit: number, - scheduledAtMillis: number, -}; - -export type AutomationScheduledRunResult = - | { - status: "skipped", - reason: "tenancy-not-found" | "rule-not-found" | "rule-disabled", - } - | { - status: "ran", - result: AutomationRunResult, - enqueuedContinuation: boolean, - }; +export const scheduledAutomationMaxPages = 10; +export const scheduledAutomationDefaultPages = 5; +export const scheduledAutomationWorkBudgetMs = 45_000; type AutomationDiscoveryPrisma = { tenancy: { findMany: (options: { where: { - id?: { - gt: string, - }, - }, - orderBy: { - id: "asc", + id?: { gt: string }, }, + orderBy: { id: "asc" }, take: number, - select: { - id: true, - }, + select: { id: true }, }) => Promise>, }, }; -type AutomationQueuePrisma = { - outgoingRequest: { - createMany: (options: { - data: Array<{ - qstashOptions: { - url: string, - body: AutomationScheduledRunPayload, - flowControl: { - key: string, - parallelism: number, - }, - }, - deduplicationKey: string, - }>, - skipDuplicates: true, - }) => Promise<{ count: number }>, - }, -}; - -type ScheduledAutomationRunner = (options: { - tenancy: AutomationRuleTenancy, +type ScheduledAutomationRunner = (options: { + tenancy: TTenancy, ruleId: string, cursor: string | null, limit: number, @@ -96,6 +46,32 @@ type ScheduledAutomationRunner = (options: { now: Date, }) => Promise; +type AutomationSchedulerStateStore = { + acquire: () => Promise, +}; + +type ScheduledAutomationOptions = { + prisma?: AutomationDiscoveryPrisma, + stateStore?: AutomationSchedulerStateStore, + now?: () => Date, + elapsedNow?: () => number, + pageLimit?: number, + maxPages?: number, + maxTenancies?: number, + workBudgetMs?: number, +}; + +export type ScheduledAutomationCronResult = { + status: "ran" | "lease-held", + tenanciesScanned: number, + rulesProcessed: number, + pagesProcessed: number, + evaluatedCount: number, + sentCount: number, + suppressedCount: number, + cycleCompleted: boolean, +}; + export function normalizeScheduledAutomationDiscoveryLimit(limit: number | undefined) { if (limit === undefined) return scheduledAutomationDiscoveryLimit; return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationDiscoveryLimit)); @@ -106,227 +82,260 @@ export function normalizeScheduledAutomationRunPageLimit(limit: number | undefin return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationRunPageLimit)); } -export async function discoverEnabledScheduledAutomationRules(options: { - prisma?: AutomationDiscoveryPrisma, - getTenancyById?: (tenancyId: string) => Promise, - limit?: number, - cursor?: string | null, -} = {}): Promise { - const prisma = options.prisma ?? globalPrismaClient; - const limit = normalizeScheduledAutomationDiscoveryLimit(options.limit); - const tenancyRows = await prisma.tenancy.findMany({ - where: { - ...(options.cursor == null ? {} : { - id: { - gt: options.cursor, - }, - }), - }, - orderBy: { - id: "asc", - }, - take: limit, - select: { - id: true, - }, - }); - const getTenancyById = options.getTenancyById ?? getTenancy; - const targets: AutomationScheduleTarget[] = []; +export function normalizeScheduledAutomationMaxPages(limit: number | undefined) { + if (limit === undefined) return scheduledAutomationDefaultPages; + return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationMaxPages)); +} - for (const row of tenancyRows) { - const tenancy = await getTenancyById(row.id); +export function normalizeScheduledAutomationWorkBudgetMs(value: number | undefined) { + if (value === undefined) return scheduledAutomationWorkBudgetMs; + return Math.max(1, Math.min(Math.floor(value), scheduledAutomationWorkBudgetMs)); +} + +export function runScheduledAutomations( + options?: ScheduledAutomationOptions & { + getTenancyById?: undefined, + runRule?: undefined, + }, +): Promise; +export function runScheduledAutomations( + options: ScheduledAutomationOptions & { + getTenancyById: (tenancyId: string) => Promise, + runRule: ScheduledAutomationRunner, + }, +): Promise; +export async function runScheduledAutomations(options: ScheduledAutomationOptions & { + getTenancyById?: (tenancyId: string) => Promise, + runRule?: ScheduledAutomationRunner, +} = {}): Promise { + if (options.getTenancyById === undefined && options.runRule === undefined) { + return await runScheduledAutomationsWithDependencies(options, { + getTenancyById: getTenancy, + runRule: runProductionScheduledAutomationRulePage, + }); + } + if (options.getTenancyById === undefined || options.runRule === undefined) { + throw new Error("Automation scheduler test dependencies must provide both getTenancyById and runRule."); + } + return await runScheduledAutomationsWithDependencies(options, { + getTenancyById: options.getTenancyById, + runRule: options.runRule, + }); +} + +async function runScheduledAutomationsWithDependencies( + options: ScheduledAutomationOptions, + dependencies: { + getTenancyById: (tenancyId: string) => Promise, + runRule: ScheduledAutomationRunner, + }, +): Promise { + const prisma = options.prisma ?? globalPrismaClient; + const stateStore = options.stateStore ?? createPrismaAutomationSchedulerStateStore({ + prisma: globalPrismaClient, + }); + const lease = await stateStore.acquire(); + if (lease === null) { + return emptyCronResult("lease-held"); + } + + try { + const result = await runWithLease({ + prisma, + lease, + getTenancyById: dependencies.getTenancyById, + runRule: dependencies.runRule, + now: options.now ?? (() => new Date()), + elapsedNow: options.elapsedNow ?? (() => performance.now()), + pageLimit: normalizeScheduledAutomationRunPageLimit(options.pageLimit), + maxPages: normalizeScheduledAutomationMaxPages(options.maxPages), + maxTenancies: normalizeScheduledAutomationDiscoveryLimit(options.maxTenancies), + workBudgetMs: normalizeScheduledAutomationWorkBudgetMs(options.workBudgetMs), + }); + await lease.release(); + return result; + } catch (error) { + try { + await lease.release(); + } catch (releaseError) { + captureError("automation-scheduler-release-after-failure", new HexclaveAssertionError("Failed to release the automation scheduler lease after an execution failure.", { + cause: releaseError, + })); + } + throw error; + } +} + +async function runWithLease(options: { + prisma: AutomationDiscoveryPrisma, + lease: AutomationSchedulerLease, + getTenancyById: (tenancyId: string) => Promise, + runRule: ScheduledAutomationRunner, + now: () => Date, + elapsedNow: () => number, + pageLimit: number, + maxPages: number, + maxTenancies: number, + workBudgetMs: number, +}): Promise { + const startedAt = options.now(); + const startedElapsedAt = options.elapsedNow(); + const scheduledAt = startedAt; + let checkpoint = options.lease.checkpoint; + let tenanciesScanned = 0; + let rulesProcessed = 0; + let pagesProcessed = 0; + let evaluatedCount = 0; + let sentCount = 0; + let suppressedCount = 0; + let cycleCompleted = false; + let discoveredTenancyIds: string[] = []; + + const saveCheckpoint = async (next: AutomationSchedulerCheckpoint) => { + await options.lease.saveCheckpoint(next); + checkpoint = next; + }; + + while ( + pagesProcessed < options.maxPages + && tenanciesScanned < options.maxTenancies + && options.elapsedNow() - startedElapsedAt < options.workBudgetMs + ) { + if (checkpoint.activeTenancyId === null) { + if (discoveredTenancyIds.length === 0) { + const rows = await options.prisma.tenancy.findMany({ + where: checkpoint.completedTenancyCursor === null ? {} : { + id: { gt: checkpoint.completedTenancyCursor }, + }, + orderBy: { id: "asc" }, + take: Math.min( + scheduledAutomationDiscoveryLimit, + options.maxTenancies - tenanciesScanned, + ), + select: { id: true }, + }); + if (rows.length === 0) { + await saveCheckpoint(emptyCheckpoint()); + cycleCompleted = true; + break; + } + discoveredTenancyIds = rows.map((row) => row.id); + } + + const nextTenancyId = discoveredTenancyIds.shift(); + if (nextTenancyId === undefined) { + throw new Error("Automation scheduler discovery returned no next tenancy unexpectedly."); + } + await saveCheckpoint({ + ...checkpoint, + activeTenancyId: nextTenancyId, + completedRuleCursor: null, + activeRuleId: null, + nextSubjectCursor: null, + }); + tenanciesScanned++; + } + + const activeTenancyId = checkpoint.activeTenancyId; + if (activeTenancyId === null) { + throw new Error("Automation scheduler checkpoint lost its active tenancy unexpectedly."); + } + const tenancy = await options.getTenancyById(activeTenancyId); if (tenancy === null) { + await saveCheckpoint(completeActiveTenancy(checkpoint)); continue; } - for (const { ruleId, rule } of listAutomationRules(tenancy)) { - if (!rule.enabled) { + const sortedRules = listAutomationRules(tenancy) + .sort((left, right) => stringCompare(left.ruleId, right.ruleId)); + if (checkpoint.activeRuleId === null) { + const nextRule = sortedRules.find(({ ruleId }) => ( + checkpoint.completedRuleCursor === null || stringCompare(ruleId, checkpoint.completedRuleCursor) > 0 + )); + if (nextRule === undefined) { + await saveCheckpoint(completeActiveTenancy(checkpoint)); continue; } - try { - assertSupportedAutomationRule(ruleId, rule); - } catch (error) { - captureError("automation-scheduler-invalid-rule", new HexclaveAssertionError(`Skipping invalid scheduled automation rule "${ruleId}" for tenancy "${tenancy.id}".`, { - cause: error, - tenancyId: tenancy.id, - ruleId, - })); - continue; - } - targets.push({ + await saveCheckpoint({ + ...checkpoint, + activeRuleId: nextRule.ruleId, + nextSubjectCursor: null, + }); + } + + const activeRuleId = checkpoint.activeRuleId; + if (activeRuleId === null) { + throw new Error("Automation scheduler checkpoint lost its active rule unexpectedly."); + } + const ruleEntry = sortedRules.find(({ ruleId }) => ruleId === activeRuleId); + if (ruleEntry === undefined) { + await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId)); + rulesProcessed++; + continue; + } + if (!ruleEntry.rule.enabled) { + await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId)); + rulesProcessed++; + continue; + } + try { + assertSupportedAutomationRule(activeRuleId, ruleEntry.rule); + } catch (error) { + captureError("automation-scheduler-invalid-rule", new HexclaveAssertionError(`Skipping invalid scheduled automation rule "${activeRuleId}" for tenancy "${tenancy.id}".`, { + cause: error, tenancyId: tenancy.id, - ruleId, + ruleId: activeRuleId, + })); + await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId)); + rulesProcessed++; + continue; + } + + if ( + pagesProcessed >= options.maxPages + || options.elapsedNow() - startedElapsedAt >= options.workBudgetMs + ) { + break; + } + await options.lease.renewIfNeeded(); + + const result = await options.runRule({ + tenancy, + ruleId: activeRuleId, + cursor: checkpoint.nextSubjectCursor, + limit: options.pageLimit, + scheduledAt, + now: options.now(), + }); + pagesProcessed++; + evaluatedCount += result.evaluatedCount; + sentCount += result.sentCount; + suppressedCount += result.suppressedCount; + + if (result.nextCursor === null) { + await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId)); + rulesProcessed++; + } else { + await saveCheckpoint({ + ...checkpoint, + nextSubjectCursor: result.nextCursor, }); } } - return { - scannedTenancyCount: tenancyRows.length, - targets, - nextCursor: tenancyRows.length === limit ? tenancyRows[tenancyRows.length - 1]?.id ?? null : null, - }; -} - -export async function enqueueScheduledAutomationRuns(options: { - prisma?: AutomationQueuePrisma, - targets: AutomationScheduleTarget[], - limit?: number, - cursor?: string | null, - scheduledAt: Date, -}): Promise<{ enqueuedCount: number }> { - if (options.targets.length === 0) { - return { enqueuedCount: 0 }; - } - - const prisma = options.prisma ?? globalPrismaClient; - const limit = normalizeScheduledAutomationRunPageLimit(options.limit); - const cursor = options.cursor ?? null; - const createResult = await prisma.outgoingRequest.createMany({ - data: options.targets.map((target) => ({ - qstashOptions: { - url: scheduledAutomationRunRoutePath, - body: { - tenancyId: target.tenancyId, - ruleId: target.ruleId, - cursor, - limit, - scheduledAtMillis: options.scheduledAt.getTime(), - }, - flowControl: { - key: getScheduledAutomationFlowControlKey(target.tenancyId), - parallelism: 1, - }, - }, - deduplicationKey: getScheduledAutomationDeduplicationKey({ - ...target, - cursor, - }), - })), - skipDuplicates: true, - }); - - return { - enqueuedCount: createResult.count, - }; -} - -export async function enqueueScheduledAutomationContinuation(options: { - tenancyId: string, - ruleId: string, - cursor: string, - limit: number, - scheduledAt: Date, - prisma?: AutomationQueuePrisma, -}): Promise<{ enqueuedCount: number }> { - return await enqueueScheduledAutomationRuns({ - prisma: options.prisma, - targets: [{ - tenancyId: options.tenancyId, - ruleId: options.ruleId, - }], - cursor: options.cursor, - limit: options.limit, - scheduledAt: options.scheduledAt, - }); -} - -export async function runScheduledAutomationRulePage(options: { - tenancyId: string, - ruleId: string, - cursor?: string | null, - limit?: number, - scheduledAt: Date, - now: Date, - getTenancyById?: (tenancyId: string) => Promise, - runRule?: ScheduledAutomationRunner, - enqueueContinuation?: typeof enqueueScheduledAutomationContinuation, -}): Promise { - const getTenancyById = options.getTenancyById ?? getTenancy; - const tenancy = await getTenancyById(options.tenancyId); - if (tenancy === null) { - return { - status: "skipped", - reason: "tenancy-not-found", - }; - } - - const rule = getAutomationRule(tenancy, options.ruleId); - if (rule === undefined) { - return { - status: "skipped", - reason: "rule-not-found", - }; - } - if (!rule.enabled) { - return { - status: "skipped", - reason: "rule-disabled", - }; - } - assertSupportedAutomationRule(options.ruleId, rule); - - const limit = normalizeScheduledAutomationRunPageLimit(options.limit); - const result = options.runRule === undefined - ? await runProductionScheduledAutomationRulePage({ - tenancyId: options.tenancyId, - ruleId: options.ruleId, - cursor: options.cursor ?? null, - limit, - scheduledAt: options.scheduledAt, - now: options.now, - }) - : await options.runRule({ - tenancy, - ruleId: options.ruleId, - cursor: options.cursor ?? null, - limit, - scheduledAt: options.scheduledAt, - now: options.now, - }); - - let enqueuedContinuation = false; - if (result.nextCursor !== null) { - const enqueueContinuation = options.enqueueContinuation ?? enqueueScheduledAutomationContinuation; - const enqueueResult = await enqueueContinuation({ - tenancyId: options.tenancyId, - ruleId: options.ruleId, - cursor: result.nextCursor, - limit, - scheduledAt: options.scheduledAt, - }); - enqueuedContinuation = enqueueResult.enqueuedCount > 0; - } - return { status: "ran", - result, - enqueuedContinuation, + tenanciesScanned, + rulesProcessed, + pagesProcessed, + evaluatedCount, + sentCount, + suppressedCount, + cycleCompleted, }; } async function runProductionScheduledAutomationRulePage(options: { - tenancyId: string, - ruleId: string, - cursor: string | null, - limit: number, - scheduledAt: Date, - now: Date, -}) { - const tenancy = await getTenancy(options.tenancyId); - if (tenancy === null) { - throw new Error(`Tenancy "${options.tenancyId}" disappeared between scheduled automation validation and execution.`); - } - return await runAutomationRuleForScheduledWorker({ - tenancy, - ruleId: options.ruleId, - cursor: options.cursor, - limit: options.limit, - scheduledAt: options.scheduledAt, - now: options.now, - }); -} - -async function runAutomationRuleForScheduledWorker(options: { tenancy: Tenancy, ruleId: string, cursor: string | null, @@ -334,7 +343,8 @@ async function runAutomationRuleForScheduledWorker(options: { scheduledAt: Date, now: Date, }): Promise { - const prisma = await getPrismaClientForTenancy(options.tenancy); + const tenancy = options.tenancy; + const prisma = await getPrismaClientForTenancy(tenancy); const sourceAdapter = createPaymentsItemQuotaSourceAdapter({ prisma, projectUserReader: prismaPaymentsItemQuotaProjectUserReader, @@ -342,7 +352,7 @@ async function runAutomationRuleForScheduledWorker(options: { }); return await runAutomationRuleForRoute({ - tenancy: options.tenancy, + tenancy, ruleId: options.ruleId, limit: options.limit, cursor: options.cursor, @@ -353,7 +363,7 @@ async function runAutomationRuleForScheduledWorker(options: { stateStore: createPrismaAutomationRuleExecutionStateStore(prisma), emailSender: async ({ action, scheduledAt }) => { await sendEmailToMany({ - tenancy: options.tenancy, + tenancy, recipients: [action.recipient], tsxSource: action.tsxSource, extraVariables: action.variables, @@ -369,14 +379,47 @@ async function runAutomationRuleForScheduledWorker(options: { }); } -function getScheduledAutomationFlowControlKey(tenancyId: string) { - return `automation-rule-run:${tenancyId}`; +function completeActiveRule(checkpoint: AutomationSchedulerCheckpoint, ruleId: string): AutomationSchedulerCheckpoint { + return { + ...checkpoint, + completedRuleCursor: ruleId, + activeRuleId: null, + nextSubjectCursor: null, + }; } -export function getScheduledAutomationDeduplicationKey(options: { - tenancyId: string, - ruleId: string, - cursor: string | null, -}) { - return `automation-rule-run:${options.tenancyId}:${options.ruleId}:${options.cursor ?? "start"}`; +function completeActiveTenancy(checkpoint: AutomationSchedulerCheckpoint): AutomationSchedulerCheckpoint { + if (checkpoint.activeTenancyId === null) { + throw new Error("Cannot complete an automation scheduler tenancy without an active tenancy."); + } + return { + completedTenancyCursor: checkpoint.activeTenancyId, + activeTenancyId: null, + completedRuleCursor: null, + activeRuleId: null, + nextSubjectCursor: null, + }; +} + +function emptyCheckpoint(): AutomationSchedulerCheckpoint { + return { + completedTenancyCursor: null, + activeTenancyId: null, + completedRuleCursor: null, + activeRuleId: null, + nextSubjectCursor: null, + }; +} + +function emptyCronResult(status: "lease-held"): ScheduledAutomationCronResult { + return { + status, + tenanciesScanned: 0, + rulesProcessed: 0, + pagesProcessed: 0, + evaluatedCount: 0, + sentCount: 0, + suppressedCount: 0, + cycleCompleted: false, + }; } diff --git a/apps/backend/src/lib/email-rendering.test.tsx b/apps/backend/src/lib/email-rendering.test.tsx index c78c3d11f..80716dbd6 100644 --- a/apps/backend/src/lib/email-rendering.test.tsx +++ b/apps/backend/src/lib/email-rendering.test.tsx @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { LightEmailTheme } from '@hexclave/shared/dist/helpers/emails'; +import { usageEmailTemplateSource } from '../../../../packages/shared/src/helpers/usage-email-template'; import { renderEmailsForTenancyBatched, renderEmailWithTemplate, type RenderEmailRequestForTenancy } from './email-rendering'; describe('renderEmailsForTenancyBatched', () => { @@ -533,6 +535,47 @@ describe('renderEmailWithTemplate', () => { } `; + it.each([ + { + thresholdKind: 'near', + currentQuantity: -1, + expectedCopy: 'Your remaining usage is low.', + unexpectedCopy: 'Your quota is at or below its limit.', + }, + { + thresholdKind: 'over', + currentQuantity: 999, + expectedCopy: 'Your quota is at or below its limit.', + unexpectedCopy: 'Your remaining usage is low.', + }, + ])('renders the built-in Usage Email from thresholdKind=$thresholdKind', async ({ + thresholdKind, + currentQuantity, + expectedCopy, + unexpectedCopy, + }) => { + const result = await renderEmailWithTemplate(usageEmailTemplateSource, LightEmailTheme, { + user: { displayName: 'QA User' }, + project: { displayName: 'QA Project' }, + variables: { + projectDisplayName: 'QA Project', + itemDisplayName: 'API requests', + currentQuantity, + thresholdKind, + }, + }); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.data.subject).toBe('API requests usage alert'); + expect(result.data.notificationCategory).toBe('Marketing'); + expect(result.data.html).toContain(expectedCopy); + expect(result.data.text).toContain(expectedCopy); + expect(result.data.html).not.toContain(unexpectedCopy); + expect(result.data.text).toContain(`Current API requests quantity: ${currentQuantity}`); + } + }); + it('preview mode: uses default user and project when not provided', async () => { const result = await renderEmailWithTemplate(simpleTemplate, simpleTheme, { previewMode: true, @@ -685,4 +728,3 @@ describe('renderEmailWithTemplate', () => { `); }); }); - diff --git a/packages/shared/src/config/schema.ts b/packages/shared/src/config/schema.ts index a9ee6dfae..ac0eb7de5 100644 --- a/packages/shared/src/config/schema.ts +++ b/packages/shared/src/config/schema.ts @@ -1123,6 +1123,13 @@ const organizationConfigDefaults = { }), } as const satisfies DefaultsType; +import.meta.vitest?.test("organization defaults include the built-in Usage Email template", ({ expect }) => { + expect(organizationConfigDefaults.emails.templates["28a45509-eb75-440d-b657-0a8640c775df"]).toMatchObject({ + displayName: "Usage Email", + themeId: undefined, + }); +}); + type _DeepOmitDefaultsImpl = T extends object ? ( ( & /* keys that are both in T and U, *and* the key's value in U is not a subtype of the key's value in T */ { [K in { [Ki in keyof T & keyof U]: U[Ki] extends T[Ki] ? never : Ki }[keyof T & keyof U]]: DeepOmitDefaults } diff --git a/packages/shared/src/helpers/emails.ts b/packages/shared/src/helpers/emails.ts index 084d8cd23..f143402ac 100644 --- a/packages/shared/src/helpers/emails.ts +++ b/packages/shared/src/helpers/emails.ts @@ -1,4 +1,5 @@ import { deindent } from "../utils/strings"; +import { usageEmailTemplateSource } from "./usage-email-template"; export const defaultNewTemplateSource = deindent` import { type } from "arktype" @@ -200,6 +201,7 @@ const EMAIL_TEMPLATE_TEAM_INVITATION_ID = "e84de395-2076-4831-9c19-8e9a96a868e4" const EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID = "066dd73c-36da-4fd0-b6d6-ebf87683f8bc"; const EMAIL_TEMPLATE_PAYMENT_RECEIPT_ID = "70372aee-0441-4d80-974c-2e858e40123a"; const EMAIL_TEMPLATE_PAYMENT_FAILED_ID = "f64b1afe-27ec-4a28-a277-7178f3261f9a"; +const EMAIL_TEMPLATE_USAGE_EMAIL_ID = "28a45509-eb75-440d-b657-0a8640c775df"; export const DEFAULT_EMAIL_TEMPLATES = { [EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID]: { @@ -236,6 +238,11 @@ export const DEFAULT_EMAIL_TEMPLATES = { "displayName": "Payment Failed", "tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n productName: \"string\",\n amount: \"string\",\n invoiceUrl: \"string?\",\n failureReason: \"string?\"\n});\n\nexport function EmailTemplate({ user, project, variables }: Props) {\n return (\n <>\n \n \n
\n
\n

\n We couldn't process your payment\n

\n

\n Hi{user.displayName ? (\", \" + user.displayName) : \"\"}! Your payment for {variables.productName} ({variables.amount}) did not go through.\n

\n {variables.failureReason ? (\n

\n Reason: {variables.failureReason}\n

\n ) : null}\n {variables.invoiceUrl ? (\n
\n \n View invoice\n \n
\n ) : null}\n
\n
\n
\n

\n Please update your payment details to avoid service interruption.\n

\n
\n
\n \n );\n}\n\nEmailTemplate.PreviewVariables = {\n productName: \"Pro Plan\",\n amount: \"$29.00\",\n invoiceUrl: \"https://example.com/billing\",\n failureReason: \"Your card was declined\"\n} satisfies typeof variablesSchema.infer;\n", "themeId": undefined, + }, + [EMAIL_TEMPLATE_USAGE_EMAIL_ID]: { + "displayName": "Usage Email", + "tsxSource": usageEmailTemplateSource, + "themeId": undefined, } }; diff --git a/packages/shared/src/helpers/usage-email-template.ts b/packages/shared/src/helpers/usage-email-template.ts new file mode 100644 index 000000000..c652b2d74 --- /dev/null +++ b/packages/shared/src/helpers/usage-email-template.ts @@ -0,0 +1,48 @@ +export const usageEmailTemplateSource = ` + import { type } from "arktype"; + import { Heading, Section, Text } from "@react-email/components"; + import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + + export const variablesSchema = type({ + projectDisplayName: "string", + itemDisplayName: "string", + currentQuantity: "number", + thresholdKind: "'near' | 'over'" + }); + + export function EmailTemplate({ variables }: Props) { + const isOverLimit = variables.thresholdKind === "over"; + + return ( + <> + + +
+ + {isOverLimit + ? variables.itemDisplayName + " limit reached" + : variables.itemDisplayName + " usage is running low"} + + + {isOverLimit + ? "Your quota is at or below its limit." + : "Your remaining usage is low."} + + + Current {variables.itemDisplayName} quantity: {variables.currentQuantity} + + + Review your plan in {variables.projectDisplayName} if you need more capacity. + +
+ + ); + } + + EmailTemplate.PreviewVariables = { + projectDisplayName: "My Project", + itemDisplayName: "API requests", + currentQuantity: 10, + thresholdKind: "near" + } satisfies typeof variablesSchema.infer; +`; From a7d6fd760d15d9ac6db7aee24f0ef852354477c2 Mon Sep 17 00:00:00 2001 From: AddarshKS Date: Fri, 17 Jul 2026 23:17:33 +0000 Subject: [PATCH 10/10] Email Automation V2 Bot Comment Fix Batch I --- .../migration.sql | 2 +- .../tests/automation-rule-execution-state.ts | 13 +- apps/backend/prisma/schema.prisma | 3 +- .../rules/[rule_id]/run/route.test.ts | 213 ++++++++++++++---- .../automations/rules/[rule_id]/run/route.ts | 11 +- .../automations/schedule/route.test.ts | 2 + .../src/lib/automations/actions/send-email.ts | 10 +- .../lib/automations/execution-state-store.ts | 101 +++++---- .../quota-email-automation.test.ts | 12 +- apps/backend/src/lib/automations/rules.ts | 48 +++- apps/backend/src/lib/automations/run-route.ts | 43 +++- .../src/lib/automations/scheduler.test.ts | 38 +++- apps/backend/src/lib/automations/scheduler.ts | 46 +++- .../sources/payments-item-quota.ts | 6 +- apps/backend/src/lib/emails.tsx | 102 +++++++-- packages/shared/src/config/schema.ts | 7 +- packages/shared/src/helpers/emails.ts | 1 + 17 files changed, 497 insertions(+), 161 deletions(-) diff --git a/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/migration.sql b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/migration.sql index 9fa6efc15..e9257d28b 100644 --- a/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/migration.sql +++ b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/migration.sql @@ -8,7 +8,7 @@ CREATE TABLE "AutomationRuleExecutionState" ( "signalKey" TEXT NOT NULL, "lastTriggeredAt" TIMESTAMP(3) NOT NULL, "lastActionAt" TIMESTAMP(3), - "lastEmailOutboxId" UUID, + "emailOutboxId" UUID NOT NULL, "lastSourceSnapshot" JSONB NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, diff --git a/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts index 95a219969..f2cb80db0 100644 --- a/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts +++ b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts @@ -40,6 +40,7 @@ export const postMigration = async (sql: Sql, ctx: Awaited { }); }); +describe("automation email enqueue result classification", () => { + it("distinguishes newly created and already-enqueued single-recipient rows", () => { + expect(getSingleAutomationEmailSendResult({ createdCount: 1, alreadyEnqueuedCount: 0 })).toEqual({ outcome: "created" }); + expect(getSingleAutomationEmailSendResult({ createdCount: 0, alreadyEnqueuedCount: 1 })).toEqual({ outcome: "already-enqueued" }); + }); + + it("fails loudly for partial or otherwise invalid single-recipient results", () => { + expect(() => getSingleAutomationEmailSendResult({ createdCount: 1, alreadyEnqueuedCount: 1 })) + .toThrow(/Expected one automation email enqueue result/); + }); +}); + type InMemoryExecutionState = { lastTriggeredAt: Date, lastActionAt: Date | null, - lastEmailOutboxId: string | null, + emailOutboxId: string, lastSourceSnapshot: Record, }; function createInMemoryStateStore() { const states = new Map(); + let nextEmailOutboxId = 1; + const createEmailOutboxId = () => `00000000-0000-4000-8000-${String(nextEmailOutboxId++).padStart(12, "0")}`; const keyFor = (options: { tenancyId: string, ruleId: string, @@ -55,7 +72,7 @@ function createInMemoryStateStore() { }) => `${options.tenancyId}:${options.ruleId}:${options.subjectType}:${options.subjectId}:${options.signalKey}`; const stateStore: AutomationRuleExecutionStateStore = { - claimExecution: vi.fn(async (options) => { + claimExecution: vi.fn(async (options): Promise>> => { const key = keyFor(options); const existing = states.get(key); const cooldownCutoff = new Date(options.lastTriggeredAt.getTime() - options.cooldownDays * 24 * 60 * 60 * 1000); @@ -73,15 +90,17 @@ function createInMemoryStateStore() { }; } + const emailOutboxId = existing?.lastActionAt === null ? existing.emailOutboxId : createEmailOutboxId(); states.set(key, { lastTriggeredAt: options.lastTriggeredAt, lastActionAt: null, - lastEmailOutboxId: null, + emailOutboxId, lastSourceSnapshot: options.sourceSnapshot, }); return { claimed: true, lastActionAt: existing?.lastActionAt ?? null, + emailOutboxId, }; }), markActionCompleted: vi.fn(async (options) => { @@ -93,7 +112,7 @@ function createInMemoryStateStore() { states.set(key, { ...existing, lastActionAt: options.lastActionAt, - lastEmailOutboxId: options.lastEmailOutboxId, + emailOutboxId: options.emailOutboxId, }); }), }; @@ -115,7 +134,7 @@ async function runWithFakes(options: { } = {}) { const sourceAdapter = createAutomationRouteTestSourceAdapter(options.decisionFactory, { evaluatedCount: 1 }); const actionAdapter = createAutomationRouteTestActionAdapter(); - const emailSender = vi.fn(options.emailSender ?? (async () => {})); + const emailSender = vi.fn(options.emailSender ?? (async () => ({ outcome: "created" as const }))); let createdStore: ReturnType | undefined; let stateStore = options.stateStore; if (stateStore === undefined) { @@ -149,10 +168,10 @@ describe("automation real-send route helpers", () => { it("returns 404 for missing manual rules before evaluating, claiming state, or sending email", async () => { const sourceAdapter = createAutomationRouteTestSourceAdapter(); const actionAdapter = createAutomationRouteTestActionAdapter(); - const emailSender = vi.fn(async () => {}); + const emailSender = vi.fn(async () => ({ outcome: "created" as const })); const { stateStore } = createInMemoryStateStore(); - const resultPromise = runAutomationRuleForRoute({ + const resultPromise = runAutomationRuleForManualRoute({ tenancy: createAutomationRouteTestTenancy({ ruleExists: false }), ruleId, scheduledAt, @@ -175,10 +194,10 @@ describe("automation real-send route helpers", () => { it("refuses disabled rules before evaluating, claiming state, or sending email", async () => { const sourceAdapter = createAutomationRouteTestSourceAdapter(); const actionAdapter = createAutomationRouteTestActionAdapter(); - const emailSender = vi.fn(async () => {}); + const emailSender = vi.fn(async () => ({ outcome: "created" as const })); const { stateStore } = createInMemoryStateStore(); - await expect(runAutomationRuleForRoute({ + await expect(runAutomationRuleForManualRoute({ tenancy: createAutomationRouteTestTenancy({ enabled: false }), ruleId, scheduledAt, @@ -215,7 +234,7 @@ describe("automation real-send route helpers", () => { })); expect([...states!.values()]).toMatchObject([{ lastActionAt: new Date("2026-07-01T12:00:00.000Z"), - lastEmailOutboxId: null, + emailOutboxId: expect.any(String), lastSourceSnapshot: expect.objectContaining({ itemId: "api_credits", thresholdKind: "near", @@ -254,7 +273,7 @@ describe("automation real-send route helpers", () => { states.set("tenancy-1:low-api-credits:user:user-1:api_credits:near", { lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), lastActionAt: null, - lastEmailOutboxId: null, + emailOutboxId: "00000000-0000-4000-8000-000000000001", lastSourceSnapshot: createAutomationRouteTestSourceDecision().sourceSnapshot, }); @@ -346,7 +365,7 @@ describe("automation real-send route helpers", () => { expect([...states.values()]).toMatchObject([{ lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), lastActionAt: null, - lastEmailOutboxId: null, + emailOutboxId: expect.any(String), }]); const immediateRetry = await runWithFakes({ @@ -378,6 +397,54 @@ describe("automation real-send route helpers", () => { expect(retryAfterStaleClaim.emailSender).toHaveBeenCalledOnce(); }); + it("does not enqueue a duplicate when completion fails after the outbox row was created", async () => { + const { prisma, rows } = createMockExecutionStatePrisma(); + const reservedEmailOutboxId = "00000000-0000-4000-8000-000000000001"; + const prismaStore = createPrismaAutomationRuleExecutionStateStore(prisma, { + createEmailOutboxId: () => reservedEmailOutboxId, + }); + let shouldFailCompletion = true; + const stateStore: AutomationRuleExecutionStateStore = { + claimExecution: prismaStore.claimExecution, + markActionCompleted: vi.fn(async (options) => { + if (shouldFailCompletion) { + shouldFailCompletion = false; + throw new Error("completion database write failed"); + } + await prismaStore.markActionCompleted(options); + }), + }; + const enqueuedEmailOutboxIds = new Set(); + const emailSender = vi.fn(async (options: Parameters[0]) => { + if (enqueuedEmailOutboxIds.has(options.emailOutboxId)) { + return { outcome: "already-enqueued" as const }; + } + enqueuedEmailOutboxIds.add(options.emailOutboxId); + return { outcome: "created" as const }; + }); + + await expect(runWithFakes({ + stateStore, + emailSender, + })).rejects.toThrow("completion database write failed"); + + const retry = await runWithFakes({ + stateStore, + emailSender, + now: new Date("2026-07-01T12:16:00.000Z"), + }); + + expect(retry.result.sentCount).toBe(1); + expect(emailSender).toHaveBeenCalledTimes(2); + expect(emailSender).toHaveBeenNthCalledWith(1, expect.objectContaining({ emailOutboxId: reservedEmailOutboxId })); + expect(emailSender).toHaveBeenNthCalledWith(2, expect.objectContaining({ emailOutboxId: reservedEmailOutboxId })); + expect(enqueuedEmailOutboxIds).toEqual(new Set([reservedEmailOutboxId])); + expect([...rows.values()]).toMatchObject([{ + emailOutboxId: reservedEmailOutboxId, + lastActionAt: new Date("2026-07-01T12:16:00.000Z"), + }]); + }); + it("suppresses a second run after an expired cooldown is reclaimed but not completed", async () => { const { stateStore } = createInMemoryStateStore(); @@ -411,7 +478,7 @@ type PrismaStoreState = { signalKey: string, lastTriggeredAt: Date, lastActionAt: Date | null, - lastEmailOutboxId: string | null, + emailOutboxId: string, lastSourceSnapshot: Prisma.InputJsonObject, }; @@ -451,6 +518,7 @@ function createMockExecutionStatePrisma(initialRows: PrismaStoreState[] = []) { return row === undefined ? null : { lastTriggeredAt: row.lastTriggeredAt, lastActionAt: row.lastActionAt, + emailOutboxId: row.emailOutboxId, }; }), updateMany: vi.fn(async (options) => { @@ -459,13 +527,16 @@ function createMockExecutionStatePrisma(initialRows: PrismaStoreState[] = []) { return { count: 0 }; } - const canClaim = options.where.OR.some((clause) => { - if ("lastTriggeredAt" in clause) { - return row.lastActionAt === null && row.lastTriggeredAt < clause.lastTriggeredAt.lt; - } - return row.lastActionAt !== null && row.lastActionAt < clause.lastActionAt.lt; - }); - if (!canClaim) { + const lastActionMatches = options.where.lastActionAt === null + ? row.lastActionAt === null + : row.lastActionAt !== null && row.lastActionAt < options.where.lastActionAt.lt; + const lastTriggeredMatches = options.where.lastTriggeredAt === undefined + || (options.where.lastTriggeredAt instanceof Date + ? row.lastTriggeredAt.getTime() === options.where.lastTriggeredAt.getTime() + : row.lastTriggeredAt < options.where.lastTriggeredAt.lt); + const emailOutboxIdMatches = options.where.emailOutboxId === undefined + || row.emailOutboxId === options.where.emailOutboxId; + if (!lastActionMatches || !lastTriggeredMatches || !emailOutboxIdMatches) { return { count: 0 }; } @@ -475,18 +546,6 @@ function createMockExecutionStatePrisma(initialRows: PrismaStoreState[] = []) { }); return { count: 1 }; }), - update: vi.fn(async (options) => { - const key = keyFor(options.where.tenancyId_ruleId_subjectType_subjectId_signalKey); - const row = rows.get(key); - if (row === undefined) { - throw new Error("Expected automation execution state row to exist before update."); - } - rows.set(key, { - ...row, - ...options.data, - }); - return rows.get(key); - }), }, }; @@ -516,19 +575,22 @@ function createClaimOptions(options: { describe("Prisma automation execution state store", () => { it("claims a new execution state row with Prisma create", async () => { const { prisma, rows } = createMockExecutionStatePrisma(); - const store = createPrismaAutomationRuleExecutionStateStore(prisma); + const store = createPrismaAutomationRuleExecutionStateStore(prisma, { + createEmailOutboxId: () => "00000000-0000-4000-8000-000000000001", + }); const result = await store.claimExecution(createClaimOptions()); expect(result).toEqual({ claimed: true, lastActionAt: null, + emailOutboxId: "00000000-0000-4000-8000-000000000001", }); expect(prisma.automationRuleExecutionState.create).toHaveBeenCalledOnce(); expect([...rows.values()]).toMatchObject([{ lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), lastActionAt: null, - lastEmailOutboxId: null, + emailOutboxId: "00000000-0000-4000-8000-000000000001", lastSourceSnapshot: expect.objectContaining({ itemId: "api_credits", }), @@ -543,7 +605,7 @@ describe("Prisma automation execution state store", () => { actionType: "send-email", lastTriggeredAt: new Date("2026-06-01T12:00:00.000Z"), lastActionAt: oldActionAt, - lastEmailOutboxId: null, + emailOutboxId: "00000000-0000-4000-8000-000000000001", lastSourceSnapshot: { itemId: "old", }, @@ -555,6 +617,7 @@ describe("Prisma automation execution state store", () => { expect(result).toEqual({ claimed: true, lastActionAt: oldActionAt, + emailOutboxId: expect.any(String), }); expect(prisma.automationRuleExecutionState.updateMany).toHaveBeenCalledOnce(); }); @@ -567,7 +630,7 @@ describe("Prisma automation execution state store", () => { actionType: "send-email", lastTriggeredAt: new Date("2026-06-30T12:00:00.000Z"), lastActionAt, - lastEmailOutboxId: null, + emailOutboxId: "00000000-0000-4000-8000-000000000001", lastSourceSnapshot: { itemId: "api_credits", }, @@ -597,6 +660,7 @@ describe("Prisma automation execution state store", () => { expect(first).toEqual({ claimed: true, lastActionAt: null, + emailOutboxId: expect.any(String), }); expect(second).toEqual({ claimed: false, @@ -608,7 +672,9 @@ describe("Prisma automation execution state store", () => { it("continues to apply cooldown and retry rules after a claim completes", async () => { const { prisma } = createMockExecutionStatePrisma(); - const store = createPrismaAutomationRuleExecutionStateStore(prisma); + const store = createPrismaAutomationRuleExecutionStateStore(prisma, { + createEmailOutboxId: () => "00000000-0000-4000-8000-000000000001", + }); await store.claimExecution(createClaimOptions({ lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), @@ -619,8 +685,9 @@ describe("Prisma automation execution state store", () => { subjectType: "user", subjectId: "user-1", signalKey: "api_credits:near", + claimTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), lastActionAt: new Date("2026-07-01T12:05:00.000Z"), - lastEmailOutboxId: null, + emailOutboxId: "00000000-0000-4000-8000-000000000001", }); await expect(store.claimExecution(createClaimOptions({ @@ -634,6 +701,7 @@ describe("Prisma automation execution state store", () => { }))).resolves.toEqual({ claimed: true, lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + emailOutboxId: expect.any(String), }); }); @@ -645,7 +713,7 @@ describe("Prisma automation execution state store", () => { actionType: "send-email", lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), lastActionAt, - lastEmailOutboxId: null, + emailOutboxId: "00000000-0000-4000-8000-000000000001", lastSourceSnapshot: { itemId: "api_credits", }, @@ -662,6 +730,7 @@ describe("Prisma automation execution state store", () => { expect(first).toEqual({ claimed: true, lastActionAt, + emailOutboxId: expect.any(String), }); expect(second).toEqual({ claimed: false, @@ -673,14 +742,58 @@ describe("Prisma automation execution state store", () => { }]); }); - it("marks a claimed action completed with Prisma update", async () => { + it("reuses an in-flight reservation after staleness and fences the previous claimant", async () => { + const reservedEmailOutboxId = "00000000-0000-4000-8000-000000000001"; + const { prisma } = createMockExecutionStatePrisma([{ + ...createClaimOptions(), + sourceType: "payments-item-quota", + actionType: "send-email", + lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt: null, + emailOutboxId: reservedEmailOutboxId, + lastSourceSnapshot: { itemId: "api_credits" }, + }]); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + await expect(store.claimExecution(createClaimOptions({ + lastTriggeredAt: new Date("2026-07-01T12:16:00.000Z"), + }))).resolves.toEqual({ + claimed: true, + lastActionAt: null, + emailOutboxId: reservedEmailOutboxId, + }); + + await expect(store.markActionCompleted({ + tenancyId: "tenancy-1", + ruleId, + subjectType: "user", + subjectId: "user-1", + signalKey: "api_credits:near", + claimTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt: new Date("2026-07-01T12:17:00.000Z"), + emailOutboxId: reservedEmailOutboxId, + })).rejects.toThrow("lost ownership"); + + await expect(store.markActionCompleted({ + tenancyId: "tenancy-1", + ruleId, + subjectType: "user", + subjectId: "user-1", + signalKey: "api_credits:near", + claimTriggeredAt: new Date("2026-07-01T12:16:00.000Z"), + lastActionAt: new Date("2026-07-01T12:16:00.000Z"), + emailOutboxId: reservedEmailOutboxId, + })).resolves.toBeUndefined(); + }); + + it("marks a claimed action completed with a fenced Prisma updateMany", async () => { const { prisma, rows } = createMockExecutionStatePrisma([{ ...createClaimOptions(), sourceType: "payments-item-quota", actionType: "send-email", lastTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), lastActionAt: null, - lastEmailOutboxId: null, + emailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", lastSourceSnapshot: { itemId: "api_credits", }, @@ -693,14 +806,26 @@ describe("Prisma automation execution state store", () => { subjectType: "user", subjectId: "user-1", signalKey: "api_credits:near", + claimTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), lastActionAt: new Date("2026-07-01T12:05:00.000Z"), - lastEmailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", + emailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", }); - expect(prisma.automationRuleExecutionState.update).toHaveBeenCalledOnce(); + expect(prisma.automationRuleExecutionState.updateMany).toHaveBeenCalledOnce(); expect([...rows.values()]).toMatchObject([{ lastActionAt: new Date("2026-07-01T12:05:00.000Z"), - lastEmailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", + emailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", }]); + + await expect(store.markActionCompleted({ + tenancyId: "tenancy-1", + ruleId, + subjectType: "user", + subjectId: "user-1", + signalKey: "api_credits:near", + claimTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + emailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", + })).resolves.toBeUndefined(); }); }); diff --git a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts index d26f90835..56da31124 100644 --- a/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts @@ -2,7 +2,8 @@ import { createSendEmailActionAdapter } from "@/lib/automations/actions/send-ema import { createPrismaAutomationRuleExecutionStateStore } from "@/lib/automations/execution-state-store"; import { automationRunResultToApiBody, - runAutomationRuleForRoute, + getSingleAutomationEmailSendResult, + runAutomationRuleForManualRoute, } from "@/lib/automations/run-route"; import { parseAutomationScheduledAtMillis } from "@/lib/automations/scheduled-at"; import { @@ -80,7 +81,7 @@ export const POST = createSmartRouteHandler({ customerDataReaders: paymentsItemQuotaCustomerDataReaders, }); const scheduledAt = parseAutomationScheduledAtMillis(body.scheduled_at_millis, "scheduled_at_millis"); - const result = await runAutomationRuleForRoute({ + const result = await runAutomationRuleForManualRoute({ tenancy: auth.tenancy, ruleId: params.rule_id, limit: body.limit, @@ -90,8 +91,8 @@ export const POST = createSmartRouteHandler({ sourceAdapter, actionAdapter: createSendEmailActionAdapter(), stateStore: createPrismaAutomationRuleExecutionStateStore(prisma), - emailSender: async ({ action, scheduledAt }) => { - await sendEmailToMany({ + emailSender: async ({ action, scheduledAt, emailOutboxId }) => { + const enqueueResult = await sendEmailToMany({ tenancy: auth.tenancy, recipients: [action.recipient], tsxSource: action.tsxSource, @@ -103,7 +104,9 @@ export const POST = createSmartRouteHandler({ createdWith: action.createdWith, overrideSubject: action.subject, overrideNotificationCategoryId: action.notificationCategoryId, + emailOutboxIds: [emailOutboxId], }); + return getSingleAutomationEmailSendResult(enqueueResult); }, }); diff --git a/apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts b/apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts index 8b9724694..26a717d99 100644 --- a/apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts +++ b/apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; +import { scheduledAutomationWorkBudgetMs } from "@/lib/automations/scheduler"; import { maxDuration, parseAutomationScheduleBound } from "./route"; describe("automation schedule route bounds", () => { it("keeps the work budget below the route runtime limit", () => { expect(maxDuration).toBe(60); expect(parseAutomationScheduleBound("45000", "max_duration_ms", 45_000)).toBe(45_000); + expect(scheduledAutomationWorkBudgetMs).toBeLessThan(maxDuration * 1000); }); it("allows omitted and lower positive integer bounds", () => { diff --git a/apps/backend/src/lib/automations/actions/send-email.ts b/apps/backend/src/lib/automations/actions/send-email.ts index 6066c555d..661e591f3 100644 --- a/apps/backend/src/lib/automations/actions/send-email.ts +++ b/apps/backend/src/lib/automations/actions/send-email.ts @@ -1,5 +1,5 @@ import { AutomationActionAdapter, AutomationActionPlan, AutomationSourceDecision } from "../rule-evaluator"; -import { AutomationRuleTenancy, PaymentsItemQuotaAutomationRule, sendEmailActionType } from "../rules"; +import { AutomationRuleTenancy, NonRetryableAutomationRuleError, PaymentsItemQuotaAutomationRule, sendEmailActionType } from "../rules"; export type SendEmailNotificationCategory = { id: string, @@ -35,15 +35,15 @@ export async function buildSendEmailActionPlan(options: { }): Promise { const template = options.tenancy.config.emails?.templates?.[options.rule.action.templateId]; if (template === undefined) { - throw new Error(`Automation rule "${options.ruleId}" references email template "${options.rule.action.templateId}", but that template does not exist.`); + throw new NonRetryableAutomationRuleError("missing-template", `Automation rule "${options.ruleId}" references email template "${options.rule.action.templateId}", but that template does not exist.`); } if (template.tsxSource === undefined) { - throw new Error(`Automation rule "${options.ruleId}" references email template "${options.rule.action.templateId}", but that template is missing tsxSource.`); + throw new NonRetryableAutomationRuleError("invalid-template", `Automation rule "${options.ruleId}" references email template "${options.rule.action.templateId}", but that template is missing tsxSource.`); } const notificationCategoryName: string = options.rule.action.notificationCategoryName ?? "Marketing"; if (notificationCategoryName !== "Marketing") { - throw new Error(`Automation rule "${options.ruleId}" has unsupported action.notificationCategoryName "${notificationCategoryName}". V1 supports only "Marketing".`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${options.ruleId}" has unsupported action.notificationCategoryName "${notificationCategoryName}". V1 supports only "Marketing".`); } const notificationCategoryReader = options.getNotificationCategoryByName ?? getNotificationCategoryByNameFromExistingEmailSystem; const notificationCategory = await notificationCategoryReader(notificationCategoryName); @@ -53,7 +53,7 @@ export async function buildSendEmailActionPlan(options: { const projectDisplayName = options.tenancy.project?.display_name; if (projectDisplayName === undefined) { - throw new Error(`Automation rule "${options.ruleId}" cannot build email variables because tenancy.project.display_name is missing.`); + throw new NonRetryableAutomationRuleError("invalid-rule-context", `Automation rule "${options.ruleId}" cannot build email variables because tenancy.project.display_name is missing.`); } return { diff --git a/apps/backend/src/lib/automations/execution-state-store.ts b/apps/backend/src/lib/automations/execution-state-store.ts index 0acb08b15..8b0bc6ae3 100644 --- a/apps/backend/src/lib/automations/execution-state-store.ts +++ b/apps/backend/src/lib/automations/execution-state-store.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { Prisma } from "@/generated/prisma/client"; import { getAutomationCooldownStatus, type AutomationCooldownStatus } from "./cooldown"; import { AutomationRuleExecutionStateStore } from "./run-route"; @@ -19,7 +20,7 @@ export type AutomationRuleExecutionStatePrisma = { actionType: string, lastTriggeredAt: Date, lastActionAt: Date | null, - lastEmailOutboxId: string | null, + emailOutboxId: string, lastSourceSnapshot: Prisma.InputJsonObject, }, }) => Promise, @@ -30,35 +31,24 @@ export type AutomationRuleExecutionStatePrisma = { select: { lastTriggeredAt: true, lastActionAt: true, + emailOutboxId: true, }, - }) => Promise<{ lastTriggeredAt: Date, lastActionAt: Date | null } | null>, + }) => Promise<{ lastTriggeredAt: Date, lastActionAt: Date | null, emailOutboxId: string } | null>, updateMany: (options: { where: AutomationRuleExecutionStateKey & { - OR: Array< - | { - lastActionAt: null, - lastTriggeredAt: { lt: Date }, - } - | { lastActionAt: { lt: Date } } - >, + lastActionAt: null | { lt: Date }, + lastTriggeredAt?: Date | { lt: Date }, + emailOutboxId?: string, }, data: { - sourceType: string, - actionType: string, - lastTriggeredAt: Date, - lastActionAt: Date | null, - lastSourceSnapshot: Prisma.InputJsonObject, + sourceType?: string, + actionType?: string, + lastTriggeredAt?: Date, + lastActionAt?: Date | null, + emailOutboxId?: string, + lastSourceSnapshot?: Prisma.InputJsonObject, }, }) => Promise<{ count: number }>, - update: (options: { - where: { - tenancyId_ruleId_subjectType_subjectId_signalKey: AutomationRuleExecutionStateKey, - }, - data: { - lastActionAt: Date, - lastEmailOutboxId: string | null, - }, - }) => Promise, }, }; @@ -103,7 +93,11 @@ export function createPrismaAutomationRuleExecutionStateReader(prisma: Automatio }; } -export function createPrismaAutomationRuleExecutionStateStore(prisma: AutomationRuleExecutionStatePrisma): AutomationRuleExecutionStateStore { +export function createPrismaAutomationRuleExecutionStateStore( + prisma: AutomationRuleExecutionStatePrisma, + options: { createEmailOutboxId?: () => string } = {}, +): AutomationRuleExecutionStateStore { + const createEmailOutboxId = options.createEmailOutboxId ?? randomUUID; return { async claimExecution(options) { const cooldownCutoff = new Date(options.lastTriggeredAt.getTime() - options.cooldownDays * 24 * 60 * 60 * 1000); @@ -112,6 +106,7 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation const staleClaimCutoff = new Date(options.lastTriggeredAt.getTime() - 15 * 60 * 1000); const stateKey = getAutomationRuleExecutionStateKey(options); const lastSourceSnapshot = getPrismaJsonObject(options.sourceSnapshot); + const initialEmailOutboxId = createEmailOutboxId(); try { await prisma.automationRuleExecutionState.create({ @@ -125,7 +120,7 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation signalKey: options.signalKey, lastTriggeredAt: options.lastTriggeredAt, lastActionAt: null, - lastEmailOutboxId: null, + emailOutboxId: initialEmailOutboxId, lastSourceSnapshot, }, }); @@ -133,6 +128,7 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation return { claimed: true, lastActionAt: null, + emailOutboxId: initialEmailOutboxId, }; } catch (error) { if (!isPrismaUniqueConstraintError(error)) { @@ -147,12 +143,15 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation select: { lastTriggeredAt: true, lastActionAt: true, + emailOutboxId: true, }, }); if (existing === null) { throw new Error("Automation rule execution state disappeared after a unique constraint conflict."); } + // Stale in-flight retries must address the same outbox row; a completed cooldown cycle needs a new row. + const emailOutboxId = existing.lastActionAt === null ? existing.emailOutboxId : createEmailOutboxId(); const updateResult = await prisma.automationRuleExecutionState.updateMany({ where: { tenancyId: options.tenancyId, @@ -160,25 +159,15 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation subjectType: options.subjectType, subjectId: options.subjectId, signalKey: options.signalKey, - OR: [ - { - lastActionAt: null, - lastTriggeredAt: { - lt: staleClaimCutoff, - }, - }, - { - lastActionAt: { - lt: cooldownCutoff, - }, - }, - ], + lastActionAt: existing.lastActionAt === null ? null : { lt: cooldownCutoff }, + ...(existing.lastActionAt === null ? { lastTriggeredAt: { lt: staleClaimCutoff } } : {}), }, data: { sourceType: options.sourceType, actionType: options.actionType, lastTriggeredAt: options.lastTriggeredAt, lastActionAt: null, + emailOutboxId, lastSourceSnapshot, }, }); @@ -186,6 +175,7 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation return { claimed: true, lastActionAt: existing.lastActionAt, + emailOutboxId, }; } if (updateResult.count !== 0) { @@ -199,6 +189,7 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation select: { lastTriggeredAt: true, lastActionAt: true, + emailOutboxId: true, }, }); if (current === null) { @@ -211,15 +202,43 @@ export function createPrismaAutomationRuleExecutionStateStore(prisma: Automation }; }, async markActionCompleted(options) { - await prisma.automationRuleExecutionState.update({ + const stateKey = getAutomationRuleExecutionStateKey(options); + const updateResult = await prisma.automationRuleExecutionState.updateMany({ where: { - tenancyId_ruleId_subjectType_subjectId_signalKey: getAutomationRuleExecutionStateKey(options), + ...stateKey, + lastActionAt: null, + lastTriggeredAt: options.claimTriggeredAt, + emailOutboxId: options.emailOutboxId, }, data: { lastActionAt: options.lastActionAt, - lastEmailOutboxId: options.lastEmailOutboxId, }, }); + if (updateResult.count === 1) { + return; + } + if (updateResult.count !== 0) { + throw new Error(`Expected at most one automation execution state row to complete, received ${updateResult.count}.`); + } + + const current = await prisma.automationRuleExecutionState.findUnique({ + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: stateKey, + }, + select: { + lastTriggeredAt: true, + lastActionAt: true, + emailOutboxId: true, + }, + }); + if ( + current?.emailOutboxId === options.emailOutboxId + && current.lastTriggeredAt.getTime() === options.claimTriggeredAt.getTime() + && current.lastActionAt !== null + ) { + return; + } + throw new Error("Automation execution completion lost ownership of its reserved EmailOutbox row."); }, }; } diff --git a/apps/backend/src/lib/automations/quota-email-automation.test.ts b/apps/backend/src/lib/automations/quota-email-automation.test.ts index c69625454..d1e00d9bb 100644 --- a/apps/backend/src/lib/automations/quota-email-automation.test.ts +++ b/apps/backend/src/lib/automations/quota-email-automation.test.ts @@ -421,11 +421,13 @@ describe("payments item quota source adapter", () => { const tenancy = createSourceTenancy({ itemExists: false }); const { adapter } = createSourceAdapterFixture({ projectUserIds: ["user-1"] }); - await expect(adapter.evaluate({ + const evaluationPromise = adapter.evaluate({ tenancy, ruleId, rule: getSupportedAutomationRule(tenancy, ruleId), - })).rejects.toThrow('Automation rule "low-api-credits" references payments item "api_credits", but that item does not exist.'); + }); + await expect(evaluationPromise).rejects.toMatchObject({ reason: "missing-item" }); + await expect(evaluationPromise).rejects.toThrow('Automation rule "low-api-credits" references payments item "api_credits", but that item does not exist.'); }); it("rejects a payments item with a non-user customer type", async () => { @@ -844,11 +846,13 @@ describe("send-email action adapter", () => { templateId: "1b477fe1-7479-4d90-ac47-23d0a5048bc8", })); - await expect(buildTestSendEmailActionPlan({ + const planPromise = buildTestSendEmailActionPlan({ tenancy, ruleId, rule: getSupportedAutomationRule(tenancy, ruleId), decision: createActionDecision(), - })).rejects.toThrow('Automation rule "low-api-credits" references email template "1b477fe1-7479-4d90-ac47-23d0a5048bc8", but that template does not exist.'); + }); + await expect(planPromise).rejects.toMatchObject({ reason: "missing-template" }); + await expect(planPromise).rejects.toThrow('Automation rule "low-api-credits" references email template "1b477fe1-7479-4d90-ac47-23d0a5048bc8", but that template does not exist.'); }); }); diff --git a/apps/backend/src/lib/automations/rules.ts b/apps/backend/src/lib/automations/rules.ts index 6669edf04..4407641b7 100644 --- a/apps/backend/src/lib/automations/rules.ts +++ b/apps/backend/src/lib/automations/rules.ts @@ -80,6 +80,27 @@ export type AutomationRuleTenancy = { config: AutomationRulesConfig, }; +export type NonRetryableAutomationRuleErrorReason = + | "rule-not-found" + | "rule-disabled" + | "unsupported-rule" + | "missing-item" + | "incompatible-item" + | "missing-template" + | "invalid-template" + | "invalid-rule-context"; + +/** A deterministic rule/configuration failure that will not recover by retrying the same scheduler page. */ +export class NonRetryableAutomationRuleError extends Error { + constructor( + readonly reason: NonRetryableAutomationRuleErrorReason, + message: string, + ) { + super(message); + this.name = "NonRetryableAutomationRuleError"; + } +} + export function listAutomationRules(tenancy: AutomationRuleTenancy) { return Object.entries(tenancy.config.automations?.rules ?? {}) .filter((entry): entry is [string, AutomationRuleConfig] => entry[1] !== undefined) @@ -90,13 +111,20 @@ export function getAutomationRule(tenancy: AutomationRuleTenancy, ruleId: string return tenancy.config.automations?.rules?.[ruleId]; } -export class AutomationRuleNotFoundError extends Error { +export class AutomationRuleNotFoundError extends NonRetryableAutomationRuleError { constructor(tenancyId: string, ruleId: string) { - super(`Automation rule "${ruleId}" was not found for tenancy "${tenancyId}".`); + super("rule-not-found", `Automation rule "${ruleId}" was not found for tenancy "${tenancyId}".`); this.name = "AutomationRuleNotFoundError"; } } +export class AutomationRuleDisabledError extends NonRetryableAutomationRuleError { + constructor(ruleId: string) { + super("rule-disabled", `Automation rule "${ruleId}" is disabled and cannot be manually sent.`); + this.name = "AutomationRuleDisabledError"; + } +} + export function getSupportedAutomationRule(tenancy: AutomationRuleTenancy, ruleId: string) { const rule = getAutomationRule(tenancy, ruleId); if (rule === undefined) { @@ -108,31 +136,31 @@ export function getSupportedAutomationRule(tenancy: AutomationRuleTenancy, ruleI export function assertSupportedAutomationRule(ruleId: string, rule: AutomationRuleConfig): asserts rule is SupportedAutomationRule { if (rule.source.type !== paymentsItemQuotaSourceType) { - throw new Error(`Automation rule "${ruleId}" has unsupported source.type "${rule.source.type}". V1 supports only "${paymentsItemQuotaSourceType}".`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" has unsupported source.type "${rule.source.type}". V1 supports only "${paymentsItemQuotaSourceType}".`); } if (rule.source.customerType !== userCustomerType) { - throw new Error(`Automation rule "${ruleId}" has unsupported source.customerType "${rule.source.customerType ?? ""}". V1 supports only "${userCustomerType}".`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" has unsupported source.customerType "${rule.source.customerType ?? ""}". V1 supports only "${userCustomerType}".`); } if (rule.action.type !== sendEmailActionType) { - throw new Error(`Automation rule "${ruleId}" has unsupported action.type "${rule.action.type}". V1 supports only "${sendEmailActionType}".`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" has unsupported action.type "${rule.action.type}". V1 supports only "${sendEmailActionType}".`); } if (rule.source.itemId === undefined) { - throw new Error(`Automation rule "${ruleId}" is missing source.itemId.`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" is missing source.itemId.`); } if (rule.source.thresholds === undefined) { - throw new Error(`Automation rule "${ruleId}" is missing source.thresholds.`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" is missing source.thresholds.`); } if ( rule.source.thresholds.nearRemainingRatio === undefined && rule.source.thresholds.nearRemainingQuantity === undefined && rule.source.thresholds.overLimitQuantity === undefined ) { - throw new Error(`Automation rule "${ruleId}" must configure at least one source.thresholds value.`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" must configure at least one source.thresholds value.`); } if (rule.action.templateId === undefined) { - throw new Error(`Automation rule "${ruleId}" is missing action.templateId.`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" is missing action.templateId.`); } if (rule.cooldown.days === undefined) { - throw new Error(`Automation rule "${ruleId}" is missing cooldown.days.`); + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" is missing cooldown.days.`); } } diff --git a/apps/backend/src/lib/automations/run-route.ts b/apps/backend/src/lib/automations/run-route.ts index 3d5842f9b..34954c52c 100644 --- a/apps/backend/src/lib/automations/run-route.ts +++ b/apps/backend/src/lib/automations/run-route.ts @@ -1,5 +1,5 @@ import { StatusError } from "@hexclave/shared/dist/utils/errors"; -import { AutomationJson, AutomationRuleNotFoundError, AutomationRuleTenancy, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; +import { AutomationJson, AutomationRuleDisabledError, AutomationRuleNotFoundError, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; import { AutomationActionPlan, AutomationEvaluationResult, AutomationSourceAdapter, AutomationActionAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; import { paymentsItemQuotaSourceSnapshotToApiBody } from "./source-snapshot"; @@ -7,6 +7,7 @@ export type AutomationRuleExecutionClaimResult = | { claimed: true, lastActionAt: Date | null, + emailOutboxId: string, } | { claimed: false, @@ -32,15 +33,34 @@ export type AutomationRuleExecutionStateStore = { subjectType: "user", subjectId: string, signalKey: string, + claimTriggeredAt: Date, lastActionAt: Date, - lastEmailOutboxId: string | null, + emailOutboxId: string, }) => Promise, }; +export type AutomationEmailSendResult = { + outcome: "created" | "already-enqueued", +}; + export type AutomationEmailSender = (options: { action: AutomationActionPlan, scheduledAt: Date, -}) => Promise; + emailOutboxId: string, +}) => Promise; + +export function getSingleAutomationEmailSendResult(result: { + createdCount: number, + alreadyEnqueuedCount: number, +}): AutomationEmailSendResult { + if (result.createdCount === 1 && result.alreadyEnqueuedCount === 0) { + return { outcome: "created" }; + } + if (result.createdCount === 0 && result.alreadyEnqueuedCount === 1) { + return { outcome: "already-enqueued" }; + } + throw new Error(`Expected one automation email enqueue result, received ${result.createdCount} created and ${result.alreadyEnqueuedCount} already enqueued.`); +} export type AutomationRunDecisionResult = { decision: EvaluatedAutomationDecision, @@ -76,9 +96,9 @@ export async function runAutomationRuleForRoute(options: { stateStore: AutomationRuleExecutionStateStore, emailSender: AutomationEmailSender, }): Promise { - const rule = getSupportedAutomationRuleForRunRoute(options.tenancy, options.ruleId); + const rule = getSupportedAutomationRule(options.tenancy, options.ruleId); if (!rule.enabled) { - throw new StatusError(StatusError.Conflict, `Automation rule "${options.ruleId}" is disabled and cannot be manually sent.`); + throw new AutomationRuleDisabledError(options.ruleId); } const evaluation = await evaluateAutomationRule({ tenancy: options.tenancy, @@ -124,6 +144,7 @@ export async function runAutomationRuleForRoute(options: { await options.emailSender({ action: decision.action, scheduledAt: options.scheduledAt, + emailOutboxId: claim.emailOutboxId, }); await options.stateStore.markActionCompleted({ tenancyId: options.tenancy.id, @@ -131,8 +152,9 @@ export async function runAutomationRuleForRoute(options: { subjectType: decision.subject.type, subjectId: decision.subject.id, signalKey: decision.signal.key, + claimTriggeredAt: options.now, lastActionAt: options.now, - lastEmailOutboxId: null, + emailOutboxId: claim.emailOutboxId, }); decisions.push({ @@ -159,13 +181,18 @@ export async function runAutomationRuleForRoute(options: { }; } -function getSupportedAutomationRuleForRunRoute(tenancy: AutomationRuleTenancy, ruleId: string) { +export async function runAutomationRuleForManualRoute( + options: Parameters[0], +): Promise { try { - return getSupportedAutomationRule(tenancy, ruleId); + return await runAutomationRuleForRoute(options); } catch (error) { if (error instanceof AutomationRuleNotFoundError) { throw new StatusError(StatusError.NotFound, error.message); } + if (error instanceof AutomationRuleDisabledError) { + throw new StatusError(StatusError.Conflict, error.message); + } throw error; } } diff --git a/apps/backend/src/lib/automations/scheduler.test.ts b/apps/backend/src/lib/automations/scheduler.test.ts index bbaa755d9..e84354b0c 100644 --- a/apps/backend/src/lib/automations/scheduler.test.ts +++ b/apps/backend/src/lib/automations/scheduler.test.ts @@ -14,7 +14,9 @@ vi.mock("@/lib/automations/sources/payments-item-quota", () => ({ paymentsItemQuotaCustomerDataReaders: {}, prismaPaymentsItemQuotaProjectUserReader: {}, })); -vi.mock("@/lib/emails", () => ({ sendEmailToMany: async () => {} })); +vi.mock("@/lib/emails", () => ({ + sendEmailToMany: async () => ({ createdCount: 1, alreadyEnqueuedCount: 0 }), +})); vi.mock("@/lib/tenancies", () => ({ getTenancy: async () => null })); vi.mock("@/prisma-client", () => ({ getPrismaClientForTenancy: async () => ({}), @@ -36,6 +38,7 @@ import { runScheduledAutomations, } from "./scheduler"; import type { AutomationRunResult } from "./run-route"; +import { NonRetryableAutomationRuleError } from "./rules"; const ruleId = "low-api-credits"; @@ -354,6 +357,39 @@ describe("native cron automation traversal", () => { expect(runRule).toHaveBeenCalledWith(expect.objectContaining({ ruleId: validRuleId })); }); + it("advances a deterministic runtime rule failure and runs the next valid rule", async () => { + captureErrorMock.mockClear(); + const state = createStateHarness(); + const validRuleId = "valid-rule"; + const runRule = vi.fn(async (options: { ruleId: string }) => { + if (options.ruleId === "bad-rule") { + throw new NonRetryableAutomationRuleError("missing-template", "Configured template no longer exists."); + } + return createRunResult(null, { ruleId: options.ruleId }); + }); + + await runScheduledAutomations({ + prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id, { + "bad-rule": createRule(), + [validRuleId]: createRule(), + }), + runRule, + maxPages: 1, + }); + + expect(captureErrorMock).toHaveBeenCalledWith("automation-scheduler-non-retryable-rule", expect.any(Error)); + expect(runRule).toHaveBeenNthCalledWith(1, expect.objectContaining({ ruleId: "bad-rule" })); + expect(runRule).toHaveBeenNthCalledWith(2, expect.objectContaining({ ruleId: validRuleId })); + expect(state.getCheckpoint()).toMatchObject({ + activeTenancyId: "00000000-0000-4000-8000-000000000010", + completedRuleCursor: validRuleId, + activeRuleId: null, + nextSubjectCursor: null, + }); + }); + it("skips disabled rules without executing them", async () => { const state = createStateHarness(); const runRule = vi.fn(async () => createRunResult(null)); diff --git a/apps/backend/src/lib/automations/scheduler.ts b/apps/backend/src/lib/automations/scheduler.ts index 364bb7019..82f9498aa 100644 --- a/apps/backend/src/lib/automations/scheduler.ts +++ b/apps/backend/src/lib/automations/scheduler.ts @@ -15,8 +15,8 @@ import { getTenancy, type Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; import { stringCompare } from "@hexclave/shared/dist/utils/strings"; -import { runAutomationRuleForRoute, type AutomationRunResult } from "./run-route"; -import { assertSupportedAutomationRule, type AutomationRuleTenancy, listAutomationRules } from "./rules"; +import { getSingleAutomationEmailSendResult, runAutomationRuleForRoute, type AutomationRunResult } from "./run-route"; +import { assertSupportedAutomationRule, type AutomationRuleTenancy, listAutomationRules, NonRetryableAutomationRuleError } from "./rules"; export const scheduledAutomationDiscoveryLimit = 500; export const scheduledAutomationRunPageLimit = 100; @@ -281,10 +281,14 @@ async function runWithLease(options: { try { assertSupportedAutomationRule(activeRuleId, ruleEntry.rule); } catch (error) { + if (!(error instanceof NonRetryableAutomationRuleError)) { + throw error; + } captureError("automation-scheduler-invalid-rule", new HexclaveAssertionError(`Skipping invalid scheduled automation rule "${activeRuleId}" for tenancy "${tenancy.id}".`, { cause: error, tenancyId: tenancy.id, ruleId: activeRuleId, + reason: error.reason, })); await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId)); rulesProcessed++; @@ -299,14 +303,30 @@ async function runWithLease(options: { } await options.lease.renewIfNeeded(); - const result = await options.runRule({ - tenancy, - ruleId: activeRuleId, - cursor: checkpoint.nextSubjectCursor, - limit: options.pageLimit, - scheduledAt, - now: options.now(), - }); + let result: AutomationRunResult; + try { + result = await options.runRule({ + tenancy, + ruleId: activeRuleId, + cursor: checkpoint.nextSubjectCursor, + limit: options.pageLimit, + scheduledAt, + now: options.now(), + }); + } catch (error) { + if (!(error instanceof NonRetryableAutomationRuleError)) { + throw error; + } + captureError("automation-scheduler-non-retryable-rule", new HexclaveAssertionError(`Skipping non-retryable scheduled automation rule "${activeRuleId}" for tenancy "${tenancy.id}".`, { + cause: error, + tenancyId: tenancy.id, + ruleId: activeRuleId, + reason: error.reason, + })); + await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId)); + rulesProcessed++; + continue; + } pagesProcessed++; evaluatedCount += result.evaluatedCount; sentCount += result.sentCount; @@ -361,8 +381,8 @@ async function runProductionScheduledAutomationRulePage(options: { sourceAdapter, actionAdapter: createSendEmailActionAdapter(), stateStore: createPrismaAutomationRuleExecutionStateStore(prisma), - emailSender: async ({ action, scheduledAt }) => { - await sendEmailToMany({ + emailSender: async ({ action, scheduledAt, emailOutboxId }) => { + const enqueueResult = await sendEmailToMany({ tenancy, recipients: [action.recipient], tsxSource: action.tsxSource, @@ -374,7 +394,9 @@ async function runProductionScheduledAutomationRulePage(options: { createdWith: action.createdWith, overrideSubject: action.subject, overrideNotificationCategoryId: action.notificationCategoryId, + emailOutboxIds: [emailOutboxId], }); + return getSingleAutomationEmailSendResult(enqueueResult); }, }); } diff --git a/apps/backend/src/lib/automations/sources/payments-item-quota.ts b/apps/backend/src/lib/automations/sources/payments-item-quota.ts index 4e32886a4..9d6aecd2f 100644 --- a/apps/backend/src/lib/automations/sources/payments-item-quota.ts +++ b/apps/backend/src/lib/automations/sources/payments-item-quota.ts @@ -5,7 +5,7 @@ import { getSubscriptionMapForCustomer, } from "@/lib/payments/customer-data"; import { AutomationSourceAdapter, AutomationSourceDecision, AutomationSourceEvaluationResult } from "../rule-evaluator"; -import { AutomationRuleTenancy, PaymentsItemQuotaAutomationRule, paymentsItemQuotaSourceType } from "../rules"; +import { AutomationRuleTenancy, NonRetryableAutomationRuleError, PaymentsItemQuotaAutomationRule, paymentsItemQuotaSourceType } from "../rules"; export type PaymentsItemQuotaProjectUserPage = { projectUserIds: string[], @@ -138,10 +138,10 @@ async function evaluatePaymentsItemQuotaSource(options: { const itemId = options.rule.source.itemId; const item = options.tenancy.config.payments?.items?.[itemId]; if (item === undefined) { - throw new Error(`Automation rule "${options.ruleId}" references payments item "${itemId}", but that item does not exist.`); + throw new NonRetryableAutomationRuleError("missing-item", `Automation rule "${options.ruleId}" references payments item "${itemId}", but that item does not exist.`); } if (item.customerType !== "user") { - throw new Error(`Automation rule "${options.ruleId}" references payments item "${itemId}" with customerType "${item.customerType ?? ""}"; V1 supports only user items.`); + throw new NonRetryableAutomationRuleError("incompatible-item", `Automation rule "${options.ruleId}" references payments item "${itemId}" with customerType "${item.customerType ?? ""}"; V1 supports only user items.`); } const limit = normalizeLimit(options.limit); diff --git a/apps/backend/src/lib/emails.tsx b/apps/backend/src/lib/emails.tsx index 3835ae2be..92d70d6e8 100644 --- a/apps/backend/src/lib/emails.tsx +++ b/apps/backend/src/lib/emails.tsx @@ -4,7 +4,7 @@ import { EmailOutboxCreatedWith } from '@/generated/prisma/client'; import { DEFAULT_TEMPLATE_IDS } from '@hexclave/shared/dist/helpers/emails'; import { UsersCrud } from '@hexclave/shared/dist/interface/crud/users'; import { getEnvBoolean, getEnvVariable } from '@hexclave/shared/dist/utils/env'; -import { HexclaveAssertionError } from '@hexclave/shared/dist/utils/errors'; +import { HexclaveAssertionError, throwErr } from '@hexclave/shared/dist/utils/errors'; import { Json } from '@hexclave/shared/dist/utils/json'; import { runEmailQueueStep, serializeRecipient } from './email-queue-step'; import { LowLevelEmailConfig, isSecureEmailPort } from './emails-low-level'; @@ -23,6 +23,32 @@ export type EmailOutboxRecipient = | { type: "user-custom-emails", userId: string, emails: string[] } | { type: "custom-emails", emails: string[] }; +export type SendEmailToManyResult = { + createdCount: number, + alreadyEnqueuedCount: number, +}; + +function addIdempotencyToEmailOutboxRows(rows: T[], emailOutboxIds: undefined): { data: T[] }; +function addIdempotencyToEmailOutboxRows(rows: T[], emailOutboxIds: string[]): { data: Array, skipDuplicates: true }; +function addIdempotencyToEmailOutboxRows(rows: T[], emailOutboxIds: string[] | undefined) { + if (emailOutboxIds === undefined) { + return { data: rows }; + } + if (emailOutboxIds.length !== rows.length) { + throw new HexclaveAssertionError("sendEmailToMany requires exactly one emailOutboxId per recipient."); + } + if (new Set(emailOutboxIds).size !== emailOutboxIds.length) { + throw new HexclaveAssertionError("sendEmailToMany requires unique emailOutboxIds."); + } + return { + data: rows.map((row, index) => ({ + ...row, + id: emailOutboxIds[index] ?? throwErr(new HexclaveAssertionError("Missing validated emailOutboxId for recipient.")), + })), + skipDuplicates: true, + }; +} + function getDefaultEmailTemplate(tenancy: Tenancy, type: keyof typeof DEFAULT_TEMPLATE_IDS) { const templateList = new Map(Object.entries(tenancy.config.emails.templates)); const defaultTemplateIdsMap = new Map(Object.entries(DEFAULT_TEMPLATE_IDS)); @@ -49,30 +75,38 @@ export async function sendEmailToMany(options: { createdWith: { type: "draft", draftId: string } | { type: "programmatic-call", templateId: string | null }, overrideSubject?: string, overrideNotificationCategoryId?: string, -}) { - await globalPrismaClient.emailOutbox.createMany({ - data: options.recipients.map(recipient => ({ - tenancyId: options.tenancy.id, - tsxSource: options.tsxSource, - themeId: options.themeId, - isHighPriority: options.isHighPriority, - createdWith: options.createdWith.type === "draft" ? EmailOutboxCreatedWith.DRAFT : EmailOutboxCreatedWith.PROGRAMMATIC_CALL, - emailDraftId: options.createdWith.type === "draft" ? options.createdWith.draftId : undefined, - emailProgrammaticCallTemplateId: options.createdWith.type === "programmatic-call" ? options.createdWith.templateId : undefined, - to: serializeRecipient(recipient)!, - extraRenderVariables: options.extraVariables, - scheduledAt: options.scheduledAt, - shouldSkipDeliverabilityCheck: options.shouldSkipDeliverabilityCheck, - overrideSubject: options.overrideSubject, - overrideNotificationCategoryId: options.overrideNotificationCategoryId, - })), - }); + /** Stable IDs make retries idempotent. When provided, there must be exactly one ID per recipient. */ + emailOutboxIds?: string[], +}): Promise { + const rows = options.recipients.map((recipient) => ({ + tenancyId: options.tenancy.id, + tsxSource: options.tsxSource, + themeId: options.themeId, + isHighPriority: options.isHighPriority, + createdWith: options.createdWith.type === "draft" ? EmailOutboxCreatedWith.DRAFT : EmailOutboxCreatedWith.PROGRAMMATIC_CALL, + emailDraftId: options.createdWith.type === "draft" ? options.createdWith.draftId : undefined, + emailProgrammaticCallTemplateId: options.createdWith.type === "programmatic-call" ? options.createdWith.templateId : undefined, + to: serializeRecipient(recipient)!, + extraRenderVariables: options.extraVariables, + scheduledAt: options.scheduledAt, + shouldSkipDeliverabilityCheck: options.shouldSkipDeliverabilityCheck, + overrideSubject: options.overrideSubject, + overrideNotificationCategoryId: options.overrideNotificationCategoryId, + })); + const createResult = options.emailOutboxIds === undefined + ? await globalPrismaClient.emailOutbox.createMany({ data: rows }) + : await globalPrismaClient.emailOutbox.createMany(addIdempotencyToEmailOutboxRows(rows, options.emailOutboxIds)); if (!getEnvBoolean("STACK_EMAIL_BRANCHING_DISABLE_QUEUE_AUTO_TRIGGER")) { // The cron job should run runEmailQueueStep() to process the emails, but we call it here again for those self-hosters // who didn't set up the cron job correctly, and also just in case something happens to the cron job. runAsynchronouslyAndWaitUntil(runEmailQueueStep()); } + + return { + createdCount: createResult.count, + alreadyEnqueuedCount: options.emailOutboxIds === undefined ? 0 : options.recipients.length - createResult.count, + }; } export async function sendEmailFromDefaultTemplate(options: { @@ -225,3 +259,33 @@ import.meta.vitest?.test('normalizeEmail(...)', async ({ expect }) => { expect(() => normalizeEmail('test@multiple@domains.com')).toThrow(); expect(() => normalizeEmail('invalid.email')).toThrow(); }); + +import.meta.vitest?.describe('sendEmailToMany idempotency rows', () => { + const { expect, test } = import.meta.vitest!; + test('uses stable IDs and skipDuplicates when IDs are provided', () => { + expect(addIdempotencyToEmailOutboxRows( + [{ recipient: 'one' }, { recipient: 'two' }], + ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000002'], + )).toEqual({ + data: [ + { recipient: 'one', id: '00000000-0000-4000-8000-000000000001' }, + { recipient: 'two', id: '00000000-0000-4000-8000-000000000002' }, + ], + skipDuplicates: true, + }); + }); + + test('preserves existing non-idempotent inserts when IDs are omitted', () => { + expect(addIdempotencyToEmailOutboxRows([{ recipient: 'one' }], undefined)).toEqual({ + data: [{ recipient: 'one' }], + }); + }); + + test('rejects mismatched or duplicated IDs before insertion', () => { + expect(() => addIdempotencyToEmailOutboxRows([{ recipient: 'one' }], [])).toThrow(/exactly one emailOutboxId/); + expect(() => addIdempotencyToEmailOutboxRows( + [{ recipient: 'one' }, { recipient: 'two' }], + ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001'], + )).toThrow(/unique emailOutboxIds/); + }); +}); diff --git a/packages/shared/src/config/schema.ts b/packages/shared/src/config/schema.ts index ac0eb7de5..0495e4941 100644 --- a/packages/shared/src/config/schema.ts +++ b/packages/shared/src/config/schema.ts @@ -6,7 +6,7 @@ import * as yup from "yup"; import { ALL_APPS } from "../apps/apps-config"; -import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID } from "../helpers/emails"; +import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID, DEFAULT_TEMPLATE_IDS } from "../helpers/emails"; import * as schemaFields from "../schema-fields"; import { productSchema, userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields"; import { SUPPORTED_CURRENCIES } from "../utils/currency-constants"; @@ -1124,10 +1124,11 @@ const organizationConfigDefaults = { } as const satisfies DefaultsType; import.meta.vitest?.test("organization defaults include the built-in Usage Email template", ({ expect }) => { - expect(organizationConfigDefaults.emails.templates["28a45509-eb75-440d-b657-0a8640c775df"]).toMatchObject({ + const template = organizationConfigDefaults.emails.templates[DEFAULT_TEMPLATE_IDS.usage_email]; + expect(template).toMatchObject({ displayName: "Usage Email", - themeId: undefined, }); + expect(template.themeId).toBeUndefined(); }); type _DeepOmitDefaultsImpl = T extends object ? ( diff --git a/packages/shared/src/helpers/emails.ts b/packages/shared/src/helpers/emails.ts index f143402ac..4f8060995 100644 --- a/packages/shared/src/helpers/emails.ts +++ b/packages/shared/src/helpers/emails.ts @@ -254,4 +254,5 @@ export const DEFAULT_TEMPLATE_IDS = { sign_in_invitation: EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID, payment_receipt: EMAIL_TEMPLATE_PAYMENT_RECEIPT_ID, payment_failed: EMAIL_TEMPLATE_PAYMENT_FAILED_ID, + usage_email: EMAIL_TEMPLATE_USAGE_EMAIL_ID, } as const;