diff --git a/backend/src/api/patchnotes_routes.py b/backend/src/api/patchnotes_routes.py index f7967ace..cb13967e 100644 --- a/backend/src/api/patchnotes_routes.py +++ b/backend/src/api/patchnotes_routes.py @@ -24,6 +24,7 @@ PatchnotesConfigError, PatchnotesDBError, approve_bullet, + current_week, deny_bullet, insert_bullet, insert_sourced_bullet, @@ -31,8 +32,10 @@ list_pending, list_published_notes, list_published_weeks, + load_preview, migrate, parse_week, + replace_preview, ) from src.patchnotes.safety import hidden_knowledge_warning from src.patchnotes.summarize import signature_ok, summarize_push @@ -318,6 +321,77 @@ def staff_create_bullet(body: CreateBulletBody): return _serialize(row, public=False) +class PreviewBody(BaseModel): + week: str | None = None + + @field_validator("week") + @classmethod + def _check_week(cls, value: str | None) -> str | None: + if value is None: + return None + return parse_week(value) + + +def _preview_snapshot(week: str) -> list[dict[str, str]]: + """Approved lines plus anything still waiting, without review fields.""" + approved = list_approved(week) + seen = {str(row["id"]) for row in approved} + pending = [row for row in list_pending(week) if str(row["id"]) not in seen] + return [ + {"id": str(row["id"]), "section": str(row["section"]), "body": str(row["body"])} + for row in approved + pending + ] + + +def _preview_payload(row: dict[str, Any]) -> dict[str, Any]: + bullets = row.get("bullets") if isinstance(row.get("bullets"), list) else [] + clean = [] + for bullet in bullets: + if not isinstance(bullet, dict): + continue + clean.append( + { + "id": str(bullet.get("id") or ""), + "section": bullet.get("section"), + "body": bullet.get("body"), + } + ) + return { + "week": row["week"], + "expires_at": _iso(row.get("expires_at")), + "bullets": clean, + } + + +@patchnotes_router.post("/staff/preview", dependencies=[Depends(_staff_guard)]) +def staff_create_preview(body: PreviewBody): + """Save this week's notes as a staff-only page that expires in one hour.""" + week_key = body.week or current_week() + try: + migrate() + row = replace_preview(week=week_key, bullets=_preview_snapshot(week_key)) + except (PatchnotesDBError, PatchnotesConfigError) as e: + logger.exception("staff_create_preview failed") + raise HTTPException(status_code=502, detail=_client_detail(e)) from e + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + return _preview_payload(row) + + +@patchnotes_router.get("/staff/preview", dependencies=[Depends(_staff_guard)]) +def staff_read_preview(): + """The test note, if it has not expired. Missing and expired both 404.""" + try: + migrate() + row = load_preview() + except (PatchnotesDBError, PatchnotesConfigError) as e: + logger.exception("staff_read_preview failed") + raise HTTPException(status_code=502, detail=_client_detail(e)) from e + if row is None: + raise HTTPException(status_code=404, detail="No test preview") + return _preview_payload(row) + + @patchnotes_router.get("/staff/queue", dependencies=[Depends(_staff_guard)]) def staff_queue(week: str | None = None): """Pending bullets, oldest first.""" diff --git a/backend/src/api/test_patchnotes_routes.py b/backend/src/api/test_patchnotes_routes.py index 7f3f6446..b9db85be 100644 --- a/backend/src/api/test_patchnotes_routes.py +++ b/backend/src/api/test_patchnotes_routes.py @@ -60,6 +60,8 @@ def tearDown(self) -> None: def test_staff_routes_require_auth(self) -> None: self.assertEqual(self.client.post("/patchnotes/staff/bullets", json={}).status_code, 401) self.assertEqual(self.client.get("/patchnotes/staff/queue").status_code, 401) + self.assertEqual(self.client.get("/patchnotes/staff/preview").status_code, 401) + self.assertEqual(self.client.post("/patchnotes/staff/preview", json={}).status_code, 401) self.assertEqual( self.client.post(f"/patchnotes/staff/bullets/{_BULLET_ID}/approve").status_code, 401 ) @@ -374,3 +376,54 @@ def test_lore_and_other_branches_are_not_stored(self, mock_insert) -> None: self.assertEqual(res.status_code, 200) self.assertTrue(res.json()["ignored"]) mock_insert.assert_not_called() + + +class PreviewRouteTest(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(app) + patcher = mock.patch("src.api.patchnotes_routes.migrate") + self.addCleanup(patcher.stop) + patcher.start() + + def tearDown(self) -> None: + self.client.close() + + @mock.patch("src.api.patchnotes_routes.list_pending", return_value=[]) + @mock.patch( + "src.api.patchnotes_routes.list_approved", + return_value=[_row(status="approved", body="Added a station")], + ) + @mock.patch("src.api.patchnotes_routes.replace_preview") + def test_preview_stores_the_week_without_review_fields( + self, mock_replace, _approved, _pending + ) -> None: + mock_replace.return_value = { + "week": "2026-W39", + "expires_at": _CREATED, + "bullets": [{"id": _BULLET_ID, "section": "new", "body": "Added a station"}], + } + res = self.client.post( + "/patchnotes/staff/preview", + headers=_HEADERS, + json={"week": "2026-W39"}, + ) + self.assertEqual(res.status_code, 200) + body = res.json() + self.assertEqual(body["week"], "2026-W39") + self.assertNotIn("deny_reason", body) + self.assertNotIn("status", body["bullets"][0]) + mock_replace.assert_called_once_with( + week="2026-W39", + bullets=[{"id": _BULLET_ID, "section": "new", "body": "Added a station"}], + ) + + @mock.patch("src.api.patchnotes_routes.load_preview", return_value=None) + def test_missing_preview_is_not_found(self, _load) -> None: + res = self.client.get("/patchnotes/staff/preview", headers=_HEADERS) + self.assertEqual(res.status_code, 404) + + @mock.patch("src.api.patchnotes_routes.list_published_notes", return_value=([], False)) + def test_public_notes_do_not_read_the_preview(self, _notes) -> None: + res = self.client.get("/patchnotes") + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["weeks"], []) diff --git a/backend/src/patchnotes/db.py b/backend/src/patchnotes/db.py index 27a503f8..6bf5391d 100644 --- a/backend/src/patchnotes/db.py +++ b/backend/src/patchnotes/db.py @@ -240,6 +240,17 @@ def migrate() -> None: ) """ ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS patchnote_previews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + week TEXT NOT NULL, + bullets JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL + ) + """ + ) finally: conn.close() _MIGRATED = True @@ -411,3 +422,58 @@ def approve_bullet(bullet_id: str) -> dict[str, Any]: def deny_bullet(bullet_id: str, reason: str) -> dict[str, Any]: """Mark a pending bullet denied and store the reason.""" return _review(bullet_id, status="denied", deny_reason=_clean_reason(reason)) + + +def replace_preview(*, week: str, bullets: list[dict[str, Any]]) -> dict[str, Any]: + """Store one staff-only test note and drop any previous one. + + The row expires one hour after it is written. Public readers never see it. + """ + week_key = parse_week(week) + stored: list[dict[str, str]] = [] + for bullet in bullets: + section = str(bullet.get("section") or "") + if section not in SECTIONS: + raise ValueError("section must be new, fixed, adjusted, or technical") + stored.append( + { + "id": str(bullet.get("id") or ""), + "section": section, + "body": _clean_body(str(bullet.get("body") or "")), + } + ) + conn = _connect() + try: + with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("DELETE FROM patchnote_previews") + cur.execute( + """ + INSERT INTO patchnote_previews (week, bullets, expires_at) + VALUES (%s, %s, now() + interval '1 hour') + RETURNING id, week, bullets, created_at, expires_at + """, + (week_key, psycopg2.extras.Json(stored)), + ) + return dict(cur.fetchone()) + finally: + conn.close() + + +def load_preview() -> dict[str, Any] | None: + """The current test note, after deleting any that have expired.""" + conn = _connect() + try: + with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("DELETE FROM patchnote_previews WHERE expires_at <= now()") + cur.execute( + """ + SELECT id, week, bullets, created_at, expires_at + FROM patchnote_previews + ORDER BY expires_at DESC + LIMIT 1 + """ + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + conn.close() diff --git a/backend/src/patchnotes/test_db.py b/backend/src/patchnotes/test_db.py index 9cd6a984..e0928d3a 100644 --- a/backend/src/patchnotes/test_db.py +++ b/backend/src/patchnotes/test_db.py @@ -96,6 +96,7 @@ def test_migrate_creates_table_once(self, mock_connect) -> None: sql = " ".join(c.args[0] for c in cursor.execute.call_args_list) self.assertIn("CREATE TABLE IF NOT EXISTS patchnote_bullets", sql) self.assertIn("CREATE TABLE IF NOT EXISTS patchnote_sources", sql) + self.assertIn("CREATE TABLE IF NOT EXISTS patchnote_previews", sql) self.assertIn("patchnote_bullets_deny_reason_chk", sql) self.assertIn("status = 'pending'", sql) self.assertTrue(db._MIGRATED) @@ -104,6 +105,45 @@ def test_migrate_creates_table_once(self, mock_connect) -> None: mock_connect.assert_called_once() +class PreviewTest(unittest.TestCase): + @mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True) + @mock.patch("patchnotes.db.psycopg2.connect") + def test_replace_preview_expires_in_one_hour(self, mock_connect) -> None: + cursor = mock.MagicMock() + cursor.fetchone.return_value = { + "id": "preview-1", + "week": "2026-W39", + "bullets": [{"id": "b1", "section": "new", "body": "Added a station"}], + "created_at": None, + "expires_at": None, + } + mock_connect.return_value = _make_conn(cursor) + + row = db.replace_preview( + week="2026-W39", + bullets=[{"id": "b1", "section": "new", "body": " Added a station "}], + ) + + self.assertEqual(row["week"], "2026-W39") + delete_sql = cursor.execute.call_args_list[0].args[0] + insert_sql, params = cursor.execute.call_args_list[1].args + self.assertIn("DELETE FROM patchnote_previews", delete_sql) + self.assertIn("interval '1 hour'", insert_sql) + self.assertEqual(params[0], "2026-W39") + self.assertEqual(params[1].adapted, [{"id": "b1", "section": "new", "body": "Added a station"}]) + + @mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True) + @mock.patch("patchnotes.db.psycopg2.connect") + def test_load_preview_deletes_expired_rows(self, mock_connect) -> None: + cursor = mock.MagicMock() + cursor.fetchone.return_value = None + mock_connect.return_value = _make_conn(cursor) + + self.assertIsNone(db.load_preview()) + sql = cursor.execute.call_args_list[0].args[0] + self.assertIn("expires_at <= now()", sql) + + class InsertBulletTest(unittest.TestCase): @mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True) @mock.patch("patchnotes.db.psycopg2.connect") diff --git a/frontend/app/components/updates/StaffTestPreview.tsx b/frontend/app/components/updates/StaffTestPreview.tsx new file mode 100644 index 00000000..b08f67ec --- /dev/null +++ b/frontend/app/components/updates/StaffTestPreview.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { WeekSections } from "@/app/components/updates/WeekArchiveList"; +import { useSiteStaffAccess } from "@/app/hooks/useSiteStaffAccess"; +import { getSession, isSessionValid } from "@/lib/characters/session"; +import { readStaffPreview, type StaffPreview } from "@/lib/patchnotes/preview"; + +function apiBase(): string { + return (process.env.NEXT_PUBLIC_API_URL || "").trim().replace(/\/$/, ""); +} + +export default function StaffTestPreview() { + const { state } = useSiteStaffAccess(); + const [preview, setPreview] = useState(null); + + useEffect(() => { + if (state !== "staff") { + setPreview(null); + return; + } + const session = getSession(); + const token = isSessionValid(session) ? (session?.session_token ?? "") : ""; + const base = apiBase(); + if (!token || !base) return; + + let cancelled = false; + void (async () => { + try { + const res = await fetch(`${base}/patchnotes/staff/preview`, { + cache: "no-store", + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + if (!cancelled) setPreview(null); + return; + } + const next = readStaffPreview(await res.json()); + if (!cancelled) setPreview(next); + } catch { + if (!cancelled) setPreview(null); + } + })(); + return () => { + cancelled = true; + }; + }, [state]); + + useEffect(() => { + if (!preview) return; + const expires = Date.parse(preview.expiresAt); + if (!Number.isFinite(expires)) return; + const timer = window.setTimeout(() => setPreview(null), Math.max(0, expires - Date.now())); + return () => window.clearTimeout(timer); + }, [preview]); + + if (!preview) return null; + + return ( + + ); +} diff --git a/frontend/app/components/updates/UpdatesPageView.tsx b/frontend/app/components/updates/UpdatesPageView.tsx index 3edd6530..6c6da1b1 100644 --- a/frontend/app/components/updates/UpdatesPageView.tsx +++ b/frontend/app/components/updates/UpdatesPageView.tsx @@ -1,4 +1,5 @@ import EarlierArchive from "@/app/components/updates/EarlierArchive"; +import StaffTestPreview from "@/app/components/updates/StaffTestPreview"; import { WeekArchiveList, WeekSections } from "@/app/components/updates/WeekArchiveList"; import type { WeekNotes } from "@/lib/patchnotes/notes"; @@ -17,6 +18,7 @@ export default function UpdatesPageView({

Updates

What changed on TFMC. Technical notes stay folded.

+ {unavailable ? (

Patch notes are unavailable right now.

diff --git a/frontend/lib/patchnotes/preview.test.ts b/frontend/lib/patchnotes/preview.test.ts new file mode 100644 index 00000000..c12ca7bc --- /dev/null +++ b/frontend/lib/patchnotes/preview.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { readStaffPreview } from "./preview"; + +describe("readStaffPreview", () => { + it("keeps the note and drops a line that carries a deny reason", () => { + const preview = readStaffPreview({ + week: "2026-W39", + expires_at: "2026-09-24T02:00:00+00:00", + bullets: [ + { id: "1", section: "new", body: "Added a station" }, + { id: "2", section: "fixed", body: "Hidden", deny_reason: "spoilers" }, + ], + }); + expect(preview?.week).toBe("2026-W39"); + expect(preview?.bullets.map((bullet) => bullet.body)).toEqual(["Added a station"]); + expect(preview?.expiresAt).toBe("2026-09-24T02:00:00+00:00"); + }); + + it("rejects a preview with nothing left to show", () => { + expect(readStaffPreview({ week: "2026-W39", expires_at: "soon", bullets: [] })).toBeNull(); + }); +}); diff --git a/frontend/lib/patchnotes/preview.ts b/frontend/lib/patchnotes/preview.ts new file mode 100644 index 00000000..0368ee61 --- /dev/null +++ b/frontend/lib/patchnotes/preview.ts @@ -0,0 +1,24 @@ +import { isSectionName, isWeekKey, weekLabel, type PublicBullet, type WeekNotes } from "./notes"; + +export type StaffPreview = WeekNotes & { expiresAt: string }; + +/** A staff test note. Review fields are dropped, and a deny reason hides the line. */ +export function readStaffPreview(value: unknown): StaffPreview | null { + if (!value || typeof value !== "object") return null; + const row = value as Record; + if (typeof row.week !== "string" || !isWeekKey(row.week) || !Array.isArray(row.bullets)) return null; + if (typeof row.expires_at !== "string" || row.expires_at.trim() === "") return null; + const bullets: PublicBullet[] = []; + for (const entry of row.bullets) { + if (!entry || typeof entry !== "object") continue; + const bullet = entry as Record; + if (typeof bullet.deny_reason === "string" && bullet.deny_reason.trim() !== "") continue; + if (typeof bullet.id !== "string" || typeof bullet.section !== "string" || typeof bullet.body !== "string") { + continue; + } + if (!isSectionName(bullet.section) || bullet.body.trim() === "") continue; + bullets.push({ id: bullet.id, section: bullet.section, body: bullet.body }); + } + if (bullets.length === 0) return null; + return { week: row.week, label: weekLabel(row.week), bullets, expiresAt: row.expires_at }; +}