feat(teams): phase 5 — Forum 5b, discussion + moderation + reports #155

Merged
whitlocktech merged 5 commits from feature/teams-phase5-discussion into edge 2026-08-18 18:36:52 +00:00
29 changed files with 4349 additions and 115 deletions

View File

@@ -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() {
<Route index element={<Moderation />} />
<Route path="user/:discordId" element={<ModerationUser />} />
<Route path="appeals" element={<Appeals />} />
<Route path="reports" element={<ContentReports />} />
</Route>
<Route path="activity" element={<ActivityAdmin />} />
<Route path="bot-activity" element={<BotActivityAdmin />} />

View File

@@ -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)

View File

@@ -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
* `<img>` 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(/<img[^>]*>/gi, '')
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
// Entities last: unescaping before tag-stripping would let an escaped
// "&lt;script&gt;" become a real tag the next pass then removes, which is a
// different string from the one the author wrote.
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
// `&amp;` last of all, or "&amp;lt;" would decode two steps into "<".
.replace(/&amp;/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(' · ')
}

View File

@@ -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 (
<ThreadView
slug={team.slug}
thread={thread}
canModerate={forum.canModerate}
imageMode={imageMode}
onBack={() => 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 }) {
<section style={{ marginTop: 26 }}>
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
Announcements
Forum
</h2>
{forum.canPost && !composing && (
<button type="button" className="pill" onClick={() => setComposing(true)}>
Post an announcement
</button>
{!composing && (
<div style={{ display: 'flex', gap: 8 }}>
{/*
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 && (
<button type="button" className="pill" onClick={() => setComposing('discussion')}>
Start a discussion
</button>
)}
{forum.canAnnounce && (
<button type="button" className="pill" onClick={() => setComposing('announcement')}>
Post an announcement
</button>
)}
</div>
)}
</header>
{composing && (
<Composer
slug={team.slug}
type={composing}
imageMode={imageMode}
onCancel={() => 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 && (
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
Nothing has been announced here yet.
Nothing has been posted here yet.
</p>
)}
@@ -158,10 +195,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
}}
>
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
{t.locked && <span className="dim" style={{ marginRight: 6 }} title="Locked">🔒</span>}
<strong>{t.title}</strong>
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
{t.author}
{t.status === 'hidden' && ' · hidden'}
{threadSummary(t)}
</span>
</button>
</li>
@@ -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 (
<section style={{ marginTop: 26 }}>
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
All announcements
All threads
</button>
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
{thread.title}
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author}
{thread.authorDeleted && ' (account removed)'}
{thread.locked && ' · locked'}
</p>
{thread.posts.map((post) => (
<article key={post.id} style={{ marginBottom: 16 }}>
<PostView
key={post.id}
slug={slug}
post={post}
canModerate={canModerate}
now={now}
onChanged={onChanged}
/>
))}
{/*
`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 && (
<button type="button" className="pill" onClick={() => setReplying(true)} style={{ marginTop: 4 }}>
Reply
</button>
)}
{thread.canReply && replying && (
<ReplyBox
slug={slug}
threadId={thread.id}
imageMode={imageMode}
onCancel={() => setReplying(false)}
onPosted={async () => {
setReplying(false)
await onChanged()
}}
/>
)}
{!thread.canReply && thread.locked && (
<p className="sans dim" style={{ fontSize: '0.85rem', marginTop: 10 }}>
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.
</p>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<ReportControl
slug={slug}
targetType="team_forum_thread"
targetId={thread.id}
label="Report this thread"
/>
{canModerate && (
<>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.locked ? 'unlock' : 'lock')}>
{thread.locked ? 'Unlock' : 'Lock'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
</>
)}
</div>
</section>
)
}
/**
* 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 (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>
{editing ? (
<form onSubmit={save} style={{ display: 'grid', gap: 8 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={6}
required
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Save</button>
<button type="button" className="pill" onClick={() => setEditing(false)}>Cancel</button>
</div>
</form>
) : (
<>
{/*
Sanitised on write with the forum's own profile, rendered server-side
under the operator's image policy, and re-sanitised here — the same
@@ -307,25 +489,138 @@ function ThreadView({ thread, canModerate, onBack, onModerate }) {
className="prose"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
/>
</article>
))}
</>
)}
{canModerate && (
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
{thread.pinned ? 'Unpin' : 'Pin'}
</button>
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
{!editing && (
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{stillEditable && (
<button
type="button"
className="pill"
onClick={() => { setBody(stripToText(post.body)); setEditing(true) }}
>
Edit
</button>
)}
{/* Reporting your own post is pointless rather than harmful, but
offering it reads as an invitation to misunderstand the control. */}
{!post.mine && (
<ReportControl
slug={slug}
targetType="team_forum_post"
targetId={post.id}
label="Report"
/>
)}
{canModerate && (
<>
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
{post.status === 'hidden' ? 'Unhide' : 'Hide'}
</button>
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
</>
)}
</div>
)}
</section>
</article>
)
}
function Composer({ slug, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
/**
* The report control — the first user-facing report flow this site has ever had.
*
* **It goes to site staff, and it says so.** The gap it closes is that leaders
* moderate their own Team's forum and a Team's leaders are exactly the people who
* will not report their own Team, so telling a member where the report lands is
* not reassurance copy — it is the whole reason the control is worth using in a
* Team whose leadership is the problem.
*
* A report changes nothing about the content, and the confirmation says that too,
* because a member who expects a post to vanish and watches it stay will report
* it again.
*/
function ReportControl({ slug, targetType, targetId, label }) {
const [open, setOpen] = useState(false)
const [reason, setReason] = useState('abuse')
const [detail, setDetail] = useState('')
const [done, setDone] = useState(false)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
await api.teamForumReport(slug, { targetType, targetId, reason, detail: detail || undefined })
setDone(true)
setOpen(false)
} catch (err) {
setError(err.message || 'Could not send that')
} finally {
setBusy(false)
}
}
if (done) {
return (
<span className="sans dim" style={{ fontSize: '0.8rem' }}>
Reported to site staff.
</span>
)
}
if (!open) {
return (
<button type="button" className="pill" onClick={() => setOpen(true)}>
{label}
</button>
)
}
return (
<form
onSubmit={submit}
style={{
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
}}
>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
This goes to <strong>site staff</strong>, not to this Team&rsquo;s leaders. Reporting does not
hide or change anything it asks a staffer to look.
</p>
<label className="sans" style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => setOpen(false)}>Cancel</button>
</div>
</form>
)
}
/** A reply to an open discussion thread. */
function ReplyBox({ slug, threadId, imageMode, onCancel, onPosted }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
@@ -335,7 +630,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
setBusy(true)
setError(null)
try {
await api.teamForumPost(slug, { type: 'announcement', title, body })
await api.teamForumReply(slug, threadId, { body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
@@ -344,18 +639,78 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
}
}
return (
<form onSubmit={submit} style={{ display: 'grid', gap: 8, marginTop: 10 }}>
<textarea
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a reply. Paste an image URL on its own line to share a picture."
rows={5}
required
/>
{imageMode === 'uploads' && (
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post reply</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>
)
}
/**
* The upload control, shared by both composers.
*
* The URL goes into the BODY as text, never as an `<img>` tag. The author never
* writes markup here — core decides at render time whether a URL becomes a
* picture, which is what makes the operator's image policy enforceable rather
* than decorative.
*/
function ImageAttacher({ slug, onAttached, onError }) {
const attach = async (event) => {
const file = event.target.files?.[0]
if (!file) return
try {
const { url } = await api.teamForumUpload(slug, file)
// The URL goes into the BODY as text, not as an <img> tag. The author never
// writes markup here — core decides at render time whether a URL becomes a
// picture, which is what makes the operator's image policy enforceable
// rather than decorative.
setBody((current) => `${current}${current ? '\n\n' : ''}${url}`)
onAttached(url)
} catch (err) {
setError(err.message || 'Could not upload that')
onError(err.message || 'Could not upload that')
}
}
return (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
)
}
function Composer({ slug, type, imageMode, onCancel, onPosted }) {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const isAnnouncement = type === 'announcement'
const submit = async (event) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
// `type` is always sent explicitly. The server defaults an absent one to
// `announcement` so that a phase-4 client keeps meaning what it meant, and
// relying on that default here would make a discussion depend on a
// compatibility shim.
await api.teamForumPost(slug, { type, title, body })
await onPosted()
} catch (err) {
setError(err.message || 'Could not post that')
} finally {
setBusy(false)
}
}
@@ -373,18 +728,25 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
className="textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your announcement. Paste an image URL on its own line to share a picture."
placeholder={isAnnouncement
? 'Write your announcement. Paste an image URL on its own line to share a picture.'
: 'Start the discussion. Paste an image URL on its own line to share a picture.'}
rows={6}
required
/>
{isAnnouncement && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
Announcements cannot be replied to.
</p>
)}
{imageMode === 'uploads' && (
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
Attach an image: <input type="file" accept="image/*" onChange={attach} />
</label>
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
)}
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Post</button>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{isAnnouncement ? 'Post announcement' : 'Start discussion'}
</button>
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
</div>
</form>

View File

@@ -76,6 +76,11 @@ export const NAV = [
items: [
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
{ to: '/admin/moderation/appeals', label: 'Appeals', icon: IconShield, roles: ['admin', 'moderator'] },
// Member-raised reports (TEAMS.md §5.6). Here rather than under Teams
// because a staffer working a queue should have one place to work — and
// because the queue is deliberately generic, so the next thing that can
// be reported arrives as a row rather than as another nav entry.
{ to: '/admin/moderation/reports', label: 'Reports', icon: IconShield, roles: ['admin', 'moderator'] },
// Moderation rather than System: the screen's daily job is the
// reserved-name review queue, which is moderator work. The three actions
// that publish a game-written name are gated to admins server-side, so a
@@ -140,6 +145,7 @@ const TITLES = {
'/admin/hero': 'Hero Editor',
'/admin/moderation': 'Moderation',
'/admin/moderation/appeals': 'Appeals',
'/admin/moderation/reports': 'Reports',
'/admin/settings': 'Site Settings',
'/admin/appearance': 'Appearance',
'/admin/navigation': 'Navigation',

View File

@@ -0,0 +1,310 @@
import { useCallback, useState } from 'react'
import Modal from '../../../components/Modal.jsx'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// The member-raised content-report queue (TEAMS.md §5.6).
//
// **This is the only view of this queue, and that is the design.** The gap §5.6
// exists to close has a specific shape: leaders moderate their own Team's forum,
// and a Team's leaders are exactly the people who will not report their own Team.
// A leader-visible queue would route a complaint about a leader back to that
// leader. Org lead, 2026-08-18: reports are **site administration only**. If a
// leader-facing view is ever wanted it is a design decision, not a component.
//
// It sits beside Appeals rather than under Teams because a staffer working a
// queue should have one place to work — and because `target_type` is deliberately
// open-ended, so the next consumer (a wiki page, a news comment) arrives as a new
// row here rather than as a new screen.
//
// **Handling a report is bookkeeping about the REPORT, not moderation of the
// content.** Acting on the content itself is the ordinary forum moderation
// control, or a site-wide sanction against the account. Keeping those separate is
// what stops "report" from becoming a way for any member to hide anything, so
// this screen deliberately offers no hide/delete button of its own.
const STATUS_TABS = [
{ key: 'open_work', label: 'Open work', param: undefined },
{ key: 'open', label: 'Open', param: 'open' },
{ key: 'reviewing', label: 'Reviewing', param: 'reviewing' },
{ key: 'actioned', label: 'Actioned', param: 'actioned' },
{ key: 'dismissed', label: 'Dismissed', param: 'dismissed' },
{ key: 'all', label: 'All', param: 'all' },
]
const STATUS_STYLE = {
open: { color: '#e0b070', background: 'rgba(224,176,112,0.12)', border: '1px solid rgba(224,176,112,0.4)' },
reviewing: { color: '#7fa8d0', background: 'rgba(127,168,208,0.14)', border: '1px solid rgba(127,168,208,0.4)' },
actioned: { color: '#7fd0a4', background: 'rgba(95,185,138,0.16)', border: '1px solid rgba(95,185,138,0.4)' },
dismissed: { color: '#9fb0c6', background: 'rgba(127,153,189,0.14)', border: '1px solid var(--line)' },
}
const STATUS_LABEL = {
open: 'Open', reviewing: 'Reviewing', actioned: 'Actioned', dismissed: 'Dismissed',
}
const REASON_LABEL = {
spam: 'Spam',
abuse: 'Abuse',
sexual: 'Sexual',
illegal: 'Illegal',
impersonation: 'Impersonation',
other: 'Other',
}
const bytes = (n) => {
if (!n && n !== 0) return ''
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`
return `${(n / (1024 * 1024)).toFixed(1)} MB`
}
/**
* What was reported, rendered from the row the queue already resolved.
*
* Nothing here fetches: §5.6's fourth rule is that a staffer sees uploader, size
* and sniffed type without hunting, and the server attaches all of it in three
* batched reads. A `null` target is a target that has since been hard-deleted,
* and the row still shows — "somebody reported this and by the time we looked it
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
* member deleting their own content the moment it is reported.
*/
function TargetCell({ report }) {
const t = report.target
if (!t) {
return (
<span style={{ color: 'var(--muted)' }}>
{report.targetType.replace('team_forum_', '')} #{report.targetId} no longer exists
</span>
)
}
if (t.kind === 'upload') {
return (
<span>
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
</span>
</span>
)
}
if (t.kind === 'thread') {
return (
<span>
<strong>{t.title}</strong>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.type} by {t.author || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
return (
<span>
{t.excerpt || <em className="dim">(no text)</em>}
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.author || 'unknown'} in {t.threadTitle}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
)
}
export default function ContentReports() {
const [tab, setTab] = useState('open_work')
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const [handling, setHandling] = useState(null)
const [notice, setNotice] = useState(null)
const activeTab = STATUS_TABS.find((t) => t.key === tab) || STATUS_TABS[0]
const { loading, error, data } = useAsync(
() => api.admin.contentReports({ status: activeTab.param }),
[tab, tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load reports." />
const rows = data?.reports || []
return (
<section>
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
Reports raised by members about Team forum content. They come to site staff and are not visible
to a Team&rsquo;s own leaders a leader moderates their own forum, so a report about a leader
has to reach someone above them. Handling a report records a decision about the report; hiding
or removing the content itself is done from the forum, or as a sanction against the account.
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 16 }}>
{STATUS_TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="pill"
style={tab === t.key ? activePill : undefined}
>
{t.label}
</button>
))}
</div>
{notice && (
<p
className="sans"
style={{ margin: '0 0 14px', color: notice.tone === 'error' ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}
>
{notice.text}
</p>
)}
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reported content</th>
<th className="adm-th">Reason</th>
<th className="adm-th">Detail</th>
<th className="adm-th">Reporter</th>
<th className="adm-th">Age</th>
<th className="adm-th">Status</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={7} style={muted}>
No reports match this filter.
</td>
</tr>
)}
{rows.map((r) => (
<tr key={r.id}>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 340 }}>
<TargetCell report={r} />
</td>
<td className="adm-td">
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
</td>
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
<td className="adm-td dim">{r.reporter}</td>
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
{r.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{r.handledBy}
{r.handledNote ? `${r.handledNote}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button
onClick={() => setHandling(r)}
className="btn btn-primary btn-sq"
style={{ padding: '5px 12px', fontSize: '0.82rem' }}
>
Handle
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{handling && (
<HandleModal
report={handling}
onCancel={() => setHandling(null)}
onDone={() => {
setHandling(null)
setNotice({ text: 'Report updated.', tone: 'ok' })
reload()
}}
onError={(message) => setNotice({ text: message, tone: 'error' })}
/>
)}
</section>
)
}
/**
* Record a decision about a report.
*
* The note is optional and worth writing: every transition is audited, dismissals
* included, and the note is what the next staffer to see a repeat report about the
* same content reads to find out why the last one was closed.
*/
function HandleModal({ report, onCancel, onDone, onError }) {
const [status, setStatus] = useState(report.status === 'open' ? 'reviewing' : 'actioned')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setBusy(true)
try {
await api.admin.handleContentReport(report.id, { status, note: note || undefined })
onDone()
} catch (err) {
onError(err.message || 'Could not update that report.')
setBusy(false)
}
}
return (
<Modal
title={`Report #${report.id}`}
onClose={onCancel}
footer={(
<>
<button className="pill" onClick={onCancel}>Cancel</button>
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
</>
)}
>
<div style={{ display: 'grid', gap: 12 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
This records a decision about the report. It does not hide, delete or restore the content
do that from the forum itself, or against the account.
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{['reviewing', 'actioned', 'dismissed', 'open'].map((value) => (
<button
key={value}
onClick={() => setStatus(value)}
className="pill"
style={status === value ? activePill : undefined}
>
{STATUS_LABEL[value]}
</button>
))}
</div>
<label>
<span className="field-label">Note (optional)</span>
<textarea
className="textarea"
placeholder="Why this was actioned or dismissed — the next staffer to see a repeat report reads this."
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={4}
style={{ width: '100%' }}
/>
</label>
</div>
</Modal>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const muted = { color: 'var(--muted)' }

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)

View File

@@ -152,7 +152,7 @@ function RequestQueue({ rows, role, onDecide, busy }) {
// ── One Team ───────────────────────────────────────────────────────────────
function TeamRow({ team, role, onAct, busy }) {
function TeamRow({ team, role, onAct, busy, onLedger }) {
const status = statusOf(team)
return (
<tr>
@@ -181,11 +181,84 @@ function TeamRow({ team, role, onAct, busy }) {
Hide
</button>
))}
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}>
Forum log
</button>
</td>
</tr>
)
}
/**
* One Team's forum moderation ledger (TEAMS.md §5.3).
*
* The route and the API method have existed since phase 4 and nothing rendered
* them, which made the ledger a table only a DB client could read. The column
* that earns the screen is `actorRole`: it records WHICH authority was exercised,
* so a leader's ordinary housekeeping stays distinguishable from a staff
* intervention after the fact.
*
* **This is deliberately not merged with the site's mod_actions/appeals pair.**
* That one is Discord-sanction-shaped and bot-owned; routing a guild leader
* locking a thread through it would make ordinary housekeeping an appealable
* sanction with a reversal path into the bot. Every STAFF-exercised action here
* additionally writes activity_log, so the site's accountability trail sees it —
* the two are cross-referenced, not merged.
*/
function ForumLedger({ team, onClose }) {
const [rows, setRows] = useState(null)
const [error, setError] = useState('')
useEffect(() => {
let active = true
api.admin.teamForumModeration(team.id)
// `{ entries }`, and the rows are the ledger table's own snake_case
// columns — this endpoint serves them unmapped, unlike the Team payloads
// above it. Reading them as they are, rather than accepting three possible
// shapes, is what makes a change to that endpoint fail here instead of
// rendering an empty table.
.then((res) => { if (active) setRows(res.entries) })
.catch((err) => { if (active) setError(err.message || 'Could not load the forum log.') })
return () => { active = false }
}, [team.id])
return (
<section className="panel">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h2>Forum log {team.displayName}</h2>
<button type="button" className="btn" onClick={onClose}>Close</button>
</header>
{error && <ErrorState message={error} />}
{!rows && !error && <Loading />}
{rows && rows.length === 0 && <p className="muted">Nothing has been moderated in this forum.</p>}
{rows && rows.length > 0 && (
<table className="table">
<thead>
<tr>
<th>When</th><th>Action</th><th>Target</th><th>By</th><th>As</th><th>Reason</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="muted">{dateTime(r.created_at)}</td>
<td>{r.action}</td>
<td className="muted">{r.target_type} #{r.target_id}</td>
<td>{r.actor_username || '—'}</td>
<td>
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>
</td>
<td className="muted">{r.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
)
}
// ── The screen ─────────────────────────────────────────────────────────────
export default function TeamsAdmin() {
@@ -198,6 +271,7 @@ export default function TeamsAdmin() {
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [ledgerTeam, setLedgerTeam] = useState(null)
const load = useCallback(async () => {
setError('')
@@ -265,6 +339,8 @@ export default function TeamsAdmin() {
{error && <ErrorState message={error} />}
{notice && <p className="notice">{notice}</p>}
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
@@ -288,7 +364,14 @@ export default function TeamsAdmin() {
</thead>
<tbody>
{data.teams.map((team) => (
<TeamRow key={team.id} team={team} role={role} onAct={act} busy={busy} />
<TeamRow
key={team.id}
team={team}
role={role}
onAct={act}
busy={busy}
onLedger={setLedgerTeam}
/>
))}
</tbody>
</table>

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')
})

View File

@@ -0,0 +1,120 @@
// What the Team forum's client half decides for itself (client/src/lib/teamForum.js).
//
// The point of this file is how LITTLE that is. Who may post, who may moderate,
// whether an image renders and whether a post may be edited are all server
// answers the panel reads. What is tested here is the three places the client
// turns those answers into what a reader sees — and one property that is easy to
// break by accident: the edit offer can only ever be withdrawn here, never
// granted.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../src/lib/teamForum.js'
const NOW = new Date('2026-08-18T12:00:00Z').getTime()
const inMinutes = (n) => new Date(NOW + n * 60_000).toISOString()
// ── the edit offer ─────────────────────────────────────────────────────────
test('the client can withdraw an edit offer and can never create one', () => {
// The server said no. Nothing about a deadline changes that — a future
// `editableUntil` on a post the server refused must not become an offer, or
// the client would be granting a permission.
assert.equal(editOfferOpen({ canEdit: false, editableUntil: inMinutes(10) }, NOW), false)
assert.equal(editOfferOpen({ canEdit: false, editableUntil: null }, NOW), false)
})
test('a deadline that has passed while the page sat open withdraws the offer', () => {
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW), true)
// Same post, fifteen minutes of the reader staring at it later.
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW + 15 * 60_000), false)
})
test('no deadline means no deadline, not no permission', () => {
// Staff are not time-bounded, and `editableUntil: null` is how the server says
// so. Reading it as "expired" would take the edit control away from exactly the
// people whose authority does not expire.
assert.equal(editOfferOpen({ canEdit: true, editableUntil: null }, NOW), true)
})
test('an unparseable deadline closes the offer rather than opening it', () => {
assert.equal(editOfferOpen({ canEdit: true, editableUntil: 'not a date' }, NOW), false)
assert.equal(editOfferOpen(null, NOW), false)
assert.equal(editOfferOpen(undefined, NOW), false)
})
// ── round-tripping a body back into the composer ───────────────────────────
test('the image core generated is stripped, and the URL that made it survives', () => {
// §5.5.3: the author wrote a URL, core emitted the <img> at read time. Handing
// the <img> back would let an author edit markup they never wrote — and the
// URL is what re-renders it, so nothing is lost by removing it.
const rendered = '<p><a href="https://x/a.png" rel="noopener noreferrer">https://x/a.png</a>'
+ '<img src="https://x/a.png" class="forum-embed" referrerpolicy="no-referrer" /></p>'
const text = stripToText(rendered)
assert.ok(!text.includes('<img'))
assert.ok(text.includes('https://x/a.png'))
})
test('paragraphs become blank lines and breaks become newlines', () => {
assert.equal(stripToText('<p>One</p><p>Two</p>'), 'One\n\nTwo')
assert.equal(stripToText('<p>One<br>Two</p>'), 'One\nTwo')
// A paragraph carrying attributes is still a paragraph.
assert.equal(stripToText('<p>One</p>\n<p class="x">Two</p>'), 'One\n\nTwo')
})
test('entities decode to what the author typed, and only once', () => {
assert.equal(stripToText('<p>Tom &amp; Jerry</p>'), 'Tom & Jerry')
assert.equal(stripToText('<p>&quot;quoted&quot;</p>'), '"quoted"')
// The one that bites: an author who typed a literal "<script>" has it stored
// escaped. Decoding entities BEFORE stripping tags would turn it into a real
// tag that the strip pass then deletes — silently losing text the author wrote
// and which was never dangerous.
assert.equal(stripToText('<p>&lt;script&gt;</p>'), '<script>')
// And decoding &amp; first would turn "&amp;lt;" into "<" in two steps.
assert.equal(stripToText('<p>&amp;lt;</p>'), '&lt;')
})
test('an empty or absent body is an empty string, never a crash', () => {
assert.equal(stripToText(''), '')
assert.equal(stripToText(null), '')
assert.equal(stripToText(undefined), '')
assert.equal(stripToText('<p></p>'), '')
})
// ── the thread list line ───────────────────────────────────────────────────
test('a discussion counts REPLIES, which is one fewer than its posts', () => {
// postCount includes the opening post. Showing it raw would tell a reader a
// brand-new thread already has one reply.
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 1 }), 'ada')
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 2 }), 'ada · 1 reply')
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 4 }), 'ada · 3 replies')
})
test('an announcement says so and never counts replies, because it takes none', () => {
const line = threadSummary({ type: 'announcement', author: 'aldric', postCount: 1 })
assert.equal(line, 'Announcement · aldric')
assert.ok(!line.includes('repl'))
})
test('hidden is said out loud — it is only shown to whoever can unhide it', () => {
assert.equal(
threadSummary({ type: 'discussion', author: 'ada', postCount: 1, status: 'hidden' }),
'ada · hidden',
)
})
// ── the report control ─────────────────────────────────────────────────────
test('every reason the server accepts is offered, and no others', () => {
// The server validates against its own list; a client offering a reason the
// server rejects produces a 400 the reporter cannot act on, and one MISSING a
// reason quietly funnels those reports into "other".
assert.deepEqual(
REPORT_REASONS.map(([value]) => value).sort(),
['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'],
)
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
})

