mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge a7d6fd760d into 7ed89a2a9c
This commit is contained in:
commit
976e79fc10
@ -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");
|
||||
@ -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<ReturnType<typeof preMigration>>) => {
|
||||
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);
|
||||
};
|
||||
@ -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);
|
||||
@ -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,
|
||||
}]);
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
];
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<PrismaClientTransaction> = 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(),
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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<string, unknown>,
|
||||
};
|
||||
|
||||
function createInMemoryStateStore() {
|
||||
const states = new Map<string, InMemoryExecutionState>();
|
||||
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<Awaited<ReturnType<AutomationRuleExecutionStateStore["claimExecution"]>>> => {
|
||||
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<typeof runAutomationRuleForRoute>[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<typeof createInMemoryStateStore> | 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<string>();
|
||||
const emailSender = vi.fn(async (options: Parameters<AutomationEmailSender>[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<string, PrismaStoreState>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
104
apps/backend/src/lib/automations/actions/send-email.ts
Normal file
104
apps/backend/src/lib/automations/actions/send-email.ts
Normal file
@ -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<SendEmailNotificationCategory | undefined>;
|
||||
|
||||
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<AutomationActionPlan> {
|
||||
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;
|
||||
}
|
||||
46
apps/backend/src/lib/automations/cooldown.ts
Normal file
46
apps/backend/src/lib/automations/cooldown.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
149
apps/backend/src/lib/automations/dry-run-route.ts
Normal file
149
apps/backend/src/lib/automations/dry-run-route.ts
Normal file
@ -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<TPrisma> = (options: {
|
||||
prisma: TPrisma,
|
||||
tenancyId: string,
|
||||
userIds: string[],
|
||||
}) => Promise<Map<string, AutomationDryRunRecipientStatus>>;
|
||||
|
||||
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<TPrisma>(options: {
|
||||
tenancy: Parameters<typeof evaluateAutomationRule>[0]["tenancy"],
|
||||
ruleId: string,
|
||||
limit?: number,
|
||||
cursor?: string | null,
|
||||
prisma: TPrisma,
|
||||
sourceAdapter: AutomationSourceAdapter,
|
||||
actionAdapter: AutomationActionAdapter,
|
||||
recipientStatusReader: AutomationDryRunRecipientStatusReader<TPrisma>,
|
||||
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<string, AutomationDryRunRecipientStatus>,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
262
apps/backend/src/lib/automations/execution-state-store.ts
Normal file
262
apps/backend/src/lib/automations/execution-state-store.ts
Normal file
@ -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<unknown>,
|
||||
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<AutomationCooldownStatus>,
|
||||
};
|
||||
|
||||
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<string, AutomationJson>): Prisma.InputJsonObject {
|
||||
return sourceSnapshot;
|
||||
}
|
||||
|
||||
function isPrismaUniqueConstraintError(error: unknown) {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
||||
}
|
||||
858
apps/backend/src/lib/automations/quota-email-automation.test.ts
Normal file
858
apps/backend/src/lib/automations/quota-email-automation.test.ts
Normal file
@ -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<string, number | undefined>,
|
||||
ownedProducts?: Record<string, Record<string, PaymentsItemQuotaOwnedProduct>>,
|
||||
subscriptions?: Record<string, Record<string, PaymentsItemQuotaSubscription>>,
|
||||
};
|
||||
|
||||
const fakePrisma: FakePrisma = {
|
||||
label: "fake-prisma",
|
||||
};
|
||||
|
||||
function createSourceTenancy(options: {
|
||||
itemCustomerType?: string,
|
||||
thresholds?: NonNullable<AutomationRuleConfig["source"]["thresholds"]>,
|
||||
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<AutomationRuleConfig["source"]["thresholds"]>) {
|
||||
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<FakePrisma> = {
|
||||
listCandidateUserIds: vi.fn(async () => ({
|
||||
projectUserIds: state.projectUserIds,
|
||||
nextCursor: state.nextCursor ?? null,
|
||||
})),
|
||||
};
|
||||
const customerDataReaders: PaymentsItemQuotaCustomerDataReaders<FakePrisma> = {
|
||||
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<Parameters<typeof buildSendEmailActionPlan>[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.');
|
||||
});
|
||||
});
|
||||
137
apps/backend/src/lib/automations/rule-evaluator.ts
Normal file
137
apps/backend/src/lib/automations/rule-evaluator.ts
Normal file
@ -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<string, AutomationJson>,
|
||||
};
|
||||
|
||||
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<string, AutomationJson>,
|
||||
};
|
||||
|
||||
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<AutomationSourceEvaluationResult>,
|
||||
};
|
||||
|
||||
export type AutomationActionAdapter = {
|
||||
buildPlan: (options: {
|
||||
tenancy: AutomationRuleTenancy,
|
||||
ruleId: string,
|
||||
rule: SupportedAutomationRule,
|
||||
decision: AutomationSourceDecision,
|
||||
}) => Promise<AutomationActionPlan>,
|
||||
};
|
||||
|
||||
export type AutomationEvaluatorAdapters = {
|
||||
sourceAdapters?: Partial<Record<typeof paymentsItemQuotaSourceType, AutomationSourceAdapter>>,
|
||||
actionAdapters?: Partial<Record<typeof sendEmailActionType, AutomationActionAdapter>>,
|
||||
};
|
||||
|
||||
export async function evaluateAutomationRule(options: {
|
||||
tenancy: AutomationRuleTenancy,
|
||||
ruleId: string,
|
||||
mode: AutomationEvaluationMode,
|
||||
limit?: number,
|
||||
cursor?: string | null,
|
||||
adapters?: AutomationEvaluatorAdapters,
|
||||
}): Promise<AutomationEvaluationResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
166
apps/backend/src/lib/automations/rules.ts
Normal file
166
apps/backend/src/lib/automations/rules.ts
Normal file
@ -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<string, AutomationRuleConfig | undefined>,
|
||||
},
|
||||
emails?: {
|
||||
selectedThemeId?: string | null,
|
||||
templates?: Record<string, {
|
||||
displayName?: string,
|
||||
tsxSource?: string,
|
||||
themeId?: string | null | false,
|
||||
} | undefined>,
|
||||
},
|
||||
payments?: {
|
||||
items?: Record<string, {
|
||||
displayName?: string,
|
||||
customerType?: string,
|
||||
} | undefined>,
|
||||
},
|
||||
};
|
||||
|
||||
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 ?? "<missing>"}". 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.`);
|
||||
}
|
||||
}
|
||||
246
apps/backend/src/lib/automations/run-route.ts
Normal file
246
apps/backend/src/lib/automations/run-route.ts
Normal file
@ -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<string, AutomationJson>,
|
||||
}) => Promise<AutomationRuleExecutionClaimResult>,
|
||||
markActionCompleted: (options: {
|
||||
tenancyId: string,
|
||||
ruleId: string,
|
||||
subjectType: "user",
|
||||
subjectId: string,
|
||||
signalKey: string,
|
||||
claimTriggeredAt: Date,
|
||||
lastActionAt: Date,
|
||||
emailOutboxId: string,
|
||||
}) => Promise<void>,
|
||||
};
|
||||
|
||||
export type AutomationEmailSendResult = {
|
||||
outcome: "created" | "already-enqueued",
|
||||
};
|
||||
|
||||
export type AutomationEmailSender = (options: {
|
||||
action: AutomationActionPlan,
|
||||
scheduledAt: Date,
|
||||
emailOutboxId: string,
|
||||
}) => Promise<AutomationEmailSendResult>;
|
||||
|
||||
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<typeof evaluateAutomationRule>[0]["tenancy"],
|
||||
ruleId: string,
|
||||
limit?: number,
|
||||
cursor?: string | null,
|
||||
scheduledAt: Date,
|
||||
now: Date,
|
||||
sourceAdapter: AutomationSourceAdapter,
|
||||
actionAdapter: AutomationActionAdapter,
|
||||
stateStore: AutomationRuleExecutionStateStore,
|
||||
emailSender: AutomationEmailSender,
|
||||
}): Promise<AutomationRunResult> {
|
||||
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<typeof runAutomationRuleForRoute>[0],
|
||||
): Promise<AutomationRunResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
13
apps/backend/src/lib/automations/scheduled-at.ts
Normal file
13
apps/backend/src/lib/automations/scheduled-at.ts
Normal file
@ -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;
|
||||
}
|
||||
154
apps/backend/src/lib/automations/scheduler-state.test.ts
Normal file
154
apps/backend/src/lib/automations/scheduler-state.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
176
apps/backend/src/lib/automations/scheduler-state.ts
Normal file
176
apps/backend/src/lib/automations/scheduler-state.ts
Normal file
@ -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<void>,
|
||||
saveCheckpoint: (checkpoint: AutomationSchedulerCheckpoint) => Promise<void>,
|
||||
release: () => Promise<void>,
|
||||
};
|
||||
|
||||
type AutomationSchedulerStatePrisma = Pick<PrismaClientTransaction, "automationSchedulerState">;
|
||||
|
||||
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<AutomationSchedulerLease | null> {
|
||||
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<typeof options.prisma.automationSchedulerState.updateMany>[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.`);
|
||||
}
|
||||
}
|
||||
406
apps/backend/src/lib/automations/scheduler.test.ts
Normal file
406
apps/backend/src/lib/automations/scheduler.test.ts
Normal file
@ -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<typeof import("@hexclave/shared/dist/utils/errors")>();
|
||||
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<AutomationSchedulerLease> => ({
|
||||
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<string, ReturnType<typeof createRule>> = { [ruleId]: createRule() }) {
|
||||
return {
|
||||
id,
|
||||
project: { display_name: "Acme App" },
|
||||
config: { automations: { rules } },
|
||||
};
|
||||
}
|
||||
|
||||
function createRunResult(nextCursor: string | null, options: Partial<AutomationRunResult> = {}): 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();
|
||||
});
|
||||
});
|
||||
447
apps/backend/src/lib/automations/scheduler.ts
Normal file
447
apps/backend/src/lib/automations/scheduler.ts
Normal file
@ -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<Array<{ id: string }>>,
|
||||
},
|
||||
};
|
||||
|
||||
type ScheduledAutomationRunner<TTenancy extends AutomationRuleTenancy> = (options: {
|
||||
tenancy: TTenancy,
|
||||
ruleId: string,
|
||||
cursor: string | null,
|
||||
limit: number,
|
||||
scheduledAt: Date,
|
||||
now: Date,
|
||||
}) => Promise<AutomationRunResult>;
|
||||
|
||||
type AutomationSchedulerStateStore = {
|
||||
acquire: () => Promise<AutomationSchedulerLease | null>,
|
||||
};
|
||||
|
||||
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<ScheduledAutomationCronResult>;
|
||||
export function runScheduledAutomations<TTenancy extends AutomationRuleTenancy>(
|
||||
options: ScheduledAutomationOptions & {
|
||||
getTenancyById: (tenancyId: string) => Promise<TTenancy | null>,
|
||||
runRule: ScheduledAutomationRunner<TTenancy>,
|
||||
},
|
||||
): Promise<ScheduledAutomationCronResult>;
|
||||
export async function runScheduledAutomations(options: ScheduledAutomationOptions & {
|
||||
getTenancyById?: (tenancyId: string) => Promise<AutomationRuleTenancy | null>,
|
||||
runRule?: ScheduledAutomationRunner<AutomationRuleTenancy>,
|
||||
} = {}): Promise<ScheduledAutomationCronResult> {
|
||||
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<TTenancy extends AutomationRuleTenancy>(
|
||||
options: ScheduledAutomationOptions,
|
||||
dependencies: {
|
||||
getTenancyById: (tenancyId: string) => Promise<TTenancy | null>,
|
||||
runRule: ScheduledAutomationRunner<TTenancy>,
|
||||
},
|
||||
): Promise<ScheduledAutomationCronResult> {
|
||||
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<TTenancy extends AutomationRuleTenancy>(options: {
|
||||
prisma: AutomationDiscoveryPrisma,
|
||||
lease: AutomationSchedulerLease,
|
||||
getTenancyById: (tenancyId: string) => Promise<TTenancy | null>,
|
||||
runRule: ScheduledAutomationRunner<TTenancy>,
|
||||
now: () => Date,
|
||||
elapsedNow: () => number,
|
||||
pageLimit: number,
|
||||
maxPages: number,
|
||||
maxTenancies: number,
|
||||
workBudgetMs: number,
|
||||
}): Promise<ScheduledAutomationCronResult> {
|
||||
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<AutomationRunResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
58
apps/backend/src/lib/automations/source-snapshot.ts
Normal file
58
apps/backend/src/lib/automations/source-snapshot.ts
Normal file
@ -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;
|
||||
}
|
||||
262
apps/backend/src/lib/automations/sources/payments-item-quota.ts
Normal file
262
apps/backend/src/lib/automations/sources/payments-item-quota.ts
Normal file
@ -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<TPrisma> = {
|
||||
listCandidateUserIds: (options: {
|
||||
prisma: TPrisma,
|
||||
tenancyId: string,
|
||||
limit: number,
|
||||
cursor?: string | null,
|
||||
}) => Promise<PaymentsItemQuotaProjectUserPage>,
|
||||
};
|
||||
|
||||
export type PaymentsItemQuotaProjectUserPrisma = {
|
||||
projectUser: {
|
||||
findMany: (options: {
|
||||
where: {
|
||||
tenancyId: string,
|
||||
projectUserId?: {
|
||||
gt: string,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
projectUserId: "asc",
|
||||
},
|
||||
take: number,
|
||||
select: {
|
||||
projectUserId: true,
|
||||
},
|
||||
}) => Promise<Array<{ projectUserId: string }>>,
|
||||
},
|
||||
};
|
||||
|
||||
export const prismaPaymentsItemQuotaProjectUserReader: PaymentsItemQuotaProjectUserReader<PaymentsItemQuotaProjectUserPrisma> = {
|
||||
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<string, {
|
||||
quantity?: number,
|
||||
} | undefined>,
|
||||
},
|
||||
productLineId?: string | null,
|
||||
};
|
||||
|
||||
export type PaymentsItemQuotaSubscription = {
|
||||
status: string,
|
||||
};
|
||||
|
||||
export type PaymentsItemQuotaCustomerDataReaders<TPrisma> = {
|
||||
getItemQuantityForCustomer: (options: {
|
||||
prisma: TPrisma,
|
||||
tenancyId: string,
|
||||
itemId: string,
|
||||
customerId: string,
|
||||
customerType: "user",
|
||||
}) => Promise<number>,
|
||||
getOwnedProductsForCustomer: (options: {
|
||||
prisma: TPrisma,
|
||||
tenancyId: string,
|
||||
customerType: "user",
|
||||
customerId: string,
|
||||
}) => Promise<Record<string, PaymentsItemQuotaOwnedProduct>>,
|
||||
getSubscriptionMapForCustomer: (options: {
|
||||
prisma: TPrisma,
|
||||
tenancyId: string,
|
||||
customerType: "user",
|
||||
customerId: string,
|
||||
}) => Promise<Record<string, PaymentsItemQuotaSubscription>>,
|
||||
};
|
||||
|
||||
export const paymentsItemQuotaCustomerDataReaders: PaymentsItemQuotaCustomerDataReaders<PrismaClientTransaction> = {
|
||||
getItemQuantityForCustomer,
|
||||
getOwnedProductsForCustomer,
|
||||
getSubscriptionMapForCustomer,
|
||||
};
|
||||
|
||||
export function createPaymentsItemQuotaSourceAdapter<TPrisma>(options: {
|
||||
prisma: TPrisma,
|
||||
projectUserReader: PaymentsItemQuotaProjectUserReader<TPrisma>,
|
||||
customerDataReaders: PaymentsItemQuotaCustomerDataReaders<TPrisma>,
|
||||
}): AutomationSourceAdapter {
|
||||
return {
|
||||
evaluate: async (evaluateOptions) => await evaluatePaymentsItemQuotaSource({
|
||||
...evaluateOptions,
|
||||
prisma: options.prisma,
|
||||
projectUserReader: options.projectUserReader,
|
||||
customerDataReaders: options.customerDataReaders,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluatePaymentsItemQuotaSource<TPrisma>(options: {
|
||||
prisma: TPrisma,
|
||||
projectUserReader: PaymentsItemQuotaProjectUserReader<TPrisma>,
|
||||
customerDataReaders: PaymentsItemQuotaCustomerDataReaders<TPrisma>,
|
||||
tenancy: AutomationRuleTenancy,
|
||||
ruleId: string,
|
||||
rule: PaymentsItemQuotaAutomationRule,
|
||||
limit?: number,
|
||||
cursor?: string | null,
|
||||
}): Promise<AutomationSourceEvaluationResult> {
|
||||
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 ?? "<missing>"}"; 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<string, PaymentsItemQuotaOwnedProduct>, 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<string, PaymentsItemQuotaOwnedProduct>) {
|
||||
return Object.keys(ownedProducts).filter((productId) => productId !== "__null__");
|
||||
}
|
||||
|
||||
function getActiveSubscriptionIds(subscriptionMap: Record<string, PaymentsItemQuotaSubscription>) {
|
||||
return Object.entries(subscriptionMap)
|
||||
.filter(([, subscription]) => subscription.status === "active" || subscription.status === "trialing")
|
||||
.map(([subscriptionId]) => subscriptionId);
|
||||
}
|
||||
@ -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', () => {
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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<T extends object>(rows: T[], emailOutboxIds: undefined): { data: T[] };
|
||||
function addIdempotencyToEmailOutboxRows<T extends object>(rows: T[], emailOutboxIds: string[]): { data: Array<T & { id: string }>, skipDuplicates: true };
|
||||
function addIdempotencyToEmailOutboxRows<T extends object>(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<SendEmailToManyResult> {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,10 @@
|
||||
{
|
||||
"path": "/api/latest/internal/external-db-sync/sequencer",
|
||||
"schedule": "* * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/latest/internal/automations/schedule",
|
||||
"schedule": "* * * * *"
|
||||
}
|
||||
],
|
||||
"github": {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,11 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Usage Emails",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageClient />
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{trigger === "button" ? (
|
||||
<DesignButton variant="outline" size="sm" className="h-8 px-3 rounded-lg">
|
||||
<DesignButton variant="outline" size="sm" className={cn("h-8 px-3 rounded-lg", props.triggerClassName)}>
|
||||
{triggerLabel}
|
||||
</DesignButton>
|
||||
) : (
|
||||
<DesignButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05]"
|
||||
className={cn("h-8 w-8 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05]", props.triggerClassName)}
|
||||
aria-label={triggerLabel}
|
||||
>
|
||||
{triggerIcon}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -63,6 +63,12 @@ type AdminAppInternalsHooks = {
|
||||
sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise<Response>,
|
||||
};
|
||||
|
||||
type AdminAppInternalsRequestType = "client" | "server" | "admin";
|
||||
|
||||
type AdminAppInternalsRequest = {
|
||||
sendRequest: (path: string, requestOptions: RequestInit, requestType?: AdminAppInternalsRequestType) => Promise<Response>,
|
||||
};
|
||||
|
||||
function getInternalsHookOrThrow<K extends keyof AdminAppInternalsHooks>(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<string, unknown>).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<Response> {
|
||||
return await getInternalsSendRequestOrThrow(adminApp)(path, requestOptions, "admin");
|
||||
}
|
||||
|
||||
function getMetricsQueryString(includeAnonymous: boolean, filters?: AnalyticsOverviewFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
if (includeAnonymous) {
|
||||
|
||||
@ -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<T> = ReadonlyArray<T extends object ? ([T] extends [any[]] ? { readonly [K in keyof T]: FuzzerConfig<T[K]> } : Required<{
|
||||
[K in keyof T]: FuzzerConfig<T[K]>;
|
||||
}> & Record<string, FuzzerConfig<any>>) : 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": [{
|
||||
|
||||
@ -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<yup.AnyObject>, 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<OrganizationRenderedConfigBeforeDefaults, [typeof environmentConfigDefaults, typeof branchConfigDefaults, typeof projectConfigDefaults]>;
|
||||
|
||||
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, U> = 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<T[K], U[K] & object> }
|
||||
|
||||
@ -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<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"Payment failed for \" + project.displayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"bg-white\">\n <h3 className=\"text-black font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n We couldn't process your payment\n </h3>\n <p className=\"text-[#474849] font-sans font-normal text-[14px] text-center pt-2 px-6 pb-4 m-0\">\n Hi{user.displayName ? (\", \" + user.displayName) : \"\"}! Your payment for {variables.productName} ({variables.amount}) did not go through.\n </p>\n {variables.failureReason ? (\n <p className=\"text-[#8a8b8b] font-sans text-[12px] text-center px-6 pb-2 m-0\">\n Reason: {variables.failureReason}\n </p>\n ) : null}\n {variables.invoiceUrl ? (\n <div className=\"text-center py-3 px-6\">\n <Button\n href={variables.invoiceUrl}\n target=\"_blank\"\n className=\"text-black font-sans font-bold text-[14px] inline-block bg-[#f0f0f0] rounded-[4px] py-3 px-5 no-underline border-0\"\n >\n View invoice\n </Button>\n </div>\n ) : null}\n <div className=\"py-4 px-6\">\n <Hr />\n </div>\n <p className=\"text-[#474849] font-sans font-normal text-[12px] text-center pt-1 px-6 pb-6 m-0\">\n Please update your payment details to avoid service interruption.\n </p>\n </Section>\n </div>\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;
|
||||
|
||||
48
packages/shared/src/helpers/usage-email-template.ts
Normal file
48
packages/shared/src/helpers/usage-email-template.ts
Normal file
@ -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<typeof variablesSchema.infer>) {
|
||||
const isOverLimit = variables.thresholdKind === "over";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Subject value={variables.itemDisplayName + " usage alert"} />
|
||||
<NotificationCategory value="Marketing" />
|
||||
<Section>
|
||||
<Heading as="h3" className="font-sans font-bold text-[20px] text-center py-4 px-6 m-0">
|
||||
{isOverLimit
|
||||
? variables.itemDisplayName + " limit reached"
|
||||
: variables.itemDisplayName + " usage is running low"}
|
||||
</Heading>
|
||||
<Text className="font-sans font-normal text-[14px] text-center pt-2 px-6 pb-2 m-0 opacity-80">
|
||||
{isOverLimit
|
||||
? "Your quota is at or below its limit."
|
||||
: "Your remaining usage is low."}
|
||||
</Text>
|
||||
<Text className="font-sans font-normal text-[14px] text-center px-6 pb-2 m-0 opacity-80">
|
||||
Current {variables.itemDisplayName} quantity: {variables.currentQuantity}
|
||||
</Text>
|
||||
<Text className="font-sans font-normal text-[12px] text-center px-6 pb-6 m-0 opacity-60">
|
||||
Review your plan in {variables.projectDisplayName} if you need more capacity.
|
||||
</Text>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
EmailTemplate.PreviewVariables = {
|
||||
projectDisplayName: "My Project",
|
||||
itemDisplayName: "API requests",
|
||||
currentQuantity: 10,
|
||||
thresholdKind: "near"
|
||||
} satisfies typeof variablesSchema.infer;
|
||||
`;
|
||||
Loading…
Reference in New Issue
Block a user