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

@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
await api.admin.disableModule('a b/c')
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
})
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
//
// The URL shapes matter more here than they look. Replies hang off a THREAD;
// edits and post moderation hang off a POST; and the report route hangs off the
// forum rather than off either, because a report can name a thread, a post or an
// upload and is not moderation of any of them.
test('a reply hangs off its thread and an edit hangs off its post', async () => {
willReply({ body: { ok: true } })
await api.teamForumReply('ossuary', 5, { body: 'hi' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
assert.equal(calls[0].opts.method, 'POST')
calls = []
willReply({ body: { ok: true } })
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
// PATCH, not POST: an edit replaces part of a post that already exists, and the
// server's route is mounted on the verb.
assert.equal(calls[0].opts.method, 'PATCH')
})
test('post moderation is a different route from thread moderation', async () => {
// Not the same route with a target kind, because the two answer to different
// rules — `pin` and `lock` mean nothing to a post at all.
willReply({ body: { ok: true } })
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
calls = []
willReply({ body: { ok: true } })
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
})
test('a report goes to the forum, and its queue is under admin moderation', async () => {
willReply({ body: { ok: true } })
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
assert.deepEqual(JSON.parse(calls[0].opts.body), {
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
})
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
// should have one place to work, and there is deliberately no leader-facing
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
calls = []
willReply({ body: { reports: [] } })
await api.admin.contentReports({ status: 'open' })
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
})
test('the report queue defaults to the open work rather than to everything', async () => {
willReply({ body: { reports: [] } })
await api.admin.contentReports()
// No query string at all — the server's default is open + reviewing, and a
// client that pinned `status=all` here would put the archive in front of a
// staffer every time they opened the screen.
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
})
test('a Team slug is URL-encoded on every forum path', async () => {
willReply({ body: { ok: true } })
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
})