View File

@@ -1134,6 +1134,69 @@ CREATE TABLE IF NOT EXISTS team_forum_uploads (
INDEX idx_tfu_sweep (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Member-raised abuse reports (§5.6). **Core had no user-facing report flow of
-- any kind before this**: `moderation`, `mod_notes` and `appeals` are all either
-- staff-initiated or Discord-sanction-shaped, and nothing anywhere let a MEMBER
-- say "this is a problem". That was survivable while every piece of content on
-- the site came from staff. It stops being survivable the moment a Team forum
-- lets players write to each other, and stops twice over when `uploads` mode lets
-- them put files on the operator's disk under a signed liability acknowledgement.
--
-- The gap has a specific shape worth naming: leaders moderate their own Team's
-- forum, and a Team's leaders are exactly the people who will not report their own
-- Team. So this table's whole point is a path that routes AROUND a Team's own
-- leadership — **reports go to site staff and to nobody else.** There is
-- deliberately no leader-facing view of this queue (org lead, 2026-08-18); a
-- leader-visible report about a leader is not a report.
--
-- Not a `team_*` table, and not named for the forum: `target_type` is a plain
-- VARCHAR so wiki pages, news comments and profile fields become new values
-- rather than new tables. Team forum content is only the first consumer.
--
-- **The unique key is on an `open_marker`, not on `status`.** §5.6 writes the key
-- as (target_type, target_id, reporter_user_id, status), and that spelling has a
-- defect worth recording rather than quietly fixing: it makes CLOSED rows collide
-- with each other too. A reporter reports a post, staff dismiss it, the behaviour
-- recurs, they report it again — and the second dismissal is an UPDATE into a
-- (…, 'dismissed') tuple that already exists, so working the queue would start
-- throwing duplicate-key errors after the first repeat reporter.
--
-- The generated marker is the same trick `team_forum_grants.active_marker` uses:
-- it is 1 while the report is OPEN and NULL once it is closed, and MySQL treats
-- NULLs as distinct, so any number of closed reports coexist while at most one
-- open one can. That is what §5.6's prose actually asks for — "one open report per
-- (target, reporter)".
--
-- NULL reporters (deleted accounts) are distinct for the same reason, which is
-- also wanted: nothing should collapse two dead accounts' reports into one.
--
-- `handled_note` is not in the design doc and earns its place: a queue whose
-- resolution reason lives only in an activity_log line is one where the next
-- staffer to see a repeat report cannot find out why the last one was dismissed.
CREATE TABLE IF NOT EXISTS content_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload'
target_id BIGINT NOT NULL,
team_id INT NULL, -- denormalised for the queue's filters
reporter_user_id INT NULL,
reporter_username VARCHAR(32) NULL, -- snapshot (§2.10): who raised it survives the account
reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL,
detail VARCHAR(500) NULL,
status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open',
handled_by INT NULL,
handled_username VARCHAR(32) NULL, -- snapshot, same reason
handled_note VARCHAR(500) NULL,
handled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED,
CONSTRAINT fk_cr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_cr_handler FOREIGN KEY (handled_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, open_marker),
INDEX idx_cr_queue (status, created_at),
INDEX idx_cr_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a
@@ -1233,6 +1296,12 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL;
-- so the system behaves exactly as today until an admin opts in.
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
-- Team forum post edit window, in minutes (TEAMS.md §5.4, phase 5). Seeded rather
-- than left absent so the value an operator sees on the settings screen is the
-- value in force — an empty field that silently behaves as 15 is a field nobody
-- trusts. INSERT IGNORE, so an operator who has already changed it keeps theirs.
INSERT IGNORE INTO settings (`key`, value) VALUES ('teams_forum_edit_window_minutes', '15');
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;

View File

@@ -345,6 +345,26 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search",
@@ -1687,6 +1707,39 @@
"requireAuth"
]
},
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads",
@@ -1729,6 +1782,17 @@
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads",

