This commit is contained in:
Addarsh K Shrivastava 2026-07-19 00:01:55 -07:00 committed by GitHub
commit 976e79fc10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 7583 additions and 23 deletions

View File

@ -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");

View File

@ -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);
};

View File

@ -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);

View File

@ -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,
}]);
};

View File

@ -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

View File

@ -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",
];

View File

@ -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,
},
}],
});
});
});

View File

@ -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(),
}),
};
},
});

View File

@ -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();
});
});

View File

@ -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),
};
},
});

View File

@ -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),
};
}

View File

@ -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");
});
});

View File

@ -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;
}

View 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;
}

View 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,
};
}

View 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,
},
};
}

View 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";
}

View 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.');
});
});

View 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,
};
}

View 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.`);
}
}

View 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,
};
}

View 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;
}

View 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");
});
});

View 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.`);
}
}

View 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();
});
});

View 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,
};
}

View 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;
}

View 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);
}

View File

@ -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', () => {
`);
});
});

View File

@ -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/);
});
});

View File

@ -12,6 +12,10 @@
{
"path": "/api/latest/internal/external-db-sync/sequencer",
"schedule": "* * * * *"
},
{
"path": "/api/latest/internal/automations/schedule",
"schedule": "* * * * *"
}
],
"github": {

View File

@ -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();
});
});

View File

@ -0,0 +1,11 @@
import PageClient from "./page-client";
export const metadata = {
title: "Usage Emails",
};
export default function Page() {
return (
<PageClient />
);
}

View File

@ -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}

View File

@ -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),

View File

@ -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) {

View File

@ -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": [{

View File

@ -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> }

View File

@ -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;

View 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;
`;