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

View File

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

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

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

View 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";

View 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&apos;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>
</>
);
}

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

View 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."
};

View 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>
</>
);
}

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

View 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."
};

View 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>
</>
);
}

View File

@@ -36,7 +36,7 @@ export default function PrivacyPolicy() {
<div class="-ml-6">(b) Data Removal:</div> Users can request the
removal of all information related to them by visiting{" "}
<A
href="/deletion/life-and-lineage"
href="https://lineage.freno.me/deletion"
class="text-blue hover-underline-animation"
>
this page