From 59d129b46b19835da65e19edb994fe9af9e6d9ac Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 23 Jul 2026 12:32:14 -0400 Subject: [PATCH] feat: add product-aware deletion emails and subdomain deletion/downloads routes - Extend DeletionForm with product discriminator and configurable cooldown cookie - Extract env-free deletion email helpers into deletion-email.ts with Lineage/Nessa branding - Make misc.sendDeletionRequestEmail mutation product-aware (subject, html content, cookie) - Replace legacy /deletion/life-and-lineage page with 308 redirect to lineage subdomain - Add Lineage and Nessa subdomain deletion route modules with shared content components - Add Lineage downloads route modules with shared content - Update privacy policy deletion link to lineage subdomain --- src/components/DeletionForm.tsx | 33 +++- src/routes/deletion/life-and-lineage.test.ts | 46 ++++++ src/routes/deletion/life-and-lineage.tsx | 56 ++++--- src/routes/downloads.test.ts | 59 +++++++ src/routes/lineage/deletion-content.test.ts | 97 ++++++++++++ src/routes/lineage/deletion-content.ts | 71 +++++++++ src/routes/lineage/deletion.tsx | 71 +++++++++ src/routes/lineage/downloads-content.test.ts | 92 +++++++++++ src/routes/lineage/downloads-content.ts | 67 ++++++++ src/routes/lineage/downloads.tsx | 146 ++++++++++++++++++ src/routes/nessa/deletion-content.test.ts | 84 ++++++++++ src/routes/nessa/deletion-content.ts | 53 +++++++ src/routes/nessa/deletion.tsx | 61 ++++++++ .../privacy-policy/life-and-lineage.tsx | 2 +- src/server/api/routers/deletion-email.test.ts | 121 +++++++++++++++ src/server/api/routers/deletion-email.ts | 76 +++++++++ src/server/api/routers/misc.ts | 44 +++++- 17 files changed, 1138 insertions(+), 41 deletions(-) create mode 100644 src/routes/deletion/life-and-lineage.test.ts create mode 100644 src/routes/downloads.test.ts create mode 100644 src/routes/lineage/deletion-content.test.ts create mode 100644 src/routes/lineage/deletion-content.ts create mode 100644 src/routes/lineage/deletion.tsx create mode 100644 src/routes/lineage/downloads-content.test.ts create mode 100644 src/routes/lineage/downloads-content.ts create mode 100644 src/routes/lineage/downloads.tsx create mode 100644 src/routes/nessa/deletion-content.test.ts create mode 100644 src/routes/nessa/deletion-content.ts create mode 100644 src/routes/nessa/deletion.tsx create mode 100644 src/server/api/routers/deletion-email.test.ts create mode 100644 src/server/api/routers/deletion-email.ts diff --git a/src/components/DeletionForm.tsx b/src/components/DeletionForm.tsx index 5193944..276022e 100644 --- a/src/components/DeletionForm.tsx +++ b/src/components/DeletionForm.tsx @@ -3,7 +3,32 @@ import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import { Spinner } from "~/components/Spinner"; import { getClientCookie } from "~/lib/cookies.client"; -export default function DeletionForm() { +/** + * Product discriminator forwarded to the generalized + * `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown + * cookie are product-appropriate (task 11). + */ +export type DeletionProduct = "lineage" | "nessa"; + +export interface DeletionFormProps { + /** + * Product whose account is being deleted. Determines the email branding + * AND the cooldown cookie name on the server. Defaults to `"lineage"` + * (the original / legacy flow) for backward compatibility. + */ + product?: DeletionProduct; + /** + * Cooldown cookie name read on mount + written by the server response + * (the mutation sets its own cookie; this is only for the client-side + * countdown). Defaults to the legacy `deletionRequestSent` name so an + * in-flight Lineage cooldown survives the legacy redirect. + */ + cookieName?: string; +} + +export default function DeletionForm(props: DeletionFormProps = {}) { + const product = () => props.product ?? "lineage"; + const cookieName = () => props.cookieName ?? "deletionRequestSent"; const [countDown, setCountDown] = createSignal(0); const [emailSent, setEmailSent] = createSignal(false); const [error, setError] = createSignal(""); @@ -28,7 +53,7 @@ export default function DeletionForm() { }; createEffect(() => { - const timer = getClientCookie("deletionRequestSent"); + const timer = getClientCookie(cookieName()); if (timer) { timerInterval = setInterval( () => calcRemainder(timer), @@ -60,14 +85,14 @@ export default function DeletionForm() { const response = await fetch("/api/trpc/misc.sendDeletionRequestEmail", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }) + body: JSON.stringify({ email, product: product() }) }); const result = await response.json(); if (response.ok && result.result?.data?.message === "request sent") { setEmailSent(true); - const timer = getClientCookie("deletionRequestSent"); + const timer = getClientCookie(cookieName()); if (timer) { if (timerInterval) { clearInterval(timerInterval); diff --git a/src/routes/deletion/life-and-lineage.test.ts b/src/routes/deletion/life-and-lineage.test.ts new file mode 100644 index 0000000..bbc49c9 --- /dev/null +++ b/src/routes/deletion/life-and-lineage.test.ts @@ -0,0 +1,46 @@ +/** + * Regression test for the legacy `/deletion/life-and-lineage` route (task 11). + * + * The route was converted from a rendered page into a 308 permanent redirect + * to `lineage.freno.me/deletion`. Because the route file is a SolidStart + * server module, this is a STATIC SOURCE AUDIT (same pattern as the + * `misc.test.ts` regression tests) asserting: + * - The route exports a `GET` handler (API-route redirect, not a page). + * - The response status is 308 (permanent). + * - The `Location` header derives from the centralized + * `LEGACY_DELETION_REDIRECT_TARGET` constant (not a hardcoded literal), so + * the unit test in `deletion-content.test.ts` is the single source of + * truth for the destination. + * - No page component / DeletionForm import remains (the form moved to + * `src/routes/lineage/deletion.tsx`). + */ +import { describe, it, expect } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const SOURCE = readFileSync( + join(import.meta.dir, "life-and-lineage.tsx"), + "utf8" +); + +describe("Legacy /deletion/life-and-lineage — redirect (task 11)", () => { + it("is a GET handler (API-route redirect, not a rendered page)", () => { + expect(SOURCE).toContain("export function GET()"); + expect(SOURCE).not.toContain("export default function"); + }); + + it("responds with a 308 permanent redirect", () => { + expect(SOURCE).toContain("status: 308"); + }); + + it("derives the Location from the centralized constant", () => { + expect(SOURCE).toContain("LEGACY_DELETION_REDIRECT_TARGET"); + // The constant is imported from the lineage deletion-content module. + expect(SOURCE).toContain("~/routes/lineage/deletion-content"); + }); + + it("no longer ships a page component / DeletionForm", () => { + expect(SOURCE).not.toContain("DeletionForm"); + expect(SOURCE).not.toContain("PageHead"); + }); +}); diff --git a/src/routes/deletion/life-and-lineage.tsx b/src/routes/deletion/life-and-lineage.tsx index d8d6015..61df63f 100644 --- a/src/routes/deletion/life-and-lineage.tsx +++ b/src/routes/deletion/life-and-lineage.tsx @@ -1,31 +1,29 @@ -import { PageHead } from "~/components/PageHead"; -import DeletionForm from "~/components/DeletionForm"; +/** + * Legacy Life and Lineage account-deletion route — now a 308 permanent + * redirect to the Lineage subdomain (task 11). + * + * The deletion form has been migrated to `src/routes/lineage/deletion.tsx` + * served at `lineage.freno.me/deletion` (vercel.json host rewrites map the + * subdomain to the `/lineage/*` internal prefix). Keeping this route as a + * permanent (308) server-side redirect — rather than a client `` — + * preserves SEO equity and gives installed / linked / support-emailed URLs a + * stable resolution path to the new home. + * + * Implemented as a SolidStart API route (`GET` handler returning a Response) + * so the redirect happens before any rendering; the route no longer ships a + * page component. The redirect target is centralized in + * `~/routes/lineage/deletion-content.ts` (`LEGACY_DELETION_REDIRECT_TARGET`) + * so the unit test can assert the destination without importing this server + * module. + */ +import { LEGACY_DELETION_REDIRECT_TARGET } from "~/routes/lineage/deletion-content"; -export default function LifeAndLinageDeletionForm() { - return ( - <> - -
-
-
-
- What will happen: -
- Once you send, if a match to the email provided is found in our - system, a 24hr grace period is started where you can request a - cancellation of the account deletion. Once the grace period ends, - the account's entry in our central database will be completely - removed, and your individual database storing your remote saves will - also be deleted. No data related to the account is retained in any - way. -
- - -
-
- - ); +export function GET() { + return new Response(null, { + status: 308, + headers: { + Location: LEGACY_DELETION_REDIRECT_TARGET, + "Cache-Control": "public, max-age=0, must-revalidate" + } + }); } diff --git a/src/routes/downloads.test.ts b/src/routes/downloads.test.ts new file mode 100644 index 0000000..4ebf881 --- /dev/null +++ b/src/routes/downloads.test.ts @@ -0,0 +1,59 @@ +/** + * Regression test for the unified `freno.me/downloads` page (task 11). + * + * Task 11's acceptance criteria require that the unified downloads page is + * UNCHANGED — it keeps listing all five products (InputHalo, Gaze, Life and + * Lineage, Cork, Shapes with Abigail) with the original asset keys + store + * links. Because the page is a SolidJS component (DOM render not configured + * under `bun:test`), this is a STATIC SOURCE AUDIT — the same pattern the + * p8-001 / p8-008 `misc.test.ts` regression tests use. + * + * Audits `src/routes/downloads.tsx` for: + * - All five product labels present (no removal / rename). + * - The Lineage APK asset key (`"lineage"`) is still wired to the download + * button — the per-subdomain `lineage.freno.me/downloads` page MUST serve + * the byte-identical APK, which requires the same S3 asset key. + * - The Life and Lineage App Store link is intact. + * - No accidental deletion of the other products' sections. + */ +import { describe, it, expect } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const SOURCE = readFileSync(join(import.meta.dir, "downloads.tsx"), "utf8"); + +describe("Unified downloads page — product list (regression)", () => { + it("renders all five product sections", () => { + // The unified page is intentionally ordered by date of initial release. + expect(SOURCE).toContain("InputHalo"); + expect(SOURCE).toContain("Gaze"); + expect(SOURCE).toContain("Life and Lineage"); + expect(SOURCE).toContain("Cork"); + expect(SOURCE).toContain("Shapes with Abigail"); + }); + + it("does not trim the Five-products comment / ordering note", () => { + expect(SOURCE).toContain("Ordered by date of initial release"); + }); +}); + +describe("Unified downloads page — Lineage section (byte-identical APK)", () => { + it("still wires the Lineage APK button to the \"lineage\" tRPC asset key", () => { + // Same asset key the per-subdomain lineage/downloads page uses → both + // origins serve the byte-identical S3 object (`Life and Lineage.apk`). + expect(SOURCE).toContain('download("lineage")'); + }); + + it("still links to the Life and Lineage App Store URL", () => { + expect(SOURCE).toContain( + "https://apps.apple.com/us/app/life-and-lineage/id6737252442" + ); + }); + + it("does not redirect Lineage downloads away to the subdomain", () => { + // The unified page keeps an inline APK download — it must NOT delegate to + // lineage.freno.me/downloads (that would be a regression of the unified + // "one page lists everything" UX). + expect(SOURCE).not.toContain("lineage.freno.me"); + }); +}); diff --git a/src/routes/lineage/deletion-content.test.ts b/src/routes/lineage/deletion-content.test.ts new file mode 100644 index 0000000..afaa16d --- /dev/null +++ b/src/routes/lineage/deletion-content.test.ts @@ -0,0 +1,97 @@ +/** + * Unit tests for the Lineage per-subdomain account-deletion page content + * (task 11). + * + * Asserts against pure constants exported from `deletion-content.ts` — no + * solid-js / router / DOM. Covers the task-11 acceptance matrix: + * - Product discriminator is `"lineage"` (selects Lineage-branded email). + * - Cooldown cookie name is the legacy `deletionRequestSent` so an in-flight + * cooldown survives the `/deletion/life-and-lineage` → subdomain redirect. + * - Cookie name matches the server-side `deletionCookieName("lineage")`. + * - Grace-period label + value mirror `LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS`. + * - Legacy redirect target points at the lineage subdomain deletion URL. + */ +import { describe, it, expect } from "bun:test"; +import { + DELETION_PRODUCT_KEY, + DELETION_COOKIE_NAME, + DELETION_GRACE_PERIOD_MS, + DELETION_GRACE_PERIOD_LABEL, + PAGE_META, + LEGACY_DELETION_REDIRECT_TARGET +} from "~/routes/lineage/deletion-content"; +import { + DELETION_PRODUCT_SCHEMA, + deletionCookieName +} from "~/server/api/routers/deletion-email"; + +describe("Lineage deletion — product discriminator", () => { + it("is \"lineage\" (selects Lineage-branded email)", () => { + expect(DELETION_PRODUCT_KEY).toBe("lineage"); + }); + + it("is accepted by the server-side product schema", () => { + expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe( + true + ); + }); +}); + +describe("Lineage deletion — cooldown cookie", () => { + it("uses the legacy cookie name for redirect backward-compat", () => { + // An in-flight cooldown from the old /deletion/life-and-lineage route + // MUST be honored across the 308 redirect — keep the legacy cookie name. + expect(DELETION_COOKIE_NAME).toBe("deletionRequestSent"); + }); + + it("matches the server-side deletionCookieName(\"lineage\")", () => { + expect(DELETION_COOKIE_NAME).toBe( + deletionCookieName(DELETION_PRODUCT_KEY) + ); + }); +}); + +describe("Lineage deletion — grace period", () => { + it("mirrors LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS (24h)", () => { + const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; + expect(DELETION_GRACE_PERIOD_MS).toBe(TWENTY_FOUR_HOURS_MS); + }); + + it("surfaces a human-readable 24-hour label in the copy", () => { + expect(DELETION_GRACE_PERIOD_LABEL).toBe("24-hour"); + }); +}); + +describe("Lineage deletion — PageHead inputs", () => { + it("passes the base title (suffix is appended by PageHead)", () => { + expect(PAGE_META.title).toBe("Account Deletion"); + }); + + it("does not pre-bake the site suffix into the title", () => { + expect(PAGE_META.title).not.toContain("|"); + }); + + it("description mentions the grace period + account data removal", () => { + const desc = PAGE_META.description.toLowerCase(); + expect(desc).toContain("24-hour"); + expect(desc).toContain("life and lineage"); + }); +}); + +describe("Lineage deletion — legacy redirect target", () => { + it("points at the lineage subdomain deletion URL", () => { + expect(LEGACY_DELETION_REDIRECT_TARGET).toBe( + "https://lineage.freno.me/deletion" + ); + }); + + it("is an absolute https URL", () => { + expect(LEGACY_DELETION_REDIRECT_TARGET.startsWith("https://")).toBe(true); + }); + + it("does not reference the legacy /deletion/life-and-lineage path", () => { + expect(LEGACY_DELETION_REDIRECT_TARGET).not.toContain( + "deletion/life-and-lineage" + ); + }); +}); diff --git a/src/routes/lineage/deletion-content.ts b/src/routes/lineage/deletion-content.ts new file mode 100644 index 0000000..9d48196 --- /dev/null +++ b/src/routes/lineage/deletion-content.ts @@ -0,0 +1,71 @@ +/** + * Pure content + metadata for the Lineage per-subdomain account-deletion page + * (task 11). + * + * Imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so the + * constants here can be unit-tested in `bun:test` without spinning up the + * router / MetaProvider, mirroring the `landing-content.ts` / + * `downloads-content.ts` pattern. + * + * Cross-task contracts encoded here: + * - `DELETION_PRODUCT_KEY` is the `product` discriminator passed to the + * generalized `misc.sendDeletionRequestEmail` mutation + * (`src/server/api/routers/misc.ts`) so the email copy + cooldown cookie + * are Lineage-branded. The legacy mutation default is also `"lineage"`, + * so the migrated page is backward-compatible with any in-flight cooldown. + * - `DELETION_COOKIE_NAME` is the cooldown cookie read/written by + * `DeletionForm` — kept as the original `deletionRequestSent` name so + * installed cooldown state from the legacy `/deletion/life-and-lineage` + * route is honored across the 308 redirect (no forced re-send). + * - `DELETION_GRACE_PERIOD_MS` mirrors `LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS` + * (24h) — the window during which a user may email michael@freno.me to + * cancel the deletion before the central account row + per-user Turso DB + * are dropped. Surfaced here as a pure constant so the page copy + tests + * can assert the grace window without importing the server-side config + * module (which validates ~30 secrets at import time). + * - `LEGACY_DELETION_REDIRECT_TARGET` is the canonical absolute URL the + * legacy `/deletion/life-and-lineage` route 308-redirects to. + */ +import type { PageHeadProps } from "~/components/page-head-meta"; + +/** + * Product discriminator for the generalized `sendDeletionRequestEmail` + * mutation. The Lineage flow is the original / default product. + */ +export const DELETION_PRODUCT_KEY = "lineage" as const; + +/** + * Cooldown cookie name for the Lineage deletion request. + * + * Kept identical to the legacy cookie so an in-flight cooldown survives the + * `/deletion/life-and-lineage` → `lineage.freno.me/deletion` redirect. + */ +export const DELETION_COOKIE_NAME = "deletionRequestSent"; + +/** + * Grace period (ms) during which a Lineage account deletion can be cancelled + * by emailing michael@freno.me. Mirrors + * `LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS` (24h). + */ +export const DELETION_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000; + +/** + * Human-readable grace-window copy interpolated into the deletion page body. + */ +export const DELETION_GRACE_PERIOD_LABEL = "24-hour"; + +/** Base page title (site suffix appended by PageHead). */ +export const PAGE_META: PageHeadProps = { + title: "Account Deletion", + description: + "Request account deletion for Life and Lineage. All account data and remote saves are removed after a 24-hour grace period." +}; + +/** + * Canonical absolute URL the legacy `/deletion/life-and-lineage` route + * 308-redirects to (task 11). Kept here so tests can assert the redirect + * target without importing the route module (which would pull the server + * runtime). + */ +export const LEGACY_DELETION_REDIRECT_TARGET = + "https://lineage.freno.me/deletion"; diff --git a/src/routes/lineage/deletion.tsx b/src/routes/lineage/deletion.tsx new file mode 100644 index 0000000..9d9f996 --- /dev/null +++ b/src/routes/lineage/deletion.tsx @@ -0,0 +1,71 @@ +/** + * Lineage per-subdomain account-deletion page — `lineage.freno.me/deletion` + * (task 11). + * + * Migrated from `src/routes/deletion/life-and-lineage.tsx` (which is now a + * 308 redirect to this public URL — see `LEGACY_DELETION_REDIRECT_TARGET`). + * + * Served at the public browser path `/deletion` (vercel.json host rewrites + * `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the + * browser URL clean — task 02 canonical rule). The nav-config "Account + * Deletion" entry points at this path (task 04). + * + * Deletion flow: + * - Reuses the shared `DeletionForm` component, now generalized to forward + * a `product` discriminator to the `misc.sendDeletionRequestEmail` + * mutation. For Lineage we pass `product="lineage"` + the legacy + * cooldown cookie name (`deletionRequestSent`) so an in-flight cooldown + * from the old `/deletion/life-and-lineage` route is honored across the + * 308 redirect (no forced re-send). + * - On the server, the mutation sends a Lineage-branded email to + * michael@freno.me + the requester; Mike then manually drops the central + * account row + the user's per-user Turso remote-save DB after the 24h + * grace window (`LINEAGE_CONFIG.DELETION_GRACE_PERIOD_MS`). This is the + * SAME flow the legacy page used — only the URL + branding moved. + * + * Site-awareness: + * - `` reads `useSite()` → lineage title suffix + canonical are + * derived automatically (task 02). + * - No auth — the deletion request is email-based (the requester may be + * locked out of their account), NOT an authenticated self-delete. + * + * Acceptance: `lineage.localhost:3000/deletion` renders the deletion form; + * the form posts to the correct tRPC mutation (Lineage-branded email). + */ +import { PageHead } from "~/components/PageHead"; +import DeletionForm from "~/components/DeletionForm"; +import { + DELETION_PRODUCT_KEY, + DELETION_COOKIE_NAME, + DELETION_GRACE_PERIOD_LABEL, + PAGE_META +} from "~/routes/lineage/deletion-content"; + +export default function LineageDeletionPage() { + return ( + <> + +
+
+
+
+ What will happen: +
+ Once you send, if a match to the email provided is found in our + system, a {DELETION_GRACE_PERIOD_LABEL} grace period is started + where you can request a cancellation of the account deletion. Once + the grace period ends, the account's entry in our central + database will be completely removed, and your individual database + storing your remote saves will also be deleted. No data related to + the account is retained in any way. +
+ + +
+
+ + ); +} diff --git a/src/routes/lineage/downloads-content.test.ts b/src/routes/lineage/downloads-content.test.ts new file mode 100644 index 0000000..24549ea --- /dev/null +++ b/src/routes/lineage/downloads-content.test.ts @@ -0,0 +1,92 @@ +/** + * Unit tests for the Lineage per-subdomain downloads page content (task 11). + * + * Mirrors the `landing-content.test.ts` pattern: assert against pure + * constants exported from `downloads-content.ts` (no solid-js / router / + * DOM). This covers the task-11 acceptance matrix that's structurally + * verifiable without rendering: + * - APK asset key is `"lineage"` (the tRPC key the downloads router maps to + * `Life and Lineage.apk`) — must match the unified downloads page's key + * so the APK is byte-identical from both origins. + * - App Store link is the canonical Life and Lineage App Store URL. + * - PageHead base title + description (suffix appended by PageHead). + * - Back-to-home link is the subdomain-relative public browser path. + */ +import { describe, it, expect } from "bun:test"; +import { + LINEAGE_DOWNLOAD_ASSET, + LINEAGE_APK_BUTTON_LABEL, + LINEAGE_APP_STORE_URL, + LINEAGE_HOME_HREF, + PAGE_META +} from "~/routes/lineage/downloads-content"; +import { APP_STORE_URL as LANDING_APP_STORE_URL } from "~/routes/lineage/landing-content"; + +describe("Lineage downloads — APK asset", () => { + it("uses the tRPC key the downloads router maps to the lineage APK", () => { + // src/server/api/routers/downloads.ts: assets["lineage"] = "Life and Lineage.apk" + expect(LINEAGE_DOWNLOAD_ASSET).toBe("lineage"); + }); + + it("matches the asset key used by the unified freno.me/downloads page", () => { + // Regression guard: the unified page calls download("lineage") for the + // same S3 object — both origins must serve the identical APK. + expect(LINEAGE_DOWNLOAD_ASSET).toBe("lineage"); + }); +}); + +describe("Lineage downloads — button label", () => { + it("surfaces the APK file extension in the CTA", () => { + expect(LINEAGE_APK_BUTTON_LABEL).toBe("download.apk"); + }); +}); + +describe("Lineage downloads — App Store link", () => { + it("matches the canonical Life and Lineage App Store URL", () => { + expect(LINEAGE_APP_STORE_URL).toBe( + "https://apps.apple.com/us/app/life-and-lineage/id6737252442" + ); + }); + + it("is an absolute https URL", () => { + expect(LINEAGE_APP_STORE_URL.startsWith("https://")).toBe(true); + }); + + it("matches the App Store URL surfaced on the landing page", () => { + // landing-content.ts exports APP_STORE_URL — same canonical link. + expect(LINEAGE_APP_STORE_URL).toBe(LANDING_APP_STORE_URL); + }); +}); + +describe("Lineage downloads — back-to-home link", () => { + it("targets the subdomain-relative public browser path", () => { + // NOT `/lineage/` (the internal vercel-rewrite prefix) — vercel rewrites + // `lineage.freno.me/` → `/lineage/` while leaving the browser URL clean. + expect(LINEAGE_HOME_HREF).toBe("/"); + }); + + it("does not leak the internal route prefix", () => { + expect(LINEAGE_HOME_HREF).not.toContain("/lineage"); + }); +}); + +describe("Lineage downloads — PageHead inputs", () => { + it("passes the base title (suffix is appended by PageHead)", () => { + expect(PAGE_META.title).toBe("Downloads"); + }); + + it("does not pre-bake the site suffix into the title", () => { + expect(PAGE_META.title).not.toContain("|"); + }); + + it("carries a non-empty description", () => { + expect(PAGE_META.description.length).toBeGreaterThan(0); + }); + + it("description mentions the product + both store fronts", () => { + const desc = PAGE_META.description.toLowerCase(); + expect(desc).toContain("life and lineage"); + expect(desc).toContain("apk"); + expect(desc).toContain("app store"); + }); +}); diff --git a/src/routes/lineage/downloads-content.ts b/src/routes/lineage/downloads-content.ts new file mode 100644 index 0000000..f82cb9a --- /dev/null +++ b/src/routes/lineage/downloads-content.ts @@ -0,0 +1,67 @@ +/** + * Pure content + metadata for the Lineage per-subdomain downloads page + * (task 11). + * + * Mirrors the `landing-content.ts` / `page-head-meta.ts` / `nav-config.ts` + * pattern: imports NOTHING from solid-js / @solidjs/router / @solidjs/meta so + * the constants here can be unit-tested in `bun:test` without spinning up the + * router / MetaProvider — and so the acceptance matrix (asset key, App Store + * URL, PageHead inputs) is asserted against structurally without a DOM render. + * + * The render layer (`./downloads.tsx`) is a thin JSX consumer of these + * values; keeping them externalized means changes to the download target / + * store link surface as test failures rather than silent regressions. + * + * Cross-task contracts encoded here: + * - `LINEAGE_DOWNLOAD_ASSET` is the tRPC `downloads.getDownloadUrl` asset key + * (`"lineage"`) → resolves to `Life and Lineage.apk` in + * `src/server/api/routers/downloads.ts`. It MUST match the key used by the + * unified `freno.me/downloads` page so the APK served is byte-identical + * from both origins (single S3 source of truth). + * - `LINEAGE_APP_STORE_URL` is the canonical App Store link — kept identical + * to the value surfaced on the landing page (`landing-content.ts`) and the + * unified downloads page, so the store front is consistent across origins. + * - `LINEAGE_DOWNLOADS_META` is consumed verbatim by ``; the + * per-site title suffix (` | Life and Lineage`) is appended automatically + * by `resolvePageHeadMeta` (task 02), so `title` here is the BASE title + * only — do NOT include the suffix. + */ +import type { PageHeadProps } from "~/components/page-head-meta"; + +/** + * tRPC `downloads.getDownloadUrl` asset key for the Lineage Android APK. + * + * Maps to `Life and Lineage.apk` in the downloads router's `assets` table. + * Shared with the unified `freno.me/downloads` page (no separate asset path). + */ +export const LINEAGE_DOWNLOAD_ASSET = "lineage" as const; + +/** + * Android download CTA copy. + * + * Kept in sync with the unified downloads page's Lineage section so the + * button label is consistent across origins. + */ +export const LINEAGE_APK_BUTTON_LABEL = "download.apk"; + +/** + * Apple App Store link — identical to `APP_STORE_URL` in `landing-content.ts` + * (single source of truth: the canonical Life and Lineage App Store URL). + */ +export const LINEAGE_APP_STORE_URL = + "https://apps.apple.com/us/app/life-and-lineage/id6737252442"; + +/** + * Public browser path back to the Lineage landing page (subdomain-relative). + * + * vercel.json rewrites `lineage.freno.me/` → the internal `/lineage/` route + * prefix while leaving the browser URL clean (task 02 canonical rule). + */ +export const LINEAGE_HOME_HREF = "/"; + +/** Base page title (site suffix appended by PageHead). */ +export const PAGE_META: PageHeadProps = { + title: "Downloads", + description: + "Download Life and Lineage — Android APK or on the App Store for iOS. A dark fantasy adventure mobile game." +}; diff --git a/src/routes/lineage/downloads.tsx b/src/routes/lineage/downloads.tsx new file mode 100644 index 0000000..cf77924 --- /dev/null +++ b/src/routes/lineage/downloads.tsx @@ -0,0 +1,146 @@ +/** + * Lineage per-subdomain downloads page — `lineage.freno.me/downloads` + * (task 11). + * + * Served at the public browser path `/downloads` (vercel.json host rewrites + * `lineage.freno.me/*` → the internal `/lineage/*` route prefix, leaving the + * browser URL clean — task 02 canonical rule). The nav-config "Downloads" + * entry and the landing page's Google Play badge both point at this path + * (tasks 04 + 08). + * + * Download surface (mirrors the Lineage section of the unified + * `freno.me/downloads` page, byte-identical asset source): + * - Android APK via tRPC `downloads.getDownloadUrl({ asset_name: "lineage" })` + * → S3 signed URL for `Life and Lineage.apk`. Reuses the shared + * `downloadAsset` helper (task 05) so the click → redirect → S3 flow is a + * single code path shared with the Gaze landing page. + * - iOS App Store link (`LINEAGE_APP_STORE_URL`) — absolute external URL, + * identical to the link surfaced on the landing page + unified downloads. + * + * Site-awareness: + * - `` reads `useSite()` → the lineage `titleSuffix` + * (` | Life and Lineage`) + canonical `https://lineage.freno.me/downloads` + * are derived automatically (task 02); we pass only the base title here. + * - No auth — Lineage's mobile JWT (`LINEAGE_JWT_SECRET`) is for the mobile + * app's API calls, not the web downloads page. + * + * Acceptance: `lineage.localhost:3000/downloads` renders APK + App Store; + * clicking APK redirects to an S3 signed URL; the unified downloads page is + * unchanged (regression check). + */ +import { A } from "@solidjs/router"; +import { createSignal, onMount, onCleanup } from "solid-js"; +import { PageHead } from "~/components/PageHead"; +import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore"; +import Button from "~/components/ui/Button"; +import { glitchText } from "~/lib/client-utils"; +import { downloadAsset } from "~/lib/download-asset"; +import { + LINEAGE_DOWNLOAD_ASSET, + LINEAGE_APK_BUTTON_LABEL, + LINEAGE_APP_STORE_URL, + LINEAGE_HOME_HREF, + PAGE_META +} from "~/routes/lineage/downloads-content"; + +export default function LineageDownloadsPage() { + const [title, setTitle] = createSignal("Life and Lineage"); + const [loading, setLoading] = createSignal(false); + + const handleDownload = () => { + if (loading()) return; + setLoading(true); + import("~/lib/api") + .then(({ api }) => + downloadAsset({ + api, + assetName: LINEAGE_DOWNLOAD_ASSET, + onError: (error) => { + console.error("Lineage download error:", error); + alert("Failed to initiate download. Please try again."); + } + }) + ) + .finally(() => setLoading(false)); + }; + + onMount(() => { + const interval = glitchText(title(), setTitle); + onCleanup(() => clearInterval(interval)); + }); + + return ( + <> + + +
+ {/* Subtle scanline effect — consistent with the unified downloads page. */} +
+
+
+ +
+
+

+ {">"} {title()} +

+ +
+ {/* Android APK via tRPC → S3 signed URL */} +
+ + platform: android + + + + # android build not optimized + +
+ + {/* iOS App Store */} +
+ + platform: ios + + + + +
+
+
+ + {/* Secondary CTA → landing page */} +

+ + ← back to Life and Lineage + +

+
+
+ + ); +} diff --git a/src/routes/nessa/deletion-content.test.ts b/src/routes/nessa/deletion-content.test.ts new file mode 100644 index 0000000..ea7222a --- /dev/null +++ b/src/routes/nessa/deletion-content.test.ts @@ -0,0 +1,84 @@ +/** + * Unit tests for the Nessa per-subdomain account-deletion page content + * (task 11). + * + * Asserts against pure constants exported from `deletion-content.ts` — no + * solid-js / router / DOM. Covers the task-11 acceptance matrix for the + * Nessa deletion flow: + * - Product discriminator is `"nessa"` (selects Nessa-branded email). + * - Cooldown cookie name is Nessa-specific + matches the server-side + * `deletionCookieName("nessa")`. + * - PageHead base title + description. + * - (Assessment rationale documented in `deletion-content.ts`.) + */ +import { describe, it, expect } from "bun:test"; +import { + DELETION_PRODUCT_KEY, + DELETION_COOKIE_NAME, + DELETION_GRACE_PERIOD_LABEL, + PAGE_META +} from "~/routes/nessa/deletion-content"; +import { + DELETION_PRODUCT_SCHEMA, + deletionCookieName +} from "~/server/api/routers/deletion-email"; + +describe("Nessa deletion — assessment outcome", () => { + it("defines a product discriminator (deletion flow IS implemented)", () => { + // Nessa stores user data (nessa.ts: users, workouts, workoutPlans, … + + // nessa-community.ts: clubs, clubMemberships). Per task 11 spec step 5, + // a deletion flow IS needed — this page provides it. + expect(typeof DELETION_PRODUCT_KEY).toBe("string"); + }); +}); + +describe("Nessa deletion — product discriminator", () => { + it("is \"nessa\" (selects Nessa-branded email)", () => { + expect(DELETION_PRODUCT_KEY).toBe("nessa"); + }); + + it("is accepted by the server-side product schema", () => { + expect(DELETION_PRODUCT_SCHEMA.safeParse(DELETION_PRODUCT_KEY).success).toBe( + true + ); + }); +}); + +describe("Nessa deletion — cooldown cookie", () => { + it("uses a Nessa-specific cookie name (independent of Lineage cooldown)", () => { + expect(DELETION_COOKIE_NAME).toBe("nessaDeletionRequestSent"); + }); + + it("matches the server-side deletionCookieName(\"nessa\")", () => { + expect(DELETION_COOKIE_NAME).toBe( + deletionCookieName(DELETION_PRODUCT_KEY) + ); + }); + + it("does NOT collide with the Lineage cooldown cookie", () => { + expect(DELETION_COOKIE_NAME).not.toBe("deletionRequestSent"); + }); +}); + +describe("Nessa deletion — grace period label", () => { + it("surfaces a human-readable 24-hour label in the copy", () => { + expect(DELETION_GRACE_PERIOD_LABEL).toBe("24-hour"); + }); +}); + +describe("Nessa deletion — PageHead inputs", () => { + it("passes the base title (suffix is appended by PageHead)", () => { + expect(PAGE_META.title).toBe("Account Deletion"); + }); + + it("does not pre-bake the site suffix into the title", () => { + expect(PAGE_META.title).not.toContain("|"); + }); + + it("description mentions Nessa + data removal + grace period", () => { + const desc = PAGE_META.description.toLowerCase(); + expect(desc).toContain("nessa"); + expect(desc).toContain("removed"); + expect(desc).toContain("24-hour"); + }); +}); diff --git a/src/routes/nessa/deletion-content.ts b/src/routes/nessa/deletion-content.ts new file mode 100644 index 0000000..54c1e11 --- /dev/null +++ b/src/routes/nessa/deletion-content.ts @@ -0,0 +1,53 @@ +/** + * Pure content + metadata for the Nessa per-subdomain account-deletion page + * (task 11). + * + * Mirrors the `lineage/deletion-content.ts` pattern: imports NOTHING from + * solid-js / @solidjs/router / @solidjs/meta so the constants here can be + * unit-tested in `bun:test` without spinning up the router / MetaProvider. + * + * Nessa deletion assessment (see task 11 spec, step 5): + * - Nessa DOES store user data. `src/server/api/routers/nessa.ts` defines + * per-user tables (`users`, `authProviders`, `workouts`, `workoutPlans`, + * `planExercises`, `planSets`, `routePoints`, `exerciseLibrary`) backed + * by a per-user Turso DB, and `nessa-community.ts` defines shared + * community tables (`clubs`, `clubMemberships`, …) keyed by `userId` / + * `ownerId`. Auth is Clerk. → A deletion flow IS needed. + * - Implemented here as the SAME email-request pattern Lineage uses: the + * requester submits their email via `DeletionForm`; the generalized + * `misc.sendDeletionRequestEmail` mutation sends a Nessa-branded email + * to michael@freno.me + the requester; Mike then manually drops the + * Nessa `users` row (+ cascades), the per-user Turso DB, and the user's + * community memberships within the 24h grace window. An authenticated + * self-delete via `nessa.deleteUser` + the Clerk Users API remains a + * follow-up (it requires Clerk backend secret wiring that is out of + * scope for the subdomain-routing feature); the email-request flow gives + * users a real, immediate deletion path today. + * + * Cross-task contracts: + * - `DELETION_PRODUCT_KEY = "nessa"` selects Nessa branding + the + * `nessaDeletionRequestSent` cooldown cookie (server-side + * `deletionCookieName("nessa")`). + * - `DELETION_COOKIE_NAME` MUST match `deletionCookieName("nessa")` so the + * client countdown reads the cookie the server actually sets. + */ +import type { PageHeadProps } from "~/components/page-head-meta"; + +/** Product discriminator forwarded to `misc.sendDeletionRequestEmail`. */ +export const DELETION_PRODUCT_KEY = "nessa" as const; + +/** + * Cooldown cookie name — MUST match `deletionCookieName("nessa")` on the + * server (`nessaDeletionRequestSent`). + */ +export const DELETION_COOKIE_NAME = "nessaDeletionRequestSent"; + +/** Human-readable grace-window copy interpolated into the page body. */ +export const DELETION_GRACE_PERIOD_LABEL = "24-hour"; + +/** Base page title (site suffix appended by PageHead). */ +export const PAGE_META: PageHeadProps = { + title: "Account Deletion", + description: + "Request account deletion for Nessa. Your Nessa account, workout data, and community memberships are removed after a 24-hour grace period." +}; diff --git a/src/routes/nessa/deletion.tsx b/src/routes/nessa/deletion.tsx new file mode 100644 index 0000000..0dd6c7d --- /dev/null +++ b/src/routes/nessa/deletion.tsx @@ -0,0 +1,61 @@ +/** + * Nessa per-subdomain account-deletion page — `nessa.freno.me/deletion` + * (task 11). + * + * Served at the public browser path `/deletion` (vercel.json host rewrites + * `nessa.freno.me/*` → the internal `/nessa/*` route prefix, leaving the + * browser URL clean — task 02 canonical rule). + * + * Nessa deletion assessment (see `./deletion-content.ts` for the full + * rationale): Nessa stores user data (`users`, `workouts`, `workoutPlans`, + * `exerciseLibrary`, community memberships) in a per-user Turso DB + + * shared community tables, authenticated via Clerk. → A deletion flow IS + * needed; this page provides it via the same email-request pattern Lineage + * uses, reusing the shared `DeletionForm` with `product="nessa"` so the + * generalized `misc.sendDeletionRequestEmail` mutation sends Nessa-branded + * email + writes a Nessa-specific cooldown cookie. + * + * Auth: NO freno.me web-auth — Nessa authenticates via Clerk; the deletion + * request is email-based (the requester may be locked out of their Clerk + * session), NOT an authenticated self-delete. The nav-config does NOT list + * a Nessa deletion link by default, so this page is reachable by direct URL + * + from the Nessa privacy policy (task-provided). + * + * Acceptance: `nessa.localhost:3000/deletion` renders the deletion form. + */ +import { PageHead } from "~/components/PageHead"; +import DeletionForm from "~/components/DeletionForm"; +import { + DELETION_PRODUCT_KEY, + DELETION_COOKIE_NAME, + DELETION_GRACE_PERIOD_LABEL, + PAGE_META +} from "~/routes/nessa/deletion-content"; + +export default function NessaDeletionPage() { + return ( + <> + +
+
+
+
+ What will happen: +
+ Once you send, if a match to the email provided is found in our + system, a {DELETION_GRACE_PERIOD_LABEL} grace period is started + where you can request a cancellation of the account deletion. Once + the grace period ends, your Nessa account entry, your workout and + plan data, and your community memberships will be completely + removed. No data related to the account is retained in any way. +
+ + +
+
+ + ); +} diff --git a/src/routes/privacy-policy/life-and-lineage.tsx b/src/routes/privacy-policy/life-and-lineage.tsx index 6fb7e60..81a9ab6 100644 --- a/src/routes/privacy-policy/life-and-lineage.tsx +++ b/src/routes/privacy-policy/life-and-lineage.tsx @@ -36,7 +36,7 @@ export default function PrivacyPolicy() {
(b) Data Removal:
Users can request the removal of all information related to them by visiting{" "} this page diff --git a/src/server/api/routers/deletion-email.test.ts b/src/server/api/routers/deletion-email.test.ts new file mode 100644 index 0000000..841f2c2 --- /dev/null +++ b/src/server/api/routers/deletion-email.test.ts @@ -0,0 +1,121 @@ +/** + * Unit tests for the generalized account-deletion-request email helpers + * (task 11). + * + * These are the pure, env-free helpers consumed by the + * `misc.sendDeletionRequestEmail` tRPC mutation (re-exported from `misc.ts`). + * Kept in a separate module so they can be exercised in `bun:test` without a + * populated `.env` (which `~/env/server` requires at import time — un-runnable + * in this worktree). + * + * Coverage: + * - `DELETION_PRODUCT_SCHEMA` accepts the two known products + rejects others. + * - `deletionCookieName` returns per-product distinct names; Lineage keeps + * the legacy `deletionRequestSent` for redirect backward-compat. + * - `deletionEmailContent` produces product-branded subject + operator + + * user HTML bodies; the requester email appears in the operator body and + * the account email appears in the user body; the 24h cancellation + * window instructions are present in the user body. + */ +import { describe, it, expect } from "bun:test"; +import { + DELETION_PRODUCT_SCHEMA, + deletionCookieName, + deletionEmailContent +} from "~/server/api/routers/deletion-email"; + +describe("DELETION_PRODUCT_SCHEMA", () => { + it("accepts \"lineage\"", () => { + expect(DELETION_PRODUCT_SCHEMA.safeParse("lineage").success).toBe(true); + }); + + it("accepts \"nessa\"", () => { + expect(DELETION_PRODUCT_SCHEMA.safeParse("nessa").success).toBe(true); + }); + + it("rejects unknown products", () => { + expect(DELETION_PRODUCT_SCHEMA.safeParse("gaze").success).toBe(false); + expect(DELETION_PRODUCT_SCHEMA.safeParse("").success).toBe(false); + expect(DELETION_PRODUCT_SCHEMA.safeParse(undefined).success).toBe(false); + }); +}); + +describe("deletionCookieName", () => { + it("returns the legacy name for lineage (redirect backward-compat)", () => { + expect(deletionCookieName("lineage")).toBe("deletionRequestSent"); + }); + + it("returns a Nessa-specific name for nessa", () => { + expect(deletionCookieName("nessa")).toBe("nessaDeletionRequestSent"); + }); + + it("returns distinct names per product", () => { + expect(deletionCookieName("lineage")).not.toBe( + deletionCookieName("nessa") + ); + }); +}); + +describe("deletionEmailContent — Lineage", () => { + const email = "player@example.com"; + const content = deletionEmailContent("lineage", email); + + it("uses the Lineage-branded subject", () => { + expect(content.subject).toBe("Life and Lineage Acct Deletion"); + }); + + it("operator body identifies the request + requester email", () => { + expect(content.operatorHtml).toContain("Life and Lineage Account Deletion"); + expect(content.operatorHtml).toContain(email); + }); + + it("user body identifies the account to delete + 24h cancellation instructions", () => { + expect(content.userHtml).toContain(email); + expect(content.userHtml).toContain("Account to delete"); + expect(content.userHtml).toContain("michael@freno.me"); + expect(content.userHtml).toContain("24hrs"); + }); +}); + +describe("deletionEmailContent — Nessa", () => { + const email = "member@example.com"; + const content = deletionEmailContent("nessa", email); + + it("uses the Nessa-branded subject", () => { + expect(content.subject).toBe("Nessa Acct Deletion"); + }); + + it("operator body identifies the Nessa request + requester email", () => { + expect(content.operatorHtml).toContain("Nessa Account Deletion"); + expect(content.operatorHtml).toContain(email); + }); + + it("user body identifies the account + mentions Nessa-specific cleanup", () => { + expect(content.userHtml).toContain(email); + expect(content.userHtml).toContain("Account to delete"); + expect(content.userHtml).toContain("michael@freno.me"); + expect(content.userHtml).toContain("24hrs"); + // Nessa-specific cleanup scope surfaced in the user-facing copy. + expect(content.userHtml.toLowerCase()).toContain("memberships"); + }); +}); + +describe("deletionEmailContent — branding isolation", () => { + const email = "x@example.com"; + const lineage = deletionEmailContent("lineage", email); + const nessa = deletionEmailContent("nessa", email); + + it("subjects differ per product", () => { + expect(lineage.subject).not.toBe(nessa.subject); + }); + + it("Nessa body does not leak Lineage branding", () => { + expect(nessa.userHtml).not.toContain("Life and Lineage"); + expect(nessa.operatorHtml).not.toContain("Life and Lineage"); + }); + + it("Lineage body does not leak Nessa branding", () => { + expect(lineage.userHtml).not.toContain("Nessa"); + expect(lineage.operatorHtml).not.toContain("Nessa"); + }); +}); diff --git a/src/server/api/routers/deletion-email.ts b/src/server/api/routers/deletion-email.ts new file mode 100644 index 0000000..75ecb65 --- /dev/null +++ b/src/server/api/routers/deletion-email.ts @@ -0,0 +1,76 @@ +/** + * Pure helpers for the generalized account-deletion-request email flow + * (task 11). + * + * Extracted from `src/server/api/routers/misc.ts` so they can be unit-tested + * in `bun:test` WITHOUT importing `~/env/server` (which validates ~30 secrets + * at import time and is therefore un-runnable in a worktree without a + * populated `.env`). This mirrors the `page-head-meta.ts` / `nav-config.ts` + * testability pattern. + * + * `misc.ts` re-exports these for convenience; the deletion tRPC mutation + * consumes them directly. + */ +import { z } from "zod"; + +/** + * Product whose account is being deleted. Drives email branding + the + * cooldown cookie name so per-product cooldowns don't interfere. + */ +export const DELETION_PRODUCT_SCHEMA = z.enum(["lineage", "nessa"]); +export type DeletionProduct = z.infer; + +/** + * Cooldown cookie name for a given product. Lineage keeps the legacy + * `deletionRequestSent` name so an in-flight cooldown from the old + * `/deletion/life-and-lineage` route is honored across the 308 redirect + * (no forced re-send). Nessa uses a distinct name so its cooldown is + * independent. + */ +export function deletionCookieName(product: DeletionProduct): string { + return product === "nessa" + ? "nessaDeletionRequestSent" + : "deletionRequestSent"; +} + +/** Branded copy for the deletion-request emails (operator + user-facing). */ +export interface DeletionEmailContent { + /** Email subject line (shared by operator + user emails). */ + subject: string; + /** HTML body sent to michael@freno.me (the operator). */ + operatorHtml: string; + /** HTML body sent to the requester (the user). */ + userHtml: string; +} + +/** + * Build the product-branded deletion-request email content. + * + * The operator email identifies the request name + requester email; the user + * email identifies the account being deleted + the 24h cancellation window. + * The `product` discriminator switches branding between Lineage (the original + * flow) and Nessa (task 11 — Nessa stores user data in its own Turso DB). + * + * `email` is interpolated verbatim into the HTML bodies. It has already been + * validated as a well-formed email by the tRPC input schema, and Sendinblue + * renders HTML bodies, so the value is not re-escaped here — matching the + * original Lineage implementation's behavior to avoid regressing the existing + * flow's email formatting. + */ +export function deletionEmailContent( + product: DeletionProduct, + email: string +): DeletionEmailContent { + if (product === "nessa") { + return { + subject: "Nessa Acct Deletion", + operatorHtml: `
Request Name: Nessa Account Deletion
Request Email: ${email}
`, + userHtml: `
Request Name: Nessa Account Deletion
Account to delete: ${email}
You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation". Your Nessa account row, workout / plan data, and community memberships will be removed.
` + }; + } + return { + subject: "Life and Lineage Acct Deletion", + operatorHtml: `
Request Name: Life and Lineage Account Deletion
Request Email: ${email}
`, + userHtml: `
Request Name: Life and Lineage Account Deletion
Account to delete: ${email}
You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation"
` + }; +} diff --git a/src/server/api/routers/misc.ts b/src/server/api/routers/misc.ts index 4af6730..efda53e 100644 --- a/src/server/api/routers/misc.ts +++ b/src/server/api/routers/misc.ts @@ -52,6 +52,24 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void { }); } } + +// ============================================================ +// Account-deletion request email (task 11 — product-aware) +// ============================================================ +// +// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit- +// tested in `bun:test` without a populated `.env`. Re-exported here for the +// tRPC mutation below + for callers that already import from `misc`. +export { + DELETION_PRODUCT_SCHEMA, + deletionCookieName, + deletionEmailContent +} from "./deletion-email"; +export type { + DeletionProduct, + DeletionEmailContent +} from "./deletion-email"; + const assets: Record = { "shapes-with-abigail": "shapes-with-abigail.apk", "magic-delve": "magic-delve.apk", @@ -453,9 +471,21 @@ export const miscRouter = createTRPCRouter({ }), sendDeletionRequestEmail: csrfProtectedProcedure - .input(z.object({ email: z.string().email() })) + .input( + z.object({ + email: z.string().email(), + /** Product discriminator (task 11) — defaults to "lineage" for backward compat. */ + product: DELETION_PRODUCT_SCHEMA.default("lineage") + }) + ) .mutation(async ({ input }) => { - const deletionExp = getCookie("deletionRequestSent"); + const cookieName = deletionCookieName(input.product); + const { subject, operatorHtml, userHtml } = deletionEmailContent( + input.product, + input.email + ); + + const deletionExp = getCookie(cookieName); let remaining = 0; if (deletionExp) { @@ -479,8 +509,8 @@ export const miscRouter = createTRPCRouter({ email: "michael@freno.me" }, to: [{ email: "michael@freno.me" }], - htmlContent: `
Request Name: Life and Lineage Account Deletion
Request Email: ${input.email}
`, - subject: "Life and Lineage Acct Deletion" + htmlContent: operatorHtml, + subject }; const sendinblueUserData = { @@ -489,8 +519,8 @@ export const miscRouter = createTRPCRouter({ email: "michael@freno.me" }, to: [{ email: input.email }], - htmlContent: `
Request Name: Life and Lineage Account Deletion
Account to delete: ${input.email}
You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation"
`, - subject: "Life and Lineage Acct Deletion" + htmlContent: userHtml, + subject }; try { @@ -538,7 +568,7 @@ export const miscRouter = createTRPCRouter({ ]); const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS); - setCookie("deletionRequestSent", exp.toUTCString(), { + setCookie(cookieName, exp.toUTCString(), { expires: exp, path: "/" });