diff --git a/README.md b/README.md index 9c552d3..c54d72a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Open `http://localhost:3000`. ## Admin suggestions -Administrators can read the configured Discord forum through the session-protected [suggestions API](docs/admin-suggestions-api.md). Set `DISCORD_SUGGESTIONS_FORUM_ID` through GitOps; the existing bot token stays server-side. This integration is read-only and does not synchronize data into the database. +Administrators can browse the configured Discord forum at `/admin/suggestions` or use the same session-protected [suggestions API](docs/admin-suggestions-api.md). The portal includes active/archive browsing, original posts, reactions, and paginated discussion. Set `DISCORD_SUGGESTIONS_FORUM_ID` through GitOps; the existing bot token stays server-side. This integration is read-only and does not synchronize data into the database. ## Product design diff --git a/apps/web/src/app/admin/(console)/layout.tsx b/apps/web/src/app/admin/(console)/layout.tsx index 929cb81..3a2ed0a 100644 --- a/apps/web/src/app/admin/(console)/layout.tsx +++ b/apps/web/src/app/admin/(console)/layout.tsx @@ -39,6 +39,7 @@ export default async function AdminConsoleLayout({ children }: { children: React Users Groups RCON + Suggestions Events diff --git a/apps/web/src/app/admin/(console)/suggestions/[id]/page.tsx b/apps/web/src/app/admin/(console)/suggestions/[id]/page.tsx new file mode 100644 index 0000000..9fd914e --- /dev/null +++ b/apps/web/src/app/admin/(console)/suggestions/[id]/page.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { SuggestionReader } from "@/components/suggestion-reader"; +import { requireAdminSession } from "@/lib/auth/require-admin"; + +export const dynamic = "force-dynamic"; + +export default async function SuggestionPage({ params }: { params: Promise<{ id: string }> }) { + await requireAdminSession(); + const { id } = await params; + if (!/^[1-9]\d{16,19}$/.test(id)) notFound(); + return
+ ← All suggestions + +
; +} diff --git a/apps/web/src/app/admin/(console)/suggestions/navigation.test.tsx b/apps/web/src/app/admin/(console)/suggestions/navigation.test.tsx new file mode 100644 index 0000000..1959e71 --- /dev/null +++ b/apps/web/src/app/admin/(console)/suggestions/navigation.test.tsx @@ -0,0 +1,14 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it, vi } from "vitest"; +vi.mock("next-auth", () => ({ getServerSession: async () => ({ user: { roles: ["ops"] } }) })); +vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" })); +vi.mock("next/headers", () => ({ headers: async () => new Headers() })); +vi.mock("@minecraft-account-manager/database", () => ({ recordEvent: vi.fn() })); +vi.mock("@/lib/database", () => ({ db: {} })); +vi.mock("@/components/admin-sign-out-button", () => ({ AdminSignOutButton: () => })); +import AdminConsoleLayout from "../layout"; +it("adds suggestions to administrator navigation", async () => { + const markup = renderToStaticMarkup(await AdminConsoleLayout({ children:

