forked from Mike/freno-dev
The lifecycle job marked rows with an unconditional UPDATE before sending, so two overlapping invocations (cron tick + manual hit, or retry) both passed the SELECT, both ran the UPDATE — re-writing the same value — and both sent the same email. Real duplicates went out to licensees. Rows are now claimed with a conditional UPDATE (... AND asked_at IS NULL), and the send happens only when the claim reports rowsAffected 1; a racing invocation claims nothing and sends nothing. Tests: lifecycle suite gains a lost-claim case; 36 nook API tests pass. Issue: FRE-11
137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
import type { APIEvent } from "@solidjs/start/server";
|
|
import { env } from "~/env/server";
|
|
import { NookConnectionFactory } from "~/server/db-connections";
|
|
import { nookSchemaBootstrap } from "~/server/nook";
|
|
import {
|
|
emailTrialReminder,
|
|
emailTestimonialAsk,
|
|
emailReviewAsk
|
|
} from "~/server/nook-lifecycle";
|
|
import { json } from "./_lib";
|
|
|
|
/**
|
|
* GET /api/the-nook/lifecycle — daily Vercel cron.
|
|
*
|
|
* Runs the scheduled half of the two lifecycle emails:
|
|
* - trials with an email and no reminder yet, aged ≥ 11 days → D+11 expiry
|
|
* reminder (skipped when that same address already bought a license)
|
|
* - licenses aged ≥ 3 days → testimonial ask
|
|
* - licenses aged ≥ 14 days → review ask
|
|
*
|
|
* Each row is marked *before* its send is attempted (best-effort, logged on
|
|
* failure), so a retry never double-sends; the purchase thank-you itself is
|
|
* sent from the Stripe webhook at purchase time. Auth: `Authorization:
|
|
* Bearer <secret>` where the secret is NOOK_LIFECYCLE_SECRET or Vercel's
|
|
* CRON_SECRET — an unauthenticated trigger could re-email real users, so an
|
|
* unconfigured secret fails closed.
|
|
*/
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function ageDays(iso: string): number {
|
|
const t = Date.parse(iso);
|
|
return Number.isFinite(t) ? (Date.now() - t) / DAY_MS : -1;
|
|
}
|
|
|
|
async function runLifecycle(): Promise<{
|
|
reminders: number;
|
|
testimonials: number;
|
|
reviews: number;
|
|
}> {
|
|
await nookSchemaBootstrap;
|
|
const conn = NookConnectionFactory();
|
|
const counts = { reminders: 0, testimonials: 0, reviews: 0 };
|
|
|
|
// ── Trial D+11 reminder ────────────────────────────────────────────────
|
|
const trials = await conn.execute(
|
|
`SELECT fingerprint, email, started_at FROM trials
|
|
WHERE email IS NOT NULL AND reminder_sent_at IS NULL`
|
|
);
|
|
for (const row of trials.rows as {
|
|
fingerprint: string;
|
|
email: string;
|
|
started_at: string;
|
|
}[]) {
|
|
if (ageDays(row.started_at) < 11) continue;
|
|
// Claim-then-send: the conditional UPDATE wins exactly once even when
|
|
// two invocations race (cron + manual hit) — the loser sees
|
|
// rowsAffected 0 and never sends. An unconditional mark-then-send let
|
|
// both invocations pass the SELECT and mail the same user twice.
|
|
const claim = await conn.execute({
|
|
sql: `
|
|
UPDATE trials SET reminder_sent_at = ?
|
|
WHERE fingerprint = ? AND reminder_sent_at IS NULL
|
|
`,
|
|
args: [new Date().toISOString(), row.fingerprint]
|
|
});
|
|
if (claim.rowsAffected === 0) continue;
|
|
// A trial that ended in a purchase gets no buy reminder. The address
|
|
// comparison is on the raw stored values; both writes lowercase.
|
|
const owned = await conn.execute({
|
|
sql: "SELECT 1 FROM licenses WHERE lower(email) = lower(?) LIMIT 1",
|
|
args: [row.email]
|
|
});
|
|
if (owned.rows.length > 0) continue;
|
|
if (await emailTrialReminder(row.email)) counts.reminders++;
|
|
}
|
|
|
|
// ── Purchase D+3 testimonial ask / D+14 review ask ────────────────────
|
|
const licenses = await conn.execute(
|
|
`SELECT id, email, created_at, testimonial_asked_at, review_asked_at
|
|
FROM licenses WHERE revoked = 0`
|
|
);
|
|
for (const row of licenses.rows as {
|
|
id: string;
|
|
email: string;
|
|
created_at: string;
|
|
testimonial_asked_at: string | null;
|
|
review_asked_at: string | null;
|
|
}[]) {
|
|
const age = ageDays(row.created_at);
|
|
if (age < 0) continue;
|
|
|
|
if (age >= 3 && !row.testimonial_asked_at) {
|
|
const claim = await conn.execute({
|
|
sql: `
|
|
UPDATE licenses SET testimonial_asked_at = ?
|
|
WHERE id = ? AND testimonial_asked_at IS NULL
|
|
`,
|
|
args: [new Date().toISOString(), row.id]
|
|
});
|
|
if (claim.rowsAffected === 0) continue;
|
|
if (await emailTestimonialAsk(row.email)) counts.testimonials++;
|
|
}
|
|
|
|
if (age >= 14 && !row.review_asked_at) {
|
|
const claim = await conn.execute({
|
|
sql: `
|
|
UPDATE licenses SET review_asked_at = ?
|
|
WHERE id = ? AND review_asked_at IS NULL
|
|
`,
|
|
args: [new Date().toISOString(), row.id]
|
|
});
|
|
if (claim.rowsAffected === 0) continue;
|
|
if (await emailReviewAsk(row.email)) counts.reviews++;
|
|
}
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
export async function GET(event: APIEvent) {
|
|
const auth = event.request.headers.get("authorization") ?? "";
|
|
const provided = auth.replace(/^Bearer\s+/i, "").trim();
|
|
const secret = env.NOOK_LIFECYCLE_SECRET ?? env.CRON_SECRET;
|
|
if (!secret || !provided || provided !== secret) {
|
|
return json({ error: "Unauthorized" }, 401);
|
|
}
|
|
|
|
try {
|
|
const counts = await runLifecycle();
|
|
return json({ success: true, ...counts });
|
|
} catch (error) {
|
|
console.error("Lifecycle job failed:", error);
|
|
return json({ error: "Lifecycle job failed" }, 500);
|
|
}
|
|
}
|