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:
121
src/server/api/routers/deletion-email.test.ts
Normal file
121
src/server/api/routers/deletion-email.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
76
src/server/api/routers/deletion-email.ts
Normal file
76
src/server/api/routers/deletion-email.ts
Normal 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>`
|
||||
};
|
||||
}
|
||||
@@ -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: "/"
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user