Console

})); + expect(markup).toContain('href="/admin/suggestions"'); + expect(markup).toContain("Suggestions"); +}); diff --git a/apps/web/src/app/admin/(console)/suggestions/page.test.tsx b/apps/web/src/app/admin/(console)/suggestions/page.test.tsx new file mode 100644 index 0000000..7429049 --- /dev/null +++ b/apps/web/src/app/admin/(console)/suggestions/page.test.tsx @@ -0,0 +1,26 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ requireAdmin: vi.fn() })); +vi.mock("@/lib/auth/require-admin", () => ({ requireAdminSession: mocks.requireAdmin })); +vi.mock("@/components/suggestions-browser", () => ({ SuggestionsBrowser: () =>
Suggestions browser
})); +vi.mock("@/components/suggestion-reader", () => ({ SuggestionReader: ({ id }: { id: string }) =>
Reader {id}
})); +import SuggestionsPage from "./page"; +import SuggestionPage from "./[id]/page"; +const params = Promise.resolve({ id: "100000000000000009" }); +beforeEach(() => { mocks.requireAdmin.mockReset(); }); +it("independently requires the admin guard before rendering either page", async () => { + mocks.requireAdmin.mockRejectedValue(new Error("Admin sign-in required")); + await expect(SuggestionsPage()).rejects.toThrow("Admin sign-in required"); + await expect(SuggestionPage({ params })).rejects.toThrow("Admin sign-in required"); +}); +it("renders the browser and detail in the existing portal layout", async () => { + mocks.requireAdmin.mockResolvedValue({ name: "Admin" }); + expect(renderToStaticMarkup(await SuggestionsPage())).toContain("Suggestions browser"); + const detail = renderToStaticMarkup(await SuggestionPage({ params })); + expect(detail).toContain("Reader"); + expect(detail).toContain('href="/admin/suggestions"'); +}); +it("rejects malformed IDs rather than constructing arbitrary API URLs", async () => { + mocks.requireAdmin.mockResolvedValue({ name: "Admin" }); + await expect(SuggestionPage({ params: Promise.resolve({ id: "../secret" }) })).rejects.toThrow("NEXT_HTTP_ERROR_FALLBACK;404"); +}); diff --git a/apps/web/src/app/admin/(console)/suggestions/page.tsx b/apps/web/src/app/admin/(console)/suggestions/page.tsx new file mode 100644 index 0000000..2c6293e --- /dev/null +++ b/apps/web/src/app/admin/(console)/suggestions/page.tsx @@ -0,0 +1,19 @@ +import { SuggestionsBrowser } from "@/components/suggestions-browser"; +import { requireAdminSession } from "@/lib/auth/require-admin"; + +export const dynamic = "force-dynamic"; + +export default async function SuggestionsPage() { + await requireAdminSession(); + return
+
+

Community / Idea desk

+

Suggestions

+
+

Ideas from the Discord forum, brought into the operations desk. Read proposals and discussion here; keep the conversation in Discord.

+ Admin-only / Read-only +
+
+ +
; +} diff --git a/apps/web/src/components/suggestion-reader.test.tsx b/apps/web/src/components/suggestion-reader.test.tsx new file mode 100644 index 0000000..4114e53 --- /dev/null +++ b/apps/web/src/components/suggestion-reader.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { SuggestionReader } from "./suggestion-reader"; +const id = "100000000000000009"; +const original = { id, author: { id: "100000000000000003", name: "Builder" }, content: " Please build stations.", createdAt: "2026-01-01T00:00:00Z", editedAt: null, reactions: [{ emoji: "👍", count: 4 }], discordUrl: `https://discord.com/channels/100000000000000001/${id}/${id}` }; +const detail = { id, title: "More railway stations", authorId: original.author.id, createdAt: original.createdAt, archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: `https://discord.com/channels/100000000000000001/${id}`, originalPost: original }; +it("paginates discussion without duplicating the original post", async () => { + const fetcher = vi.fn().mockImplementation(async (url: string) => { + if (!url.includes("/messages")) return Response.json(detail); + const older = url.includes("cursor="); + return Response.json({ items: [original, { ...original, id: "100000000000000020", content: older ? "Earlier reply" : "Latest reply", editedAt: "2026-01-01T01:00:00Z" }], nextCursor: older ? null : "100000000000000020" }); + }); + vi.stubGlobal("fetch", fetcher); + render(); + await screen.findByText("Latest reply"); + expect(screen.getAllByText(original.content)).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + await screen.findByText("Earlier reply"); + expect(fetcher.mock.lastCall?.[0]).toBe(`/api/suggestions/${id}/messages?limit=25&cursor=100000000000000020`); + expect(screen.getByText("2026-01-01T01:00:00Z")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Previous page" })); + await screen.findByText("Latest reply"); +}); +it("distinguishes deleted starters and empty discussion", async () => { + vi.stubGlobal("fetch", vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : { ...detail, originalPost: null }))); + render(); + expect(await screen.findByText("The original post was deleted or is unavailable.")).toBeTruthy(); + expect(await screen.findByText("No replies on this page.")).toBeTruthy(); +}); +it("handles detail access denial without loading the discussion", async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({}, { status: 403 })); + vi.stubGlobal("fetch", fetcher); + render(); + await screen.findByRole("link", { name: "Admin sign-in" }); + expect(screen.queryByRole("heading", { name: "More railway stations" })).toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +it("makes empty message content explicit and retries discussion failures without writes", async () => { + let messageReads = 0; + const fetcher = vi.fn().mockImplementation(async (url: string) => { + if (!url.includes("/messages")) return Response.json({ ...detail, originalPost: { ...original, content: "" } }); + messageReads += 1; + return messageReads === 1 ? new Response(null, { status: 503 }) : Response.json({ items: [], nextCursor: null }); + }); + vi.stubGlobal("fetch", fetcher); + render(); + await screen.findByText("No text was returned. View this message in Discord."); + await screen.findByRole("alert"); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByText("No replies on this page."); + expect(fetcher.mock.calls.every(([, options]) => options.method === undefined || options.method === "GET")).toBe(true); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); +it("reads suggestion details and safely renders starter text, tags, reactions and Discord links", async () => { + const fetcher = vi.fn().mockImplementation(async (url: string) => Response.json(url.includes("/messages") ? { items: [], nextCursor: null } : detail)); + vi.stubGlobal("fetch", fetcher); + render(); + expect(screen.getByRole("status").textContent).toContain("Loading suggestion"); + await screen.findByRole("heading", { name: "More railway stations", level: 1 }); + expect(screen.getByText(original.content)).toBeTruthy(); + expect(document.querySelector("script")).toBeNull(); + expect(screen.getByText("World")).toBeTruthy(); + expect(screen.getByText("👍 4")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Open in Discord" }).getAttribute("href")).toBe(detail.discordUrl); + expect(fetcher.mock.calls[0]?.[0]).toBe(`/api/suggestions/${id}`); + expect(screen.queryByRole("textbox")).toBeNull(); +}); diff --git a/apps/web/src/components/suggestion-reader.tsx b/apps/web/src/components/suggestion-reader.tsx new file mode 100644 index 0000000..71f869d --- /dev/null +++ b/apps/web/src/components/suggestion-reader.tsx @@ -0,0 +1,67 @@ +"use client"; + +import type { MessagePage, SuggestionDetail, SuggestionMessage } from "@/lib/discord/suggestion-types"; +import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared"; + +export function SuggestionReader({ id }: { id: string }) { + const { data, error, retry } = useSuggestionRead(`/api/suggestions/${id}`); + if (error) return ; + if (!data) return Loading suggestion…; + return <> +
+

