forked from Mike/freno-dev
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:
10
.env.example
10
.env.example
@@ -83,3 +83,13 @@ NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8
|
|||||||
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
|
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
|
||||||
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
||||||
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
|
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
|
||||||
|
|
||||||
|
# ── 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="<openssl-rand-base64>"
|
||||||
|
# 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=""
|
||||||
|
|||||||
9
src/env/server.ts
vendored
9
src/env/server.ts
vendored
@@ -73,7 +73,14 @@ const serverEnvSchema = z.object({
|
|||||||
NOOK_LICENSE_PRIVATE_KEY: z.string().min(1),
|
NOOK_LICENSE_PRIVATE_KEY: z.string().min(1),
|
||||||
NOOK_STRIPE_SK: z.string().min(1),
|
NOOK_STRIPE_SK: z.string().min(1),
|
||||||
NOOK_STRIPE_WEBHOOK_SECRET: 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<typeof serverEnvSchema>;
|
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||||
|
|||||||
210
src/routes/api/the-nook/lifecycle.test.ts
Normal file
210
src/routes/api/the-nook/lifecycle.test.ts
Normal file
@@ -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<string, unknown>[] = [];
|
||||||
|
let licenseRows: Record<string, unknown>[] = [];
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/routes/api/the-nook/trial-email.test.ts
Normal file
77
src/routes/api/the-nook/trial-email.test.ts
Normal file
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/routes/api/the-nook/trial-email.ts
Normal file
36
src/routes/api/the-nook/trial-email.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { APIEvent } from "@solidjs/start/server";
|
|||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { NookConnectionFactory } from "~/server/db-connections";
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
|
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
|
||||||
|
import { emailThankYou } from "~/server/nook-lifecycle";
|
||||||
import { json } from "../_lib";
|
import { json } from "../_lib";
|
||||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
@@ -92,6 +93,9 @@ export async function POST(event: APIEvent) {
|
|||||||
try {
|
try {
|
||||||
const { key } = await issueLicense(email, sessionId);
|
const { key } = await issueLicense(email, sessionId);
|
||||||
await emailLicenseKey(email, key);
|
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) {
|
} catch (error) {
|
||||||
// UNIQUE stripe_session_id conflict from a racing duplicate delivery.
|
// UNIQUE stripe_session_id conflict from a racing duplicate delivery.
|
||||||
console.error("Failed to issue The Nook license (webhook):", error);
|
console.error("Failed to issue The Nook license (webhook):", error);
|
||||||
|
|||||||
108
src/server/nook-lifecycle.ts
Normal file
108
src/server/nook-lifecycle.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
|||||||
stripe_session_id TEXT UNIQUE NOT NULL,
|
stripe_session_id TEXT UNIQUE NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
revoked INTEGER NOT NULL DEFAULT 0,
|
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)`);
|
const licenseCols = await conn.execute(`PRAGMA table_info(licenses)`);
|
||||||
@@ -46,6 +48,18 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
|||||||
`ALTER TABLE licenses ADD COLUMN max_devices INTEGER NOT NULL DEFAULT 3`
|
`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(`
|
await conn.execute(`
|
||||||
CREATE TABLE IF NOT EXISTS activations (
|
CREATE TABLE IF NOT EXISTS activations (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -59,9 +73,27 @@ export const nookSchemaBootstrap: Promise<unknown> = (async () => {
|
|||||||
await conn.execute(`
|
await conn.execute(`
|
||||||
CREATE TABLE IF NOT EXISTS trials (
|
CREATE TABLE IF NOT EXISTS trials (
|
||||||
fingerprint TEXT PRIMARY KEY,
|
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() {
|
function privateKeyObject() {
|
||||||
@@ -214,6 +246,57 @@ export async function grantLicense(
|
|||||||
return insertLicense(email, `gift:${crypto.randomUUID()}`, maxDevices);
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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
|
* Sends a license key to the buyer. Best-effort — failures are logged, never
|
||||||
* thrown, so the caller (checkout webhook / resend) isn't interrupted.
|
* thrown, so the caller (checkout webhook / resend) isn't interrupted.
|
||||||
|
|||||||
@@ -55,5 +55,8 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"crons": [
|
||||||
|
{ "path": "/api/the-nook/lifecycle", "schedule": "0 13 * * *" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user