Merge branch 'downloads-and-deletion-flows' (task 11)
# Conflicts: # src/routes/privacy-policy/life-and-lineage.tsx
This commit is contained in:
@@ -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);
|
||||
|
||||
46
src/routes/deletion/life-and-lineage.test.ts
Normal file
46
src/routes/deletion/life-and-lineage.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 `<Navigate>` —
|
||||
* 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 (
|
||||
<>
|
||||
<PageHead
|
||||
title="Account Deletion - Life and Lineage"
|
||||
description="Request account deletion for Life and Lineage. Remove all your data from our system with a 24-hour grace period."
|
||||
/>
|
||||
<div class="pt-20">
|
||||
<div class="mx-auto p-4 md:p-6 lg:p-12">
|
||||
<div class="text-text w-full justify-center">
|
||||
<div class="text-xl">
|
||||
<em>What will happen</em>:
|
||||
</div>
|
||||
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.
|
||||
</div>
|
||||
|
||||
<DeletionForm />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
export function GET() {
|
||||
return new Response(null, {
|
||||
status: 308,
|
||||
headers: {
|
||||
Location: LEGACY_DELETION_REDIRECT_TARGET,
|
||||
"Cache-Control": "public, max-age=0, must-revalidate"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
59
src/routes/downloads.test.ts
Normal file
59
src/routes/downloads.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
97
src/routes/lineage/deletion-content.test.ts
Normal file
97
src/routes/lineage/deletion-content.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
71
src/routes/lineage/deletion-content.ts
Normal file
71
src/routes/lineage/deletion-content.ts
Normal file
@@ -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";
|
||||
71
src/routes/lineage/deletion.tsx
Normal file
71
src/routes/lineage/deletion.tsx
Normal file
@@ -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:
|
||||
* - `<PageHead>` 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 (
|
||||
<>
|
||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||
<div class="pt-20">
|
||||
<div class="mx-auto p-4 md:p-6 lg:p-12">
|
||||
<div class="text-text w-full justify-center">
|
||||
<div class="text-xl">
|
||||
<em>What will happen</em>:
|
||||
</div>
|
||||
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.
|
||||
</div>
|
||||
|
||||
<DeletionForm
|
||||
product={DELETION_PRODUCT_KEY}
|
||||
cookieName={DELETION_COOKIE_NAME}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
92
src/routes/lineage/downloads-content.test.ts
Normal file
92
src/routes/lineage/downloads-content.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
67
src/routes/lineage/downloads-content.ts
Normal file
67
src/routes/lineage/downloads-content.ts
Normal file
@@ -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 `<PageHead>`; 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."
|
||||
};
|
||||
146
src/routes/lineage/downloads.tsx
Normal file
146
src/routes/lineage/downloads.tsx
Normal file
@@ -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:
|
||||
* - `<PageHead>` 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 (
|
||||
<>
|
||||
<PageHead
|
||||
title={PAGE_META.title}
|
||||
description={PAGE_META.description}
|
||||
/>
|
||||
|
||||
<div class="bg-base relative min-h-screen overflow-hidden px-4 pt-[15vh] pb-12 md:px-8">
|
||||
{/* Subtle scanline effect — consistent with the unified downloads page. */}
|
||||
<div class="pointer-events-none absolute inset-0 opacity-5">
|
||||
<div
|
||||
class="h-full w-full"
|
||||
style={{
|
||||
"background-image":
|
||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.2) 2px, rgba(0,0,0,0.2) 4px)"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mx-auto max-w-3xl">
|
||||
<div class="border-overlay0 rounded-lg border p-6 md:p-8">
|
||||
<h2 class="text-text mb-6 font-mono text-2xl">
|
||||
<span class="text-yellow">{">"}</span> {title()}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-8 sm:flex-row sm:justify-around">
|
||||
{/* Android APK via tRPC → S3 signed URL */}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span class="text-subtext0 font-mono text-sm">
|
||||
platform: android
|
||||
</span>
|
||||
<Button
|
||||
variant="download"
|
||||
size="lg"
|
||||
loading={loading()}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{LINEAGE_APK_BUTTON_LABEL}
|
||||
</Button>
|
||||
<span class="text-subtext1 max-w-xs text-center text-xs italic">
|
||||
# android build not optimized
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* iOS App Store */}
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span class="text-subtext0 font-mono text-sm">
|
||||
platform: ios
|
||||
</span>
|
||||
<A
|
||||
class="transition-all duration-200 ease-out hover:scale-105 active:scale-95"
|
||||
href={LINEAGE_APP_STORE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadOnAppStore size={50} />
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary CTA → landing page */}
|
||||
<p class="mt-12 text-center text-sm text-subtext0">
|
||||
<A
|
||||
href={LINEAGE_HOME_HREF}
|
||||
class="underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||
>
|
||||
← back to Life and Lineage
|
||||
</A>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/routes/nessa/deletion-content.test.ts
Normal file
84
src/routes/nessa/deletion-content.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
53
src/routes/nessa/deletion-content.ts
Normal file
53
src/routes/nessa/deletion-content.ts
Normal file
@@ -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."
|
||||
};
|
||||
61
src/routes/nessa/deletion.tsx
Normal file
61
src/routes/nessa/deletion.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||
<div class="pt-20">
|
||||
<div class="mx-auto p-4 md:p-6 lg:p-12">
|
||||
<div class="text-text w-full justify-center">
|
||||
<div class="text-xl">
|
||||
<em>What will happen</em>:
|
||||
</div>
|
||||
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.
|
||||
</div>
|
||||
|
||||
<DeletionForm
|
||||
product={DELETION_PRODUCT_KEY}
|
||||
cookieName={DELETION_COOKIE_NAME}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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