From 3f7e61af1cc75f410b48ec0580efa6416405e98b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 18 Aug 2026 13:08:59 -0500 Subject: [PATCH] =?UTF-8?q?feat(teams):=20the=20phase=205=20surface=20?= =?UTF-8?q?=E2=80=94=20discussion,=20replies,=20reports,=20and=20two=20adm?= =?UTF-8?q?in=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 241 client tests pass (224 before). **The forum panel becomes a forum.** It was "Announcements" with one composer; it now has two, because phase 5 split one server capability into two: `canPost` means "may open a discussion" and every participant may — a granted guest with no game character included, which is path 3 doing its job — while `canAnnounce` is the leader-only half `canPost` used to carry alone. Threads gain replies, an edit control, per-post moderation and a report control, all still inside the one slot the module declares, still navigating by `?thread=`. **Almost nothing here is the client's decision, and the file says so.** `canPost`, `canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer whose deadline passed while the page sat open — it can never grant one, because a time-bounded permission must not take its clock from the party it bounds. That asymmetry is the first thing client/test/teamForum.test.js asserts. The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than they look: * `stripToText` decodes entities AFTER stripping tags, and `&` last of all. Decoding first turns an author's literal "<script>" into a real tag the strip pass then deletes — silently losing text that was never dangerous. * `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing the raw count tells a reader a brand-new thread already has one reply. **Three admin surfaces.** The forum settings screen gains the edit-window field (0 = posts permanent once written). The reports queue is a new screen beside Appeals — under moderation rather than under Teams, because a staffer working a queue should have one place to work and `target_type` is deliberately open-ended, so the next reportable thing arrives as a row rather than as another nav entry. Its copy tells a member where a report lands and that reporting changes nothing, because a member who expects a post to vanish and watches it stay reports it again. There is no leader-facing view and there is not meant to be. And the per-Team forum moderation ledger finally renders: the route and `api.admin.teamForumModeration()` have both existed since phase 4 with nothing calling them, which made `actor_role` — the column that keeps a leader's ordinary housekeeping distinguishable from a staff intervention — readable only from a DB client. Co-Authored-By: Claude --- client/src/App.jsx | 2 + client/src/api/client.js | 27 ++ client/src/lib/teamForum.js | 85 ++++ client/src/modules/TeamForumPanel.jsx | 442 ++++++++++++++++-- client/src/routes/admin/AdminLayout.jsx | 6 + .../src/routes/admin/views/ContentReports.jsx | 310 ++++++++++++ .../routes/admin/views/TeamForumSettings.jsx | 38 +- client/src/routes/admin/views/TeamsAdmin.jsx | 87 +++- client/test/apiClient.test.js | 67 +++ client/test/teamForum.test.js | 120 +++++ .../src/router/v1/admin/teams.controller.js | 8 + 11 files changed, 1145 insertions(+), 47 deletions(-) create mode 100644 client/src/lib/teamForum.js create mode 100644 client/src/routes/admin/views/ContentReports.jsx create mode 100644 client/test/teamForum.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 1750361..a63c557 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -47,6 +47,7 @@ import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' import Appeals from './routes/admin/views/Appeals.jsx' +import ContentReports from './routes/admin/views/ContentReports.jsx' // Player portal import PlayerLogin from './routes/player/PlayerLogin.jsx' @@ -163,6 +164,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 447803d..a8ae529 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -162,6 +162,21 @@ export const api = { req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }), teamForumModerate: (slug, id, body) => req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', body }), + // Phase 5 ("5b"). A reply, an edit and post-level moderation are separate + // routes from their thread-level cousins rather than the same route with a + // target kind, because they answer to different rules: a reply is refused by a + // lock, an edit by a clock, and `pin`/`lock` mean nothing to a post at all. + teamForumReply: (slug, threadId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${threadId}/posts`, { method: 'POST', body }), + teamForumEditPost: (slug, postId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}`, { method: 'PATCH', body }), + teamForumModeratePost: (slug, postId, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/posts/${postId}/moderate`, { method: 'POST', body }), + // The report goes to SITE STAFF, never to the Team's leaders — the whole point + // of it is a path that routes around a Team's own leadership (TEAMS.md §5.6). + // There is no leader-facing counterpart to this call and there should not be. + teamForumReport: (slug, body) => + req(`/player/teams/${encodeURIComponent(slug)}/forum/report`, { method: 'POST', body }), teamForumUpload: (slug, file) => { const fd = new FormData() fd.append('image', file) @@ -318,6 +333,18 @@ export const api = { // ----- moderation dashboard (admin + moderator) ----- modSummary: () => req('/admin/moderation/stats/summary'), + // The content-report queue (TEAMS.md §5.6). Under moderation rather than + // under Teams because a staffer working a queue should have one place to + // work, and a report about a forum post is the same job as a report about + // anything else — which is also why `targetType` is open-ended. + contentReports: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.status) qs.set('status', opts.status) + if (opts.teamId) qs.set('teamId', String(opts.teamId)) + return req(`/admin/moderation/reports${withQs(qs.toString())}`) + }, + handleContentReport: (id, body) => + req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }), modRecent: (params = {}) => { const qs = new URLSearchParams() if (params.type) qs.set('type', params.type) diff --git a/client/src/lib/teamForum.js b/client/src/lib/teamForum.js new file mode 100644 index 0000000..b52ae3a --- /dev/null +++ b/client/src/lib/teamForum.js @@ -0,0 +1,85 @@ +// The Team forum's client-side judgements — the few there are (TEAMS.md Part 5). +// +// This file is small on purpose. **Almost nothing about the forum is the +// client's to decide**: who may post, who may moderate, whether an image +// renders, and whether a post may be edited are all answered by the server and +// read from the payload. What is left here is the handful of pure functions that +// turn those answers into what a reader sees, and they are extracted so they can +// be tested without a browser. +// +// The one that deserves a second look is `editOfferOpen`. It can only ever take +// an offer AWAY — the server grants the edit and re-derives the window from +// `created_at` when the write arrives. A client that granted one would be +// deciding a time-bounded permission against the clock of the party it bounds. + +export const REPORT_REASONS = [ + ['abuse', 'Abusive or harassing'], + ['spam', 'Spam'], + ['sexual', 'Sexual content'], + ['illegal', 'Illegal content'], + ['impersonation', 'Impersonation'], + ['other', 'Something else'], +] + +/** + * Should the Edit control still be offered for this post? + * + * Three states, and the middle one is the reason this exists: + * • the server said no → no offer, and nothing here can create one + * • the server said yes, no deadline (staff) → offer + * • the server said yes with a deadline that has since passed while the page + * sat open → withdraw the offer, rather than leave a button that fails + */ +export function editOfferOpen(post, now = Date.now()) { + if (!post || !post.canEdit) return false + if (!post.editableUntil) return true + const until = new Date(post.editableUntil).getTime() + return Number.isFinite(until) && until > now +} + +/** + * Turn a rendered body back into something an author can edit. + * + * The server stores sanitised HTML and generates images at READ time from the + * URLs an author wrote (§5.5.3), so what comes back is not what was typed. The + * `` has to go — it is core's output, not the author's input, and leaving it + * in would let an author "edit" markup they never wrote and cannot control. + * The URL survives as the link text beside it, which is what re-renders. + */ +export function stripToText(html) { + return String(html || '') + .replace(/]*>/gi, '') + .replace(/<\/p>\s*]*>/gi, '\n\n') + .replace(//gi, '\n') + .replace(/<[^>]*>/g, '') + // Entities last: unescaping before tag-stripping would let an escaped + // "<script>" become a real tag the next pass then removes, which is a + // different string from the one the author wrote. + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + // `&` last of all, or "&lt;" would decode two steps into "<". + .replace(/&/g, '&') + .trim() +} + +/** + * The one-line summary under a thread's title in the list. + * + * `postCount` counts every post including the opening one, so a discussion's + * REPLY count is one less — and an announcement has no replies to count at all, + * which is why the count is omitted rather than shown as zero. + */ +export function threadSummary(thread) { + const parts = [] + if (thread.type === 'announcement') parts.push('Announcement') + parts.push(thread.author) + if (thread.type === 'discussion' && thread.postCount > 1) { + const replies = thread.postCount - 1 + parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`) + } + if (thread.status === 'hidden') parts.push('hidden') + return parts.join(' · ') +} diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx index a823bdb..5b2ea9f 100644 --- a/client/src/modules/TeamForumPanel.jsx +++ b/client/src/modules/TeamForumPanel.jsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import DOMPurify from 'dompurify' import { api } from '../api/client.js' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' +import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js' // Core's Team forum, rendered into a second slot a MODULE declares // (TEAMS.md Part 5, and the phase 3 amendment to §3.4). @@ -28,6 +29,15 @@ import { useSite } from '../contexts/SiteContext.jsx' // upload control that would otherwise 404. If the two ever disagree, the server // is right. // +// **Phase 5 added discussion, and with it three capabilities this file must not +// invent for itself.** `canPost`, `canAnnounce` and each post's `canEdit` are +// computed on the server and read here. In particular the edit window is a +// server decision twice over — the read path stamps `canEdit`/`editableUntil` and +// the write re-derives it — because a time-bounded permission must not take its +// clock from the party it bounds. What this file does with `editableUntil` is +// stop OFFERING an edit whose deadline has passed while the page sat open; it +// never grants one. +// // Like the feed, everything here degrades to rendering nothing. A 404 from the // thread list is the ordinary case — the forum is switched off, or this viewer // has no access — and putting an error box on a page core does not own would be @@ -40,7 +50,7 @@ export default function TeamForumPanel({ externalId, moduleId }) { const [team, setTeam] = useState(null) const [state, setState] = useState({ loading: true, forum: null }) const [thread, setThread] = useState(null) - const [composing, setComposing] = useState(false) + const [composing, setComposing] = useState(null) // 'discussion' | 'announcement' | null const openThreadId = params.get('thread') const imageMode = settings?.teams_forum_images || 'disabled' @@ -54,6 +64,14 @@ export default function TeamForumPanel({ externalId, moduleId }) { } }, []) + const loadThread = useCallback(async (slug, id) => { + try { + setThread(await api.teamForumThread(slug, id)) + } catch { + setThread(null) + } + }, []) + useEffect(() => { let active = true // An anonymous visitor has no forum by definition — every route is behind @@ -100,9 +118,12 @@ export default function TeamForumPanel({ externalId, moduleId }) { if (openThreadId && thread) { return ( openThread(null)} + onChanged={() => loadThread(team.slug, thread.id)} onModerate={async (action) => { await api.teamForumModerate(team.slug, thread.id, { action }) await loadThreads(team.slug) @@ -116,22 +137,38 @@ export default function TeamForumPanel({ externalId, moduleId }) {

