godcrm/backend/routes/v3/admin/chatNotifications.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

138 lines
4.9 KiB
JavaScript

// Admin-only endpoints — app-owner (space-11 owner) gated.
//
// Mount in server.js:
// app.use('/api/v3/admin', authenticate, adminRoutesV3);
//
// Authz mirrors routes/v3/secrets.js and routes/v3/owner.js: only the
// Development space (id 11) owner may access. Admin role alone is NOT
// enough. Per ADR-0040 §RBAC and reused by ADR-0064 §Decision for the
// global notification-defaults layer.
import express from 'express';
import { dbGet, dbRun, safeJsonParse } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
import { success, error, badRequest, forbidden } from '../../../utils/response.js';
import { notifyInvalidate } from '../../../services/notifications/resolveChatPrefs.js';
const log = apiLogger.child({ module: 'admin_api' });
const OWNER_SPACE_ID = 11;
const GLOBAL_CHAT_PREFS_KEY = 'chat_notifications_global';
const router = express.Router();
async function requireOwner(req, res) {
if (!req.user?.id) {
forbidden(res, 'Authentication required');
return false;
}
const space = await dbGet('SELECT id, owner_id FROM spaces WHERE id = ?', [OWNER_SPACE_ID]);
if (!space) {
error(res, 'OWNER_SPACE_MISSING', `Space ${OWNER_SPACE_ID} not found`, 500);
return false;
}
if (space.owner_id !== req.user.id) {
forbidden(res, 'Owner-only endpoint');
return false;
}
return true;
}
// ─── Prefs validation (mirrors notificationPrefsController.js) ──────────
const TOP_SCALAR_TYPES = {
enabled: 'boolean',
sound_enabled: 'boolean',
sound_volume: 'number',
};
const NESTED_BLOCKS = ['humans', 'agents'];
const NESTED_KEYS = ['sound', 'popup', 'badge'];
function validatePrefsShape(input) {
if (input === null || input === undefined) return { ok: true, value: null };
if (typeof input !== 'object' || Array.isArray(input)) {
return { ok: false, reason: 'prefs must be an object' };
}
const out = {};
for (const [k, v] of Object.entries(input)) {
if (k in TOP_SCALAR_TYPES) {
if (v === null) continue;
// eslint-disable-next-line valid-typeof
if (typeof v !== TOP_SCALAR_TYPES[k]) {
return { ok: false, reason: `${k} must be ${TOP_SCALAR_TYPES[k]}` };
}
if (k === 'sound_volume' && (v < 0 || v > 1)) {
return { ok: false, reason: 'sound_volume must be in [0, 1]' };
}
out[k] = v;
} else if (NESTED_BLOCKS.includes(k)) {
if (v === null) continue;
if (typeof v !== 'object' || Array.isArray(v)) {
return { ok: false, reason: `${k} must be an object` };
}
const block = {};
for (const [nk, nv] of Object.entries(v)) {
if (!NESTED_KEYS.includes(nk)) {
return { ok: false, reason: `unknown key ${k}.${nk}` };
}
if (nv === null) continue;
if (typeof nv !== 'boolean') {
return { ok: false, reason: `${k}.${nk} must be boolean` };
}
block[nk] = nv;
}
out[k] = block;
} else {
return { ok: false, reason: `unknown key: ${k}` };
}
}
return { ok: true, value: out };
}
// ─── Routes ─────────────────────────────────────────────────────────────
// GET /api/v3/admin/global/chat-notifications — read the global default JSON.
router.get('/global/chat-notifications', async (req, res) => {
if (!(await requireOwner(req, res))) return;
try {
const row = await dbGet(
`SELECT value FROM _app_settings WHERE key = ?`,
[GLOBAL_CHAT_PREFS_KEY]
);
const prefs = safeJsonParse(row?.value, null);
return success(res, { prefs });
} catch (err) {
log.error({ err }, 'admin/global/chat-notifications GET failed');
return error(res, 'GET_GLOBAL_PREFS_ERROR', err.message, 500);
}
});
// PUT /api/v3/admin/global/chat-notifications — overwrite the global default.
router.put('/global/chat-notifications', async (req, res) => {
if (!(await requireOwner(req, res))) return;
try {
const v = validatePrefsShape(req.body?.prefs);
if (!v.ok) return badRequest(res, v.reason);
const valueJson = JSON.stringify(v.value || {});
await dbRun(
`INSERT INTO _app_settings (key, value, updated_by, updated_at)
VALUES (?, ?::jsonb, ?, NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()`,
[GLOBAL_CHAT_PREFS_KEY, valueJson, req.user.id]
);
// Global write: cache-bust everything.
await notifyInvalidate({ scope: 'global' });
log.info({ userId: req.user.id }, 'admin/global/chat-notifications updated');
return success(res, { prefs: v.value });
} catch (err) {
log.error({ err }, 'admin/global/chat-notifications PUT failed');
return error(res, 'PUT_GLOBAL_PREFS_ERROR', err.message, 500);
}
});
export default router;
export { validatePrefsShape };