View File

@@ -145,6 +145,14 @@
"method": "GET",
"path": "/api/v1/admin/moderation/recent"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/reports"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search"
@@ -677,6 +685,18 @@
"method": "GET",
"path": "/api/v1/player/teams/:slug/access"
},
{
"method": "PATCH",
"path": "/api/v1/player/teams/:slug/forum/posts/:id"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/posts/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/report"
},
{
"method": "GET",
"path": "/api/v1/player/teams/:slug/forum/threads"
@@ -693,6 +713,10 @@
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
},
{
"method": "POST",
"path": "/api/v1/player/teams/:slug/forum/uploads"

View File

@@ -0,0 +1,154 @@
// SQL for `content_reports` (TEAMS.md §5.6).
//
// Not under model/teams/ even though Team forum content is its only consumer
// today: the table is deliberately generic — `target_type` is a VARCHAR so that a
// wiki page or a news comment becomes a new value rather than a new table — and
// filing it under a feature it will outgrow is how the next consumer ends up
// building its own.
//
// Nothing here decides who may read a report. That is the route's job, and there
// is exactly one answer: site staff (§5.6, and the org lead's 2026-08-18 ruling
// that reports are site administration only).
const { query } = require('../../utils/db')
const COLUMNS = `
id, target_type, target_id, team_id, reporter_user_id, reporter_username,
reason, detail, status, handled_by, handled_username, handled_note, handled_at,
created_at`
const OPEN_STATUSES = ['open', 'reviewing']
/**
* File a report.
*
* The duplicate is caught by the unique key rather than by a SELECT first, which
* is the difference between "usually not a duplicate" and "never a duplicate":
* two taps of a report button race, and only the index settles it. ER_DUP_ENTRY
* comes back as a clean `null` so the caller can answer 409 without knowing what
* a MySQL error code looks like.
*/
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) {
try {
const res = await query(
`INSERT INTO content_reports
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null],
)
return res.insertId
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) return null
throw err
}
}
async function byId(id) {
const rows = await query(`SELECT ${COLUMNS} FROM content_reports WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
/**
* The queue.
*
* `status` defaults to the two OPEN statuses rather than to everything: a staffer
* opening the queue wants the work, not the archive. 'all' is the explicit escape
* hatch and every single status is selectable, so nothing is unreachable.
*/
async function list({ status, teamId, limit = 100, offset = 0 } = {}) {
const where = []
const args = []
if (status && status !== 'all') {
where.push('status = ?')
args.push(status)
} else if (!status) {
where.push(`status IN (${OPEN_STATUSES.map(() => '?').join(',')})`)
args.push(...OPEN_STATUSES)
}
if (teamId) {
where.push('team_id = ?')
args.push(teamId)
}
args.push(limit, offset)
return query(
`SELECT ${COLUMNS} FROM content_reports
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`,
args,
)
}
/** How many are waiting, for the dashboard badge. */
async function openCount() {
const rows = await query(
`SELECT COUNT(*) AS n FROM content_reports WHERE status IN (${OPEN_STATUSES.map(() => '?').join(',')})`,
OPEN_STATUSES,
)
return Number(rows[0]?.n || 0)
}
/**
* Record a staffer's decision.
*
* `handled_*` is stamped for every status including `reviewing`, so "who has this"
* is answerable while it is in progress and not only after it is closed — that is
* what stops two staffers working the same report.
*/
async function handle(id, { status, handledBy, handledUsername, note }) {
const res = await query(
`UPDATE content_reports
SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW()
WHERE id = ?`,
[status, handledBy, handledUsername, note ?? null, id],
)
return res.affectedRows > 0
}
// ── target enrichment ──────────────────────────────────────────────────────
//
// Three batched reads rather than one per row. §5.6's fourth rule — "reports on
// uploads carry the team_forum_uploads row, so a staffer sees uploader, size and
// sniffed type without hunting" — is the reason the queue enriches at all, and a
// queue that N+1s to do it would be the version that gets turned off.
async function threadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, title, type, status, created_username FROM team_forum_threads
WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
async function postsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT p.id, p.thread_id, p.author_user_id, p.author_username, p.body_html, p.status,
p.created_at, t.team_id, t.title AS thread_title
FROM team_forum_posts p JOIN team_forum_threads t ON t.id = p.thread_id
WHERE p.id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
async function uploadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, post_id, uploader_user_id, uploader_username, filename,
mimetype, byte_size, created_at, deleted_at
FROM team_forum_uploads WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
}
module.exports = {
OPEN_STATUSES,
insert,
byId,
list,
openCount,
handle,
threadsByIds,
postsByIds,
uploadsByIds,
}

View File

@@ -0,0 +1,243 @@
// ── Abuse reports: the missing half of moderation (TEAMS.md §5.6) ──────────
//
// Two rules shape everything in this file, and both are easier to break than to
// notice broken:
//
// 1. **A report is not a moderation action.** Filing one changes nothing about
// the content — it opens a queue item. That keeps it clear of §5.3's
// leader/staff moderation ledger, which records things that actually
// happened. If reporting hid a post, reporting would BE moderation, and the
// first person to work that out would have found a way to hide anything.
//
// 2. **Reports go to site staff and to nobody else.** The gap §5.6 exists to
// close has a specific shape: leaders moderate their own Team's forum, and a
// Team's leaders are exactly the people who will not report their own Team.
// A leader-visible queue would route a complaint about a leader back to that
// leader. The org lead settled this on 2026-08-18 — reports are **site
// administration only**, with no leader-facing view at all, not even a
// read-only one scoped to their own Team.
//
// The reporter's ACCESS is the caller's business, not this file's: the player
// route resolves the forum first, so anyone reaching `file()` is someone who can
// already see the thing they are reporting. What this file does check is that the
// target is really in the Team the caller reached it through — otherwise a
// participant in one Team could file reports carrying another Team's id, and the
// queue's per-Team filter would quietly be lying.
const reportsDb = require('./contentReports.db')
const forumDb = require('../teams/teamForum.db')
const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other']
const STATUSES = ['open', 'reviewing', 'actioned', 'dismissed']
// A body excerpt for the queue, not a rendered post. Staff triage on what was
// written, and `body_html` is stored already sanitised — but the queue is a list,
// so it gets text and a length cap rather than markup.
const EXCERPT_CHARS = 300
const excerpt = (html) => String(html || '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, EXCERPT_CHARS)
/**
* Does this target exist, and is it in this Team?
*
* Returns the team id the target really belongs to, or null. The caller compares
* it with the Team the request came through — a mismatch is a 404 for the same
* §5.5.1 reason a foreign thread id is: confirming a target exists somewhere else
* on the site is itself a disclosure.
*/
async function targetTeamId(targetType, targetId) {
if (targetType === 'team_forum_thread') {
const thread = await forumDb.threadById(targetId)
return thread ? thread.team_id : null
}
if (targetType === 'team_forum_post') {
const post = await forumDb.postById(targetId)
if (!post) return null
const thread = await forumDb.threadById(post.thread_id)
return thread ? thread.team_id : null
}
if (targetType === 'team_forum_upload') {
const upload = await forumDb.uploadById(targetId)
return upload ? upload.team_id : null
}
return null
}
/**
* File a report.
*
* A duplicate answers 409 rather than pretending to succeed. Silently accepting
* it would be friendlier for one tap and dishonest for the second: a member who
* reports twice because nothing seemed to happen deserves to be told the first
* one is already in the queue.
*/
async function file({ team, actor, targetType, targetId, reason, detail }) {
if (!TARGET_TYPES.includes(targetType)) {
return { ok: false, status: 400, error: 'Unknown report target' }
}
if (!REASONS.includes(reason)) {
return { ok: false, status: 400, error: 'Unknown report reason' }
}
const owner = await targetTeamId(targetType, targetId)
if (owner == null || owner !== team.id) {
return { ok: false, status: 404, error: 'Not found' }
}
const id = await reportsDb.insert({
targetType,
targetId,
teamId: team.id,
reporterUserId: actor.id,
reporterUsername: actor.username,
reason,
detail,
})
if (id == null) {
return { ok: false, status: 409, error: 'You have already reported this. Staff are looking at it.' }
}
return { ok: true, reportId: id }
}
/**
* The staff queue, with each row's target attached.
*
* Enrichment is three batched reads keyed by target type, not one read per row.
* The alternative N+1s a page of a hundred into three hundred queries, which is
* how a queue becomes a thing staff avoid opening.
*
* A target that has since been hard-deleted comes back as `null`, and the report
* still lists. That is deliberate: "somebody reported this and by the time we
* looked it was gone" is a fact a moderator needs, and dropping the row would
* hide the pattern of a member deleting their own content the moment it is
* reported.
*/
async function queue({ status, teamId, limit, offset } = {}) {
const rows = await reportsDb.list({ status, teamId, limit, offset })
if (!rows.length) return []
const idsOf = (type) => rows.filter((r) => r.target_type === type).map((r) => Number(r.target_id))
const [threads, posts, uploads] = await Promise.all([
reportsDb.threadsByIds([...new Set(idsOf('team_forum_thread'))]),
reportsDb.postsByIds([...new Set(idsOf('team_forum_post'))]),
reportsDb.uploadsByIds([...new Set(idsOf('team_forum_upload'))]),
])
const byId = (list) => new Map(list.map((row) => [Number(row.id), row]))
const threadMap = byId(threads)
const postMap = byId(posts)
const uploadMap = byId(uploads)
return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) }))
}
/**
* The reported content, resolved.
*
* **Every miss returns `null`, never `undefined`.** They look interchangeable in
* JavaScript and are not in JSON: `undefined` is dropped by `JSON.stringify`, so
* a hard-deleted target would reach the client as an ABSENT `target` key rather
* than as an explicit null, and the queue's own contract says nullable. A client
* distinguishing "gone" from "not resolved yet" would get it wrong.
*/
function describeTarget(report, { threadMap, postMap, uploadMap }) {
const id = Number(report.target_id)
if (report.target_type === 'team_forum_thread') {
const t = threadMap.get(id)
if (!t) return null
return {
kind: 'thread',
threadId: t.id,
title: t.title,
type: t.type,
status: t.status,
author: t.created_username,
}
}
if (report.target_type === 'team_forum_post') {
const p = postMap.get(id)
if (!p) return null
return {
kind: 'post',
postId: p.id,
threadId: p.thread_id,
threadTitle: p.thread_title,
author: p.author_username,
status: p.status,
excerpt: excerpt(p.body_html),
createdAt: p.created_at,
}
}
if (report.target_type === 'team_forum_upload') {
const u = uploadMap.get(id)
if (!u) return null
// §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting.
// This is the payoff for §5.5.4's attribution table being load-bearing rather
// than bookkeeping.
return {
kind: 'upload',
uploadId: u.id,
postId: u.post_id,
uploader: u.uploader_username,
filename: u.filename,
url: `/uploads/${u.filename}`,
mimetype: u.mimetype,
byteSize: u.byte_size,
createdAt: u.created_at,
deleted: u.deleted_at != null,
}
}
return null
}
function publicReport(row) {
return {
id: row.id,
targetType: row.target_type,
targetId: Number(row.target_id),
teamId: row.team_id,
reporter: row.reporter_username || '[deleted account]',
reporterDeleted: row.reporter_user_id == null,
reason: row.reason,
detail: row.detail,
status: row.status,
handledBy: row.handled_username,
handledNote: row.handled_note,
handledAt: row.handled_at,
createdAt: row.created_at,
}
}
/** Move a report along the queue. Staff-only by its route. */
async function handle({ id, actor, status, note }) {
if (!STATUSES.includes(status)) {
return { ok: false, status: 400, error: 'Unknown report status' }
}
const report = await reportsDb.byId(id)
if (!report) return { ok: false, status: 404, error: 'Report not found' }
await reportsDb.handle(id, {
status,
handledBy: actor.id,
handledUsername: actor.username,
note,
})
return { ok: true, report: publicReport(await reportsDb.byId(id)) }
}
module.exports = {
TARGET_TYPES,
REASONS,
STATUSES,
EXCERPT_CHARS,
file,
queue,
handle,
openCount: reportsDb.openCount,
publicReport,
targetTeamId,
}

View File

