feat(teams): the forum schema, the operator's two switches, and the ack gate

The whole forum schema lands at once — threads, posts, the moderation ledger and
upload attribution — including the columns only phase 5's discussion threads use.
That is TEAMS.md 5.1's split BY LAYER rather than by feature: phase 5 opens paths
instead of migrating data.

Three settings keys, and only one of them is ordinary. `teams_forums_enabled` and
`teams_forum_images` are enum keys on the existing admin settings endpoint;
`teams_forum_images` also carries a server-side PRECONDITION, which is why the
three live in their own model rather than in the generic setMany() loop where a
reader would never find it.

The gate is the server's. `PUT teams_forum_images = 'uploads'` is rejected 400
unless the same request carries the acknowledgement version — the admin checkbox
is how the gate is presented, never the gate. What is stored is the TEXT VERSION,
so "which wording did they agree to" is answerable later; settings already record
updated_by/updated_at, and an activity_log row puts it in the staff audit trail.

A reworded notice makes a stored acknowledgement stale, and neither obvious answer
is right: uploads KEEP WORKING, and no other forum setting may be saved until it is
re-given. Non-destructive, and impossible to ignore.

Both reads fail closed. A DB fault reports the forum off and images disabled — a
forum that 404s for a minute is the cheap failure; a policy that is not a policy
is not.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:23:15 -05:00
parent 7ed2ac9983
commit 11fd9821bf
5 changed files with 325 additions and 1 deletions

View File

@@ -1022,6 +1022,118 @@ CREATE TABLE IF NOT EXISTS team_forum_grants (
INDEX idx_tfg_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Team forums (TEAMS.md Part 5, phase 4 "5a") ────────────────────────────
--
-- The WHOLE forum schema lands here, in 5a, including the columns only 5b uses.
-- That is §5.1's split-by-layer: 5a ships the access model and announcements, 5b
-- enables discussion by opening paths rather than by migrating data. `type`,
-- `locked`, `pinned` and the whole post table exist from day one so that the
-- second half adds no ALTER.
--
-- Every table here is guarded by `teams_forums_enabled` at the ROUTE level and
-- never at the data level (§5.5.1). Switching the forum off must not delete a
-- thread, revoke a grant or clear a subscription, because the operator will
-- switch it back on and expects what they had.
CREATE TABLE IF NOT EXISTS team_forum_threads (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
type ENUM('announcement','discussion') NOT NULL DEFAULT 'discussion',
title VARCHAR(200) NOT NULL,
created_by INT NULL, -- SET NULL: the body survives the account (§2.10)
created_username VARCHAR(32) NULL, -- snapshot, so a deleted author still reads
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_post_at DATETIME NULL,
post_count INT NOT NULL DEFAULT 0,
pinned TINYINT(1) NOT NULL DEFAULT 0,
locked TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tft_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tft_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tft_team_feed (team_id, status, pinned, last_post_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- `body_html` is sanitised ON WRITE and served without re-sanitising, the same
-- contract the wiki and the CMS already follow — but through the FORUM's own
-- profile (utils/forumHtml.js), not the shared one. The shared profile allows
-- `<img>` from any host, which would make `teams_forum_images` unenforceable:
-- every post could hotlink in every mode and the setting would be decoration.
-- No stored body ever contains an `<img>`; core's renderer emits those at read
-- time from the URLs the author wrote (§5.5.3), which is why flipping the policy
-- back to `disabled` un-renders every image on every existing post with no
-- migration at all.
CREATE TABLE IF NOT EXISTS team_forum_posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
author_user_id INT NULL,
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
edited_at DATETIME NULL,
edited_by INT NULL,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE,
CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfp_editor FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfp_thread (thread_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Append-only. Never updated, never deleted.
--
-- Deliberately NOT merged into the site's mod_actions/appeals pair (§5.3), which
-- 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. The two are cross-referenced instead — every
-- STAFF-exercised action here additionally writes an activity_log row, so the
-- site's staff-accountability trail sees it; a LEADER-exercised one writes only
-- this ledger. `actor_role` records WHICH authority was exercised, which is the
-- column that makes that distinction auditable after the fact.
CREATE TABLE IF NOT EXISTS team_forum_moderation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
target_type ENUM('thread','post') NOT NULL,
target_id BIGINT NOT NULL,
action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL,
actor_user_id INT NULL,
actor_username VARCHAR(32) NULL, -- snapshot (§2.10)
actor_role ENUM('leader','staff') NOT NULL,
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfm_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfm_target (target_type, target_id),
INDEX idx_tfm_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Upload attribution (§5.2a, §5.5.4). Not bookkeeping: the acknowledgement an
-- operator gives before enabling uploads is meaningless if "who uploaded this"
-- cannot be answered afterwards, and the deletion sweep needs a row to sweep.
--
-- `post_id` is NULL between the upload and the post that embeds it — the composer
-- uploads first and references the URL in the body — and that is exactly the state
-- the orphan sweep looks for. `deleted_at` is a soft delete: the file survives a
-- retention window so a mis-click is recoverable, then the nightly sweep removes
-- the bytes.
CREATE TABLE IF NOT EXISTS team_forum_uploads (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
post_id BIGINT NULL,
uploader_user_id INT NULL,
uploader_username VARCHAR(32) NULL, -- snapshot: attribution must survive the account
filename VARCHAR(255) NOT NULL, -- the STORED name, never originalname
mimetype VARCHAR(64) NOT NULL, -- the SNIFFED type, never the client's header
byte_size INT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
deleted_by INT NULL,
CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL,
CONSTRAINT fk_tfu_user FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfu_deleter FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_tfu_filename (filename),
INDEX idx_tfu_uploader (uploader_user_id, created_at),
INDEX idx_tfu_sweep (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a