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
This commit is contained in:
2026-07-23 12:32:14 -04:00
parent 5705c5add7
commit 59d129b46b
17 changed files with 1138 additions and 41 deletions

View File

@@ -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");
});
});

View File

@@ -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<typeof DELETION_PRODUCT_SCHEMA>;
/**
* 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: `<html><head></head><body><div>Request Name: Nessa Account Deletion</div><div>Request Email: ${email}</div></body></html>`,
userHtml: `<html><head></head><body><div>Request Name: Nessa Account Deletion</div><div>Account to delete: ${email}</div><div>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.</div></body></html>`
};
}
return {
subject: "Life and Lineage Acct Deletion",
operatorHtml: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Request Email: ${email}</div></body></html>`,
userHtml: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Account to delete: ${email}</div><div>You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation"</div></body></html>`
};
}

View File

@@ -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<string, string> = {
"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: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Request Email: ${input.email}</div></body></html>`,
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: `<html><head></head><body><div>Request Name: Life and Lineage Account Deletion</div><div>Account to delete: ${input.email}</div><div>You can email michael@freno.me in the next 24hrs to cancel the deletion, email with subject line "Account Deletion Cancellation"</div></body></html>`,
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: "/"
});