ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.
- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
and a 2FA or recovery code when 2FA is on. A wrong password or code is
400, not 401 (AD12), so the app's refresh-on-401 never replays it.
409 password_required and last_admin; the demo's shared logins are
locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
reserves a staff username (reserved_usernames, AD9) and deletes the row,
then a Teams reconcile, the audit entry (no username) and a notice to
the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
"[deleted player]" to members and the stored name, marked, to staff
(AD5, AD6). Used by the forum, the Reports queue, the guest list and
the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
155 lines
5.2 KiB
JavaScript
155 lines
5.2 KiB
JavaScript
// 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_by, 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,
|
|
}
|