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 Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
import Appeals from './routes/admin/views/Appeals.jsx'
|
import Appeals from './routes/admin/views/Appeals.jsx'
|
||||||
|
import ContentReports from './routes/admin/views/ContentReports.jsx'
|
||||||
|
|
||||||
// Player portal
|
// Player portal
|
||||||
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
import PlayerLogin from './routes/player/PlayerLogin.jsx'
|
||||||
@@ -163,6 +164,7 @@ export default function App() {
|
|||||||
<Route index element={<Moderation />} />
|
<Route index element={<Moderation />} />
|
||||||
<Route path="user/:discordId" element={<ModerationUser />} />
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||||
<Route path="appeals" element={<Appeals />} />
|
<Route path="appeals" element={<Appeals />} />
|
||||||
|
<Route path="reports" element={<ContentReports />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
|
|||||||
@@ -162,6 +162,21 @@ export const api = {
|
|||||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads`, { method: 'POST', body }),
|
||||||
teamForumModerate: (slug, id, body) =>
|
teamForumModerate: (slug, id, body) =>
|
||||||
req(`/player/teams/${encodeURIComponent(slug)}/forum/threads/${id}/moderate`, { method: 'POST', 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) => {
|
teamForumUpload: (slug, file) => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('image', file)
|
fd.append('image', file)
|
||||||
@@ -318,6 +333,18 @@ export const api = {
|
|||||||
|
|
||||||
// ----- moderation dashboard (admin + moderator) -----
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
modSummary: () => req('/admin/moderation/stats/summary'),
|
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 = {}) => {
|
modRecent: (params = {}) => {
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
if (params.type) qs.set('type', params.type)
|
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 { useSearchParams } from 'react-router-dom'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { api } from '../api/client.js'
|
import { api } from '../api/client.js'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../contexts/SiteContext.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
|
// Core's Team forum, rendered into a second slot a MODULE declares
|
||||||
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
|
// (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
|
// upload control that would otherwise 404. If the two ever disagree, the server
|
||||||
// is right.
|
// 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
|
// 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
|
// 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
|
// 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 [team, setTeam] = useState(null)
|
||||||
const [state, setState] = useState({ loading: true, forum: null })
|
const [state, setState] = useState({ loading: true, forum: null })
|
||||||
const [thread, setThread] = useState(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 openThreadId = params.get('thread')
|
||||||
const imageMode = settings?.teams_forum_images || 'disabled'
|
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(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
// An anonymous visitor has no forum by definition — every route is behind
|
// 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) {
|
if (openThreadId && thread) {
|
||||||
return (
|
return (
|
||||||
<ThreadView
|
<ThreadView
|
||||||
|
slug={team.slug}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
canModerate={forum.canModerate}
|
canModerate={forum.canModerate}
|
||||||
|
imageMode={imageMode}
|
||||||
onBack={() => openThread(null)}
|
onBack={() => openThread(null)}
|
||||||
|
onChanged={() => loadThread(team.slug, thread.id)}
|
||||||
onModerate={async (action) => {
|
onModerate={async (action) => {
|
||||||
await api.teamForumModerate(team.slug, thread.id, { action })
|
await api.teamForumModerate(team.slug, thread.id, { action })
|
||||||
await loadThreads(team.slug)
|
await loadThreads(team.slug)
|
||||||
@@ -116,22 +137,38 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
<section style={{ marginTop: 26 }}>
|
<section style={{ marginTop: 26 }}>
|
||||||
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
<header style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
|
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: 0 }}>
|
||||||
Announcements
|
Forum
|
||||||
</h2>
|
</h2>
|
||||||
{forum.canPost && !composing && (
|
{!composing && (
|
||||||
<button type="button" className="pill" onClick={() => setComposing(true)}>
|
<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
|
Post an announcement
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{composing && (
|
{composing && (
|
||||||
<Composer
|
<Composer
|
||||||
slug={team.slug}
|
slug={team.slug}
|
||||||
|
type={composing}
|
||||||
imageMode={imageMode}
|
imageMode={imageMode}
|
||||||
onCancel={() => setComposing(false)}
|
onCancel={() => setComposing(null)}
|
||||||
onPosted={async () => {
|
onPosted={async () => {
|
||||||
setComposing(false)
|
setComposing(null)
|
||||||
await loadThreads(team.slug)
|
await loadThreads(team.slug)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -139,7 +176,7 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
|
|
||||||
{forum.threads.length === 0 && !composing && (
|
{forum.threads.length === 0 && !composing && (
|
||||||
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
|
<p className="sans dim" style={{ fontSize: '0.9rem', marginTop: 8 }}>
|
||||||
Nothing has been announced here yet.
|
Nothing has been posted here yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -158,10 +195,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.pinned && <span className="dim" style={{ marginRight: 6 }} title="Pinned">📌</span>}
|
{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>
|
<strong>{t.title}</strong>
|
||||||
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
|
<span className="dim" style={{ marginLeft: 8, fontSize: '0.82rem' }}>
|
||||||
{t.author}
|
{threadSummary(t)}
|
||||||
{t.status === 'hidden' && ' · hidden'}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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 (
|
return (
|
||||||
<section style={{ marginTop: 26 }}>
|
<section style={{ marginTop: 26 }}>
|
||||||
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
|
<button type="button" className="pill" onClick={onBack} style={{ marginBottom: 10 }}>
|
||||||
← All announcements
|
← All threads
|
||||||
</button>
|
</button>
|
||||||
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
|
<h2 className="display" style={{ fontSize: '1.15rem', color: 'var(--head)', margin: '0 0 4px' }}>
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
|
||||||
|
{thread.type === 'announcement' ? 'Announcement · ' : ''}
|
||||||
{thread.author}
|
{thread.author}
|
||||||
{thread.authorDeleted && ' (account removed)'}
|
{thread.authorDeleted && ' (account removed)'}
|
||||||
|
{thread.locked && ' · locked'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{thread.posts.map((post) => (
|
{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
|
Sanitised on write with the forum's own profile, rendered server-side
|
||||||
under the operator's image policy, and re-sanitised here — the same
|
under the operator's image policy, and re-sanitised here — the same
|
||||||
@@ -307,25 +489,138 @@ function ThreadView({ thread, canModerate, onBack, onModerate }) {
|
|||||||
className="prose"
|
className="prose"
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body || '', { ADD_ATTR: ['referrerpolicy'] }) }}
|
||||||
/>
|
/>
|
||||||
</article>
|
</>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{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 && (
|
{canModerate && (
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
<>
|
||||||
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||||
{thread.pinned ? 'Unpin' : 'Pin'}
|
{post.status === 'hidden' ? 'Unhide' : 'Hide'}
|
||||||
</button>
|
|
||||||
<button type="button" className="pill" onClick={() => onModerate(thread.status === 'hidden' ? 'unhide' : 'hide')}>
|
|
||||||
{thread.status === 'hidden' ? 'Unhide' : 'Hide'}
|
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="pill" onClick={() => moderate('delete')}>Delete</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</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 [body, setBody] = useState('')
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -335,7 +630,7 @@ function Composer({ slug, imageMode, onCancel, onPosted }) {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.teamForumPost(slug, { type: 'announcement', title, body })
|
await api.teamForumReply(slug, threadId, { body })
|
||||||
await onPosted()
|
await onPosted()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Could not post that')
|
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 attach = async (event) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
try {
|
try {
|
||||||
const { url } = await api.teamForumUpload(slug, file)
|
const { url } = await api.teamForumUpload(slug, file)
|
||||||
// The URL goes into the BODY as text, not as an <img> tag. The author never
|
onAttached(url)
|
||||||
// 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}`)
|
|
||||||
} catch (err) {
|
} 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"
|
className="textarea"
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
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}
|
rows={6}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{isAnnouncement && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
|
||||||
|
Announcements cannot be replied to.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{imageMode === 'uploads' && (
|
{imageMode === 'uploads' && (
|
||||||
<label className="sans dim" style={{ fontSize: '0.85rem' }}>
|
<ImageAttacher slug={slug} onAttached={(url) => setBody((c) => `${c}${c ? '\n\n' : ''}${url}`)} onError={setError} />
|
||||||
Attach an image: <input type="file" accept="image/*" onChange={attach} />
|
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<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>
|
<button type="button" className="pill" onClick={onCancel}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export const NAV = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
{ to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] },
|
||||||
{ to: '/admin/moderation/appeals', label: 'Appeals', 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
|
// Moderation rather than System: the screen's daily job is the
|
||||||
// reserved-name review queue, which is moderator work. The three actions
|
// 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
|
// 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/hero': 'Hero Editor',
|
||||||
'/admin/moderation': 'Moderation',
|
'/admin/moderation': 'Moderation',
|
||||||
'/admin/moderation/appeals': 'Appeals',
|
'/admin/moderation/appeals': 'Appeals',
|
||||||
|
'/admin/moderation/reports': 'Reports',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/appearance': 'Appearance',
|
'/admin/appearance': 'Appearance',
|
||||||
'/admin/navigation': 'Navigation',
|
'/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 { api } from '../../../api/client.js'
|
||||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
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
|
// 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
|
// 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 [state, setState] = useState(null)
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
const [mode, setMode] = useState('disabled')
|
const [mode, setMode] = useState('disabled')
|
||||||
|
const [editWindow, setEditWindow] = useState('15')
|
||||||
const [dialog, setDialog] = useState(null)
|
const [dialog, setDialog] = useState(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -79,6 +81,7 @@ export default function TeamForumSettings() {
|
|||||||
setState(s)
|
setState(s)
|
||||||
setEnabled(s.enabled)
|
setEnabled(s.enabled)
|
||||||
setMode(s.imageMode)
|
setMode(s.imageMode)
|
||||||
|
setEditWindow(String(s.editWindowMinutes ?? 15))
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not load forum settings.')
|
setError('Could not load forum settings.')
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,7 @@ export default function TeamForumSettings() {
|
|||||||
await api.admin.updateSettings({
|
await api.admin.updateSettings({
|
||||||
teams_forums_enabled: next.enabled ? '1' : '0',
|
teams_forums_enabled: next.enabled ? '1' : '0',
|
||||||
teams_forum_images: next.mode,
|
teams_forum_images: next.mode,
|
||||||
|
teams_forum_edit_window_minutes: String(next.editWindow),
|
||||||
...(acknowledge ? { acknowledge } : {}),
|
...(acknowledge ? { acknowledge } : {}),
|
||||||
})
|
})
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
@@ -115,14 +119,14 @@ export default function TeamForumSettings() {
|
|||||||
function save() {
|
function save() {
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
if (mode === 'uploads' && (!state.acknowledgement?.given || stale || state.imageMode !== 'uploads')) {
|
||||||
setDialog({ enabled, mode })
|
setDialog({ enabled, mode, editWindow })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (stale) {
|
if (stale) {
|
||||||
setDialog({ enabled, mode })
|
setDialog({ enabled, mode, editWindow })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
persist({ enabled, mode })
|
persist({ enabled, mode, editWindow })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,6 +163,25 @@ export default function TeamForumSettings() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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 }}>
|
<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>)}
|
{HELP_TEXT.map((line) => <p key={line} style={{ margin: '0 0 6px' }}>{line}</p>)}
|
||||||
<ul style={{ margin: '0 0 6px 18px' }}>
|
<ul style={{ margin: '0 0 6px 18px' }}>
|
||||||
@@ -181,7 +204,12 @@ export default function TeamForumSettings() {
|
|||||||
{dialog && (
|
{dialog && (
|
||||||
<UploadsDialog
|
<UploadsDialog
|
||||||
version={state.acknowledgement.version}
|
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) => {
|
onConfirm={async (version) => {
|
||||||
setDialog(null)
|
setDialog(null)
|
||||||
await persist(dialog, version)
|
await persist(dialog, version)
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ function RequestQueue({ rows, role, onDecide, busy }) {
|
|||||||
|
|
||||||
// ── One Team ───────────────────────────────────────────────────────────────
|
// ── One Team ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TeamRow({ team, role, onAct, busy }) {
|
function TeamRow({ team, role, onAct, busy, onLedger }) {
|
||||||
const status = statusOf(team)
|
const status = statusOf(team)
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -181,11 +181,84 @@ function TeamRow({ team, role, onAct, busy }) {
|
|||||||
Hide
|
Hide
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button type="button" className="btn" onClick={() => onLedger(team)} style={{ marginLeft: 6 }}>
|
||||||
|
Forum log
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 ─────────────────────────────────────────────────────────────
|
// ── The screen ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TeamsAdmin() {
|
export default function TeamsAdmin() {
|
||||||
@@ -198,6 +271,7 @@ export default function TeamsAdmin() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [notice, setNotice] = useState('')
|
const [notice, setNotice] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [ledgerTeam, setLedgerTeam] = useState(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setError('')
|
setError('')
|
||||||
@@ -265,6 +339,8 @@ export default function TeamsAdmin() {
|
|||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} />}
|
||||||
{notice && <p className="notice">{notice}</p>}
|
{notice && <p className="notice">{notice}</p>}
|
||||||
|
|
||||||
|
{ledgerTeam && <ForumLedger team={ledgerTeam} onClose={() => setLedgerTeam(null)} />}
|
||||||
|
|
||||||
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
<SyncPanel sync={data} syncState={data.syncState} onResync={resync} busy={busy} />
|
||||||
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
<ReviewQueue rows={review} role={role} onAct={act} busy={busy} />
|
||||||
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
<RequestQueue rows={requests} role={role} onDecide={decide} busy={busy} />
|
||||||
@@ -288,7 +364,14 @@ export default function TeamsAdmin() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.teams.map((team) => (
|
{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>
|
</tbody>
|
||||||
</table>
|
</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')
|
await api.admin.disableModule('a b/c')
|
||||||
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
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)
|
INDEX idx_tfu_sweep (deleted_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
-- 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
|
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
|
||||||
-- performing one applies it immediately. Rows are kept after a decision — "a
|
-- 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.
|
-- so the system behaves exactly as today until an admin opts in.
|
||||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled');
|
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 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 category_id INT NULL;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1;
|
||||||
|
|||||||
@@ -345,6 +345,26 @@
|
|||||||
"requireAuth"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/moderation/search",
|
"path": "/api/v1/admin/moderation/search",
|
||||||
@@ -1687,6 +1707,39 @@
|
|||||||
"requireAuth"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/teams/:slug/forum/threads",
|
"path": "/api/v1/player/teams/:slug/forum/threads",
|
||||||
@@ -1729,6 +1782,17 @@
|
|||||||
"validate"
|
"validate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts",
|
||||||
|
"handlers": 5,
|
||||||
|
"gates": [
|
||||||
|
"noindex",
|
||||||
|
"requireAuth",
|
||||||
|
"middleware",
|
||||||
|
"validate"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/player/teams/:slug/forum/uploads",
|
"path": "/api/v1/player/teams/:slug/forum/uploads",
|
||||||
|
|||||||
@@ -145,6 +145,14 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/moderation/recent"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/moderation/search"
|
"path": "/api/v1/admin/moderation/search"
|
||||||
@@ -677,6 +685,18 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/teams/:slug/access"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/teams/:slug/forum/threads"
|
"path": "/api/v1/player/teams/:slug/forum/threads"
|
||||||
@@ -693,6 +713,10 @@
|
|||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
|
"path": "/api/v1/player/teams/:slug/forum/threads/:id/moderate"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/player/teams/:slug/forum/threads/:id/posts"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/player/teams/:slug/forum/uploads"
|
"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
|
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) ────────────────────────────────────
|
// ── the moderation ledger (append-only) ────────────────────────────────────
|
||||||
|
|
||||||
async function insertModeration({ teamId, targetType, targetId, action, actorUserId, actorUsername, actorRole, reason }) {
|
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. */
|
/** Rows soft-deleted longer ago than the retention window — the sweep's worklist. */
|
||||||
async function sweepableUploads(retentionDays) {
|
async function sweepableUploads(retentionDays) {
|
||||||
return query(
|
return query(
|
||||||
@@ -222,6 +277,8 @@ module.exports = {
|
|||||||
postById,
|
postById,
|
||||||
insertPost,
|
insertPost,
|
||||||
setPostStatus,
|
setPostStatus,
|
||||||
|
updatePostBody,
|
||||||
|
recountThread,
|
||||||
insertModeration,
|
insertModeration,
|
||||||
moderationForTeam,
|
moderationForTeam,
|
||||||
insertUpload,
|
insertUpload,
|
||||||
@@ -230,6 +287,7 @@ module.exports = {
|
|||||||
listUploads,
|
listUploads,
|
||||||
softDeleteUpload,
|
softDeleteUpload,
|
||||||
softDeleteUploadsForPost,
|
softDeleteUploadsForPost,
|
||||||
|
restoreUploadsForPost,
|
||||||
sweepableUploads,
|
sweepableUploads,
|
||||||
orphanedUploads,
|
orphanedUploads,
|
||||||
deleteUploadRows,
|
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
|
// 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 opens discussion threads,
|
// model and a single announcements stream per Team; 5b (phase 5) opens discussion
|
||||||
// replies and editing. The schema for all of it landed together, so 5b enables
|
// threads, replies, editing and post-level moderation. The schema for all of it
|
||||||
// paths here rather than migrating data — which is why `type` is a parameter
|
// landed together, so this phase added no ALTER — every column it needed
|
||||||
// below and not a constant, and why `locked` is honoured on a thread nothing can
|
// (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the
|
||||||
// reply to yet.
|
// ledger's `target_type='post'`) was already there waiting.
|
||||||
//
|
//
|
||||||
// **Every function here takes an already-resolved access decision.** Nothing in
|
// **Every function here takes an already-resolved access decision.** Nothing in
|
||||||
// this file reads `team_members` or `team_forum_grants`; the caller asks
|
// 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 forumSettings = require('./teamForumSettings.model')
|
||||||
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
|
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
|
||||||
|
|
||||||
// Announcements are leader-authored and replies are disabled; 5b's discussion
|
// Announcements are leader-authored and take no replies; discussion threads are
|
||||||
// threads are member-authored and take replies. Both types exist in the enum from
|
// member-authored and do. Both have been in the enum since 5a — what phase 5
|
||||||
// day one — this is the list of what 5a will CREATE.
|
// 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']
|
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]'
|
const DELETED_AUTHOR = '[deleted account]'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +63,17 @@ const THREAD_ACTIONS = {
|
|||||||
restore: { status: 'visible' },
|
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) {
|
function publicThread(row) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
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.
|
* `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
|
* 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
|
* output and nothing on disk, which is the property §5.5.3 exists to give and the
|
||||||
* one acceptance criterion 3 measures.
|
* 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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
author: row.author_username || DELETED_AUTHOR,
|
author: row.author_username || DELETED_AUTHOR,
|
||||||
@@ -81,6 +137,8 @@ function renderPost(row, mode) {
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
editedAt: row.edited_at,
|
editedAt: row.edited_at,
|
||||||
status: row.status,
|
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)
|
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)
|
const thread = await forumDb.threadById(threadId)
|
||||||
// The team check is here rather than in the SQL so a thread id from another
|
// 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
|
// 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 mode = await forumSettings.imageMode()
|
||||||
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
|
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
|
* 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
|
* is why phase 5 added no migration — a discussion thread is the same two writes
|
||||||
* left false: replies are refused because the TYPE takes none, not because the
|
* with a different `type`. The FIRST post is an ordinary post and is moderated,
|
||||||
* thread was closed, and conflating the two would make "unlock" look like it
|
* edited and reported like any other; nothing here marks it as special, because a
|
||||||
* would open replies on an announcement.
|
* 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 }) {
|
async function createThread({ team, actor, type, title, body }) {
|
||||||
if (!CREATABLE_TYPES_5A.includes(type)) {
|
if (!CREATABLE_TYPES.includes(type)) {
|
||||||
return { ok: false, status: 400, error: 'Only announcements can be posted yet' }
|
return { ok: false, status: 400, error: 'Unknown thread type' }
|
||||||
}
|
}
|
||||||
const cleaned = cleanForumBody(body)
|
const cleaned = cleanForumBody(body)
|
||||||
if (!cleaned || !cleaned.replace(/<[^>]*>/g, '').trim()) {
|
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({
|
const threadId = await forumDb.insertThread({
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
@@ -136,13 +211,102 @@ async function createThread({ team, actor, type, title, body }) {
|
|||||||
createdBy: actor.id,
|
createdBy: actor.id,
|
||||||
createdUsername: actor.username,
|
createdUsername: actor.username,
|
||||||
})
|
})
|
||||||
await forumDb.insertPost({
|
const postId = await forumDb.insertPost({
|
||||||
threadId,
|
threadId,
|
||||||
authorUserId: actor.id,
|
authorUserId: actor.id,
|
||||||
authorUsername: actor.username,
|
authorUsername: actor.username,
|
||||||
bodyHtml: cleaned,
|
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 }
|
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. */
|
/** The ledger for the admin Team page. Staff-only by its route, not by this function. */
|
||||||
async function moderationLedger(teamId, opts) {
|
async function moderationLedger(teamId, opts) {
|
||||||
return forumDb.moderationForTeam(teamId, opts)
|
return forumDb.moderationForTeam(teamId, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
CREATABLE_TYPES,
|
||||||
CREATABLE_TYPES_5A,
|
CREATABLE_TYPES_5A,
|
||||||
|
REPLYABLE_TYPES,
|
||||||
THREAD_ACTIONS,
|
THREAD_ACTIONS,
|
||||||
|
POST_ACTIONS,
|
||||||
listThreads,
|
listThreads,
|
||||||
getThread,
|
getThread,
|
||||||
createThread,
|
createThread,
|
||||||
|
createPost,
|
||||||
|
editPost,
|
||||||
moderateThread,
|
moderateThread,
|
||||||
|
moderatePost,
|
||||||
moderationLedger,
|
moderationLedger,
|
||||||
publicThread,
|
publicThread,
|
||||||
renderPost,
|
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
|
// TEAMS.md §5.5, plus phase 5's edit window. Four `settings` keys, and the reason
|
||||||
// file rather than in settings.model.js is that only one of them is an ordinary
|
// they live in their own file rather than in settings.model.js is that only two
|
||||||
// key: `teams_forum_images` has a server-side precondition, and a precondition
|
// of them are ordinary keys: `teams_forum_images` has a server-side precondition,
|
||||||
// buried in the generic setMany() loop is one nobody reading that loop would
|
// and a precondition buried in the generic setMany() loop is one nobody reading
|
||||||
// know about.
|
// that loop would know about.
|
||||||
//
|
//
|
||||||
// teams_forums_enabled '0' | '1' default '0' — off
|
// teams_forums_enabled '0' | '1' default '0' — off
|
||||||
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
|
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
|
||||||
// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given
|
// 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
|
// **Every read fails closed.** A DB fault reports the forum off, images disabled
|
||||||
// disabled, because the alternative is a transient error opening a feature the
|
// and the edit window shut, because the alternative is a transient error opening a
|
||||||
// operator turned off, or rendering third-party images on a site whose operator
|
// feature the operator turned off, or rendering third-party images on a site whose
|
||||||
// chose not to. The cost of failing closed here is a forum that 404s for a minute;
|
// operator chose not to. The cost of failing closed here is a forum that 404s for a
|
||||||
// the cost of failing open is a policy that is not a policy.
|
// minute; the cost of failing open is a policy that is not a policy.
|
||||||
|
|
||||||
const settingsDb = require('../settings/settings.db')
|
const settingsDb = require('../settings/settings.db')
|
||||||
|
|
||||||
const ENABLED_KEY = 'teams_forums_enabled'
|
const ENABLED_KEY = 'teams_forums_enabled'
|
||||||
const IMAGES_KEY = 'teams_forum_images'
|
const IMAGES_KEY = 'teams_forum_images'
|
||||||
const ACK_KEY = 'teams_forum_uploads_ack'
|
const ACK_KEY = 'teams_forum_uploads_ack'
|
||||||
|
const EDIT_WINDOW_KEY = 'teams_forum_edit_window_minutes'
|
||||||
|
|
||||||
const IMAGE_MODES = ['disabled', 'remote', 'uploads']
|
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
|
// 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
|
// makes every stored acknowledgement stale — see `ackState` below for what that
|
||||||
// then does, which is deliberately NOT "turn uploads off".
|
// 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. */
|
/** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
|
||||||
async function uploadsEnabled() {
|
async function uploadsEnabled() {
|
||||||
return (await imageMode()) === 'uploads'
|
return (await imageMode()) === 'uploads'
|
||||||
@@ -127,7 +160,7 @@ async function assertAcknowledged(nextMode, acknowledge) {
|
|||||||
* key.
|
* key.
|
||||||
*/
|
*/
|
||||||
async function assertSettingsWritable(keys, acknowledge) {
|
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 }
|
if (!touchesForum) return { ok: true }
|
||||||
const state = await ackState()
|
const state = await ackState()
|
||||||
if (!state.stale) return { ok: true }
|
if (!state.stale) return { ok: true }
|
||||||
@@ -148,10 +181,14 @@ module.exports = {
|
|||||||
ENABLED_KEY,
|
ENABLED_KEY,
|
||||||
IMAGES_KEY,
|
IMAGES_KEY,
|
||||||
ACK_KEY,
|
ACK_KEY,
|
||||||
|
EDIT_WINDOW_KEY,
|
||||||
IMAGE_MODES,
|
IMAGE_MODES,
|
||||||
ACK_VERSION,
|
ACK_VERSION,
|
||||||
|
EDIT_WINDOW_DEFAULT,
|
||||||
|
EDIT_WINDOW_MAX,
|
||||||
forumsEnabled,
|
forumsEnabled,
|
||||||
imageMode,
|
imageMode,
|
||||||
|
editWindowMinutes,
|
||||||
uploadsEnabled,
|
uploadsEnabled,
|
||||||
ackState,
|
ackState,
|
||||||
assertAcknowledged,
|
assertAcknowledged,
|
||||||
|
|||||||
@@ -612,6 +612,20 @@ async function updateSettings(req, res) {
|
|||||||
const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
|
const gate = await forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
|
||||||
if (!gate.ok) return res.status(gate.status).json({ message: gate.error })
|
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
|
// 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).
|
// 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 modNotes = require('../../../model/modNotes/modNotes.model')
|
||||||
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
||||||
const appeals = require('../../../model/appeals/appeals.model')
|
const appeals = require('../../../model/appeals/appeals.model')
|
||||||
|
const contentReports = require('../../../model/reports/contentReports.model')
|
||||||
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
|
const { isTerminal, isAppealableType, reversalStatusFor } = require('../../../model/appeals/appeals.pure')
|
||||||
const botInternalClient = require('../../../utils/botInternalClient')
|
const botInternalClient = require('../../../utils/botInternalClient')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
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 = {
|
module.exports = {
|
||||||
getSummary,
|
getSummary,
|
||||||
getRecent,
|
getRecent,
|
||||||
@@ -311,4 +374,6 @@ module.exports = {
|
|||||||
claimAppeal,
|
claimAppeal,
|
||||||
resolveAppeal,
|
resolveAppeal,
|
||||||
getUserAppeals,
|
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
|
// Mounted at /api/v1/admin/moderation by admin/index.js, which already applied
|
||||||
// `noindex, isLoggedIn, staffOnly`. Read-only views over the Discord bot's
|
// `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 { body, param } = require('express-validator')
|
||||||
|
|
||||||
const moderation = require('./moderation.controller')
|
const moderation = require('./moderation.controller')
|
||||||
|
const contentReports = require('../../../model/reports/contentReports.model')
|
||||||
const { requireRole } = require('../../../utils/auth')
|
const { requireRole } = require('../../../utils/auth')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
|
|
||||||
@@ -171,4 +173,34 @@ moderationRouter.get(
|
|||||||
moderation.getUserAppeals,
|
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
|
module.exports = moderationRouter
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ async function forumSettingsState(req, res) {
|
|||||||
return res.json({
|
return res.json({
|
||||||
enabled: await forumSettings.forumsEnabled(),
|
enabled: await forumSettings.forumsEnabled(),
|
||||||
imageMode: await forumSettings.imageMode(),
|
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(),
|
acknowledgement: await forumSettings.ackState(),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const grants = require('../../../model/teams/teamGrants.model')
|
|||||||
const forum = require('../../../model/teams/teamForum.model')
|
const forum = require('../../../model/teams/teamForum.model')
|
||||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||||
const uploads = require('../../../model/teams/teamForumUploads.model')
|
const uploads = require('../../../model/teams/teamForumUploads.model')
|
||||||
|
const reports = require('../../../model/reports/contentReports.model')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('teams')
|
const log = require('../../../utils/logger')('teams')
|
||||||
@@ -59,6 +60,7 @@ async function resolveForum(req) {
|
|||||||
return {
|
return {
|
||||||
team,
|
team,
|
||||||
access: resolved,
|
access: resolved,
|
||||||
|
staff,
|
||||||
// Staff moderate anywhere; a leader moderates their own Team. `actorRole`
|
// Staff moderate anywhere; a leader moderates their own Team. `actorRole`
|
||||||
// records WHICH of the two was exercised, and leadership wins when both are
|
// 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
|
// 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 ────────────────────────────────────────────────────────────────
|
// ── threads ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function listThreads(req, res) {
|
async function listThreads(req, res) {
|
||||||
@@ -77,7 +94,15 @@ async function listThreads(req, res) {
|
|||||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||||
return res.json({
|
return res.json({
|
||||||
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
|
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,
|
canModerate: ctx.canModerate,
|
||||||
imageMode: await forumSettings.imageMode(),
|
imageMode: await forumSettings.imageMode(),
|
||||||
})
|
})
|
||||||
@@ -90,7 +115,10 @@ async function getThread(req, res) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await resolveForum(req)
|
const ctx = await resolveForum(req)
|
||||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
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' })
|
if (!thread) return res.status(404).json({ message: 'Not found' })
|
||||||
return res.json({ ...thread, canModerate: ctx.canModerate })
|
return res.json({ ...thread, canModerate: ctx.canModerate })
|
||||||
} catch (err) {
|
} 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
|
* **The check splits by TYPE, which is what phase 4 said would happen here.** An
|
||||||
* phase only: in 5a the only creatable type is an announcement, whose author must
|
* announcement is leader-authored; a discussion is open to every participant — and
|
||||||
* be a leader. 5b adds `type: 'discussion'`, which any member may create — at
|
* "participant" means anyone `resolveForum` let through, which includes a granted
|
||||||
* which point the check splits by type rather than being widened.
|
* 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) {
|
async function createThread(req, res) {
|
||||||
try {
|
try {
|
||||||
const ctx = await resolveForum(req)
|
const ctx = await resolveForum(req)
|
||||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
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({
|
const result = await forum.createThread({
|
||||||
team: ctx.team,
|
team: ctx.team,
|
||||||
actor: req.user,
|
actor: req.user,
|
||||||
type: req.body.type || 'announcement',
|
type,
|
||||||
title: req.body.title,
|
title: req.body.title,
|
||||||
body: req.body.body,
|
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.
|
* 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) ───────────────────────────────────────────────────────
|
// ── uploads (§5.5.4) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,10 +440,14 @@ module.exports = {
|
|||||||
listThreads,
|
listThreads,
|
||||||
getThread,
|
getThread,
|
||||||
createThread,
|
createThread,
|
||||||
|
createPost,
|
||||||
|
editPost,
|
||||||
moderateThread,
|
moderateThread,
|
||||||
|
moderatePost,
|
||||||
listGrants,
|
listGrants,
|
||||||
createGrant,
|
createGrant,
|
||||||
revokeGrant,
|
revokeGrant,
|
||||||
createUpload,
|
createUpload,
|
||||||
deleteUpload,
|
deleteUpload,
|
||||||
|
createReport,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const express = require('express')
|
|||||||
const { body, param } = require('express-validator')
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
const ctrl = require('./teamForum.controller')
|
const ctrl = require('./teamForum.controller')
|
||||||
|
const contentReports = require('../../../model/reports/contentReports.model')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
const { makeLimiter } = require('../../../middleware/rateLimit')
|
const { makeLimiter } = require('../../../middleware/rateLimit')
|
||||||
const { upload } = require('../admin/imageUpload')
|
const { upload } = require('../admin/imageUpload')
|
||||||
@@ -40,6 +41,17 @@ const grantLimiter = makeLimiter({
|
|||||||
message: 'Too many grant changes. Please slow down.',
|
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,
|
// 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.
|
// and this is the per-IP flood guard in front of it.
|
||||||
const uploadLimiter = makeLimiter({
|
const uploadLimiter = makeLimiter({
|
||||||
@@ -64,16 +76,16 @@ forumRouter.get(
|
|||||||
forumRouter.post(
|
forumRouter.post(
|
||||||
'/:slug/forum/threads',
|
'/:slug/forum/threads',
|
||||||
// #swagger.tags = ['Player · Teams']
|
// #swagger.tags = ['Player · Teams']
|
||||||
// #swagger.summary = 'Post an announcement'
|
// #swagger.summary = 'Open a thread — an announcement or a discussion'
|
||||||
// #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.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.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.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, threadId: { type: 'integer' } } } } } } */
|
/* #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,
|
postLimiter,
|
||||||
param('slug').isString().trim().isLength({ min: 1, max: 191 }),
|
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('title').isString().trim().isLength({ min: 1, max: 200 }),
|
||||||
body('body').isString().isLength({ min: 1, max: 40000 }),
|
body('body').isString().isLength({ min: 1, max: 40000 }),
|
||||||
validate,
|
validate,
|
||||||
@@ -113,6 +125,71 @@ forumRouter.post(
|
|||||||
ctrl.moderateThread,
|
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 ─────────────────────────────────────────────────────────────────
|
// ── grants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
forumRouter.get(
|
forumRouter.get(
|
||||||
@@ -162,6 +239,28 @@ forumRouter.delete(
|
|||||||
ctrl.revokeGrant,
|
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 ────────────────────────────────────────────────────────────────
|
// ── uploads ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
forumRouter.post(
|
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' },
|
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: {
|
AppealQueueItem: {
|
||||||
allOf: [{ $ref: '#/components/schemas/Appeal' }],
|
allOf: [{ $ref: '#/components/schemas/Appeal' }],
|
||||||
description: 'A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated.',
|
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
|
// The forum's access model, its switches, its renderer (phase 4, "5a") and its
|
||||||
// (docs/website/TEAMS.md Part 5, phase 4 "5a").
|
// 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
|
// 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
|
// names a property that the code around it can lose without any screen looking
|
||||||
// different.
|
// 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) ───────────────────────────────────────────────────
|
// ── 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, 'insertThread', async () => 1)
|
||||||
patch(forumDb, 'insertPost', async () => 1)
|
patch(forumDb, 'insertPost', async () => 1)
|
||||||
|
|
||||||
const ok = await forum.createThread({ team, actor: leader, type: 'announcement', title: 'Raid', body: '<p>Hi</p>' })
|
// Phase 5 opened `discussion`. Neither type needed a migration: both have been
|
||||||
assert.equal(ok.ok, true)
|
// 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
|
const refused = await forum.createThread({ team, actor: leader, type: 'sticky', title: 'x', body: '<p>Hi</p>' })
|
||||||
// nothing creates one yet.
|
|
||||||
const refused = await forum.createThread({ team, actor: leader, type: 'discussion', title: 'Chat', body: '<p>Hi</p>' })
|
|
||||||
assert.equal(refused.ok, false)
|
assert.equal(refused.ok, false)
|
||||||
assert.equal(refused.status, 400)
|
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)])
|
const wav = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE'), Buffer.alloc(4)])
|
||||||
assert.equal(uploads.sniff(wav), null)
|
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 forum = require('../src/model/teams/teamForum.model')
|
||||||
const grants = require('../src/model/teams/teamGrants.model')
|
const grants = require('../src/model/teams/teamGrants.model')
|
||||||
const access = require('../src/model/teams/teamAccess.model')
|
const access = require('../src/model/teams/teamAccess.model')
|
||||||
|
const reports = require('../src/model/reports/contentReports.model')
|
||||||
const db = require('../src/utils/db')
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
after(() => db.close())
|
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}`, {
|
const post = (app, path, body) => fetch(`${app.url}${path}`, {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}),
|
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 ──────────────────
|
// ── 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, 'getThread', async () => mark())
|
||||||
patch(forum, 'createThread', async () => mark())
|
patch(forum, 'createThread', async () => mark())
|
||||||
patch(forum, 'moderateThread', 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) => {
|
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')).status, 404)
|
||||||
assert.equal((await get(app, '/api/v1/player/teams/a/forum/threads/1')).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', { 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/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')
|
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 () => {
|
test('with the forum ON, the same routes answer — the switch is the only difference', async () => {
|
||||||
signInAs(player)
|
signInAs(player)
|
||||||
patch(forumSettings, 'forumsEnabled', async () => true)
|
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')
|
const res = await get(app, '/api/v1/player/teams/a/forum/threads')
|
||||||
assert.equal(res.status, 200)
|
assert.equal(res.status, 200)
|
||||||
const body = await res.json()
|
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)
|
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