feat(teams): the phase 5 surface — discussion, replies, reports, and two admin screens

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:08:59 -05:00
parent 128de0ff2e
commit 3f7e61af1c
11 changed files with 1145 additions and 47 deletions

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
import { api } from '../../../api/client.js'
import { useSite } from '../../../contexts/SiteContext.jsx'
// The operator's two Team-forum controls (TEAMS.md §5.5), and the acknowledgement.
// The operator's Team-forum controls (TEAMS.md §5.5, plus phase 5's edit window),
// and the acknowledgement.
//
// Its own panel rather than two more rows in SettingsAdmin's FIELDS table, for the
// same reason EmailDelivery is its own: one of these settings has a server-side
@@ -68,6 +69,7 @@ export default function TeamForumSettings() {
const [state, setState] = useState(null)
const [enabled, setEnabled] = useState(false)
const [mode, setMode] = useState('disabled')
const [editWindow, setEditWindow] = useState('15')
const [dialog, setDialog] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
@@ -79,6 +81,7 @@ export default function TeamForumSettings() {
setState(s)
setEnabled(s.enabled)
setMode(s.imageMode)
setEditWindow(String(s.editWindowMinutes ?? 15))
} catch {
setError('Could not load forum settings.')
}
@@ -97,6 +100,7 @@ export default function TeamForumSettings() {
await api.admin.updateSettings({
teams_forums_enabled: next.enabled ? '1' : '0',
teams_forum_images: next.mode,
teams_forum_edit_window_minutes: String(next.editWindow),
...(acknowledge ? { acknowledge } : {}),
})
setSaved(true)
@@ -115,14 +119,14 @@ export default function TeamForumSettings() {
function save() {
setSaved(false)
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
setDialog({ enabled, mode })
setDialog({ enabled, mode, editWindow })
return
}
if (stale) {
setDialog({ enabled, mode })
setDialog({ enabled, mode, editWindow })
return
}
persist({ enabled, mode })
persist({ enabled, mode, editWindow })
}
return (
@@ -159,6 +163,25 @@ export default function TeamForumSettings() {
</select>
</label>
<label style={{ display: 'block', marginTop: 14 }}>
<span className="field-label">Post edit window (minutes)</span>
<input
type="number"
className="input"
min={0}
max={state.editWindowMax ?? 1440}
value={editWindow}
onChange={(e) => { setEditWindow(e.target.value); setSaved(false) }}
style={{ maxWidth: 120 }}
/>
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
How long an author may edit their own post after writing it. Staff are not bound by it and
may edit at any time. Set it to 0 to make posts permanent once written a bound of some
kind is what stops a post being rewritten out from under someone quoting it, or under a
moderator about to act on a report.
</span>
</label>
<div className="sans dim" style={{ marginTop: 8, fontSize: '0.76rem', lineHeight: 1.55 }}>
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
<ul style={{ margin: '0 0 6px 18px' }}>
@@ -181,7 +204,12 @@ export default function TeamForumSettings() {
{dialog && (
<UploadsDialog
version={state.acknowledgement.version}
onCancel={() => { setDialog(null); setMode(state.imageMode); setEnabled(state.enabled) }}
onCancel={() => {
setDialog(null)
setMode(state.imageMode)
setEnabled(state.enabled)
setEditWindow(String(state.editWindowMinutes ?? 15))
}}
onConfirm={async (version) => {
setDialog(null)
await persist(dialog, version)