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..e9257d28b --- /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), + "emailOutboxId" UUID NOT NULL, + "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..f2cb80db0 --- /dev/null +++ b/apps/backend/prisma/migrations/20260701000000_add_automation_rule_execution_state/tests/automation-rule-execution-state.ts @@ -0,0 +1,166 @@ +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", + "emailOutboxId", + "lastSourceSnapshot", + "createdAt", + "updatedAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + 'lowCreditsUpgradeEmail', + 'payments-item-quota', + 'send-email', + 'user', + ${projectUserId}, + 'credits:near', + NOW(), + ${emailOutboxId}::uuid, + ${JSON.stringify(initialSnapshot)}::jsonb, + NOW(), + NOW() + ) + `; + + const inserted = await sql` + SELECT + "sourceType", + "actionType", + "subjectType", + "subjectId", + "signalKey", + "lastActionAt", + "emailOutboxId", + "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, + emailOutboxId, + }]); + expect(JSON.parse(inserted[0].lastSourceSnapshot)).toMatchObject(initialSnapshot); + + const updatedSnapshot = { + ...initialSnapshot, + currentQuantity: 0, + thresholdKind: "over", + }; + await sql` + UPDATE "AutomationRuleExecutionState" + SET + "lastActionAt" = NOW(), + "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", "emailOutboxId", "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].emailOutboxId).toBe(emailOutboxId); + expect(JSON.parse(updated[0].lastSourceSnapshot)).toMatchObject(updatedSnapshot); + + await expect(sql` + INSERT INTO "AutomationRuleExecutionState" ( + "tenancyId", + "ruleId", + "sourceType", + "actionType", + "subjectType", + "subjectId", + "signalKey", + "lastTriggeredAt", + "emailOutboxId", + "lastSourceSnapshot", + "createdAt", + "updatedAt" + ) + VALUES ( + ${ctx.tenancyId}::uuid, + 'lowCreditsUpgradeEmail', + 'payments-item-quota', + 'send-email', + 'user', + ${projectUserId}, + 'credits:near', + NOW(), + ${randomUUID()}::uuid, + ${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/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 ae7fa75bf..aa00b7b72 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -73,6 +73,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? @@ -1161,6 +1162,49 @@ 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? + // Reserved before enqueue so an ambiguous retry addresses the same EmailOutbox row. + emailOutboxId 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 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/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..003f69da7 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/dry-run/route.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi } from "vitest"; +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 now = new Date("2026-07-01T00:00:00.000Z"); + +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 404 for missing rules before evaluating or writing state", async () => { + const fakePrisma = createFakePrisma(); + const sourceAdapter = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); + + const resultPromise = evaluateAutomationRuleDryRunForRoute({ + tenancy: createAutomationRouteTestTenancy({ 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 = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); + + await expect(evaluateAutomationRuleDryRunForRoute({ + tenancy: createAutomationRouteTestTenancy({ 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 = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); + const recipientStatusReader = vi.fn(async () => new Map([["user-1", { + userExists: true, + hasPrimaryEmail: true, + }]])); + + await expect(evaluateAutomationRuleDryRunForRoute({ + tenancy: createAutomationRouteTestTenancy(), + 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: createAutomationRouteTestTenancy(), + ruleId, + prisma: fakePrisma, + sourceAdapter: createAutomationRouteTestSourceAdapter(), + actionAdapter: createAutomationRouteTestActionAdapter(), + 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: createAutomationRouteTestTenancy(), + ruleId, + prisma: fakePrisma, + sourceAdapter: createAutomationRouteTestSourceAdapter(), + actionAdapter: createAutomationRouteTestActionAdapter(), + 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: createAutomationRouteTestTenancy(), + ruleId, + prisma: fakePrisma, + sourceAdapter: createAutomationRouteTestSourceAdapter(), + actionAdapter: createAutomationRouteTestActionAdapter(), + 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..113d5cd1d --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.test.ts @@ -0,0 +1,831 @@ +import { describe, expect, it, vi } from "vitest"; +import { Prisma } from "@/generated/prisma/client"; +import { + AutomationRuleExecutionStatePrisma, + createPrismaAutomationRuleExecutionStateStore, +} from "@/lib/automations/execution-state-store"; +import { AutomationSourceDecision } from "@/lib/automations/rule-evaluator"; +import { + AutomationEmailSender, + AutomationRuleExecutionStateStore, + automationRunResultToApiBody, + getSingleAutomationEmailSendResult, + runAutomationRuleForManualRoute, + 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 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.]`); + }); +}); + +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, + 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, + subjectType: string, + subjectId: string, + signalKey: string, + }) => `${options.tenancyId}:${options.ruleId}:${options.subjectType}:${options.subjectId}:${options.signalKey}`; + + const stateStore: AutomationRuleExecutionStateStore = { + 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); + 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, + }; + } + + const emailOutboxId = existing?.lastActionAt === null ? existing.emailOutboxId : createEmailOutboxId(); + states.set(key, { + lastTriggeredAt: options.lastTriggeredAt, + lastActionAt: null, + emailOutboxId, + lastSourceSnapshot: options.sourceSnapshot, + }); + return { + claimed: true, + lastActionAt: existing?.lastActionAt ?? null, + emailOutboxId, + }; + }), + 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, + emailOutboxId: options.emailOutboxId, + }); + }), + }; + + return { + stateStore, + states, + }; +} + +async function runWithFakes(options: { + stateStore?: AutomationRuleExecutionStateStore, + decisionFactory?: () => AutomationSourceDecision, + emailSender?: Parameters[0]["emailSender"], + now?: Date, + limit?: number, + cursor?: string | null, + ruleEnabled?: boolean, +} = {}) { + const sourceAdapter = createAutomationRouteTestSourceAdapter(options.decisionFactory, { evaluatedCount: 1 }); + const actionAdapter = createAutomationRouteTestActionAdapter(); + const emailSender = vi.fn(options.emailSender ?? (async () => ({ outcome: "created" as const }))); + let createdStore: ReturnType | undefined; + let stateStore = options.stateStore; + if (stateStore === undefined) { + createdStore = createInMemoryStateStore(); + stateStore = createdStore.stateStore; + } + + const result = await runAutomationRuleForRoute({ + tenancy: createAutomationRouteTestTenancy(options.ruleEnabled === undefined ? {} : { enabled: options.ruleEnabled }), + 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("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 () => ({ outcome: "created" as const })); + const { stateStore } = createInMemoryStateStore(); + + const resultPromise = runAutomationRuleForManualRoute({ + tenancy: createAutomationRouteTestTenancy({ 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 = createAutomationRouteTestSourceAdapter(); + const actionAdapter = createAutomationRouteTestActionAdapter(); + const emailSender = vi.fn(async () => ({ outcome: "created" as const })); + const { stateStore } = createInMemoryStateStore(); + + await expect(runAutomationRuleForManualRoute({ + tenancy: createAutomationRouteTestTenancy({ 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(); + + 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"), + emailOutboxId: expect.any(String), + 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, + emailOutboxId: "00000000-0000-4000-8000-000000000001", + lastSourceSnapshot: createAutomationRouteTestSourceDecision().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: () => createAutomationRouteTestSourceDecision({ 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); + }); + + 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, + emailOutboxId: expect.any(String), + }]); + + 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("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(); + + 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 = { + tenancyId: string, + ruleId: string, + sourceType: string, + actionType: string, + subjectType: "user", + subjectId: string, + signalKey: string, + lastTriggeredAt: Date, + lastActionAt: Date | null, + emailOutboxId: string, + 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, + emailOutboxId: row.emailOutboxId, + }; + }), + updateMany: vi.fn(async (options) => { + const row = rows.get(keyFor(options.where)); + if (row === undefined) { + return { count: 0 }; + } + + 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 }; + } + + rows.set(keyFor(options.where), { + ...row, + ...options.data, + }); + return { count: 1 }; + }), + }, + }; + + 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: createAutomationRouteTestSourceDecision().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, { + 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, + emailOutboxId: "00000000-0000-4000-8000-000000000001", + 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, + emailOutboxId: "00000000-0000-4000-8000-000000000001", + lastSourceSnapshot: { + itemId: "old", + }, + }]); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + const result = await store.claimExecution(createClaimOptions()); + + expect(result).toEqual({ + claimed: true, + lastActionAt: oldActionAt, + emailOutboxId: expect.any(String), + }); + 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, + emailOutboxId: "00000000-0000-4000-8000-000000000001", + 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, + emailOutboxId: expect.any(String), + }); + 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, { + createEmailOutboxId: () => "00000000-0000-4000-8000-000000000001", + }); + + 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", + claimTriggeredAt: new Date("2026-07-01T12:00:00.000Z"), + lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + emailOutboxId: "00000000-0000-4000-8000-000000000001", + }); + + 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"), + emailOutboxId: expect.any(String), + }); + }); + + 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, + emailOutboxId: "00000000-0000-4000-8000-000000000001", + 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, + emailOutboxId: expect.any(String), + }); + expect(second).toEqual({ + claimed: false, + lastActionAt: null, + }); + expect([...rows.values()]).toMatchObject([{ + lastTriggeredAt: new Date("2026-07-09T12:06:00.000Z"), + lastActionAt: null, + }]); + }); + + 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, + emailOutboxId: "9ddfd5da-8cca-48be-944a-f59235892877", + lastSourceSnapshot: { + itemId: "api_credits", + }, + }]); + const store = createPrismaAutomationRuleExecutionStateStore(prisma); + + await 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", + }); + + expect(prisma.automationRuleExecutionState.updateMany).toHaveBeenCalledOnce(); + expect([...rows.values()]).toMatchObject([{ + lastActionAt: new Date("2026-07-01T12:05:00.000Z"), + 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 new file mode 100644 index 000000000..56da31124 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/rules/[rule_id]/run/route.ts @@ -0,0 +1,119 @@ +import { createSendEmailActionAdapter } from "@/lib/automations/actions/send-email"; +import { createPrismaAutomationRuleExecutionStateStore } from "@/lib/automations/execution-state-store"; +import { + automationRunResultToApiBody, + getSingleAutomationEmailSendResult, + runAutomationRuleForManualRoute, +} from "@/lib/automations/run-route"; +import { parseAutomationScheduledAtMillis } from "@/lib/automations/scheduled-at"; +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 = parseAutomationScheduledAtMillis(body.scheduled_at_millis, "scheduled_at_millis"); + const result = await runAutomationRuleForManualRoute({ + 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, emailOutboxId }) => { + const enqueueResult = 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, + emailOutboxIds: [emailOutboxId], + }); + return getSingleAutomationEmailSendResult(enqueueResult); + }, + }); + + return { + statusCode: 200, + bodyType: "json", + body: automationRunResultToApiBody(result), + }; + }, +}); 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/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..26a717d99 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/schedule/route.test.ts @@ -0,0 +1,21 @@ +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", () => { + 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 new file mode 100644 index 000000000..d583f8b09 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/automations/schedule/route.ts @@ -0,0 +1,93 @@ +import { + 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"; +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 maxDuration = 60; + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Run scheduled automation rules", + description: "Internal endpoint invoked by cron to execute bounded scheduled automation work.", + 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({ + max_tenancies: yupString().optional(), + limit: yupString().optional(), + max_pages: yupString().optional(), + max_duration_ms: yupString().optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + ok: yupBoolean().defined(), + status: yupString().oneOf(["ran", "lease-held"]).defined(), + tenancies_scanned: yupNumber().integer().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 }) => { + 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 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 { + statusCode: 200, + bodyType: "json", + body: { + ok: true, + 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, + }, + }; + }, +}); + +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 || parsed > maximum || String(parsed) !== value) { + throw new StatusError(400, `${label} must be an integer between 1 and ${maximum}`); + } + return parsed; +} 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..661e591f3 --- /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, NonRetryableAutomationRuleError, 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 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 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 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); + 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 NonRetryableAutomationRuleError("invalid-rule-context", `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..fac366291 --- /dev/null +++ b/apps/backend/src/lib/automations/dry-run-route.ts @@ -0,0 +1,149 @@ +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 { AutomationRuleNotFoundError, AutomationRuleTenancy, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; +import { PaymentsItemQuotaSourceApiBody, paymentsItemQuotaSourceSnapshotToApiBody } from "./source-snapshot"; + +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: PaymentsItemQuotaSourceApiBody, + 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 = getSupportedAutomationRuleForDryRunRoute(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); +} + +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, + 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: paymentsItemQuotaSourceSnapshotToApiBody(decision, "Automation dry-run decision"), + 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, + }, + }; +} 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..8b0bc6ae3 --- /dev/null +++ b/apps/backend/src/lib/automations/execution-state-store.ts @@ -0,0 +1,262 @@ +import { randomUUID } from "node:crypto"; +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, + emailOutboxId: string, + lastSourceSnapshot: Prisma.InputJsonObject, + }, + }) => Promise, + findUnique: (options: { + where: { + tenancyId_ruleId_subjectType_subjectId_signalKey: AutomationRuleExecutionStateKey, + }, + select: { + lastTriggeredAt: true, + lastActionAt: true, + emailOutboxId: true, + }, + }) => Promise<{ lastTriggeredAt: Date, lastActionAt: Date | null, emailOutboxId: string } | null>, + updateMany: (options: { + where: AutomationRuleExecutionStateKey & { + lastActionAt: null | { lt: Date }, + lastTriggeredAt?: Date | { lt: Date }, + emailOutboxId?: string, + }, + data: { + sourceType?: string, + actionType?: string, + lastTriggeredAt?: Date, + lastActionAt?: Date | null, + emailOutboxId?: string, + lastSourceSnapshot?: Prisma.InputJsonObject, + }, + }) => Promise<{ count: number }>, + }, +}; + +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, + 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); + // 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); + const initialEmailOutboxId = createEmailOutboxId(); + + 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, + emailOutboxId: initialEmailOutboxId, + lastSourceSnapshot, + }, + }); + + return { + claimed: true, + lastActionAt: null, + emailOutboxId: initialEmailOutboxId, + }; + } 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, + 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, + ruleId: options.ruleId, + subjectType: options.subjectType, + subjectId: options.subjectId, + signalKey: options.signalKey, + 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, + }, + }); + if (updateResult.count === 1) { + return { + claimed: true, + lastActionAt: existing.lastActionAt, + emailOutboxId, + }; + } + 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, + emailOutboxId: true, + }, + }); + if (current === null) { + throw new Error("Automation rule execution state disappeared while evaluating cooldown."); + } + + return { + claimed: false, + lastActionAt: current.lastActionAt, + }; + }, + async markActionCompleted(options) { + const stateKey = getAutomationRuleExecutionStateKey(options); + const updateResult = await prisma.automationRuleExecutionState.updateMany({ + where: { + ...stateKey, + lastActionAt: null, + lastTriggeredAt: options.claimTriggeredAt, + emailOutboxId: options.emailOutboxId, + }, + data: { + lastActionAt: options.lastActionAt, + }, + }); + 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."); + }, + }; +} + +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..d1e00d9bb --- /dev/null +++ b/apps/backend/src/lib/automations/quota-email-automation.test.ts @@ -0,0 +1,858 @@ +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("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(); + + 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"] }); + + const evaluationPromise = adapter.evaluate({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + }); + 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 () => { + 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("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 }); + 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("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 }); + 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", + })); + + const planPromise = buildTestSendEmailActionPlan({ + tenancy, + ruleId, + rule: getSupportedAutomationRule(tenancy, ruleId), + decision: createActionDecision(), + }); + 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/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..4407641b7 --- /dev/null +++ b/apps/backend/src/lib/automations/rules.ts @@ -0,0 +1,166 @@ +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 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) + .map(([ruleId, rule]) => ({ ruleId, rule })); +} + +export function getAutomationRule(tenancy: AutomationRuleTenancy, ruleId: string) { + return tenancy.config.automations?.rules?.[ruleId]; +} + +export class AutomationRuleNotFoundError extends NonRetryableAutomationRuleError { + constructor(tenancyId: string, ruleId: string) { + 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) { + throw new AutomationRuleNotFoundError(tenancy.id, ruleId); + } + assertSupportedAutomationRule(ruleId, rule); + return rule; +} + +export function assertSupportedAutomationRule(ruleId: string, rule: AutomationRuleConfig): asserts rule is SupportedAutomationRule { + if (rule.source.type !== 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 NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" has unsupported source.customerType "${rule.source.customerType ?? ""}". V1 supports only "${userCustomerType}".`); + } + if (rule.action.type !== 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 NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" is missing source.itemId.`); + } + if (rule.source.thresholds === undefined) { + 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 NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" must configure at least one source.thresholds value.`); + } + if (rule.action.templateId === undefined) { + throw new NonRetryableAutomationRuleError("unsupported-rule", `Automation rule "${ruleId}" is missing action.templateId.`); + } + if (rule.cooldown.days === undefined) { + 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 new file mode 100644 index 000000000..34954c52c --- /dev/null +++ b/apps/backend/src/lib/automations/run-route.ts @@ -0,0 +1,246 @@ +import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { AutomationJson, AutomationRuleDisabledError, AutomationRuleNotFoundError, getSupportedAutomationRule, paymentsItemQuotaSourceType, sendEmailActionType } from "./rules"; +import { AutomationActionPlan, AutomationEvaluationResult, AutomationSourceAdapter, AutomationActionAdapter, EvaluatedAutomationDecision, evaluateAutomationRule } from "./rule-evaluator"; +import { paymentsItemQuotaSourceSnapshotToApiBody } from "./source-snapshot"; + +export type AutomationRuleExecutionClaimResult = + | { + claimed: true, + lastActionAt: Date | null, + emailOutboxId: string, + } + | { + 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, + claimTriggeredAt: Date, + lastActionAt: Date, + emailOutboxId: string, + }) => Promise, +}; + +export type AutomationEmailSendResult = { + outcome: "created" | "already-enqueued", +}; + +export type AutomationEmailSender = (options: { + action: AutomationActionPlan, + scheduledAt: Date, + 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, + 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); + if (!rule.enabled) { + throw new AutomationRuleDisabledError(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, + emailOutboxId: claim.emailOutboxId, + }); + await options.stateStore.markActionCompleted({ + tenancyId: options.tenancy.id, + ruleId: options.ruleId, + subjectType: decision.subject.type, + subjectId: decision.subject.id, + signalKey: decision.signal.key, + claimTriggeredAt: options.now, + lastActionAt: options.now, + emailOutboxId: claim.emailOutboxId, + }); + + 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 async function runAutomationRuleForManualRoute( + options: Parameters[0], +): Promise { + try { + 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; + } +} + +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: paymentsItemQuotaSourceSnapshotToApiBody(resultDecision.decision, "Automation run decision"), + 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, + }; +} 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-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 new file mode 100644 index 000000000..e84354b0c --- /dev/null +++ b/apps/backend/src/lib/automations/scheduler.test.ts @@ -0,0 +1,406 @@ +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 () => ({ createdCount: 1, alreadyEnqueuedCount: 0 }), +})); +vi.mock("@/lib/tenancies", () => ({ getTenancy: async () => null })); +vi.mock("@/prisma-client", () => ({ + getPrismaClientForTenancy: async () => ({}), + globalPrismaClient: { + tenancy: { findMany: async () => [] }, + automationSchedulerState: {}, + }, +})); +vi.mock("@hexclave/shared/dist/utils/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, captureError: captureErrorMock }; +}); + +import { + normalizeScheduledAutomationDiscoveryLimit, + normalizeScheduledAutomationMaxPages, + normalizeScheduledAutomationRunPageLimit, + normalizeScheduledAutomationWorkBudgetMs, + runScheduledAutomations, +} from "./scheduler"; +import type { AutomationRunResult } from "./run-route"; +import { NonRetryableAutomationRuleError } from "./rules"; + +const ruleId = "low-api-credits"; + +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", + customerType: "user", + thresholds: { nearRemainingQuantity: 10 }, + }, + action: { + type: "send-email", + templateId: "8c6f6960-7a87-4ebd-b2a6-bfd06d68e2d1", + notificationCategoryName: "Marketing", + }, + cooldown: { days: 7 }, + }; +} + +function createTenancy(id: string, rules: Record> = { [ruleId]: createRule() }) { + return { + id, + project: { display_name: "Acme App" }, + config: { automations: { rules } }, + }; +} + +function createRunResult(nextCursor: string | null, options: Partial = {}): AutomationRunResult { + return { + ruleId, + mode: "run", + evaluatedCount: 100, + eligibleCount: 1, + suppressedCount: 0, + sentCount: 1, + nextCursor, + decisions: [], + ...options, + }; +} + +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("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("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(runScheduledAutomations({ + prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id), + runRule, + maxPages: 1, + })).resolves.toMatchObject({ + status: "ran", + pagesProcessed: 1, + evaluatedCount: 100, + sentCount: 1, + }); + + expect(runRule).toHaveBeenCalledWith(expect.objectContaining({ + cursor: null, + limit: 100, + })); + expect(state.getCheckpoint()).toMatchObject({ + activeRuleId: ruleId, + nextSubjectCursor: "00000000-0000-4000-8000-000000000100", + }); + expect(state.renewIfNeeded).toHaveBeenCalledOnce(); + expect(state.release).toHaveBeenCalledOnce(); + }); + + 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(runScheduledAutomations({ + prisma: createPrisma(["00000000-0000-4000-8000-000000000010"]), + stateStore: state.stateStore, + getTenancyById: async (id) => createTenancy(id), + runRule, + now: () => new Date(wallClockMillis -= 60_000), + elapsedNow: () => elapsedMillis, + workBudgetMs: 10, + })).resolves.toMatchObject({ + pagesProcessed: 1, + }); + + expect(runRule).toHaveBeenCalledOnce(); + expect(state.getCheckpoint()).toMatchObject({ + activeRuleId: ruleId, + nextSubjectCursor: "00000000-0000-4000-8000-000000000100", + }); + }); + + 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(runScheduledAutomations({ + prisma: createPrisma([tenancyId]), + stateStore: state.stateStore, + getTenancyById: async () => null, + runRule, + 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("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)); + + 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 new file mode 100644 index 000000000..82f9498aa --- /dev/null +++ b/apps/backend/src/lib/automations/scheduler.ts @@ -0,0 +1,447 @@ +import { createSendEmailActionAdapter } from "@/lib/automations/actions/send-email"; +import { createPrismaAutomationRuleExecutionStateStore } from "@/lib/automations/execution-state-store"; +import { + createPrismaAutomationSchedulerStateStore, + type AutomationSchedulerCheckpoint, + type AutomationSchedulerLease, +} from "@/lib/automations/scheduler-state"; +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 { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; +import { stringCompare } from "@hexclave/shared/dist/utils/strings"; +import { getSingleAutomationEmailSendResult, runAutomationRuleForRoute, type AutomationRunResult } from "./run-route"; +import { assertSupportedAutomationRule, type AutomationRuleTenancy, listAutomationRules, NonRetryableAutomationRuleError } from "./rules"; + +export const scheduledAutomationDiscoveryLimit = 500; +export const scheduledAutomationRunPageLimit = 100; +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" }, + take: number, + select: { id: true }, + }) => Promise>, + }, +}; + +type ScheduledAutomationRunner = (options: { + tenancy: TTenancy, + ruleId: string, + cursor: string | null, + limit: number, + scheduledAt: Date, + 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)); +} + +export function normalizeScheduledAutomationRunPageLimit(limit: number | undefined) { + if (limit === undefined) return scheduledAutomationRunPageLimit; + return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationRunPageLimit)); +} + +export function normalizeScheduledAutomationMaxPages(limit: number | undefined) { + if (limit === undefined) return scheduledAutomationDefaultPages; + return Math.max(1, Math.min(Math.floor(limit), scheduledAutomationMaxPages)); +} + +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; + } + + 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; + } + 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) { + 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++; + continue; + } + + if ( + pagesProcessed >= options.maxPages + || options.elapsedNow() - startedElapsedAt >= options.workBudgetMs + ) { + break; + } + await options.lease.renewIfNeeded(); + + 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; + suppressedCount += result.suppressedCount; + + if (result.nextCursor === null) { + await saveCheckpoint(completeActiveRule(checkpoint, activeRuleId)); + rulesProcessed++; + } else { + await saveCheckpoint({ + ...checkpoint, + nextSubjectCursor: result.nextCursor, + }); + } + } + + return { + status: "ran", + tenanciesScanned, + rulesProcessed, + pagesProcessed, + evaluatedCount, + sentCount, + suppressedCount, + cycleCompleted, + }; +} + +async function runProductionScheduledAutomationRulePage(options: { + tenancy: Tenancy, + ruleId: string, + cursor: string | null, + limit: number, + scheduledAt: Date, + now: Date, +}): Promise { + const tenancy = options.tenancy; + const prisma = await getPrismaClientForTenancy(tenancy); + const sourceAdapter = createPaymentsItemQuotaSourceAdapter({ + prisma, + projectUserReader: prismaPaymentsItemQuotaProjectUserReader, + customerDataReaders: paymentsItemQuotaCustomerDataReaders, + }); + + return await runAutomationRuleForRoute({ + 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, emailOutboxId }) => { + const enqueueResult = await sendEmailToMany({ + 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, + emailOutboxIds: [emailOutboxId], + }); + return getSingleAutomationEmailSendResult(enqueueResult); + }, + }); +} + +function completeActiveRule(checkpoint: AutomationSchedulerCheckpoint, ruleId: string): AutomationSchedulerCheckpoint { + return { + ...checkpoint, + completedRuleCursor: ruleId, + activeRuleId: null, + nextSubjectCursor: null, + }; +} + +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/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/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..9d6aecd2f --- /dev/null +++ b/apps/backend/src/lib/automations/sources/payments-item-quota.ts @@ -0,0 +1,262 @@ +import { PrismaClientTransaction } from "@/prisma-client"; +import { + getItemQuantityForCustomer, + getOwnedProductsForCustomer, + getSubscriptionMapForCustomer, +} from "@/lib/payments/customer-data"; +import { AutomationSourceAdapter, AutomationSourceDecision, AutomationSourceEvaluationResult } from "../rule-evaluator"; +import { AutomationRuleTenancy, NonRetryableAutomationRuleError, 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 NonRetryableAutomationRuleError("missing-item", `Automation rule "${options.ruleId}" references payments item "${itemId}", but that item does not exist.`); + } + if (item.customerType !== "user") { + 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); + 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; + if (thresholds.overLimitQuantity !== undefined && options.currentQuantity <= thresholds.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/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/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/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..f9a7fea74 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.test.ts @@ -0,0 +1,468 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildRuleFromDraft, + getMissingPrerequisites, + parseAutomationRouteResult, + readRules, + readUserItemOptions, + deleteUsageEmailAutomationRule, + saveUsageEmailAutomationRule, +} 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("@/components/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, + } + `); + }); + + 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]`); + }); + + 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 new file mode 100644 index 000000000..dc3f439da --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-automations/page-client.tsx @@ -0,0 +1,1293 @@ +"use client"; + +import { + DesignAlert, + DesignBadge, + DesignButton, + DesignCard, + DesignDialog, + DesignDialogClose, + DesignEmptyState, + DesignInput, + DesignMenu, + DesignPillToggle, + DesignSelectorDropdown, +} from "@/components/design-components"; +import { StyledLink } from "@/components/link"; +import { Label, Typography } from "@/components/ui"; +import { useUpdateConfig } from "@/components/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, + 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 ConfigUpdater = (options: { + adminApp: TAdminApp, + configUpdate: Parameters>[0]["configUpdate"], + pushable: boolean, +}) => Promise; + +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: "", + nearRemainingQuantity: "", + overLimitQuantity: "", + 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 limitsParts = [ + thresholds.nearRemainingRatio === undefined ? undefined : `${Math.round(thresholds.nearRemainingRatio * 100)}%`, + thresholds.nearRemainingQuantity === undefined ? undefined : `${thresholds.nearRemainingQuantity}`, + ].filter((part) => part !== undefined); + + 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) { + return itemOptions.find((item) => item.value === itemId)?.label ?? itemId; +} + +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") { + 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.map((rawDecision) => parseDecision(rawDecision)), + }; +} + +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) { + 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") { + throw new Error(`Automation response threshold_kind "${thresholdKind}" is unsupported`); + } + + return { + subjectType, + 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 saveUsageEmailAutomationRule({ + applyConfigUpdate: updateConfig, + adminApp: hexclaveAdminApp, + ruleId, + rule, + }); + }; + + const deleteRule = async (ruleId: string) => { + await deleteUsageEmailAutomationRule({ + applyConfigUpdate: updateConfig, + adminApp: hexclaveAdminApp, + ruleId, + }); + }; + + return ( + + setEditorMode({ type: "create" })}> + New Email Rule + + )} + > +
+ + + + {rules.length === 0 ? ( + + setEditorMode({ type: "create" })}> + New Email Rule + + + ) : ( +
+ {rules.map((entry) => ( +
+
+
+
+ +
+
+ {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 + + , + 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} + + {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 */} +
+
+ {(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" + /> + )} +
+
+
+ Rule Status + setDraftField("enabled", id === "enabled")} + size="sm" + className="w-full h-9" + /> +
+
+
+ + {/* SECTION 1: Stepper Flow */} +
+ + {/* STEP 1: MONITOR TRIGGER SOURCE */} +
+
+ +
+ +
+
+ 1. Trigger Source + +
+ + Select the Payments item you want to monitor for account limits. + +
+ +
+
+ {(fieldId) => ( + setDraftField("itemId", value)} + options={props.itemOptions} + placeholder="Select item" + disabled={props.itemOptions.length === 0} + size="md" + /> + )} + {(fieldId) => ( + setDraftField("cooldownDays", event.target.value)} + size="md" + placeholder="e.g. 7" + /> + )} +
+
+
+ + {/* STEP 2: THRESHOLD CONDITIONS */} +
+
+ +
+ +
+
+ 2. Define Thresholds +
+ + Specify the conditions under which notifications are fired. Leave a field blank to ignore it. + +
+ +
+
+ {(fieldId) => ( + setDraftField("nearRemainingRatio", event.target.value)} + size="md" + placeholder="Between 0.0 and 1.0" + /> + )} + {(fieldId) => ( + setDraftField("nearRemainingQuantity", event.target.value)} + size="md" + placeholder="e.g. 10" + /> + )} + {(fieldId) => ( + 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. + +
+ +
+
+ {(fieldId) => ( + setDraftField("templateId", value)} + options={props.templateOptions} + placeholder="Select template" + 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 + +
+ )}
+
+
+
+ +
+
+
+ ); +} + +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" ? "Email Rule Preview" : "Send Email Rule"; + const description = props.mode === "dry-run" + ? "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); + 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: (id: string) => React.ReactNode }) { + const id = `usage-email-field-${props.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; + return ( +
+ +
{props.children(id)}
+
+ ); +} 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/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} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index ad47392af..ca5ed00a2 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 45ab1985c..51d2bba94 100644 --- a/apps/dashboard/src/lib/hexclave-app-internals.ts +++ b/apps/dashboard/src/lib/hexclave-app-internals.ts @@ -63,6 +63,12 @@ type AdminAppInternalsHooks = { sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise, }; +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)) { @@ -99,6 +105,28 @@ 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"); +} + function getMetricsQueryString(includeAnonymous: boolean, filters?: AnalyticsOverviewFilters): string { const params = new URLSearchParams(); if (includeAnonymous) { diff --git a/packages/shared/src/config/schema-fuzzer.test.ts b/packages/shared/src/config/schema-fuzzer.test.ts index cfb5aca1a..e4050eb7a 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,45 @@ 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": [{ + type: ["postgres"] as const, + connectionString: [ + "postgres://user:password@host:port/database", + "some-connection-string", + ], + }], + }], + }], dataVault: [{ stores: [{ "some-store-id": [{ diff --git a/packages/shared/src/config/schema.ts b/packages/shared/src/config/schema.ts index cb262800f..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"; @@ -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, @@ -808,6 +1005,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, @@ -899,6 +1123,14 @@ const organizationConfigDefaults = { }), } as const satisfies DefaultsType; +import.meta.vitest?.test("organization defaults include the built-in Usage Email template", ({ expect }) => { + const template = organizationConfigDefaults.emails.templates[DEFAULT_TEMPLATE_IDS.usage_email]; + expect(template).toMatchObject({ + displayName: "Usage Email", + }); + expect(template.themeId).toBeUndefined(); +}); + 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..4f8060995 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, } }; @@ -247,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; 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; +`;