nook: capture trial email and run the two lifecycle emails
Trials were anonymous — a 14-day trial you cannot talk to. trials gains an email column (idempotent ALTER), POST /api/the-nook/trial-email attaches the optional first-launch prompt's answer, and the address lands in a Brevo list when NOOK_TRIAL_LIST_ID is set. The lifecycle emails follow: a D+11 trial expiry reminder with a buy link, a purchase thank-you from the Stripe webhook, and scheduled testimonial (D+3) and review (D+14) asks via the daily /api/the-nook/lifecycle cron. Rows are marked before sending so retries never double-send; trial addresses that already bought get no buy reminder; the endpoint requires a Bearer secret (CRON_SECRET / NOOK_LIFECYCLE_SECRET) and fails closed when unconfigured — an open trigger would email real users. All sends are best-effort and logged, never thrown. Tests: trial-email (3) and lifecycle (6) suites; full run 539 pass. Issues: FRE-10, FRE-11
This commit is contained in:
120
src/routes/api/the-nook/lifecycle.ts
Normal file
120
src/routes/api/the-nook/lifecycle.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
await conn.execute({
|
||||
sql: "UPDATE trials SET reminder_sent_at = ? WHERE fingerprint = ?",
|
||||
args: [new Date().toISOString(), row.fingerprint]
|
||||
});
|
||||
// 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) {
|
||||
await conn.execute({
|
||||
sql: "UPDATE licenses SET testimonial_asked_at = ? WHERE id = ?",
|
||||
args: [new Date().toISOString(), row.id]
|
||||
});
|
||||
if (await emailTestimonialAsk(row.email)) counts.testimonials++;
|
||||
}
|
||||
|
||||
if (age >= 14 && !row.review_asked_at) {
|
||||
await conn.execute({
|
||||
sql: "UPDATE licenses SET review_asked_at = ? WHERE id = ?",
|
||||
args: [new Date().toISOString(), row.id]
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user