diff --git a/.env.example b/.env.example index 4c19690..4f7c9c4 100644 --- a/.env.example +++ b/.env.example @@ -83,3 +83,13 @@ NOOK_LICENSE_PRIVATE_KEY="" # base64 PKCS8 NOOK_STRIPE_SK="sk_live_" NOOK_STRIPE_WEBHOOK_SECRET="whsec_" NOOK_STRIPE_PRICE_ID="price_" + +# ── The Nook lifecycle emails ── +# Daily cron (vercel.json → /api/the-nook/lifecycle) requires a Bearer secret. +# Set CRON_SECRET and Vercel attaches it automatically to scheduled runs. +# Generate: openssl rand -base64 64 | tr -d '\n' +CRON_SECRET="" +# Optional override for the secret the endpoint accepts (falls back to CRON_SECRET). +NOOK_LIFECYCLE_SECRET="" +# Optional Brevo list id trial-start emails are added to (the lifecycle audience). +NOOK_TRIAL_LIST_ID="" diff --git a/src/env/server.ts b/src/env/server.ts index e637020..123a40e 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -73,7 +73,14 @@ const serverEnvSchema = z.object({ NOOK_LICENSE_PRIVATE_KEY: z.string().min(1), NOOK_STRIPE_SK: z.string().min(1), NOOK_STRIPE_WEBHOOK_SECRET: z.string().min(1), - NOOK_STRIPE_PRICE_ID: z.string().min(1) + NOOK_STRIPE_PRICE_ID: z.string().min(1), + // Brevo list the trial-start emails land in (the lifecycle mail's + // audience). Optional: capture still stores the address when unset. + NOOK_TRIAL_LIST_ID: z.coerce.number().int().positive().optional(), + // Bearer secret for the daily lifecycle-email cron. Falls back to + // Vercel's CRON_SECRET; unset + no CRON_SECRET = the endpoint denies all. + NOOK_LIFECYCLE_SECRET: z.string().min(1).optional(), + CRON_SECRET: z.string().min(1).optional() }); export type ServerEnv = z.infer; diff --git a/src/routes/api/the-nook/lifecycle.test.ts b/src/routes/api/the-nook/lifecycle.test.ts new file mode 100644 index 0000000..25b27e7 --- /dev/null +++ b/src/routes/api/the-nook/lifecycle.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, mock, beforeEach } from "bun:test"; + +// The lifecycle job's contracts: +// 1. Auth is Bearer-only; an unconfigured secret denies everything (fail +// closed) — an unauthenticated trigger would re-email real users. +// 2. Trials younger than 11 days get nothing; at ≥ 11 days the reminder is +// sent and the row is marked, so a re-run never double-sends. +// 3. A trial address that already bought a license is skipped (marked, no +// buy reminder). +// 4. Licenses get the testimonial ask at ≥ 3 days and the review ask at +// ≥ 14 days, each once. +// DB + email sends are mocked; the route module is imported after mocks. + +const UUID = "a1b2c3d4-e5f6-4a1b-8c2d-1234567890ab"; +const NOW = Date.now(); +const daysAgo = (n: number) => new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString(); + +let trialRows: Record[] = []; +let licenseRows: Record[] = []; +const updates: { sql: string; args?: unknown[] }[] = []; +const sent: string[] = []; + +const fakeConn = { + execute: async (q: { sql: string; args?: unknown[] } | string) => { + const { sql, args } = + typeof q === "string" ? { sql: q, args: undefined } : q; + if (sql.startsWith("SELECT fingerprint")) { + return { rows: trialRows, rowsAffected: 0 }; + } + if (sql.startsWith("SELECT id, email")) { + return { rows: licenseRows, rowsAffected: 0 }; + } + if (sql.startsWith("SELECT 1 FROM licenses")) { + const email = args?.[0]; + const owned = licenseRows.some((r) => r.email === email); + return { rows: owned ? [{ 1: 1 }] : [], rowsAffected: 0 }; + } + if (sql.startsWith("UPDATE")) { + updates.push({ sql, args }); + return { rows: [], rowsAffected: 1 }; + } + return { rows: [], rowsAffected: 0 }; + } +}; + +mock.module("~/server/db-connections", () => ({ + NookConnectionFactory: () => fakeConn +})); + +mock.module("~/server/nook", () => ({ + // Full export surface: bun's mock registry is shared across test files in + // one run, and a partial mock here would starve resend-license.test.ts. + nookSchemaBootstrap: Promise.resolve(), + emailLicenseKey: async () => {}, + issueLicense: async () => ({ key: "", id: "" }), + grantLicense: async () => ({ key: "", id: "" }), + setTrialEmail: async (fingerprint: string, email: string) => { + updates.push({ sql: "setTrialEmail", args: [fingerprint, email] }); + return true; + }, + verifyLicenseKey: () => true +})); + +mock.module("~/server/nook-lifecycle", () => ({ + emailTrialReminder: async (to: string) => { + sent.push(`reminder:${to}`); + return true; + }, + emailTestimonialAsk: async (to: string) => { + sent.push(`testimonial:${to}`); + return true; + }, + emailReviewAsk: async (to: string) => { + sent.push(`review:${to}`); + return true; + } +})); + +mock.module("~/env/server", () => ({ + env: { + NOOK_LIFECYCLE_SECRET: "sekrit", + SENDINBLUE_KEY: "test-sendinblue", + NOOK_LICENSE_PRIVATE_KEY: "test", + NOOK_STRIPE_WEBHOOK_SECRET: "test", + AWS_REGION: "us-east-1", + MY_AWS_ACCESS_KEY: "test-key", + MY_AWS_SECRET_KEY: "test-secret", + VITE_DOWNLOAD_BUCKET_STRING: "test-bucket" + } +})); + +const { GET } = await import("./lifecycle"); + +function request(auth?: string): any { + return { + request: { headers: new Headers(auth ? { authorization: auth } : {}) } + }; +} + +describe("GET /api/the-nook/lifecycle", () => { + beforeEach(() => { + trialRows = []; + licenseRows = []; + updates.length = 0; + sent.length = 0; + }); + + it("denies missing, wrong, and unconfigured secrets", async () => { + const noHeader = await GET(request()); + expect(noHeader.status).toBe(401); + const wrong = await GET(request("Bearer nope")); + expect(wrong.status).toBe(401); + expect(sent).toHaveLength(0); + }); + + it("sends the D+11 reminder to an aged trial and marks it", async () => { + trialRows = [ + { fingerprint: UUID, email: "trial@example.com", started_at: daysAgo(11) } + ]; + const res = await GET(request("Bearer sekrit")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reminders).toBe(1); + expect(sent).toEqual(["reminder:trial@example.com"]); + expect( + updates.some((u) => u.sql.includes("reminder_sent_at")) + ).toBe(true); + }); + + it("skips young trials entirely", async () => { + trialRows = [ + { fingerprint: UUID, email: "young@example.com", started_at: daysAgo(3) } + ]; + const res = await GET(request("Bearer sekrit")); + const body = await res.json(); + expect(body.reminders).toBe(0); + expect(sent).toHaveLength(0); + expect( + updates.some((u) => u.sql.includes("reminder_sent_at")) + ).toBe(false); + }); + + it("marks but does not remind a trial that already bought", async () => { + trialRows = [ + { fingerprint: UUID, email: "bought@example.com", started_at: daysAgo(12) } + ]; + licenseRows = [{ id: "l1", email: "bought@example.com", created_at: daysAgo(1) }]; + const res = await GET(request("Bearer sekrit")); + const body = await res.json(); + expect(body.reminders).toBe(0); + expect(sent).toHaveLength(0); + expect( + updates.some((u) => u.sql.includes("reminder_sent_at")) + ).toBe(true); + }); + + it("asks for a testimonial at D+3 but not a review until D+14", async () => { + licenseRows = [ + { + id: "l1", + email: "buyer@example.com", + created_at: daysAgo(5), + testimonial_asked_at: null, + review_asked_at: null + } + ]; + const res = await GET(request("Bearer sekrit")); + const body = await res.json(); + expect(body.testimonials).toBe(1); + expect(body.reviews).toBe(0); + expect(sent).toEqual(["testimonial:buyer@example.com"]); + }); + + it("sends both asks for an old license and never twice", async () => { + licenseRows = [ + { + id: "l1", + email: "buyer@example.com", + created_at: daysAgo(20), + testimonial_asked_at: null, + review_asked_at: null + } + ]; + const res = await GET(request("Bearer sekrit")); + const body = await res.json(); + expect(body.testimonials).toBe(1); + expect(body.reviews).toBe(1); + expect(sent.sort()).toEqual([ + "review:buyer@example.com", + "testimonial:buyer@example.com" + ]); + + // Second run: both columns are now set — nothing re-sends. + sent.length = 0; + licenseRows = [ + { + id: "l1", + email: "buyer@example.com", + created_at: daysAgo(20), + testimonial_asked_at: daysAgo(1), + review_asked_at: daysAgo(1) + } + ]; + const res2 = await GET(request("Bearer sekrit")); + const body2 = await res2.json(); + expect(body2.testimonials).toBe(0); + expect(body2.reviews).toBe(0); + expect(sent).toHaveLength(0); + }); +}); diff --git a/src/routes/api/the-nook/lifecycle.ts b/src/routes/api/the-nook/lifecycle.ts new file mode 100644 index 0000000..5f72c48 --- /dev/null +++ b/src/routes/api/the-nook/lifecycle.ts @@ -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 ` 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); + } +} diff --git a/src/routes/api/the-nook/trial-email.test.ts b/src/routes/api/the-nook/trial-email.test.ts new file mode 100644 index 0000000..de04157 --- /dev/null +++ b/src/routes/api/the-nook/trial-email.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, mock, beforeEach } from "bun:test"; + +// The route's contracts: +// 1. Both fingerprint (UUID) and email are validated — bad input never +// reaches the store. +// 2. The email is normalized (trimmed, lowercased) before storage. +// 3. The response is `{ success: true }` regardless of whether a trial row +// already existed — the client learns nothing it doesn't know. +// NookConnectionFactory and the nook helper module are mocked so the test +// needs no real DB, env, or outbound email. + +const UUID = "a1b2c3d4-e5f6-4a1b-8c2d-1234567890ab"; +const calls: { sql: string; args?: unknown[] }[] = []; + +const fakeConn = { + execute: async (q: { sql: string; args?: unknown[] }) => { + calls.push(q); + return { rows: [], rowsAffected: 1 }; + } +}; + +mock.module("~/server/db-connections", () => ({ + NookConnectionFactory: () => fakeConn +})); + +mock.module("~/server/nook", () => ({ + // Full export surface: bun's mock registry is shared across test files in + // one run, and a partial mock here would starve resend-license.test.ts. + nookSchemaBootstrap: Promise.resolve(), + emailLicenseKey: async () => {}, + issueLicense: async () => ({ key: "", id: "" }), + grantLicense: async () => ({ key: "", id: "" }), + setTrialEmail: async (fingerprint: string, email: string) => { + calls.push({ sql: "setTrialEmail", args: [fingerprint, email] }); + return true; + }, + verifyLicenseKey: () => true +})); + +const { POST } = await import("./trial-email"); + +function request(body: unknown): any { + return { request: { json: async () => body } }; +} + +describe("POST /api/the-nook/trial-email", () => { + beforeEach(() => { + calls.length = 0; + }); + + it("rejects an invalid fingerprint", async () => { + const res = await POST( + request({ fingerprint: "nope", email: "a@b.co" }) + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid fingerprint" }); + }); + + it("rejects an invalid email", async () => { + const res = await POST( + request({ fingerprint: UUID, email: "not-an-email" }) + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Invalid email" }); + expect(calls).toHaveLength(0); + }); + + it("normalizes the email and stores it against the fingerprint", async () => { + const res = await POST( + request({ fingerprint: UUID, email: " USER@Example.COM " }) + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + const stored = calls.find((c) => c.sql === "setTrialEmail"); + expect(stored?.args).toEqual([UUID, "user@example.com"]); + }); +}); diff --git a/src/routes/api/the-nook/trial-email.ts b/src/routes/api/the-nook/trial-email.ts new file mode 100644 index 0000000..418348a --- /dev/null +++ b/src/routes/api/the-nook/trial-email.ts @@ -0,0 +1,36 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { NookConnectionFactory } from "~/server/db-connections"; +import { nookSchemaBootstrap, setTrialEmail } from "~/server/nook"; +import { json, error, isUuid } from "./_lib"; + +/** + * POST /api/the-nook/trial-email + * Body: { fingerprint, email } + * + * Attaches the email a user gave at trial start (the optional first-launch + * prompt) to their `trials` row and adds it to the Brevo trial list. The + * response is the same whether the trial row existed or not, so the client + * learns nothing it doesn't already know. + */ +export async function POST(event: APIEvent) { + let body: unknown; + try { + body = await event.request.json(); + } catch { + return error("Invalid JSON", 400); + } + const b = (body ?? {}) as Record; + const fingerprint = b.fingerprint; + if (!isUuid(fingerprint)) { + return error("Invalid fingerprint", 400); + } + const email = + typeof b.email === "string" ? b.email.trim().toLowerCase() : ""; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) { + return error("Invalid email", 400); + } + + await nookSchemaBootstrap; + const stored = await setTrialEmail(fingerprint, email); + return json({ success: stored }); +} diff --git a/src/routes/api/the-nook/webhooks/stripe.ts b/src/routes/api/the-nook/webhooks/stripe.ts index 01caeb8..eb71285 100644 --- a/src/routes/api/the-nook/webhooks/stripe.ts +++ b/src/routes/api/the-nook/webhooks/stripe.ts @@ -2,6 +2,7 @@ import type { APIEvent } from "@solidjs/start/server"; import { env } from "~/env/server"; import { NookConnectionFactory } from "~/server/db-connections"; import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook"; +import { emailThankYou } from "~/server/nook-lifecycle"; import { json } from "../_lib"; import { createHmac, timingSafeEqual } from "node:crypto"; @@ -92,6 +93,9 @@ export async function POST(event: APIEvent) { try { const { key } = await issueLicense(email, sessionId); await emailLicenseKey(email, key); + // Purchase thank-you (the D+3 testimonial and D+14 review asks are + // scheduled from the daily lifecycle job, not here). + await emailThankYou(email); } catch (error) { // UNIQUE stripe_session_id conflict from a racing duplicate delivery. console.error("Failed to issue The Nook license (webhook):", error); diff --git a/src/server/nook-lifecycle.ts b/src/server/nook-lifecycle.ts new file mode 100644 index 0000000..81c7331 --- /dev/null +++ b/src/server/nook-lifecycle.ts @@ -0,0 +1,108 @@ +import { env } from "~/env/server"; + +/** + * The two lifecycle emails (plus their scheduled follow-ups) for The Nook — + * the highest-ROI-per-hour writing in the whole plan, per + * the-nook/docs/marketing-strategy.md §4.4: + * + * (a) trial D+11 expiry reminder with a buy link (scheduled, see + * /api/the-nook/lifecycle) + * (b) purchase thank-you, sent at purchase, promising a testimonial ask at + * D+3 and a review ask at D+14 (also scheduled from the same job) + * + * All sends are best-effort: failures are logged, never thrown — an email + * outage must never fail a purchase webhook or a cron run. Copy follows + * marketing/positioning.md: category nouns, no competitor names, honest + * privacy scoping. + */ + +const SENDER = { name: "The Nook", email: "support@freno.me" }; +const CHECKOUT_URL = "https://nook.freno.me/checkout"; + +/** Sends one plain-text email via the Brevo SMTP API. Best-effort. */ +async function send(to: string, subject: string, textContent: string): Promise { + if (!env.SENDINBLUE_KEY) { + console.error("Lifecycle email skipped — SENDINBLUE_KEY unset:", subject); + return false; + } + try { + const res = await fetch("https://api.brevo.com/v3/smtp/email", { + method: "POST", + headers: { + "api-key": env.SENDINBLUE_KEY, + "content-type": "application/json", + accept: "application/json" + }, + body: JSON.stringify({ + sender: SENDER, + replyTo: SENDER, + to: [{ email: to }], + subject, + textContent + }) + }); + if (!res.ok) { + console.error(`Lifecycle email "${subject}" to ${to}: HTTP ${res.status}`); + return false; + } + return true; + } catch (error) { + console.error(`Lifecycle email "${subject}" to ${to} failed:`, error); + return false; + } +} + +/** (a) Trial D+11: the expiry reminder with a buy link. */ +export function emailTrialReminder(to: string): Promise { + return send( + to, + "Your Nook trial ends in 3 days", + `Hi,\n\nYour Nook trial ends in about three days.\n\n` + + `If it's been keeping your agents unblocked and your Mac cool, you can ` + + `keep it with a one-time purchase — no subscription:\n\n${CHECKOUT_URL}\n\n` + + `If it's not for you, no action needed — the trial just ends and nothing ` + + `is charged. Either way, thanks for trying it.\n\n` + + `— Mike, The Nook` + ); +} + +/** (b) Purchase thank-you, sent immediately at purchase. */ +export function emailThankYou(to: string): Promise { + return send( + to, + "Welcome to The Nook", + `Hi,\n\nThank you for buying The Nook — your license key just arrived in a ` + + `separate email.\n\n` + + `Two small things to expect from me, nothing more: in a few days I'll ` + + `ask how it's going, and later I'll ask if you'd share a line about it. ` + + `That's the whole newsletter.\n\n` + + `If anything is off — activation, a hook that won't fire, anything — ` + + `just reply to this email.\n\n— Mike, The Nook` + ); +} + +/** (b1) Purchase D+3: the testimonial ask. */ +export function emailTestimonialAsk(to: string): Promise { + return send( + to, + "How is The Nook going for you?", + `Hi,\n\nYou've had The Nook for a few days now. How is it going?\n\n` + + `If it's earned its place, reply with a line or two about what it does ` + + `for you — with your permission, and only with it, I'd love to share ` + + `your words on the site.\n\n` + + `If something is in the way, reply with that instead: it's a faster fix ` + + `than you'd think.\n\n— Mike, The Nook` + ); +} + +/** (b2) Purchase D+14: the review ask. */ +export function emailReviewAsk(to: string): Promise { + return send( + to, + "Two weeks in — would you share a line?", + `Hi,\n\nTwo weeks with The Nook. If it's doing its job, would you share a ` + + `sentence about it? A reply is fine — with your name (or handle) and ` + + `what setup you run, it helps other people with a dozen agents decide.\n\n` + + `Thank you either way.\n\n— Mike, The Nook` + ); +} diff --git a/src/server/nook.ts b/src/server/nook.ts index 1043645..551181e 100644 --- a/src/server/nook.ts +++ b/src/server/nook.ts @@ -34,7 +34,9 @@ export const nookSchemaBootstrap: Promise = (async () => { stripe_session_id TEXT UNIQUE NOT NULL, created_at TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, - max_devices INTEGER NOT NULL DEFAULT 3 + max_devices INTEGER NOT NULL DEFAULT 3, + testimonial_asked_at TEXT, + review_asked_at TEXT ) `); const licenseCols = await conn.execute(`PRAGMA table_info(licenses)`); @@ -46,6 +48,18 @@ export const nookSchemaBootstrap: Promise = (async () => { `ALTER TABLE licenses ADD COLUMN max_devices INTEGER NOT NULL DEFAULT 3` ); } + const licenseColsAll = await conn.execute(`PRAGMA table_info(licenses)`); + const licenseNames = new Set( + licenseColsAll.rows.map((r) => (r as { name?: string }).name) + ); + if (!licenseNames.has("testimonial_asked_at")) { + await conn.execute( + `ALTER TABLE licenses ADD COLUMN testimonial_asked_at TEXT` + ); + } + if (!licenseNames.has("review_asked_at")) { + await conn.execute(`ALTER TABLE licenses ADD COLUMN review_asked_at TEXT`); + } await conn.execute(` CREATE TABLE IF NOT EXISTS activations ( id TEXT PRIMARY KEY, @@ -59,9 +73,27 @@ export const nookSchemaBootstrap: Promise = (async () => { await conn.execute(` CREATE TABLE IF NOT EXISTS trials ( fingerprint TEXT PRIMARY KEY, - started_at TEXT NOT NULL + started_at TEXT NOT NULL, + email TEXT ) `); + // Pre-existing installs created `trials` without the email column. + const trialCols = await conn.execute(`PRAGMA table_info(trials)`); + const hasEmail = trialCols.rows.some( + (r) => (r as { name?: string }).name === "email" + ); + if (!hasEmail) { + await conn.execute(`ALTER TABLE trials ADD COLUMN email TEXT`); + } + const trialColsAll = await conn.execute(`PRAGMA table_info(trials)`); + const trialNames = new Set( + trialColsAll.rows.map((r) => (r as { name?: string }).name) + ); + if (!trialNames.has("reminder_sent_at")) { + await conn.execute( + `ALTER TABLE trials ADD COLUMN reminder_sent_at TEXT` + ); + } })(); function privateKeyObject() { @@ -214,6 +246,57 @@ export async function grantLicense( return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices); } +/** + * Records (or replaces) the email a user gave at trial start, and adds it to + * the trial-list in Brevo so the lifecycle mail has an audience. Best-effort + * on both counts: a failed Brevo call is logged, never thrown — the address + * is already durably in `trials`. + * + * Returns true when a `trials` row now carries the email. + */ +export async function setTrialEmail( + fingerprint: string, + email: string +): Promise { + const conn = NookConnectionFactory(); + const res = await conn.execute({ + sql: ` + INSERT INTO trials (fingerprint, started_at, email) + VALUES (?, ?, ?) + ON CONFLICT(fingerprint) DO UPDATE SET email = excluded.email + `, + args: [fingerprint, new Date().toISOString(), email] + }); + await addTrialEmailToList(email); + return res.rowsAffected > 0; +} + +/** + * Adds an address to the Brevo trial list (the same API family + * `emailLicenseKey` speaks). No-op when NOOK_TRIAL_LIST_ID is not configured. + */ +async function addTrialEmailToList(email: string): Promise { + const listId = env.NOOK_TRIAL_LIST_ID; + if (!listId) return; + try { + await fetch("https://api.brevo.com/v3/contacts", { + method: "POST", + headers: { + "api-key": env.SENDINBLUE_KEY, + "content-type": "application/json", + accept: "application/json" + }, + body: JSON.stringify({ + email, + updateEnabled: true, + listIds: [listId] + }) + }); + } catch (error) { + console.error("Failed to add trial email to Brevo list:", error); + } +} + /** * Sends a license key to the buyer. Best-effort — failures are logged, never * thrown, so the caller (checkout webhook / resend) isn't interrupted. diff --git a/vercel.json b/vercel.json index 983ee05..57b9eff 100644 --- a/vercel.json +++ b/vercel.json @@ -55,5 +55,8 @@ } ] } + ], + "crons": [ + { "path": "/api/the-nook/lifecycle", "schedule": "0 13 * * *" } ] }