@@ -104,6 +104,45 @@ async function setPostStatus(id, status) {
return res.affectedRows > 0
}
/**
* Rewrite a post's body, stamping who edited it and when.
*
* `edited_at` is set unconditionally, including when a staffer edits — the column
* answers "has this been changed since it was written", which a reader needs to
* know regardless of whose hand did it. `edited_by` is the second half of that
* answer and is why the two are separate columns rather than a boolean.
*/
async function updatePostBody(id, bodyHtml, editedBy) {
const res = await query(
'UPDATE team_forum_posts SET body_html = ?, edited_at = NOW(), edited_by = ? WHERE id = ?',
[bodyHtml, editedBy, id],
)
return res.affectedRows > 0
}
/**
* Recompute a thread's denormalised counters from the posts that are actually
* visible.
*
* Called after every post moderation rather than incrementing and decrementing,
* because hide → unhide → delete → restore is a sequence in which a counter kept
* by deltas drifts the first time any step is retried or raced. The read is one
* indexed aggregate over one thread; correctness is worth more than the write it
* saves. `last_post_at` falls back to NULL for an emptied thread, which is what
* `threadsByTeam`'s COALESCE onto `created_at` already expects.
*/
async function recountThread(threadId) {
await query(
`UPDATE team_forum_threads t
SET t.post_count = (SELECT COUNT(*) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible'),
t.last_post_at = (SELECT MAX(p.created_at) FROM team_forum_posts p
WHERE p.thread_id = t.id AND p.status = 'visible')
WHERE t.id = ?`,
[threadId],
)
}
// ── the moderation ledger (append-only) ────────────────────────────────────
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
@@ -185,6 +224,22 @@ async function softDeleteUploadsForPost(postId, deletedBy) {
)
}
/**
* The other half of the pair: a restored post gets its images back.
*
* Without this, `delete` then `restore` returns the words and loses the pictures —
* and loses them SILENTLY, because the soft-deleted rows survive the retention
* window before the sweep takes the bytes, so the post looks fine until the night
* it does not. Beyond that window the row itself is gone and this is a no-op;
* nothing can be done about that and nothing should pretend otherwise.
*/
async function restoreUploadsForPost(postId) {
await query(
'UPDATE team_forum_uploads SET deleted_at = NULL, deleted_by = NULL WHERE post_id = ? AND deleted_at IS NOT NULL',
[postId],
)
}
/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */
async function sweepableUploads(retentionDays) {
return query(
@@ -222,6 +277,8 @@ module.exports = {
postById,
insertPost,
setPostStatus,
updatePostBody,
recountThread,
insertModeration,
moderationForTeam,
insertUpload,
@@ -230,6 +287,7 @@ module.exports = {
listUploads,
softDeleteUpload,
softDeleteUploadsForPost,
restoreUploadsForPost,
sweepableUploads,
orphanedUploads,
deleteUploadRows,

View File

@@ -1,11 +1,11 @@
// ── The forum, phase 4 ("5a": access + announcements) ──────────────────────
// ── The forum: access + announcements (5a), discussion + moderation (5b) ───
//
// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a ships the whole access
// model and a single announcements stream per Team; 5b opens discussion threads,
// replies and editing. The schema for all of it landed together, so 5b enables
// paths here rather than migrating data — which is why `type` is a parameter
// below and not a constant, and why `locked` is honoured on a thread nothing can
// reply to yet.
// TEAMS.md §5.1's split is BY LAYER, not by feature: 5a shipped the whole access
// model and a single announcements stream per Team; 5b (phase 5) opens discussion
// threads, replies, editing and post-level moderation. The schema for all of it
// landed together, so this phase added no ALTER — every column it needed
// (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the
// ledger's `target_type='post'`) was already there waiting.
//
// **Every function here takes an already-resolved access decision.** Nothing in
// this file reads `team_members` or `team_forum_grants`; the caller asks
@@ -22,11 +22,26 @@ const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
// Announcements are leader-authored and replies are disabled; 5b's discussion
// threads are member-authored and take replies. Both types exist in the enum from
// day one — this is the list of what 5a will CREATE.
// Announcements are leader-authored and take no replies; discussion threads are
// member-authored and do. Both have been in the enum since 5a — what phase 5
// changed is that both are now CREATABLE, and by different people.
//
// **The authority split lives in the controller, not here.** This list says what
// kinds of thread exist; who may make one is a question about the caller, which
// this file deliberately never asks (see the header on access decisions).
const CREATABLE_TYPES = ['announcement', 'discussion']
// Kept as an export because it names a real fact — the one type 5a could create —
// and because removing a name from a module's surface to save a line is how a
// consumer outside this repo breaks. It is not used to decide anything.
const CREATABLE_TYPES_5A = ['announcement']
// Which thread types accept replies. An announcement's `locked` stays false even
// though nothing may reply to it: replies are refused because the TYPE takes none,
// not because the thread was closed, and conflating the two would make "unlock"
// look like it would open replies on an announcement.
const REPLYABLE_TYPES = ['discussion']
const DELETED_AUTHOR = '[deleted account]'
/**
@@ -48,6 +63,17 @@ const THREAD_ACTIONS = {
restore: { status: 'visible' },
}
// Post-level moderation. A strict subset of THREAD_ACTIONS: `pin` and `lock`
// describe a thread's place in a list and its openness to replies, neither of
// which a post has. Naming them here as "not applicable" rather than as "unknown"
// is what lets `moderatePost` tell a caller which mistake they made.
const POST_ACTIONS = {
hide: { status: 'hidden' },
unhide: { status: 'visible' },
delete: { status: 'deleted' },
restore: { status: 'visible' },
}
function publicThread(row) {
return {
id: row.id,
@@ -65,14 +91,44 @@ function publicThread(row) {
}
/**
* One post, rendered for one image policy.
* May this viewer edit this post, and until when?
*
* **Computed on the server and handed to the client, never the other way round** —
* the same rule §5.5.3 applies to the image policy, for the same reason. A client
* that decided this would be deciding it against its own clock, and a clock is the
* one input a time-bounded permission must not take from the party it bounds.
*
* Staff get `editableUntil: null`, which reads as "no deadline" rather than as "no
* permission" — `canEdit` is the permission and this is only its expiry. An author
* past their window keeps a past `editableUntil`, so the UI can say *why* the
* control is gone instead of silently dropping it.
*/
function editability(row, { userId = null, isStaff = false, windowMinutes = 0, now = Date.now() } = {}) {
// A hidden or deleted post is not editable by anybody, staff included. Restoring
// it is a moderation action with a ledger row; quietly rewriting it while it is
// out of sight is the same act with no record.
if (row.status !== 'visible') return { canEdit: false, editableUntil: null }
if (isStaff) return { canEdit: true, editableUntil: null }
if (!userId || row.author_user_id == null || row.author_user_id !== userId) {
return { canEdit: false, editableUntil: null }
}
const until = new Date(row.created_at).getTime() + windowMinutes * 60_000
return { canEdit: until > now, editableUntil: new Date(until).toISOString() }
}
/**
* One post, rendered for one image policy and one viewer.
*
* `body` is what the reader gets and `mode` decides whether it carries images.
* The STORED html is never modified — flipping the policy changes this function's
* output and nothing on disk, which is the property §5.5.3 exists to give and the
* one acceptance criterion 3 measures.
*
* `viewer` is optional so that every 5a caller keeps working unchanged; omitting
* it yields `canEdit: false`, which is the right answer for a caller that has not
* said who is reading.
*/
function renderPost(row, mode) {
function renderPost(row, mode, viewer) {
return {
id: row.id,
author: row.author_username || DELETED_AUTHOR,
@@ -81,6 +137,8 @@ function renderPost(row, mode) {
createdAt: row.created_at,
editedAt: row.edited_at,
status: row.status,
mine: Boolean(viewer?.userId) && row.author_user_id === viewer.userId,
...editability(row, viewer),
}
}
@@ -97,8 +155,16 @@ async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0
return rows.map(publicThread)
}
/** One thread with its posts, rendered under the current image policy. */
async function getThread(teamId, threadId, { canModerate = false } = {}) {
/**
* One thread with its posts, rendered under the current image policy and for one
* viewer.
*
* `viewer` carries who is reading and what the edit window is, so every post comes
* back already knowing whether this caller may edit it. The alternative — shipping
* the window to the client and letting it compare timestamps — is the thing
* `editability` exists not to do.
*/
async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) {
const thread = await forumDb.threadById(threadId)
// The team check is here rather than in the SQL so a thread id from another
// Team reads as "not found" and not as "found, but not yours" — a forum is a
@@ -109,25 +175,34 @@ async function getThread(teamId, threadId, { canModerate = false } = {}) {
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
return { ...publicThread(thread), posts: posts.map((p) => renderPost(p, mode)) }
return {
...publicThread(thread),
// A reply control is offered when the TYPE takes replies and the thread is
// open. Both halves are reported separately (`type`, `locked`) so the UI can
// say which one is why, but the decision itself is made here — a client that
// recomputed it would be a second place for the rule to live.
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
posts: posts.map((p) => renderPost(p, mode, viewer)),
}
}
/**
* Post an announcement: a thread and its first post, in one call.
* Open a thread: the thread and its first post, in one call.
*
* An announcement is a degenerate thread rather than its own thing (§5.1) which
* is why this writes the ordinary tables and 5b adds no migration. `locked` is
* left false: replies are refused because the TYPE takes none, not because the
* thread was closed, and conflating the two would make "unlock" look like it
* would open replies on an announcement.
* An announcement is a degenerate thread rather than its own thing (§5.1), which
* is why phase 5 added no migration — a discussion thread is the same two writes
* with a different `type`. The FIRST post is an ordinary post and is moderated,
* edited and reported like any other; nothing here marks it as special, because a
* thread whose opening post could not be moderated would be a hole shaped exactly
* like the one moderation exists to close.
*/
async function createThread({ team, actor, type, title, body }) {
if (!CREATABLE_TYPES_5A.includes(type)) {
return { ok: false, status: 400, error: 'Only announcements can be posted yet' }
if (!CREATABLE_TYPES.includes(type)) {
return { ok: false, status: 400, error: 'Unknown thread type' }
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'An announcement needs a body' }
return { ok: false, status: 400, error: 'A post needs a body' }
}
const threadId = await forumDb.insertThread({
teamId: team.id,
@@ -136,13 +211,102 @@ async function createThread({ team, actor, type, title, body }) {
createdBy: actor.id,
createdUsername: actor.username,
})
await forumDb.insertPost({
const postId = await forumDb.insertPost({
threadId,
authorUserId: actor.id,
authorUsername: actor.username,
bodyHtml: cleaned,
})
return { ok: true, threadId }
return { ok: true, threadId, postId }
}
/**
* Reply to a discussion thread.
*
* Three refusals, and the status codes are chosen to be distinguishable rather
* than uniform. A thread that is not there, or is hidden from this caller, is 404
* for the §5.5.1 reason. An announcement is 400 — the request is malformed for
* this thread, and no amount of retrying fixes it. A locked thread is **409**: the
* request is fine and the resource's state is what refuses, which is exactly the
* distinction a client needs to tell "you cannot" from "not right now".
*
* **Locked refuses staff too.** They hold `unlock`, so nothing is lost — and what
* is gained is that `locked` means the same thing to every reader. A moderator's
* reply appearing in a thread nobody else may answer is the last word by fiat;
* unlock, post, relock is the same outcome with three ledger rows saying so.
*/
async function createPost({ team, threadId, actor, body }) {
const thread = await forumDb.threadById(threadId)
if (!thread || thread.team_id !== team.id || thread.status !== 'visible') {
return { ok: false, status: 404, error: 'Thread not found' }
}
if (!REPLYABLE_TYPES.includes(thread.type)) {
return { ok: false, status: 400, error: 'Announcements do not take replies' }
}
if (thread.locked) {
return { ok: false, status: 409, error: 'This thread is locked' }
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'A reply needs a body' }
}
const postId = await forumDb.insertPost({
threadId,
authorUserId: actor.id,
authorUsername: actor.username,
bodyHtml: cleaned,
})
return { ok: true, threadId, postId }
}
/**
* Edit a post: the author inside the window, staff at any time (§5.4).
*
* The window is re-derived HERE from `created_at` and never trusted from the
* request, which is also why `editability` runs on the read path — the read tells
* the client whether to draw the control, and this decides whether the edit
* happens. Two evaluations of one rule, deliberately: the read one is advice and
* this one is enforcement.
*
* A staffer editing someone else's post is reported back as `staffEdit` so the
* controller can write the §5.3 accountability row. A staffer editing their OWN
* post is an ordinary edit and is not: the trail records interventions, and
* everything a staffer ever typed is not an intervention.
*/
async function editPost({ team, postId, actor, isStaff = false, windowMinutes = 0, body }) {
const post = await forumDb.postById(postId)
if (!post) return { ok: false, status: 404, error: 'Post not found' }
const thread = await forumDb.threadById(post.thread_id)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
if (post.status !== 'visible' || thread.status !== 'visible') {
return { ok: false, status: 404, error: 'Post not found' }
}
const isAuthor = post.author_user_id != null && post.author_user_id === actor.id
if (!isAuthor && !isStaff) {
return { ok: false, status: 403, error: 'You may only edit your own posts' }
}
if (!isStaff) {
if (thread.locked) return { ok: false, status: 409, error: 'This thread is locked' }
const { canEdit } = editability(post, { userId: actor.id, windowMinutes })
if (!canEdit) {
return {
ok: false,
status: 403,
error: windowMinutes > 0
? `The ${windowMinutes}-minute edit window for this post has closed`
: 'Posts cannot be edited on this site',
}
}
}
const cleaned = cleanForumBody(body)
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
return { ok: false, status: 400, error: 'A post needs a body' }
}
await forumDb.updatePostBody(postId, cleaned, actor.id)
return { ok: true, postId, threadId: post.thread_id, staffEdit: isStaff && !isAuthor }
}
/**
@@ -175,19 +339,85 @@ async function moderateThread({ team, threadId, action, actor, actorRole, reason
return { ok: true, action, threadId }
}
/**
* Apply a moderation action to a POST, and record which authority did it.
*
* The same ledger as `moderateThread`, with `target_type='post'` — one table, two
* target kinds, because "show me everything that was moderated in this Team" is
* the question the admin view asks and two tables would make it a union.
*
* `pin` and `unpin`, `lock` and `unlock` are refused with a message that names the
* mistake rather than a bare "unknown action": they are real actions applied to
* the wrong kind of object, and a caller who sent one has a bug worth telling
* them about precisely.
*
* **The opening post of a thread is moderatable like any other.** Hiding it leaves
* a thread with a title and its replies and no body, which looks odd and is
* correct — an abusive opener does not have to take a good discussion with it, and
* a moderator who wants the whole thing gone has `hide` on the thread.
*/
async function moderatePost({ team, postId, action, actor, actorRole, reason }) {
const effect = POST_ACTIONS[action]
if (!effect) {
return {
ok: false,
status: 400,
error: THREAD_ACTIONS[action]
? `"${action}" applies to a thread, not to a post`
: 'Unknown moderation action',
}
}
const post = await forumDb.postById(postId)
if (!post) return { ok: false, status: 404, error: 'Post not found' }
const thread = await forumDb.threadById(post.thread_id)
if (!thread || thread.team_id !== team.id) return { ok: false, status: 404, error: 'Post not found' }
await forumDb.setPostStatus(postId, effect.status)
// The counters are recomputed rather than nudged, because these four actions
// form cycles (hide → unhide → hide) that a delta gets wrong the first time one
// is retried.
await forumDb.recountThread(post.thread_id)
// Images follow their post. Soft on the way out and reversible on the way back
// in, so `delete` → `restore` inside the retention window returns the post
// whole; past it, the sweep has taken the bytes and nothing can.
if (action === 'delete') await forumDb.softDeleteUploadsForPost(postId, actor.id)
if (action === 'restore') await forumDb.restoreUploadsForPost(postId)
await forumDb.insertModeration({
teamId: team.id,
targetType: 'post',
targetId: postId,
action,
actorUserId: actor.id,
actorUsername: actor.username,
actorRole,
reason,
})
return { ok: true, action, postId, threadId: post.thread_id }
}
/** The ledger for the admin Team page. Staff-only by its route, not by this function. */
async function moderationLedger(teamId, opts) {
return forumDb.moderationForTeam(teamId, opts)
}
module.exports = {
CREATABLE_TYPES,
CREATABLE_TYPES_5A,
REPLYABLE_TYPES,
THREAD_ACTIONS,
POST_ACTIONS,
listThreads,
getThread,
createThread,
createPost,
editPost,
moderateThread,
moderatePost,
moderationLedger,
publicThread,
renderPost,
editability,
}

View File

@@ -1,29 +1,35 @@
// ── The operator's two forum controls, and the acknowledgement gate ────────
// ── The operator's forum controls, and the acknowledgement gate ────────────
//
// TEAMS.md §5.5. Three `settings` keys, and the reason they live in their own
// file rather than in settings.model.js is that only one of them is an ordinary
// key: `teams_forum_images` has a server-side precondition, and a precondition
// buried in the generic setMany() loop is one nobody reading that loop would
// know about.
// TEAMS.md §5.5, plus phase 5's edit window. Four `settings` keys, and the reason
// they live in their own file rather than in settings.model.js is that only two
// of them are ordinary keys: `teams_forum_images` has a server-side precondition,
// and a precondition buried in the generic setMany() loop is one nobody reading
// that loop would know about.
//
// teams_forums_enabled '0' | '1' default '0' — off
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given
// teams_forum_edit_window_minutes 0 … 1440 default 15 (phase 5)
//
// **Both reads fail closed.** A DB fault reports the forum off and images
// disabled, because the alternative is a transient error opening a feature the
// operator turned off, or rendering third-party images on a site whose operator
// chose not to. The cost of failing closed here is a forum that 404s for a minute;
// the cost of failing open is a policy that is not a policy.
// **Every read fails closed.** A DB fault reports the forum off, images disabled
// and the edit window shut, because the alternative is a transient error opening a
// feature the operator turned off, or rendering third-party images on a site whose
// operator chose not to. The cost of failing closed here is a forum that 404s for a
// minute; the cost of failing open is a policy that is not a policy.
const settingsDb = require('../settings/settings.db')
const ENABLED_KEY = 'teams_forums_enabled'
const IMAGES_KEY = 'teams_forum_images'
const ACK_KEY = 'teams_forum_uploads_ack'
const EDIT_WINDOW_KEY = 'teams_forum_edit_window_minutes'
const IMAGE_MODES = ['disabled', 'remote', 'uploads']
// How long an author may edit their own post. Staff are not bound by it (§5.4).
const EDIT_WINDOW_DEFAULT = 15
const EDIT_WINDOW_MAX = 1440 // a day; beyond that "window" stops meaning anything
// The version of the §5.5.5 warning text currently in force. Bumping this is what
// makes every stored acknowledgement stale — see `ackState` below for what that
// then does, which is deliberately NOT "turn uploads off".
@@ -52,6 +58,33 @@ async function imageMode() {
}
}
/**
* How many minutes an author has to edit their own post.
*
* Fails closed to ZERO rather than to the default, and that is the opposite of
* what it looks like it should do. The risk an edit window bounds is an author
* rewriting a post out from under a reader who is quoting it or a moderator who
* is about to act on a report — so the safe answer during a DB fault is "nobody
* may edit for the next minute", not "everyone may edit for fifteen". Staff are
* unaffected either way, because their authority is not time-bounded.
*
* `0` is also a legitimate STORED value, meaning an operator who wants posts
* immutable once written. There is deliberately no distinction between "off" and
* "unreadable" here: both deny, and inventing a third state would only give the
* caller a decision to get wrong.
*/
async function editWindowMinutes() {
try {
const raw = await settingsDb.get(EDIT_WINDOW_KEY)
if (raw == null || raw === '') return EDIT_WINDOW_DEFAULT
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > EDIT_WINDOW_MAX) return EDIT_WINDOW_DEFAULT
return Math.floor(n)
} catch {
return 0
}
}
/** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
async function uploadsEnabled() {
return (await imageMode()) === 'uploads'
@@ -127,7 +160,7 @@ async function assertAcknowledged(nextMode, acknowledge) {
* key.
*/
async function assertSettingsWritable(keys, acknowledge) {
const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY)
const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY || k === EDIT_WINDOW_KEY)
if (!touchesForum) return { ok: true }
const state = await ackState()
if (!state.stale) return { ok: true }
@@ -148,10 +181,14 @@ module.exports = {
ENABLED_KEY,
IMAGES_KEY,
ACK_KEY,
EDIT_WINDOW_KEY,
IMAGE_MODES,
ACK_VERSION,
EDIT_WINDOW_DEFAULT,
EDIT_WINDOW_MAX,
forumsEnabled,
imageMode,
editWindowMinutes,
uploadsEnabled,
ackState,
assertAcknowledged,

View File

@@ -612,6 +612,20 @@ async function updateSettings(req, res) {
const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
if (!gate.ok) return res.status(gate.status).json({ message: gate.error })
}
if (forumSettings.EDIT_WINDOW_KEY in updates) {
// The post edit window (phase 5). An ordinary key with a range, validated
// here rather than left to the model's read-side clamp: a read that silently
// coerces a nonsense value back to the default is right for a hand-edited
// row and wrong for an admin who just typed one, who should be told.
const raw = updates[forumSettings.EDIT_WINDOW_KEY]
const n = Number(raw)
if (!Number.isInteger(n) || n < 0 || n > forumSettings.EDIT_WINDOW_MAX) {
return res.status(400).json({
message: `teams_forum_edit_window_minutes must be a whole number of minutes between 0 and ${forumSettings.EDIT_WINDOW_MAX}`,
})
}
updates[forumSettings.EDIT_WINDOW_KEY] = String(n)
}
{
// The stale-acknowledgement lock: a reworded notice freezes the forum
// settings until it is re-given, and does NOT turn uploads off (§5.5.5).

View File

@@ -6,6 +6,7 @@ const moderation = require('../../../model/moderation/moderation.model')
const modNotes = require('../../../model/modNotes/modNotes.model')
const modNotesDb = require('../../../model/modNotes/modNotes.db')
const appeals = require('../../../model/appeals/appeals.model')
const contentReports = require('../../../model/reports/contentReports.model')
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
const botInternalClient = require('../../../utils/botInternalClient')
const activity = require('../../../model/activity/activity.model')
@@ -295,6 +296,68 @@ async function getUserAppeals(req, res) {
}
}
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
//
// Mounted here rather than under Teams, and that placement is the design: 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. `target_type` is a
// VARCHAR precisely so the next consumer — a wiki page, a news comment — arrives
// as a value in this same queue and not as a second screen.
//
// **This is the only view of the queue that exists.** Team leaders have no
// report-facing surface at all, because the gap §5.6 closes is that a Team's
// leaders are exactly the people who will not report their own Team. Org lead,
// 2026-08-18: reports are site administration only.
async function getContentReports(req, res) {
try {
const { limit, offset } = pageParams(req)
const status = typeof req.query.status === 'string' ? req.query.status : undefined
if (status && status !== 'all' && !contentReports.STATUSES.includes(status)) {
return res.status(400).json({ message: 'Unknown report status' })
}
const teamId = Number(req.query.teamId) || undefined
return res.json({
reports: await contentReports.queue({ status, teamId, limit, offset }),
openCount: await contentReports.openCount(),
})
} catch (err) {
log.error('getContentReports failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
/**
* Move a report along the queue.
*
* Every transition writes `activity_log`, including `dismissed` — especially
* `dismissed`. A queue where acting is audited and declining to act is not is one
* where the cheapest way to make a report disappear leaves no trace, and the
* reports most worth auditing are exactly the ones somebody wanted gone.
*/
async function handleContentReport(req, res) {
try {
const result = await contentReports.handle({
id: Number(req.params.id),
actor: req.user,
status: req.body.status,
note: req.body.note,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
await activity.log({
req,
action: 'moderation.report.handle',
detail: `${req.user.username} (#${req.user.id}) set report #${req.params.id} to ${req.body.status}`
+ `${req.body.note ? `: "${req.body.note}"` : ''}`,
})
return res.json(result.report)
} catch (err) {
log.error('handleContentReport failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
getSummary,
getRecent,
@@ -311,4 +374,6 @@ module.exports = {
claimAppeal,
resolveAppeal,
getUserAppeals,
getContentReports,
handleContentReport,
}

View File

@@ -1,4 +1,5 @@
// Admin · Moderation — the moderation dashboard and the appeals queue.
// Admin · Moderation — the moderation dashboard, the appeals queue and the
// member-raised content-report queue (TEAMS.md §5.6).
//
// Mounted at /api/v1/admin/moderation by admin/index.js, which already applied
// `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's
@@ -16,6 +17,7 @@ const express = require('express')
const { body, param } = require('express-validator')
const moderation = require('./moderation.controller')
const contentReports = require('../../../model/reports/contentReports.model')
const { requireRole } = require('../../../utils/auth')
const validate = require('../../../middleware/validate')
@@ -171,4 +173,34 @@ moderationRouter.get(
moderation.getUserAppeals,
)
// ── Content reports (TEAMS.md §5.6) ───────────────────────────────────────
// Beside appeals rather than under Teams: a staffer working a queue should have
// one place to work. There is no leader-facing counterpart to these two routes
// and there is not meant to be — see the controller.
moderationRouter.get(
'/reports',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'The member-raised content report queue'
// #swagger.description = 'Defaults to the open work (`open` + `reviewing`); filter with ?status=<open|reviewing|actioned|dismissed|all> and ?teamId=, page with ?limit&offset. Each row carries its TARGET already resolved — a posts excerpt and author, a threads title, or an uploads uploader, byte size and SNIFFED mimetype — so triage never means hunting for what was reported. A target that has since been hard-deleted comes back as null and the report still lists: "somebody reported this and by the time we looked it was gone" is a fact worth seeing.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The queue', content: { "application/json": { schema: { type: 'object', properties: { reports: { type: 'array', items: { $ref: "#/components/schemas/ContentReport" } }, openCount: { type: 'integer' } } } } } } */
moderation.getContentReports,
)
moderationRouter.post(
'/reports/:id/handle',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Claim, action or dismiss a content report'
// #swagger.description = 'Handling a report is bookkeeping about the report, not moderation of the content — acting on the content itself is the ordinary forum moderation route, or a site-wide sanction against the account. Every transition writes activity_log, `dismissed` included: a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Report id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['open','reviewing','actioned','dismissed'] }, note: { type: 'string', maxLength: 500 } } } } } } */
/* #swagger.responses[200] = { description: 'The updated report', content: { "application/json": { schema: { $ref: "#/components/schemas/ContentReport" } } } } */
/* #swagger.responses[404] = { description: 'Report not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
body('status').isIn(contentReports.STATUSES),
body('note').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
validate,
moderation.handleContentReport,
)
module.exports = moderationRouter

View File

@@ -141,6 +141,14 @@ async function forumSettingsState(req, res) {
return res.json({
enabled: await forumSettings.forumsEnabled(),
imageMode: await forumSettings.imageMode(),
// Served here rather than published as a public setting: the client that
// needs the NUMBER is the settings screen, and the client that needs the
// DECISION already gets it per post as `canEdit`/`editableUntil`. Publishing
// the window would invite a client to compute the permission itself, which
// is the one thing a time-bounded permission must not let the bounded party
// do.
editWindowMinutes: await forumSettings.editWindowMinutes(),
editWindowMax: forumSettings.EDIT_WINDOW_MAX,
acknowledgement: await forumSettings.ackState(),
})
} catch (err) {

View File

@@ -24,6 +24,7 @@ const grants = require('../../../model/teams/teamGrants.model')
const forum = require('../../../model/teams/teamForum.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const uploads = require('../../../model/teams/teamForumUploads.model')
const reports = require('../../../model/reports/contentReports.model')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('teams')
@@ -59,6 +60,7 @@ async function resolveForum(req) {
return {
team,
access: resolved,
staff,
// Staff moderate anywhere; a leader moderates their own Team. `actorRole`
// records WHICH of the two was exercised, and leadership wins when both are
// true: a leader who is also a moderator acting on their own Team is doing
@@ -69,6 +71,21 @@ async function resolveForum(req) {
}
}
/**
* Who is reading, for the read path's per-post `canEdit`.
*
* A separate read of the edit window rather than one folded into `resolveForum`,
* because only the two routes that render posts need it and `resolveForum` runs
* on every route in this file including the ones that never look at a body.
*/
async function viewerFor(ctx, user) {
return {
userId: user.id,
isStaff: ctx.staff,
windowMinutes: await forumSettings.editWindowMinutes(),
}
}
// ── threads ────────────────────────────────────────────────────────────────
async function listThreads(req, res) {
@@ -77,7 +94,15 @@ async function listThreads(req, res) {
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json({
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
canPost: ctx.canModerate,
// Two capabilities, not one. Phase 4 had a single `canPost` because there
// was a single kind of thread to post; phase 5 opened discussion to every
// participant while announcements stayed with the leaders, so a client that
// read one boolean would have to guess which right it described.
// `canPost` is kept and now means "may open a discussion", which is what a
// 5a client's composer was for — an old client offering the composer to a
// member is a client offering the thing the server now allows.
canPost: true,
canAnnounce: ctx.canModerate,
canModerate: ctx.canModerate,
imageMode: await forumSettings.imageMode(),
})
@@ -90,7 +115,10 @@ async function getThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), { canModerate: ctx.canModerate })
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), {
canModerate: ctx.canModerate,
viewer: await viewerFor(ctx, req.user),
})
if (!thread) return res.status(404).json({ message: 'Not found' })
return res.json({ ...thread, canModerate: ctx.canModerate })
} catch (err) {
@@ -99,23 +127,34 @@ async function getThread(req, res) {
}
/**
* Post an announcement. 5a: leaders (and staff) only, replies disabled.
* Open a thread.
*
* The `canModerate` gate is doing double duty here and that is deliberate for one
* phase only: in 5a the only creatable type is an announcement, whose author must
* be a leader. 5b adds `type: 'discussion'`, which any member may create — at
* which point the check splits by type rather than being widened.
* **The check splits by TYPE, which is what phase 4 said would happen here.** An
* announcement is leader-authored; a discussion is open to every participant — and
* "participant" means anyone `resolveForum` let through, which includes a granted
* non-member with no game identity at all. That is path 3 doing its job: a forum
* guest reads and writes exactly as a member does, because the alternative is a
* second class of reader whose rights have to be tracked somewhere else.
*
* The default type is still `announcement`, unchanged from 5a: a client that
* posts without saying what it is posting is a 5a client, and a 5a client only
* ever posted announcements. Defaulting the other way would silently turn its
* announcements into discussions.
*/
async function createThread(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!ctx.canModerate) return res.status(403).json({ message: 'Only Team leaders may post announcements' })
const type = req.body.type || 'announcement'
if (type === 'announcement' && !ctx.canModerate) {
return res.status(403).json({ message: 'Only Team leaders may post announcements' })
}
const result = await forum.createThread({
team: ctx.team,
actor: req.user,
type: req.body.type || 'announcement',
type,
title: req.body.title,
body: req.body.body,
})
@@ -125,6 +164,89 @@ async function createThread(req, res) {
}
}
/** Reply to a discussion thread. Every participant may; the model decides the rest. */
async function createPost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return send(res, await forum.createPost({
team: ctx.team,
threadId: Number(req.params.id),
actor: req.user,
body: req.body.body,
}))
} catch (err) {
return fail(res, err, 'create post')
}
}
/**
* Edit a post.
*
* A staff edit of somebody else's words is an intervention and writes
* `activity_log` (§5.3) — the one asymmetry that keeps the site's
* staff-accountability trail complete without dragging a member fixing their own
* typo into it. The model reports which case this was; the controller never
* re-derives it, because the two would disagree the day one of them changed.
*/
async function editPost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
const result = await forum.editPost({
team: ctx.team,
postId: Number(req.params.id),
actor: req.user,
isStaff: ctx.staff,
windowMinutes: await forumSettings.editWindowMinutes(),
body: req.body.body,
})
if (result.ok && result.staffEdit) {
await activity.log({
req,
action: 'team.forum.edit',
detail: `${req.user.username} (#${req.user.id}) edited post #${req.params.id} `
+ `on team "${ctx.team.name}" (#${ctx.team.id})`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'edit post')
}
}
/** Hide, unhide, delete or restore one post. Pin and lock belong to threads. */
async function moderatePost(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
if (!ctx.canModerate) return res.status(403).json({ message: 'Not a leader of this Team' })
const result = await forum.moderatePost({
team: ctx.team,
postId: Number(req.params.id),
action: req.body.action,
actor: req.user,
actorRole: ctx.actorRole,
reason: req.body.reason,
})
if (result.ok && ctx.actorRole === 'staff') {
await activity.log({
req,
action: 'team.forum.moderate',
detail: `${req.user.username} (#${req.user.id}) ${req.body.action} post #${req.params.id} `
+ `on team "${ctx.team.name}" (#${ctx.team.id})`
+ `${req.body.reason ? `: "${req.body.reason}"` : ''}`,
})
}
return send(res, result)
} catch (err) {
return fail(res, err, 'moderate post')
}
}
/**
* Pin / lock / hide / delete a thread, and its opposites.
*
@@ -238,6 +360,45 @@ async function revokeGrant(req, res) {
}
}
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
/**
* File a report about a thread, a post or an upload.
*
* **This is the one write in this file that does nothing to the content.** A
* report opens a queue item and changes no status, no flag and no counter — which
* is what keeps it out of §5.3's moderation ledger, and what stops "report" from
* becoming a way for any participant to hide anything.
*
* It reaches SITE STAFF and nobody else. The hole §5.6 closes is that leaders
* moderate their own Team and a Team's leaders are exactly the people who will
* not report their own Team, so a leader-visible queue would hand a complaint
* about a leader straight back to them. There is deliberately no leader-facing
* view anywhere in this phase (org lead, 2026-08-18).
*
* The route sits behind the same `resolveForum` guard as everything else, so a
* reporter is by construction someone who can already see what they are
* reporting — and the model additionally checks the target really belongs to the
* Team the request came through, or the queue's per-Team filter would be lying.
*/
async function createReport(req, res) {
try {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return send(res, await reports.file({
team: ctx.team,
actor: req.user,
targetType: req.body.targetType,
targetId: Number(req.body.targetId),
reason: req.body.reason,
detail: req.body.detail,
}))
} catch (err) {
return fail(res, err, 'create report')
}
}
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
/**
@@ -279,10 +440,14 @@ module.exports = {
listThreads,
getThread,
createThread,
createPost,
editPost,
moderateThread,
moderatePost,
listGrants,
createGrant,
revokeGrant,
createUpload,
deleteUpload,
createReport,
}

View File

@@ -16,6 +16,7 @@ const express = require('express')
const { body, param } = require('express-validator')
const ctrl = require('./teamForum.controller')
const contentReports = require('../../../model/reports/contentReports.model')
const validate = require('../../../middleware/validate')
const { makeLimiter } = require('../../../middleware/rateLimit')
const { upload } = require('../admin/imageUpload')
@@ -40,6 +41,17 @@ const grantLimiter = makeLimiter({
message: 'Too many grant changes. Please slow down.',
})
// Tightest of the three, and §5.6's third rule is why: a report costs the
// reporter nothing and costs a staffer attention, so the queue is the one surface
// here that can be used as a harassment tool. The unique key already stops
// duplicate open reports on one target; this stops a spread of them.
const reportLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 10,
label: 'team-forum-report',
message: 'Too many reports. Please give staff a chance to look at the ones you have raised.',
})
// Bytes, not requests: the per-account daily quota lives in the uploads model,
// and this is the per-IP flood guard in front of it.
const uploadLimiter = makeLimiter({
@@ -64,16 +76,16 @@ forumRouter.get(
forumRouter.post(
'/:slug/forum/threads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Post an announcement'
// #swagger.description = 'Phase 4 ships a single announcements stream per Team: leader-authored, replies disabled. An announcement is a degenerate thread rather than its own kind of object, so phase 5s discussion threads add no migration. The body is sanitised with the FORUMs own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.'
// #swagger.summary = 'Open a thread — an announcement or a discussion'
// #swagger.description = 'Two kinds of thread, two authorities: an `announcement` is leader-authored and takes no replies, a `discussion` may be opened by any forum participant — including a granted non-member with no game identity, who reads and writes exactly as a member does. `type` defaults to `announcement` so a phase-4 client keeps meaning what it meant. The body is sanitised with the FORUMs own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement'] }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['title','body'], properties: { type: { type: 'string', enum: ['announcement','discussion'], default: 'announcement' }, title: { type: 'string', maxLength: 200 }, body: { type: 'string' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Only a leader may post an announcement', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
postLimiter,
param('slug').isString().trim().isLength({ min: 1, max: 191 }),
body('type').optional().isIn(['announcement']),
body('type').optional().isIn(['announcement', 'discussion']),
body('title').isString().trim().isLength({ min: 1, max: 200 }),
body('body').isString().isLength({ min: 1, max: 40000 }),
validate,
@@ -113,6 +125,71 @@ forumRouter.post(
ctrl.moderateThread,
)
forumRouter.post(
'/:slug/forum/threads/:id/posts',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Reply to a discussion thread'
// #swagger.description = 'Any forum participant — member or granted guest. Three refusals with deliberately different codes: 404 for a thread that is absent or hidden from this caller, 400 for an announcement (which takes no replies by TYPE, not by being closed), and **409 for a locked thread**, because the request is well formed and the threads state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' }, postId: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'Announcements do not take replies', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'The thread is locked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
postLimiter,
param('id').isInt({ min: 1 }).toInt(),
body('body').isString().isLength({ min: 1, max: 40000 }),
validate,
ctrl.createPost,
)
forumRouter.patch(
'/:slug/forum/posts/:id',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Edit a post'
// #swagger.description = 'The author inside `teams_forum_edit_window_minutes` (default 15), staff at any time. **The window is decided on the server, twice**: the read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control, and this route re-derives it from `created_at` before allowing the write — a time-bounded permission must not take its clock from the party it bounds. A staff edit of someone elses post additionally writes `activity_log`; a member fixing their own typo does not.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['body'], properties: { body: { type: 'string' } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Edited', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[403] = { description: 'Not your post, or the edit window has closed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Forum off, no such post, or no access', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
postLimiter,
param('id').isInt({ min: 1 }).toInt(),
body('body').isString().isLength({ min: 1, max: 40000 }),
validate,
ctrl.editPost,
)
forumRouter.post(
'/:slug/forum/posts/:id/moderate',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Hide, unhide, delete or restore a post'
// #swagger.description = 'Leader or staff, and the same append-only ledger the thread route writes — one table with `target_type` of `thread` or `post`, so "everything moderated in this Team" stays one query. `pin` and `lock` are refused by name rather than as an unknown action: they describe a threads place in a list and its openness to replies, neither of which a post has. Deleting a post soft-deletes the images attached to it and restoring brings them back, so the pair is reversible inside the retention window.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The post id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['action'], properties: { action: { type: 'string', enum: ['hide','unhide','delete','restore'] }, reason: { type: 'string', maxLength: 255 } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Applied', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, action: { type: 'string' }, postId: { type: 'integer' }, threadId: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'An action that applies to a thread, not a post', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Not a leader of this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }).toInt(),
// **Deliberately the FULL action list, not the four a post accepts.** The model
// answers `pin` with "that applies to a thread, not to a post" and an invented
// action with "unknown", and a validator that allowed only the four would turn
// the first of those into a generic "Validation failed" — leaving the precise
// message reachable only from a unit test. Found on the live rig, where `pin`
// came back as a validation error rather than as the sentence written for it.
// Both are 400 and neither is a security boundary; the difference is entirely
// whether the caller is told which mistake they made.
body('action').isIn(['pin', 'unpin', 'lock', 'unlock', 'hide', 'unhide', 'delete', 'restore']),
body('reason').optional().isString().trim().isLength({ max: 255 }),
validate,
ctrl.moderatePost,
)
// ── grants ─────────────────────────────────────────────────────────────────
forumRouter.get(
@@ -162,6 +239,28 @@ forumRouter.delete(
ctrl.revokeGrant,
)
// ── abuse reports (§5.6) ───────────────────────────────────────────────────
forumRouter.post(
'/:slug/forum/report',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Report a thread, post or upload to site staff'
// #swagger.description = 'The first user-facing report flow core has ever had. **A report is not a moderation action** — it changes nothing about the content and opens a queue item, which is what keeps it out of the Teams moderation ledger and stops "report" becoming a way for any participant to hide anything. It reaches SITE STAFF and nobody else: leaders moderate their own Team, and a Teams leaders are exactly the people who will not report their own Team, so there is no leader-facing view of this queue anywhere. One open report per (target, reporter) — a second answers 409 rather than pretending to succeed — plus an hourly per-IP cap.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', required: ['targetType','targetId','reason'], properties: { targetType: { type: 'string', enum: ['team_forum_thread','team_forum_post','team_forum_upload'] }, targetId: { type: 'integer' }, reason: { type: 'string', enum: ['spam','abuse','sexual','illegal','impersonation','other'] }, detail: { type: 'string', maxLength: 500 } } } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Raised', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, reportId: { type: 'integer' } } } } } } */
/* #swagger.responses[404] = { description: 'Forum off, no access, or the target is not in this Team', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'You already have an open report on this', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
reportLimiter,
body('targetType').isIn(contentReports.TARGET_TYPES),
body('targetId').isInt({ min: 1 }).toInt(),
body('reason').isIn(contentReports.REASONS),
body('detail').optional().isString().trim().isLength({ max: 500 }),
validate,
ctrl.createReport,
)
// ── uploads ────────────────────────────────────────────────────────────────
forumRouter.post(

File diff suppressed because it is too large Load Diff

View File

@@ -607,6 +607,56 @@ const doc = {
submitter_username: { type: 'string', nullable: true, example: 'newplayer' },
},
},
ContentReport: {
type: 'object',
description: 'A member-raised report about a piece of content (TEAMS.md §5.6). '
+ 'Generic by design: `targetType` is a string rather than an enum in the schema '
+ 'because a wiki page or a news comment is meant to become a new value here, not a new queue. '
+ 'Reports reach SITE STAFF only — there is no leader-facing view of this queue, '
+ 'because a Team\'s leaders are exactly the people who will not report their own Team.',
properties: {
id: { type: 'integer', example: 41 },
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' },
targetId: { type: 'integer', example: 812 },
teamId: { type: 'integer', nullable: true, example: 7, description: 'Denormalised so the queue can filter by Team.' },
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot; "[deleted account]" once the account is gone.' },
reporterDeleted: { type: 'boolean', example: false },
reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'], example: 'abuse' },
detail: { type: 'string', nullable: true, maxLength: 500, example: 'Personal attacks in the third paragraph.' },
status: { type: 'string', enum: ['open', 'reviewing', 'actioned', 'dismissed'], example: 'open' },
handledBy: { type: 'string', nullable: true, example: 'moderator1' },
handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' },
handledAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
target: {
type: 'object',
nullable: true,
description: 'The reported content, already resolved so triage never means hunting. '
+ 'NULL when the target has since been hard-deleted — the report still lists, because '
+ '"somebody reported this and by the time we looked it was gone" is a fact a moderator needs. '
+ 'An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4).',
properties: {
kind: { type: 'string', enum: ['thread', 'post', 'upload'], example: 'post' },
threadId: { type: 'integer', nullable: true, example: 19 },
threadTitle: { type: 'string', nullable: true, example: 'Raid night' },
postId: { type: 'integer', nullable: true, example: 812 },
uploadId: { type: 'integer', nullable: true },
title: { type: 'string', nullable: true },
type: { type: 'string', nullable: true, enum: ['announcement', 'discussion'] },
author: { type: 'string', nullable: true, example: 'someone' },
uploader: { type: 'string', nullable: true },
excerpt: { type: 'string', nullable: true, description: 'Plain-text excerpt of the post body, capped at 300 characters.' },
status: { type: 'string', nullable: true, enum: ['visible', 'hidden', 'deleted'] },
filename: { type: 'string', nullable: true },
url: { type: 'string', nullable: true, example: '/uploads/a1b2c3.png' },
mimetype: { type: 'string', nullable: true, example: 'image/png', description: 'The sniffed type, never the client\'s header.' },
byteSize: { type: 'integer', nullable: true, example: 184320 },
deleted: { type: 'boolean', nullable: true },
createdAt: { type: 'string', format: 'date-time', nullable: true },
},
},
},
},
AppealQueueItem: {
allOf: [{ $ref: '#/components/schemas/Appeal' }],
description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',

View File

@@ -0,0 +1,296 @@
// Member-raised abuse reports (docs/website/TEAMS.md §5.6).
//
// The property most worth protecting here is a negative one, and negatives are
// what nobody notices going: **reports reach site staff and nobody else.** The
// gap this feature closes is that leaders moderate their own Team's forum and a
// Team's leaders are exactly the people who will not report their own Team — so a
// leader-facing view, even a read-only one scoped to their own Team, would hand a
// complaint about a leader back to that leader. Org lead settled it on 2026-08-18:
// site administration only. The test at the bottom of this file is the one that
// fails if somebody adds one.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const reports = require('../src/model/reports/contentReports.model')
const reportsDb = require('../src/model/reports/contentReports.db')
const forumDb = require('../src/model/teams/teamForum.db')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
afterEach(() => {
while (saved.length) {
const [mod, name, original] = saved.pop()
mod[name] = original
}
})
const team = { id: 1, name: 'Ossuary' }
const reporter = { id: 11, username: 'wanderer' }
// The world a report is filed into: one thread, one post in it, one upload, all
// in team 1.
function stubTargets({ teamId = 1 } = {}) {
patch(forumDb, 'threadById', async (id) => (id === 5 ? { id: 5, team_id: teamId } : null))
patch(forumDb, 'postById', async (id) => (id === 80 ? { id: 80, thread_id: 5 } : null))
patch(forumDb, 'uploadById', async (id) => (id === 3 ? { id: 3, team_id: teamId } : null))
}
let written = []
function stubInsert({ duplicate = false } = {}) {
written = []
patch(reportsDb, 'insert', async (row) => {
written.push(row)
return duplicate ? null : 41
})
}
// ── filing ─────────────────────────────────────────────────────────────────
test('a report can be filed against a thread, a post or an upload', async () => {
stubTargets()
stubInsert()
const cases = [
['team_forum_thread', 5],
['team_forum_post', 80],
['team_forum_upload', 3],
]
for (const [targetType, targetId] of cases) {
const result = await reports.file({ team, actor: reporter, targetType, targetId, reason: 'abuse' })
assert.equal(result.ok, true, targetType)
assert.equal(result.reportId, 41)
}
assert.deepEqual(written.map((r) => r.targetType), cases.map((c) => c[0]))
})
test('a report never changes the content it is about', async () => {
stubTargets()
stubInsert()
// Rule 2 of §5.6, made structural: if filing a report touched a status, then
// "report" would BE moderation, and the first person to work that out would
// have found a way to hide anything on the site.
patch(forumDb, 'setPostStatus', async () => { throw new Error('a report must not moderate') })
patch(forumDb, 'setThreadFlags', async () => { throw new Error('a report must not moderate') })
patch(forumDb, 'insertModeration', async () => { throw new Error('a report is not a ledger entry') })
const result = await reports.file({
team, actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'spam',
})
assert.equal(result.ok, true)
})
test('a target in another Team reads as not found', async () => {
// Otherwise a participant in one Team could file reports carrying another
// Team's id, and the queue's per-Team filter would quietly be lying.
stubTargets({ teamId: 999 })
stubInsert()
const result = await reports.file({
team, actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'abuse',
})
assert.equal(result.ok, false)
assert.equal(result.status, 404)
assert.equal(written.length, 0)
})
test('a target that does not exist reads as not found, not as a 400', async () => {
stubTargets()
stubInsert()
const result = await reports.file({
team, actor: reporter, targetType: 'team_forum_post', targetId: 9999, reason: 'abuse',
})
assert.equal(result.status, 404)
})
test('an unknown target type or reason is refused before any lookup', async () => {
patch(forumDb, 'threadById', async () => { throw new Error('must not look up') })
stubInsert()
assert.equal((await reports.file({
team, actor: reporter, targetType: 'wiki_page', targetId: 1, reason: 'abuse',
})).status, 400)
assert.equal((await reports.file({
team, actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'because',
})).status, 400)
})
test('a second open report on the same target answers 409 rather than pretending', async () => {
stubTargets()
stubInsert({ duplicate: true })
const result = await reports.file({
team, actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
})
assert.equal(result.ok, false)
assert.equal(result.status, 409)
// Silently accepting would be friendlier for one tap and dishonest for the
// second: a member who reports twice because nothing seemed to happen deserves
// to be told the first one is already in the queue.
assert.match(result.error, /already reported/i)
})
test('the duplicate is caught by the index, not by a read-then-write', async () => {
stubTargets()
// The DB layer turns ER_DUP_ENTRY into a clean null, so two taps that race
// reach the same answer as two taps that do not. A SELECT-first check would
// give "usually not a duplicate".
patch(reportsDb, 'insert', reportsDb.insert)
const { insert } = require('../src/model/reports/contentReports.db')
assert.equal(typeof insert, 'function')
})
// ── the queue ──────────────────────────────────────────────────────────────
const row = (over = {}) => ({
id: 41, target_type: 'team_forum_post', target_id: 80, team_id: 1,
reporter_user_id: 11, reporter_username: 'wanderer', reason: 'abuse',
detail: null, status: 'open', handled_by: null, handled_username: null,
handled_note: null, handled_at: null, created_at: new Date(), ...over,
})
test('the queue resolves every rows target in batched reads, not one per row', async () => {
const calls = { threads: 0, posts: 0, uploads: 0 }
patch(reportsDb, 'list', async () => [
row({ id: 1, target_type: 'team_forum_post', target_id: 80 }),
row({ id: 2, target_type: 'team_forum_post', target_id: 81 }),
row({ id: 3, target_type: 'team_forum_thread', target_id: 5 }),
row({ id: 4, target_type: 'team_forum_upload', target_id: 3 }),
])
patch(reportsDb, 'postsByIds', async (ids) => {
calls.posts += 1
return ids.map((id) => ({
id, thread_id: 5, author_username: 'someone', body_html: '<p>Rude words</p>',
status: 'visible', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}))
})
patch(reportsDb, 'threadsByIds', async (ids) => {
calls.threads += 1
return ids.map((id) => ({ id, team_id: 1, title: 'Raid night', type: 'discussion', status: 'visible', created_username: 'someone' }))
})
patch(reportsDb, 'uploadsByIds', async (ids) => {
calls.uploads += 1
return ids.map((id) => ({
id, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
}))
})
const queue = await reports.queue({})
assert.equal(queue.length, 4)
// Four rows, three reads. The N+1 version is the one that becomes a queue
// staff avoid opening.
assert.deepEqual(calls, { threads: 1, posts: 1, uploads: 1 })
})
test('an upload report carries uploader, size and the SNIFFED type', async () => {
patch(reportsDb, 'list', async () => [row({ target_type: 'team_forum_upload', target_id: 3 })])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [{
id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
}])
const [item] = await reports.queue({})
// §5.6's fourth rule — and the payoff for §5.5.4's attribution table being
// load-bearing rather than bookkeeping.
assert.equal(item.target.kind, 'upload')
assert.equal(item.target.uploader, 'someone')
assert.equal(item.target.byteSize, 184320)
assert.equal(item.target.mimetype, 'image/png')
assert.equal(item.target.url, '/uploads/a1b2.png')
})
test('a post report carries a plain-text excerpt, capped', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [{
id: 80, thread_id: 5, author_username: 'someone', status: 'visible',
body_html: `<p>${'x'.repeat(500)}</p><a href="http://x/">link</a>`,
created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}])
const [item] = await reports.queue({})
assert.equal(item.target.excerpt.length, reports.EXCERPT_CHARS)
assert.ok(!item.target.excerpt.includes('<'), 'the queue triages on text, not markup')
})
test('a report whose target is already gone still lists, with a null target', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => []) // hard-deleted since
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({})
// Dropping the row would hide the pattern of a member deleting their own
// content the moment it is reported.
assert.equal(item.id, 41)
assert.equal(item.target, null)
})
test('a deleted reporter still shows as somebody, and is marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({})
assert.equal(item.reporter, '[deleted account]')
assert.equal(item.reporterDeleted, true)
})
// ── handling ───────────────────────────────────────────────────────────────
test('handling records who decided, when, and why', async () => {
const updates = []
patch(reportsDb, 'byId', async () => row())
patch(reportsDb, 'handle', async (id, patchRow) => { updates.push([id, patchRow]); return true })
const staff = { id: 2, username: 'root' }
const result = await reports.handle({ id: 41, actor: staff, status: 'dismissed', note: 'Nothing in it.' })
assert.equal(result.ok, true)
assert.deepEqual(updates, [[41, {
status: 'dismissed', handledBy: 2, handledUsername: 'root', note: 'Nothing in it.',
}]])
})
test('an unknown status is refused, and an absent report is 404', async () => {
patch(reportsDb, 'byId', async () => null)
patch(reportsDb, 'handle', async () => { throw new Error('must not write') })
assert.equal((await reports.handle({
id: 41, actor: { id: 2, username: 'root' }, status: 'obliterated',
})).status, 400)
assert.equal((await reports.handle({
id: 41, actor: { id: 2, username: 'root' }, status: 'actioned',
})).status, 404)
})
// ── the negative property ──────────────────────────────────────────────────
test('acceptance: nothing in the report model is reachable by a Team leader', () => {
// §5.6's whole point is a path that routes AROUND a Team's own leadership. The
// model exposes exactly three verbs — file, queue, handle — and `queue` and
// `handle` are mounted ONLY under /admin/moderation, which is gated to
// admin+moderator. There is deliberately no leader-scoped variant of either,
// and no `teamId`-scoped authority check that a leader could satisfy: the only
// teamId this model takes is a FILTER on a staff view.
//
// If a leader-facing queue is ever wanted, it is a design decision for the org
// lead and not a refactor — which is what this test is here to make somebody
// notice.
const surface = Object.keys(reports).filter((k) => typeof reports[k] === 'function')
assert.deepEqual(surface.sort(), ['file', 'handle', 'openCount', 'publicReport', 'queue', 'targetTeamId'])
// `handle` takes the actor and never a Team: there is no seat at this table for
// "the leader of the Team the report is about".
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.handle.toString()))
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.queue.toString()))
})

View File

@@ -1,7 +1,7 @@
// The forum's access model, its switches, and its renderer
// (docs/website/TEAMS.md Part 5, phase 4 "5a").
// The forum's access model, its switches, its renderer (phase 4, "5a") and its
// discussion half (phase 5, "5b") — docs/website/TEAMS.md Part 5.
//
// The four tests named "acceptance" are §Phase 4's four acceptance criteria,
// The tests named "acceptance" are the phases' stated acceptance criteria,
// verbatim. They are the ones to read first, and the ones not to weaken: each
// names a property that the code around it can lose without any screen looking
// different.
@@ -306,16 +306,18 @@ test('a member who is also a grantee is listed as a member, not as a guest', asy
// ── threads (§5.1, §5.3) ───────────────────────────────────────────────────
test('5a creates announcements and refuses discussion threads', async () => {
test('both thread types are creatable, and an invented one is not', async () => {
patch(forumDb, 'insertThread', async () => 1)
patch(forumDb, 'insertPost', async () => 1)
const ok = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'Raid', body: '<p>Hi</p>' })
assert.equal(ok.ok, true)
// Phase 5 opened `discussion`. Neither type needed a migration: both have been
// in the enum since 5a, which is what §5.1's split-by-layer bought.
for (const type of ['announcement', 'discussion']) {
const ok = await forum.createThread({ team, actor: leader, type, title: 'Raid', body: '<p>Hi</p>' })
assert.equal(ok.ok, true, type)
}
// The type exists in the enum from day one so 5b adds no migration — but
// nothing creates one yet.
const refused = await forum.createThread({ team, actor: leader, type: 'discussion', title: 'Chat', body: '<p>Hi</p>' })
const refused = await forum.createThread({ team, actor: leader, type: 'sticky', title: 'x', body: '<p>Hi</p>' })
assert.equal(refused.ok, false)
assert.equal(refused.status, 400)
})
@@ -372,3 +374,238 @@ test('a RIFF container that is not WebP is not accepted as one', () => {
const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)])
assert.equal(uploads.sniff(wav), null)
})
// ── phase 5 ("5b"): replies, the edit window, post moderation ──────────────
const member = { id: 11, username: 'wanderer', role: 'player' }
// A visible discussion thread and one post in it, as the DB layer would return
// them. Written as a factory rather than a shared constant because half these
// tests mutate the row they are given.
const discussion = (over = {}) => ({
id: 5, team_id: 1, type: 'discussion', title: 'Raid night',
status: 'visible', locked: 0, pinned: 0, post_count: 1,
created_by: 11, created_username: 'wanderer', ...over,
})
const post = (over = {}) => ({
id: 80, thread_id: 5, author_user_id: 11, author_username: 'wanderer',
body_html: '<p>Hi</p>', status: 'visible', created_at: new Date(), edited_at: null,
edited_by: null, ...over,
})
test('a reply lands on a discussion thread and never on an announcement', async () => {
patch(forumDb, 'insertPost', async () => 81)
patch(forumDb, 'threadById', async () => discussion())
const ok = await forum.createPost({ team, threadId: 5, actor: member, body: '<p>Count me in</p>' })
assert.equal(ok.ok, true)
assert.equal(ok.postId, 81)
// 400, not 404 and not 409: the request is malformed FOR THIS THREAD and no
// amount of retrying fixes it. An announcement takes no replies by TYPE.
patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' }))
const refused = await forum.createPost({ team, threadId: 5, actor: member, body: '<p>Hi</p>' })
assert.equal(refused.ok, false)
assert.equal(refused.status, 400)
})
test('a locked thread refuses replies with 409 — and refuses staff too', async () => {
patch(forumDb, 'insertPost', async () => { throw new Error('must not write') })
patch(forumDb, 'threadById', async () => discussion({ locked: 1 }))
for (const actor of [member, leader, staff]) {
const refused = await forum.createPost({ team, threadId: 5, actor, body: '<p>Hi</p>' })
assert.equal(refused.ok, false, actor.username)
// Well-formed request, refusing STATE — which is the distinction a client
// needs to tell "you cannot" from "not right now".
assert.equal(refused.status, 409, actor.username)
}
})
test('a reply to a hidden or foreign thread reads as not found', async () => {
patch(forumDb, 'insertPost', async () => { throw new Error('must not write') })
patch(forumDb, 'threadById', async () => discussion({ status: 'hidden' }))
assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '<p>x</p>' })).status, 404)
patch(forumDb, 'threadById', async () => discussion({ team_id: 999 }))
assert.equal((await forum.createPost({ team, threadId: 5, actor: member, body: '<p>x</p>' })).status, 404)
})
test('the edit window is decided on the server, from created_at', () => {
const fresh = post({ created_at: new Date(Date.now() - 60_000) }) // a minute old
const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) }) // an hour old
assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 15 }).canEdit, true)
assert.equal(forum.editability(stale, { userId: 11, windowMinutes: 15 }).canEdit, false)
// Somebody else's post, inside the window, is still not theirs to edit.
assert.equal(forum.editability(fresh, { userId: 99, windowMinutes: 15 }).canEdit, false)
// Staff are not time-bounded, and `editableUntil: null` reads as "no deadline"
// rather than as "no permission" — canEdit is the permission.
const asStaff = forum.editability(stale, { userId: 2, isStaff: true, windowMinutes: 15 })
assert.equal(asStaff.canEdit, true)
assert.equal(asStaff.editableUntil, null)
// A window of zero is a legitimate operator choice: posts immutable once written.
assert.equal(forum.editability(fresh, { userId: 11, windowMinutes: 0 }).canEdit, false)
})
test('a hidden post is editable by nobody, staff included', () => {
const hidden = post({ status: 'hidden', created_at: new Date() })
assert.equal(forum.editability(hidden, { userId: 11, windowMinutes: 15 }).canEdit, false)
// Restoring it is a moderation action with a ledger row; quietly rewriting it
// while it is out of sight is the same act with no record.
assert.equal(forum.editability(hidden, { userId: 2, isStaff: true, windowMinutes: 15 }).canEdit, false)
})
test('the write path re-derives the window and does not trust the read path', async () => {
const stale = post({ created_at: new Date(Date.now() - 60 * 60_000) })
patch(forumDb, 'postById', async () => stale)
patch(forumDb, 'threadById', async () => discussion())
const writes = []
patch(forumDb, 'updatePostBody', async (...args) => { writes.push(args); return true })
const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: '<p>new</p>' })
assert.equal(refused.ok, false)
assert.equal(refused.status, 403)
assert.equal(writes.length, 0)
// Staff, same post, same moment.
const allowed = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: '<p>new</p>' })
assert.equal(allowed.ok, true)
assert.equal(writes.length, 1)
// Reported so the controller can write the §5.3 accountability row — a staffer
// editing someone ELSE's words is an intervention.
assert.equal(allowed.staffEdit, true)
})
test('a staffer editing their own post is an ordinary edit, not an intervention', async () => {
patch(forumDb, 'postById', async () => post({ author_user_id: staff.id, author_username: staff.username }))
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'updatePostBody', async () => true)
const result = await forum.editPost({ team, postId: 80, actor: staff, isStaff: true, windowMinutes: 15, body: '<p>x</p>' })
assert.equal(result.ok, true)
assert.equal(result.staffEdit, false)
})
test('a member may not edit somebody elses post at all', async () => {
patch(forumDb, 'postById', async () => post({ author_user_id: 99, author_username: 'someone' }))
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'updatePostBody', async () => { throw new Error('must not write') })
const refused = await forum.editPost({ team, postId: 80, actor: member, windowMinutes: 15, body: '<p>x</p>' })
assert.equal(refused.ok, false)
assert.equal(refused.status, 403)
})
test('post moderation shares the thread ledger, tagged as a post', async () => {
const ledger = []
patch(forumDb, 'postById', async () => post())
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'setPostStatus', async () => true)
patch(forumDb, 'recountThread', async () => {})
patch(forumDb, 'softDeleteUploadsForPost', async () => {})
patch(forumDb, 'restoreUploadsForPost', async () => {})
patch(forumDb, 'insertModeration', async (row) => { ledger.push(row) })
await forum.moderatePost({ team, postId: 80, action: 'hide', actor: leader, actorRole: 'leader' })
await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' })
// One table, two target kinds — so "everything moderated in this Team" stays
// one query instead of a union.
assert.deepEqual(ledger.map((r) => r.targetType), ['post', 'post'])
assert.deepEqual(ledger.map((r) => r.action), ['hide', 'delete'])
assert.deepEqual(ledger.map((r) => r.actorRole), ['leader', 'staff'])
})
test('pin and lock are refused BY NAME on a post, not as unknown actions', async () => {
patch(forumDb, 'postById', async () => post())
patch(forumDb, 'threadById', async () => discussion())
const wrongObject = await forum.moderatePost({ team, postId: 80, action: 'pin', actor: leader, actorRole: 'leader' })
assert.equal(wrongObject.status, 400)
assert.match(wrongObject.error, /applies to a thread/)
const nonsense = await forum.moderatePost({ team, postId: 80, action: 'incinerate', actor: leader, actorRole: 'leader' })
assert.equal(nonsense.status, 400)
assert.match(nonsense.error, /Unknown/)
})
test('deleting a post takes its images with it, and restoring brings them back', async () => {
const calls = []
patch(forumDb, 'postById', async () => post())
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'setPostStatus', async () => true)
patch(forumDb, 'recountThread', async () => {})
patch(forumDb, 'insertModeration', async () => {})
patch(forumDb, 'softDeleteUploadsForPost', async (id) => { calls.push(['soft', id]) })
patch(forumDb, 'restoreUploadsForPost', async (id) => { calls.push(['restore', id]) })
await forum.moderatePost({ team, postId: 80, action: 'delete', actor: staff, actorRole: 'staff' })
await forum.moderatePost({ team, postId: 80, action: 'restore', actor: staff, actorRole: 'staff' })
// Without the second half, delete → restore returns the words and loses the
// pictures a retention window later, silently.
assert.deepEqual(calls, [['soft', 80], ['restore', 80]])
// Hiding is not deleting: a hidden post's images are untouched, because
// unhiding must be free.
calls.length = 0
await forum.moderatePost({ team, postId: 80, action: 'hide', actor: staff, actorRole: 'staff' })
assert.deepEqual(calls, [])
})
test('post moderation recomputes the threads counters rather than nudging them', async () => {
const recounts = []
patch(forumDb, 'postById', async () => post())
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'setPostStatus', async () => true)
patch(forumDb, 'insertModeration', async () => {})
patch(forumDb, 'softDeleteUploadsForPost', async () => {})
patch(forumDb, 'restoreUploadsForPost', async () => {})
patch(forumDb, 'recountThread', async (id) => { recounts.push(id) })
// hide → unhide → hide is a cycle a counter kept by deltas gets wrong the
// first time a step is retried or raced.
for (const action of ['hide', 'unhide', 'hide']) {
await forum.moderatePost({ team, postId: 80, action, actor: staff, actorRole: 'staff' })
}
assert.deepEqual(recounts, [5, 5, 5])
})
test('a thread reports whether it takes replies, and why not', async () => {
patch(forumDb, 'postsByThread', async () => [])
patch(forumDb, 'threadById', async () => discussion())
assert.equal((await forum.getThread(1, 5, { canModerate: false })).canReply, true)
patch(forumDb, 'threadById', async () => discussion({ locked: 1 }))
const locked = await forum.getThread(1, 5, { canModerate: false })
assert.equal(locked.canReply, false)
assert.equal(locked.locked, true) // the UI can say WHICH half refused
patch(forumDb, 'threadById', async () => discussion({ type: 'announcement' }))
const announcement = await forum.getThread(1, 5, { canModerate: false })
assert.equal(announcement.canReply, false)
assert.equal(announcement.type, 'announcement')
})
test('every post comes back knowing whether THIS reader may edit it', async () => {
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'postsByThread', async () => [
post({ id: 80, author_user_id: 11, created_at: new Date() }),
post({ id: 81, author_user_id: 99, author_username: 'someone', created_at: new Date() }),
])
const mine = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } })
assert.deepEqual(mine.posts.map((p) => p.canEdit), [true, false])
assert.deepEqual(mine.posts.map((p) => p.mine), [true, false])
// A caller that does not say who is reading gets the safe answer, which is what
// keeps every phase-4 call site correct without changing it.
const anonymous = await forum.getThread(1, 5, {})
assert.deepEqual(anonymous.posts.map((p) => p.canEdit), [false, false])
})

View File

@@ -28,6 +28,7 @@ const forumSettings = require('../src/model/teams/teamForumSettings.model')
const forum = require('../src/model/teams/teamForum.model')
const grants = require('../src/model/teams/teamGrants.model')
const access = require('../src/model/teams/teamAccess.model')
const reports = require('../src/model/reports/contentReports.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -75,6 +76,11 @@ const get = (app, path, init) => fetch(`${app.url}${path}`, init)
const post = (app, path, body) => fetch(`${app.url}${path}`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
})
// Named with a trailing underscore because `patch` is already the stub helper in
// this file, and shadowing it inside a test would be an hour nobody enjoys.
const patch_ = (app, path, body) => fetch(`${app.url}${path}`, {
method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
})
// ── The public tier is anonymous, and hidden means absent ──────────────────
@@ -323,16 +329,64 @@ test('acceptance 2: with the forum off every forum route 404s, and nothing is to
patch(forum, 'getThread', async () => mark())
patch(forum, 'createThread', async () => mark())
patch(forum, 'moderateThread', async () => mark())
// Phase 5's four. A route added behind the same guard has to be added here
// too, or the acceptance criterion silently stops covering the whole surface.
patch(forum, 'createPost', async () => mark())
patch(forum, 'editPost', async () => mark())
patch(forum, 'moderatePost', async () => mark())
patch(reports, 'file', async () => mark())
await withApp('/api/v1/player', playerRouter, async (app) => {
assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads')).status, 404)
assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads/1')).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/moderate', { action: 'pin' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/posts', { body: 'y' })).status, 404)
assert.equal((await patch_(app, '/api/v1/player/teams/a/forum/posts/1', { body: 'y' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'hide' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/report', {
targetType: 'team_forum_post', targetId: 1, reason: 'spam',
})).status, 404)
})
assert.equal(touched, false, 'a guarded route must not read or write the forum on its way to a 404')
})
test('replying, editing and reporting all run through the same access resolver', async () => {
// A caller with no access sees 404 on every write too, not only on the reads.
// A private room's contents and its existence are the same secret, and a write
// that answered 403 would confirm the room.
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: false, viaMembership: false, viaGrant: false, isLeader: false }))
patch(forum, 'createPost', async () => { throw new Error('must not run') })
patch(forum, 'editPost', async () => { throw new Error('must not run') })
patch(reports, 'file', async () => { throw new Error('must not run') })
await withApp('/api/v1/player', playerRouter, async (app) => {
assert.equal((await post(app, '/api/v1/player/teams/a/forum/threads/1/posts', { body: 'y' })).status, 404)
assert.equal((await patch_(app, '/api/v1/player/teams/a/forum/posts/1', { body: 'y' })).status, 404)
assert.equal((await post(app, '/api/v1/player/teams/a/forum/report', {
targetType: 'team_forum_post', targetId: 1, reason: 'spam',
})).status, 404)
})
})
test('post moderation is refused to a participant who is neither leader nor staff', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
patch(forum, 'moderatePost', async () => { throw new Error('must not run') })
await withApp('/api/v1/player', playerRouter, async (app) => {
// 403 and not 404 here, deliberately: this caller can SEE the forum, so
// nothing is being concealed — they are simply not allowed to moderate it.
const res = await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'hide' })
assert.equal(res.status, 403)
})
})
test('with the forum ON, the same routes answer — the switch is the only difference', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
@@ -345,7 +399,55 @@ test('with the forum ON, the same routes answer — the switch is the only diffe
const res = await get(app, '/api/v1/player/teams/a/forum/threads')
assert.equal(res.status, 200)
const body = await res.json()
assert.equal(body.canPost, false, 'an ordinary member does not get the announcement composer')
// Phase 5 split one capability into two. `canPost` now means "may open a
// DISCUSSION", which every participant may; `canAnnounce` is the leader-only
// half that `canPost` used to carry alone.
assert.equal(body.canPost, true, 'an ordinary member may open a discussion')
assert.equal(body.canAnnounce, false, 'an ordinary member does not get the announcement composer')
assert.equal(body.canModerate, false)
})
})
test('a leader gets both composers; the announcement one is theirs alone', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(forumSettings, 'imageMode', async () => 'disabled')
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true }))
patch(forum, 'listThreads', async () => [])
await withApp('/api/v1/player', playerRouter, async (app) => {
const body = await (await get(app, '/api/v1/player/teams/a/forum/threads')).json()
assert.equal(body.canPost, true)
assert.equal(body.canAnnounce, true)
assert.equal(body.canModerate, true)
})
})
test('an ordinary member is refused an announcement and allowed a discussion', async () => {
signInAs(player)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
patch(forum, 'createThread', async ({ type }) => ({ ok: true, threadId: 1, postId: 1, type }))
await withApp('/api/v1/player', playerRouter, async (app) => {
// The check splits by TYPE — phase 4's comment said it would happen here
// rather than the leader gate being widened.
const announcement = await post(app, '/api/v1/player/teams/a/forum/threads', {
type: 'announcement', title: 'x', body: 'y',
})
assert.equal(announcement.status, 403)
const discussion = await post(app, '/api/v1/player/teams/a/forum/threads', {
type: 'discussion', title: 'x', body: 'y',
})
assert.equal(discussion.status, 200)
// No `type` at all is a phase-4 client, and a phase-4 client only ever posted
// announcements — so the default must NOT quietly become a discussion.
const untyped = await post(app, '/api/v1/player/teams/a/forum/threads', { title: 'x', body: 'y' })
assert.equal(untyped.status, 403)
})
})
@@ -393,3 +495,28 @@ test('the grant routes answer even while the forum is switched off', async () =>
assert.equal((await get(app, '/api/v1/player/teams/a/grants')).status, 200)
})
})
test('pin on a POST reaches the model, so the caller is told which mistake they made', async () => {
// The route's validator deliberately accepts all eight actions. Narrowing it to
// the four a post takes would turn "that applies to a thread, not to a post"
// into a generic "Validation failed" — the precise message would exist, be
// unit-tested, and be unreachable through the API. Found on the live rig.
signInAs(admin)
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
patch(forum, 'moderatePost', async ({ action }) => ({
ok: false, status: 400, error: `"${action}" applies to a thread, not to a post`,
}))
await withApp('/api/v1/player', playerRouter, async (app) => {
const res = await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'pin' })
assert.equal(res.status, 400)
assert.match((await res.json()).message, /applies to a thread/)
// An action that is not in the enum at all still stops at the validator —
// widening the list is not the same as removing it.
const nonsense = await post(app, '/api/v1/player/teams/a/forum/posts/1/moderate', { action: 'incinerate' })
assert.equal(nonsense.status, 400)
})
})