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:
@@ -17,6 +17,20 @@ async function set(key, value, updatedBy = null) {
|
||||
)
|
||||
}
|
||||
|
||||
// One row WITH its provenance. `updated_by`/`updated_at` are already stored for
|
||||
// every key; this is the only reader that needs them, because TEAMS.md §5.5.5
|
||||
// makes the uploads acknowledgement a RECORDED consent rather than a displayed
|
||||
// one, and "which admin accepted it, and when" is the question that has to be
|
||||
// answerable afterwards.
|
||||
async function getRow(key) {
|
||||
const rows = await query(
|
||||
'SELECT s.`key`, s.value, s.updated_by, s.updated_at, u.username AS updated_by_username '
|
||||
+ 'FROM settings s LEFT JOIN users u ON u.id = s.updated_by WHERE s.`key` = ? LIMIT 1',
|
||||
[key],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Insert a default only if the key does not already exist.
|
||||
async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
@@ -30,4 +44,4 @@ async function remove(key) {
|
||||
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault, remove }
|
||||
module.exports = { getAll, get, getRow, set, seedDefault, remove }
|
||||
|
||||
@@ -16,6 +16,18 @@ const PUBLIC_KEYS = [
|
||||
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
|
||||
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
|
||||
'nav_public', // public site nav overrides (JSON). §6.4.
|
||||
// The two Team-forum controls (TEAMS.md §5.5.6). The client needs the first to
|
||||
// know whether to render the forum panel at all, and the second to decide which
|
||||
// composer to show — an upload control that 404s is worse than no control.
|
||||
// Neither is sensitive.
|
||||
//
|
||||
// `teams_forum_uploads_ack` is deliberately NOT here: who accepted a liability
|
||||
// notice is operator detail, exactly as `failure_reason` is in MODULE_API.md
|
||||
// §2.9. And publishing the mode does not move the DECISION client-side — the
|
||||
// server still resolves what renders (§5.5.3); the client is only told which
|
||||
// composer to draw.
|
||||
'teams_forums_enabled',
|
||||
'teams_forum_images',
|
||||
]
|
||||
|
||||
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
|
||||
|
||||
143
server/src/model/teams/teamForumSettings.model.js
Normal file
143
server/src/model/teams/teamForumSettings.model.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// ── The operator's two forum controls, and the acknowledgement gate ────────
|
||||
//
|
||||
// TEAMS.md §5.5. Three `settings` keys, and the reason they live in their own
|
||||
// file rather than in settings.model.js is that only one of them is an ordinary
|
||||
// key: `teams_forum_images` has a server-side precondition, and a precondition
|
||||
// buried in the generic setMany() loop is one nobody reading that loop would
|
||||
// know about.
|
||||
//
|
||||
// teams_forums_enabled '0' | '1' default '0' — off
|
||||
// teams_forum_images 'disabled' | 'remote' | 'uploads' default 'disabled'
|
||||
// teams_forum_uploads_ack the acknowledged TEXT VERSION absent until given
|
||||
//
|
||||
// **Both reads fail closed.** A DB fault reports the forum off and images
|
||||
// disabled, because the alternative is a transient error opening a feature the
|
||||
// operator turned off, or rendering third-party images on a site whose operator
|
||||
// chose not to. The cost of failing closed here is a forum that 404s for a minute;
|
||||
// the cost of failing open is a policy that is not a policy.
|
||||
|
||||
const settingsDb = require('../settings/settings.db')
|
||||
|
||||
const ENABLED_KEY = 'teams_forums_enabled'
|
||||
const IMAGES_KEY = 'teams_forum_images'
|
||||
const ACK_KEY = 'teams_forum_uploads_ack'
|
||||
|
||||
const IMAGE_MODES = ['disabled', 'remote', 'uploads']
|
||||
|
||||
// The version of the §5.5.5 warning text currently in force. Bumping this is what
|
||||
// makes every stored acknowledgement stale — see `ackState` below for what that
|
||||
// then does, which is deliberately NOT "turn uploads off".
|
||||
const ACK_VERSION = '1'
|
||||
|
||||
/** Is the forum switched on? Fail closed. */
|
||||
async function forumsEnabled() {
|
||||
try {
|
||||
return String(await settingsDb.get(ENABLED_KEY)) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The image policy. Fail closed, and coerce any unexpected stored value back to
|
||||
* 'disabled' — a hand-edited row must not be able to widen the policy by being
|
||||
* unreadable.
|
||||
*/
|
||||
async function imageMode() {
|
||||
try {
|
||||
const value = await settingsDb.get(IMAGES_KEY)
|
||||
return IMAGE_MODES.includes(value) ? value : 'disabled'
|
||||
} catch {
|
||||
return 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
/** Are uploads accepted? The one mode where files come to rest on the operator's disk. */
|
||||
async function uploadsEnabled() {
|
||||
return (await imageMode()) === 'uploads'
|
||||
}
|
||||
|
||||
/**
|
||||
* The acknowledgement's state, for the admin surface.
|
||||
*
|
||||
* `stale` is the case §5.5.5 spends its longest paragraph on: the text was
|
||||
* reworded after an operator accepted it. Neither obvious answer is right —
|
||||
* silently downgrading a live feature because a legal text changed strands users
|
||||
* mid-conversation, and honouring an old acceptance forever defeats versioning.
|
||||
* So uploads keep working, `stale` drives a persistent banner, and
|
||||
* `assertSettingsWritable` below refuses every other forum setting until it is
|
||||
* re-given. Non-destructive, and impossible to ignore.
|
||||
*/
|
||||
async function ackState() {
|
||||
const stored = await settingsDb.get(ACK_KEY)
|
||||
const row = await settingsDb.getRow(ACK_KEY)
|
||||
return {
|
||||
version: ACK_VERSION,
|
||||
acknowledgedVersion: stored ?? null,
|
||||
given: stored != null,
|
||||
stale: stored != null && String(stored) !== ACK_VERSION,
|
||||
...(row ? { acknowledgedBy: row.updated_by_username ?? null, acknowledgedAt: row.updated_at } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate. `PUT teams_forum_images = 'uploads'` is rejected 400 unless the SAME
|
||||
* request carries `acknowledge: <currentVersion>`.
|
||||
*
|
||||
* The checkbox in the admin UI is not the gate — it is how the gate is presented.
|
||||
* That distinction is the whole reason this function exists on the server: an
|
||||
* acknowledgement a client could skip is not an acknowledgement.
|
||||
*
|
||||
* Returns `{ ok }` or `{ ok: false, error, status }`, matching the model result
|
||||
* shape the Teams controllers already translate.
|
||||
*/
|
||||
function assertAcknowledged(nextMode, acknowledge) {
|
||||
if (nextMode !== 'uploads') return { ok: true }
|
||||
if (String(acknowledge ?? '') !== ACK_VERSION) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: `Enabling uploads requires acknowledging the current notice (version ${ACK_VERSION}).`,
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The stale-acknowledgement lock: while an acknowledgement is stale, NO forum
|
||||
* setting may be saved until it is re-given. Not "uploads are disabled" — see
|
||||
* `ackState`. The re-acknowledgement itself is exempt, or the lock would have no
|
||||
* key.
|
||||
*/
|
||||
async function assertSettingsWritable(keys, acknowledge) {
|
||||
const touchesForum = keys.some((k) => k === ENABLED_KEY || k === IMAGES_KEY)
|
||||
if (!touchesForum) return { ok: true }
|
||||
const state = await ackState()
|
||||
if (!state.stale) return { ok: true }
|
||||
if (String(acknowledge ?? '') === ACK_VERSION) return { ok: true }
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: 'The image-upload notice has changed. Re-acknowledge it before saving forum settings.',
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the acknowledgement. `updated_by`/`updated_at` come free from the settings schema. */
|
||||
async function recordAck(adminUserId) {
|
||||
await settingsDb.set(ACK_KEY, ACK_VERSION, adminUserId)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ENABLED_KEY,
|
||||
IMAGES_KEY,
|
||||
ACK_KEY,
|
||||
IMAGE_MODES,
|
||||
ACK_VERSION,
|
||||
forumsEnabled,
|
||||
imageMode,
|
||||
uploadsEnabled,
|
||||
ackState,
|
||||
assertAcknowledged,
|
||||
assertSettingsWritable,
|
||||
recordAck,
|
||||
}
|
||||
Reference in New Issue
Block a user