// ── event_action_settings — SQL only ─────────────────────────────────────── // // EVENTS.md §D/§K, and Phase 6 of EVENTS_PLAN.md. The deployment's switchboard: // one row per action an admin has an opinion about, and **nothing else in the // permission model beyond the role**. // // **A missing row is not "disabled".** It is "the default for this action's risk // class", and that default is computed in `eventActionSettings.model.js` from the // registry rather than stored here. The reason is structural: the registry is // assembled by `registerCore()` and by module `register()`, both of which run // under `routeManifest.js` and `swagger.js` against a dead pool (MODULE_API.md // §2.2), so a boot-time seed of one row per registered action would be precisely // the database write those two forbid. A deployment that never opens the // switchboard has no rows at all and behaves correctly. // // **Rows outlive their actions on purpose.** Uninstalling a module leaves its // settings standing, so re-installing restores the caps the operator chose // instead of silently resetting them to the default. The switchboard lists what // is registered *now*, so a stranded row is invisible until its action returns. const { query } = require('../../utils/db') const { parseJson } = require('./eventJson') const hydrate = (row) => row && { ...row, caps: parseJson(row.caps, {}) || {} } /** Every stored row, action id first. Stranded rows included — the caller filters. */ async function all() { const rows = await query( `SELECT s.action_id, s.enabled, s.caps, s.updated_by, s.updated_at, u.username AS updated_by_username FROM event_action_settings s LEFT JOIN users u ON u.id = s.updated_by ORDER BY s.action_id`, ) return rows.map(hydrate) } /** One row, or null when the deployment has never had an opinion about this action. */ async function get(actionId) { const rows = await query( `SELECT action_id, enabled, caps, updated_by, updated_at FROM event_action_settings WHERE action_id = ?`, [actionId], ) return rows.length ? hydrate(rows[0]) : null } /** * The rows for a set of action ids, as a Map keyed by id. * * The shape the authorisation path wants: `mayInvoke` is asked about one action * at a time but the run start prices a whole version at once, and one query per * step of a twelve-step definition is twelve round trips to answer a question * about a table with one row per action in the process. */ async function byIds(actionIds) { const ids = [...new Set(actionIds || [])].filter(Boolean) if (!ids.length) return new Map() const rows = await query( `SELECT action_id, enabled, caps, updated_by, updated_at FROM event_action_settings WHERE action_id IN (${ids.map(() => '?').join(',')})`, ids, ) return new Map(rows.map((r) => [r.action_id, hydrate(r)])) } /** * Write one action's switch and caps. * * An upsert rather than a read-then-write, for the ordinary reason: two admins on * the switchboard at once should leave one of their two opinions standing, not an * error and not a row that never appeared. There is no compare-and-set here * because there is nothing to race — this is configuration, and the value that * matters is the last one a human chose. */ async function put(actionId, { enabled, caps }, userId = null) { await query( `INSERT INTO event_action_settings (action_id, enabled, caps, updated_by) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), caps = VALUES(caps), updated_by = VALUES(updated_by)`, [actionId, enabled ? 1 : 0, JSON.stringify(caps || {}), userId], ) return get(actionId) } module.exports = { all, get, byIds, put }