feat(teams): phase 5 — Forum 5b, discussion + moderation + reports #155
@@ -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 />} />
|
||||
|
||||
@@ -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)
|
||||
|
||||
85
client/src/lib/teamForum.js
Normal file
85
client/src/lib/teamForum.js
Normal 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
|
||||
// "<script>" become a real tag the next pass then removes, which is a
|
||||
// different string from the one the author wrote.
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
// `&` last of all, or "&lt;" would decode two steps into "<".
|
||||
.replace(/&/g, '&')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary under a thread's title in the list.
|
||||
*
|
||||
* `postCount` counts every post including the opening one, so a discussion's
|
||||
* REPLY count is one less — and an announcement has no replies to count at all,
|
||||
* which is why the count is omitted rather than shown as zero.
|
||||
*/
|
||||
export function threadSummary(thread) {
|
||||
const parts = []
|
||||
if (thread.type === 'announcement') parts.push('Announcement')
|
||||
parts.push(thread.author)
|
||||
if (thread.type === 'discussion' && thread.postCount > 1) {
|
||||
const replies = thread.postCount - 1
|
||||
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
|
||||
}
|
||||
if (thread.status === 'hidden') parts.push('hidden')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
@@ -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’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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
310
client/src/routes/admin/views/ContentReports.jsx
Normal file
310
client/src/routes/admin/views/ContentReports.jsx
Normal 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’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)' }
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
120
client/test/teamForum.test.js
Normal file
120
client/test/teamForum.test.js
Normal 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 & Jerry</p>'), 'Tom & Jerry')
|
||||
assert.equal(stripToText('<p>"quoted"</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><script></p>'), '<script>')
|
||||
// And decoding & first would turn "&lt;" into "<" in two steps.
|
||||
assert.equal(stripToText('<p>&lt;</p>'), '<')
|
||||
})
|
||||
|
||||
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))
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
154
server/src/model/reports/contentReports.db.js
Normal file
154
server/src/model/reports/contentReports.db.js
Normal 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,
|
||||
}
|
||||
243
server/src/model/reports/contentReports.model.js
Normal file
243
server/src/model/reports/contentReports.model.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 post’s excerpt and author, a thread’s title, or an upload’s 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 5’s discussion threads add no migration. The body is sanitised with the FORUM’s 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 FORUM’s 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 thread’s 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 else’s 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 thread’s 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 Team’s 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 Team’s 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
@@ -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.',
|
||||
|
||||
296
server/test/contentReports.test.js
Normal file
296
server/test/contentReports.test.js
Normal 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 row’s 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()))
|
||||
})
|
||||
@@ -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 else’s 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 thread’s 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])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user