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:
@@ -9,6 +9,7 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
|
||||
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
|
||||
const registries = require('../../../modules/registries')
|
||||
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const forumSettings = require('../../../model/teams/teamForumSettings.model')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
@@ -589,8 +590,50 @@ async function updateSettings(req, res) {
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
||||
}
|
||||
// The Team-forum controls (TEAMS.md §5.5). Two enum keys and one PRECONDITION —
|
||||
// the only key on this endpoint whose write depends on something other than its
|
||||
// own value. `acknowledge` is a request field, not a setting: it is consumed
|
||||
// here and never stored, because what gets stored is the text VERSION the
|
||||
// operator accepted, written by recordAck() below.
|
||||
if (forumSettings.ENABLED_KEY in updates) {
|
||||
const v = updates[forumSettings.ENABLED_KEY]
|
||||
if (v !== '0' && v !== '1' && v !== true && v !== false) {
|
||||
return res.status(400).json({ message: 'Invalid teams_forums_enabled value' })
|
||||
}
|
||||
updates[forumSettings.ENABLED_KEY] = v === true || v === '1' ? '1' : '0'
|
||||
}
|
||||
const nextImageMode = updates[forumSettings.IMAGES_KEY]
|
||||
if (forumSettings.IMAGES_KEY in updates) {
|
||||
if (!forumSettings.IMAGE_MODES.includes(nextImageMode)) {
|
||||
return res.status(400).json({ message: 'Invalid teams_forum_images value' })
|
||||
}
|
||||
// THE GATE (§5.5.5). Server-side, and rejected 400 with the admin UI's
|
||||
// checkbox bypassed — a checkbox is how the gate is presented, never the gate.
|
||||
const gate = forumSettings.assertAcknowledged(nextImageMode, req.body.acknowledge)
|
||||
if (!gate.ok) return res.status(gate.status).json({ message: gate.error })
|
||||
}
|
||||
{
|
||||
// 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).
|
||||
const writable = await forumSettings.assertSettingsWritable(Object.keys(updates), req.body.acknowledge)
|
||||
if (!writable.ok) return res.status(writable.status).json({ message: writable.error })
|
||||
}
|
||||
const acknowledging = String(req.body.acknowledge ?? '') === forumSettings.ACK_VERSION
|
||||
delete updates.acknowledge
|
||||
try {
|
||||
await settings.setMany(updates, req.user.id)
|
||||
if (acknowledging && (nextImageMode === 'uploads' || forumSettings.IMAGES_KEY in updates)) {
|
||||
// Recorded, not merely displayed: `updated_by`/`updated_at` come from the
|
||||
// settings schema, and the activity_log row puts it in the staff audit trail
|
||||
// with the acting admin's IP alongside every other consequential action.
|
||||
await forumSettings.recordAck(req.user.id)
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'team.forum.uploads.acknowledged',
|
||||
detail: `${req.user.username} (#${req.user.id}) acknowledged the image-upload notice `
|
||||
+ `(version ${forumSettings.ACK_VERSION})`,
|
||||
})
|
||||
}
|
||||
// The HTML shell is templated from brand_assets and theme_visual, and is
|
||||
// cached per process (utils/htmlShell.js) — a write that can change it has
|
||||
// to say so, or the favicon an admin just uploaded appears only after the
|
||||
|
||||
Reference in New Issue
Block a user