- Announcements + Forum

- {forum.canPost && !composing && ( - + {!composing && ( +
+ {/* + Two buttons, because phase 5 split one capability in two. `canPost` + means "may open a discussion" and every participant may — including a + granted guest with no game character, which is path 3 doing its job. + `canAnnounce` is the leader-only half. + */} + {forum.canPost && ( + + )} + {forum.canAnnounce && ( + + )} +
)}
{composing && ( setComposing(false)} + onCancel={() => setComposing(null)} onPosted={async () => { - setComposing(false) + setComposing(null) await loadThreads(team.slug) }} /> @@ -139,7 +176,7 @@ export default function TeamForumPanel({ externalId, moduleId }) { {forum.threads.length === 0 && !composing && (

- Nothing has been announced here yet. + Nothing has been posted here yet.

)} @@ -158,10 +195,10 @@ export default function TeamForumPanel({ externalId, moduleId }) { }} > {t.pinned && 📌} + {t.locked && 🔒} {t.title} - {t.author} - {t.status === 'hidden' && ' · hidden'} + {threadSummary(t)} @@ -272,22 +309,167 @@ function GuestManager({ slug }) { ) } -function ThreadView({ thread, canModerate, onBack, onModerate }) { +function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) { + // A clock that ticks, so an edit control whose deadline passed while the page + // sat open goes away instead of becoming a button that fails. It only ever + // REMOVES an offer — the server decides whether an edit happens, and re-derives + // the window from created_at when it does. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 30_000) + return () => clearInterval(id) + }, []) + + const [replying, setReplying] = useState(false) + return (

{thread.title}

+ {thread.type === 'announcement' ? 'Announcement · ' : ''} {thread.author} {thread.authorDeleted && ' (account removed)'} + {thread.locked && ' · locked'}

{thread.posts.map((post) => ( -
+ + ))} + + {/* + `canReply` is the server's answer to "does this thread take replies right + now", and it folds together the two reasons it might not: an announcement + takes none by TYPE, and a locked thread takes none by STATE. Both are + reported separately above so the reader can see which. + */} + {thread.canReply && !replying && ( + + )} + {thread.canReply && replying && ( + setReplying(false)} + onPosted={async () => { + setReplying(false) + await onChanged() + }} + /> + )} + {!thread.canReply && thread.locked && ( +

+ This thread is locked. Nobody can reply to it, including staff — a moderator who wants the + last word unlocks it first, which leaves a record. +

+ )} + +
+ + {canModerate && ( + <> + + + + + )} +
+
+ ) +} + +/** + * One post, with whatever this reader may do to it. + * + * Every capability shown here was decided by the server and is read, not + * computed: `canEdit` and `editableUntil` come stamped on the post, and + * `canModerate` on the thread. The one local judgement is whether an + * already-granted edit window has since elapsed, which can only take an offer + * away. + */ +function PostView({ slug, post, canModerate, now, onChanged }) { + const [editing, setEditing] = useState(false) + const [body, setBody] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const stillEditable = useMemo(() => editOfferOpen(post, now), [post, now]) + + const save = async (event) => { + event.preventDefault() + setBusy(true) + setError(null) + try { + await api.teamForumEditPost(slug, post.id, { body }) + setEditing(false) + await onChanged() + } catch (err) { + setError(err.message || 'Could not save that') + } finally { + setBusy(false) + } + } + + const moderate = async (action) => { + setError(null) + try { + await api.teamForumModeratePost(slug, post.id, { action }) + await onChanged() + } catch (err) { + setError(err.message || 'Could not do that') + } + } + + return ( +
+

+ {post.author} + {post.authorDeleted && ' (account removed)'} + {post.editedAt && ' · edited'} + {post.status === 'hidden' && ' · hidden'} +

+ + {editing ? ( +
+