Community proposal / Read-only

+ +

{data.title}

+
+

Thread / {data.id}

+ Open in Discord +
+
+
+

Original suggestion

+ {data.originalPost ? :

The original post was deleted or is unavailable.

} +
+ +

Discord remains the source of truth. Open Discord for attachments, formatting, replies, and reactions. Message text may be unavailable if the bot lacks Message Content access.

+ ; +} + +function Discussion({ id }: { id: string }) { + const paging = useSuggestionPages(); + const query = new URLSearchParams({ limit: "25" }); + if (paging.cursor) query.set("cursor", paging.cursor); + const url = `/api/suggestions/${id}/messages?${query}`; + return
+
+

Discussion

+

Newest messages first / Read-only

+
+ +
; +} + +function DiscussionPage({ id, url, paging }: { id: string; url: string; paging: SuggestionPaging }) { + const { data, error, retry } = useSuggestionRead(url); + if (error) return ; + if (!data) return Loading discussion…; + const replies = data.items.filter((message) => message.id !== id); + return <> +
+ {replies.map((message) => )} + {!replies.length &&

No replies on this page.

} +
+ + ; +} + +function MessageCard({ message }: { message: SuggestionMessage }) { + return
+
+

{message.author.name}

+ +
+

{message.content || "No text was returned. View this message in Discord."}

+ {message.editedAt &&

Edited

} + {!!message.reactions.length &&
    {message.reactions.map((reaction, index) =>
  • {reaction.emoji} {reaction.count}
  • )}
} +
; +} diff --git a/apps/web/src/components/suggestion-shared.tsx b/apps/web/src/components/suggestion-shared.tsx new file mode 100644 index 0000000..5af1342 --- /dev/null +++ b/apps/web/src/components/suggestion-shared.tsx @@ -0,0 +1,80 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import type { Suggestion } from "@/lib/discord/suggestion-types"; + +export const suggestionControl = "border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider transition-colors hover:bg-ink hover:text-panel disabled:cursor-not-allowed disabled:opacity-40"; + +// Consumers remount on URL changes so previous-page data is never shown as new data. +type ReadFailure = { status: number; message: string }; +class ReadError extends Error { constructor(public status: number) { super("Suggestions request failed"); } } +function readFailure(error: unknown): ReadFailure { + const status = error instanceof ReadError ? error.status : 0; + const messages: Record = { + 401: "Your admin session has expired. Sign in to continue.", + 403: "Your account does not have the required administrator role.", + 404: "This suggestion is no longer available in the configured forum.", + 503: "Discord suggestions are temporarily unavailable or not configured. Try again shortly.", + }; + return { status, message: messages[status] ?? "Suggestions could not be loaded. Try again shortly." }; +} + +export function useSuggestionRead(url: string) { + const [result, setResult] = useState<{ data: T | null; error: ReadFailure | null }>({ data: null, error: null }); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + const controller = new AbortController(); + fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller.signal }) + .then(async (response) => { + if (!response.ok) throw new ReadError(response.status); + return await response.json() as T; + }) + .then((data) => { if (!controller.signal.aborted) setResult({ data, error: null }); }) + .catch((error: unknown) => { if (!controller.signal.aborted) setResult({ data: null, error: readFailure(error) }); }); + return () => controller.abort(); + }, [url, attempt]); + return { ...result, retry: () => { setResult({ data: null, error: null }); setAttempt((value) => value + 1); } }; +} + +export function SuggestionBadges({ suggestion }: { suggestion: Suggestion }) { + return
+ {suggestion.archived ? "Archived" : "Active"} + {suggestion.locked && Locked} + {suggestion.tags.map((tag) => {tag.name})} +
; +} + +export function SuggestionFailure({ error, retry }: { error: ReadFailure; retry: () => void }) { + const authError = error.status === 401 || error.status === 403; + return
+

{authError ? "Admin access required" : "Unable to load suggestions"}

+

{error.message}

+
{authError ? Admin sign-in : }
+
; +} + +export function useSuggestionPages() { + const [state, setState] = useState<{ cursors: (string | undefined)[]; hasPaged: boolean }>({ cursors: [undefined], hasPaged: false }); + return { + cursor: state.cursors.at(-1), page: state.cursors.length, focusOnMount: state.hasPaged, + onNext: (cursor: string) => setState((current) => ({ cursors: [...current.cursors, cursor], hasPaged: true })), + onPrevious: () => setState((current) => ({ cursors: current.cursors.length > 1 ? current.cursors.slice(0, -1) : current.cursors, hasPaged: true })), + reset: () => setState({ cursors: [undefined], hasPaged: false }), + }; +} + +export type SuggestionPaging = { focusOnMount: boolean; onNext: (cursor: string) => void; onPrevious: () => void; page: number }; +export function SuggestionPagination({ nextCursor, onNext, onPrevious, page, focusOnMount }: SuggestionPaging & { nextCursor: string | null }) { + const label = useRef(null); + useEffect(() => { if (focusOnMount) label.current?.focus(); }, [focusOnMount]); + return ; +} + +export function SuggestionLoading({ children }: { children: string }) { + return

{children}

; +} diff --git a/apps/web/src/components/suggestions-browser.test.tsx b/apps/web/src/components/suggestions-browser.test.tsx new file mode 100644 index 0000000..1eee219 --- /dev/null +++ b/apps/web/src/components/suggestions-browser.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { SuggestionsBrowser } from "./suggestions-browser"; + +const suggestion = { id: "100000000000000009", title: "More railway stations", authorId: "100000000000000003", createdAt: "2026-01-01T00:00:00Z", archived: false, locked: false, tags: [{ id: "100000000000000004", name: "World" }], messageCount: 3, discordUrl: "https://discord.com/channels/100000000000000001/100000000000000009" }; +it("switches archives, pages forward and back, and resets pagination on status change", async () => { + const fetcher = vi.fn().mockImplementation(async (url: string) => { + const archived = url.includes("status=archived"); + const next = url.includes("cursor="); + return Response.json({ items: [{ ...suggestion, title: archived ? (next ? "Earlier archived idea" : "Archived idea") : "Active idea", archived }], nextCursor: next ? null : "2026-01-01T00:00:00Z" }); + }); + vi.stubGlobal("fetch", fetcher); + render(); + await screen.findByRole("link", { name: "Active idea" }); + fireEvent.click(screen.getByRole("button", { name: "Archived" })); + await screen.findByRole("link", { name: "Archived idea" }); + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + await screen.findByRole("link", { name: "Earlier archived idea" }); + expect(fetcher.mock.lastCall?.[0]).toContain("cursor=2026-01-01T00%3A00%3A00Z"); + expect(document.activeElement?.textContent).toBe("Page 2"); + expect((screen.getByRole("button", { name: "Next page" }) as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Previous page" })); + await screen.findByRole("link", { name: "Archived idea" }); + expect(document.activeElement?.textContent).toBe("Page 1"); + fireEvent.click(screen.getByRole("button", { name: "Active" })); + await screen.findByRole("link", { name: "Active idea" }); + expect(fetcher.mock.lastCall?.[0]).toBe("/api/suggestions?status=active&limit=25"); + expect((screen.getByRole("button", { name: "Previous page" }) as HTMLButtonElement).disabled).toBe(true); +}); +it("shows an explicit empty state", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ items: [], nextCursor: null }))); + render(); + expect(await screen.findByText("No suggestions on this page.")).toBeTruthy(); +}); +it("retries failed reads and distinguishes expired admin access", async () => { + const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ detail: "private upstream text" }, { status: 503 })).mockResolvedValueOnce(Response.json({ items: [], nextCursor: null })); + vi.stubGlobal("fetch", fetcher); + render(); + expect((await screen.findByRole("alert")).textContent).not.toContain("private upstream text"); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByText("No suggestions on this page."); + fetcher.mockResolvedValueOnce(Response.json({}, { status: 401 })); + fireEvent.click(screen.getByRole("button", { name: "Archived" })); + expect((await screen.findByRole("link", { name: "Admin sign-in" })).getAttribute("href")).toBe("/admin/login"); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); +}); +it("ignores a late response from the previously selected status", async () => { + let resolveActive!: (response: Response) => void; + const fetcher = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolveActive = resolve; })).mockResolvedValueOnce(Response.json({ items: [{ ...suggestion, title: "Archived result", archived: true }], nextCursor: null })); + vi.stubGlobal("fetch", fetcher); + render(); + fireEvent.click(screen.getByRole("button", { name: "Archived" })); + await screen.findByRole("link", { name: "Archived result" }); + resolveActive(Response.json({ items: [suggestion], nextCursor: null })); + await waitFor(() => expect(fetcher.mock.calls[0]?.[1].signal.aborted).toBe(true)); + expect(screen.queryByRole("link", { name: "More railway stations" })).toBeNull(); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); +it("loads and displays suggestions with tags and links through the admin API", async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ items: [suggestion], nextCursor: null })); + vi.stubGlobal("fetch", fetcher); + render(); + expect(screen.getByRole("status").textContent).toContain("Loading suggestions"); + const link = await screen.findByRole("link", { name: "More railway stations" }); + expect(link.getAttribute("href")).toBe(`/admin/suggestions/${suggestion.id}`); + expect(screen.getByText("World")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Open More railway stations in Discord" }).getAttribute("href")).toBe(suggestion.discordUrl); + expect(fetcher).toHaveBeenCalledWith("/api/suggestions?status=active&limit=25", expect.objectContaining({ credentials: "same-origin", cache: "no-store" })); +}); diff --git a/apps/web/src/components/suggestions-browser.tsx b/apps/web/src/components/suggestions-browser.tsx new file mode 100644 index 0000000..a7f2531 --- /dev/null +++ b/apps/web/src/components/suggestions-browser.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import type { SuggestionPage } from "@/lib/discord/suggestion-types"; +import { SuggestionBadges, SuggestionFailure, SuggestionLoading, SuggestionPagination, suggestionControl, useSuggestionRead, useSuggestionPages, type SuggestionPaging } from "./suggestion-shared"; + +export function SuggestionsBrowser() { + const [status, setStatus] = useState<"active" | "archived">("active"); + const paging = useSuggestionPages(); + const query = new URLSearchParams({ status, limit: "25" }); + if (paging.cursor) query.set("cursor", paging.cursor); + const url = `/api/suggestions?${query}`; + return
+
+
+ {(["active", "archived"] as const).map((value) => )} +
+

{status === "active" ? "Newest ideas first" : "Most recently archived first"}

+
+ +
; +} + +function SuggestionList({ url, paging }: { url: string; paging: SuggestionPaging }) { + const { data, error, retry } = useSuggestionRead(url); + if (error) return ; + if (!data) return Loading suggestions…; + return <>
    + {!data.items.length &&
  1. No suggestions on this page.

    Try the other status, or return to the previous page.

  2. } + {data.items.map((suggestion, index) =>
  3. + +
    + +

    {suggestion.title}

    +


    Author / {suggestion.authorId}

    +
    +
    +

    ~{suggestion.messageCount} messages

    + Discord ↗ +
    +
  4. )} +
; +} diff --git a/apps/web/src/lib/discord/suggestions.test.ts b/apps/web/src/lib/discord/suggestions.test.ts index c606d4f..194115b 100644 --- a/apps/web/src/lib/discord/suggestions.test.ts +++ b/apps/web/src/lib/discord/suggestions.test.ts @@ -26,6 +26,18 @@ it("reads archived forum pages using Discord's archive timestamp cursor", async expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null }); }); +it("normalizes offset archive timestamps without losing cursor precision", async () => { + const raw = "2026-01-01T00:00:00.123456+00:00"; + const cursor = "2026-01-01T00:00:00.123456Z"; + const { client } = setup({ + [`/channels/${forumId}`]: forum, + [`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true }, + [`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false }, + }); + const first = await client.list({ status: "archived", limit: 1 }); + expect(first.nextCursor).toBe(cursor); + expect(await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! })).toEqual({ items: [], nextCursor: null }); +}); const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] }; it("returns the starter post and paginates discussion with authors and reactions", async () => { const { client } = setup({ diff --git a/apps/web/src/lib/discord/suggestions.ts b/apps/web/src/lib/discord/suggestions.ts index 222a654..814536f 100644 --- a/apps/web/src/lib/discord/suggestions.ts +++ b/apps/web/src/lib/discord/suggestions.ts @@ -30,6 +30,13 @@ function timestamp(value: string | number) { return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z"); } +function archiveCursor(value: string) { + // Discord timestamps may use +00:00 and microseconds. Date alone truncates + // that precision, potentially skipping posts at the archive page boundary. + const fraction = value.match(/\.(\d{1,6})(?:Z|[+-]\d{2}:\d{2})$/)?.[1]; + return new Date(value).toISOString().replace(/\.\d{3}Z$/, fraction && /[1-9]/.test(fraction) ? `.${fraction}Z` : "Z"); +} + export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) { const { token, guildId, forumId } = options; const fetcher = options.fetch ?? fetch; @@ -144,7 +151,7 @@ export function createSuggestionsClient(options: { token: string; guildId: strin const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`); return { items: data.threads.filter(belongs).map((thread) => summary(thread, forum)), - nextCursor: data.has_more && data.threads.length ? data.threads.at(-1)!.thread_metadata.archive_timestamp.replace(/\.000Z$/, "Z") : null, + nextCursor: data.has_more && data.threads.length ? archiveCursor(data.threads.at(-1)!.thread_metadata.archive_timestamp) : null, }; } const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`); diff --git a/docs/admin-suggestions-api.md b/docs/admin-suggestions-api.md index 77379b2..1d5a757 100644 --- a/docs/admin-suggestions-api.md +++ b/docs/admin-suggestions-api.md @@ -2,6 +2,14 @@ The portal provides a read-only view of one Discord **forum channel**, using the existing NextAuth admin session and configured Keycloak role. Player sessions and Discord bot credentials are not accepted as API credentials. Sign in at `/admin/login` first; same-origin browser calls send the session cookie. Unattended machine authentication is not provided. +## Portal interface + +Open `/admin/suggestions` from the administrator navigation. The idea desk lists active or archived forum posts with tags, status, approximate message counts, author IDs, timestamps, and Discord links. Select a title to open `/admin/suggestions/:id`, read the starter post and its reaction counts, and page through discussion newest-first. The starter is not duplicated in the discussion view. + +The UI uses these same session-protected GET endpoints, not a second integration. Both pages independently check admin access before rendering; the APIs recheck it on every read. Expired/unauthorized API access offers an admin sign-in link. Loading, empty/deleted content, missing-text, and retryable failure states are explicit. Changing status aborts obsolete requests, and pagination restores keyboard focus to the page indicator. Text is rendered literally with React escaping, never as HTML or interpreted Discord Markdown. No bot token, forum configuration value, reply input, vote button, or moderation control is added to the client bundle. + +Pagination history is page-local and resets when switching status or leaving the page. Reload the browser to refresh a view; upstream reads may use the documented 30-second cache. The real forum ID is still configured only through GitOps, not the UI or source. + ## Runtime configuration - `DISCORD_SUGGESTIONS_FORUM_ID`: required forum channel snowflake, configured through GitOps. No source-code default. @@ -21,7 +29,7 @@ The bot must have **View Channel** and **Read Message History** for the forum an | `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. | | `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. | -Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. This is a live view, not a consistent snapshot: threads can move between active and archived lists. +Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps normalized to `Z` while preserving fractional precision; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. This is a live view, not a consistent snapshot: threads can move between active and archived lists. Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`, `tags`, `messageCount`, `discordUrl`. Discord's message count is approximate, not a vote count. Detail messages include `id`, `author` (`id`, `name`), `content`, `createdAt`, `editedAt`, `reactions` (`emoji`, `count`), and `discordUrl`. Reactions remain reaction counts, not interpreted votes. Attachments, embeds, and rendered Discord Markdown are not mirrored; use Discord links for the original presentation. @@ -42,6 +50,6 @@ The client checks the configured forum's guild/type and each requested thread's ## Verification before rollout -Run the source checks and focused tests in `apps/web/src/lib/discord/suggestions.test.ts` and `apps/web/src/app/api/suggestions/route.test.ts`. Under the separately approved GitOps/release plan, verify one known active post, one archived post, message content, and admin/non-admin access against the actual forum. Offline fixtures do not establish live Discord permissions or intent approval. +Run the source checks and focused API tests in `apps/web/src/lib/discord/suggestions.test.ts` and `apps/web/src/app/api/suggestions/route.test.ts`, plus UI tests in `apps/web/src/components/suggestions-browser.test.tsx`, `suggestion-reader.test.tsx`, and `apps/web/src/app/admin/(console)/suggestions/`. Under the separately approved GitOps/release plan, verify one known active post, one archived post, message content, and admin/non-admin access against the actual forum. Offline fixtures do not establish live Discord permissions or intent approval. References: [Discord threads](https://discord.com/developers/docs/topics/threads), [Channel resource](https://discord.com/developers/docs/resources/channel), [Message content intent](https://discord.com/developers/docs/events/gateway#message-content-intent). diff --git a/docs/security-review.md b/docs/security-review.md index 9931010..7312e3e 100644 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -1,6 +1,6 @@ # Security review -Review timestamp: 2026-09-10T12:00:10Z +Review timestamp: 2026-09-10T12:20:32Z ## Scope @@ -8,11 +8,12 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut ## Automated checks -- Full source Semgrep `auto`: 0 findings across 169 scanned files (331 rules; 13 files excluded by ignore patterns). +- Full source Semgrep `auto`: 0 findings across 178 scanned files (331 rules; 13 files excluded by ignore patterns). - `npm audit` and `npm audit --omit=dev`: 0 known vulnerabilities after remediation. Baseline had six affected entries (two moderate, two high, two critical), covering Next.js/Sharp, the NextAuth dependency path, Vitest/mocker, and js-yaml. - Resolved patched versions: Next.js and eslint-config-next 16.3.4, Sharp 0.35.4, Vitest/mocker 4.1.11, js-yaml 4.3.2. Retained Next.js's PostCSS override at 8.5.25. No forced major dependency upgrades or database changes. -- TypeScript, 181 workspace tests, Next.js production build, and Velocity Java tests/shaded plugin build pass with patched dependencies. ESLint exits successfully with two new framework-rule warnings about existing `window.location.assign()` calls in `map-view-toggle.tsx`; those unrelated navigation behaviors were not changed. -- Verification is local source evidence, not a claim that patched artifacts have been deployed. API CI run 1979 passed for the preceding API source `1a01c0ed641f4eda83f81c855c38651aa116933e`. +- TypeScript, 196 workspace tests, Next.js production build, and Velocity Java tests/shaded plugin build pass with patched dependencies. ESLint exits successfully with two new framework-rule warnings about existing `window.location.assign()` calls in `map-view-toggle.tsx`; those unrelated navigation behaviors were not changed. +- Verification is local source evidence, not a claim that patched artifacts have been deployed. API CI run 1979 passed for `1a01c0ed641f4eda83f81c855c38651aa116933e`; dependency-fix CI run 1985 passed for `0af4884f7e16161d0c0ea3a8f9c26b3fa9384cf9`. +- Suggestions UI tests verify both page guards, malformed-ID rejection, literal text rendering (including script-like content), read-only API usage, explicit auth/error states, aborted obsolete reads, and pagination focus. Actual Discord permissions/Message Content access and production browser acceptance remain rollout checks. ## Implemented controls