godcrm/backend/routes/v3/chat/notificationPrefsController.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

259 lines
11 KiB
JavaScript

// notificationPrefsController.js — ADR-0064 WP-A.
//
// Chat-side endpoints for the notification-preferences hierarchy + the
// unread badge summary used by FloatingChatButton.
//
// Mounted via chat/index.js under /api/v3/chat/*.
//
// Endpoints (6 of the 11 total — space and admin live in their own files):
// GET /chat/unread-summary
// GET /chat/notification-prefs/resolved?conversation_id=X
// GET /chat/notification-prefs/personal
// PUT /chat/notification-prefs/personal
// GET /chat/notification-prefs/conversation/:id
// PUT /chat/notification-prefs/conversation/:id
// DELETE /chat/notification-prefs/conversation/:id
import {
dbRun, dbGet, dbAll, isPostgres, safeJsonParse, apiLogger,
success, error, badRequest, notFound, forbidden,
requireAuth,
} from './chatShared.js';
import { resolveChatPrefs, notifyInvalidate } from '../../../services/notifications/resolveChatPrefs.js';
// ─── Shape validation ───────────────────────────────────────────────────────
// The canonical JSON shape is partial — every field optional. We reject
// unknown keys and bad types but tolerate missing ones.
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 };
}
// ─── Helpers ────────────────────────────────────────────────────────────────
async function assertParticipant(userId, conversationId) {
const part = await dbGet(
`SELECT id FROM conversation_participants WHERE user_id = $1 AND conversation_id = $2`,
[userId, conversationId]
);
return !!part;
}
// ─── Routes ─────────────────────────────────────────────────────────────────
export default function registerNotificationPrefsRoutes(router) {
// GET /chat/unread-summary — total + by_conversation for the current user.
router.get('/unread-summary', requireAuth, async (req, res) => {
try {
const userId = req.user.userId;
// Use the maintained conversation_participants.unread_count + the
// last_read_at fallback. For each conversation where the user is a
// participant, count messages > last_read_at (excluding system noise).
const rows = await dbAll(
`SELECT cp.conversation_id,
GREATEST(
COALESCE(cp.unread_count, 0),
COALESCE((
SELECT COUNT(*) FROM messages m
WHERE m.conversation_id = cp.conversation_id
AND m.sender_id IS DISTINCT FROM $1
AND (cp.last_read_at IS NULL OR m.created_at > cp.last_read_at)
AND (m.content_type IS NULL OR m.content_type NOT IN ('tool_call','tool_result','thinking','plan','agent_status'))
), 0)
) AS unread_count
FROM conversation_participants cp
WHERE cp.user_id = $1`,
[userId]
);
const by_conversation = rows
.map((r) => ({
conversation_id: parseInt(r.conversation_id, 10),
unread_count: parseInt(r.unread_count || 0, 10),
}))
.filter((r) => r.unread_count > 0);
const total = by_conversation.reduce((sum, r) => sum + r.unread_count, 0);
return success(res, { total, by_conversation });
} catch (err) {
apiLogger.error('chat/unread-summary error:', err);
return error(res, 'UNREAD_SUMMARY_ERROR', err.message, 500);
}
});
// GET /chat/notification-prefs/resolved?conversation_id=X
router.get('/notification-prefs/resolved', requireAuth, async (req, res) => {
try {
const userId = req.user.userId;
const conversationId = parseInt(req.query.conversation_id, 10);
if (!conversationId) return badRequest(res, 'conversation_id required');
const prefs = await resolveChatPrefs({ userId, conversationId });
return success(res, { prefs });
} catch (err) {
apiLogger.error('chat/notification-prefs/resolved error:', err);
return error(res, 'RESOLVE_ERROR', err.message, 500);
}
});
// GET /chat/notification-prefs/personal
router.get('/notification-prefs/personal', requireAuth, async (req, res) => {
try {
const userId = req.user.userId;
const row = await dbGet(
`SELECT setting_value_encrypted AS value FROM user_settings
WHERE user_id = $1 AND setting_key = 'chat_notifications'`,
[userId]
);
const prefs = safeJsonParse(row?.value, null);
return success(res, { prefs });
} catch (err) {
apiLogger.error('chat/notification-prefs/personal GET error:', err);
return error(res, 'GET_PERSONAL_ERROR', err.message, 500);
}
});
// PUT /chat/notification-prefs/personal
router.put('/notification-prefs/personal', requireAuth, async (req, res) => {
try {
const userId = req.user.userId;
const v = validatePrefsShape(req.body?.prefs);
if (!v.ok) return badRequest(res, v.reason);
const valueJson = JSON.stringify(v.value || {});
await dbRun(
`INSERT INTO user_settings (user_id, setting_key, setting_value_encrypted, setting_type)
VALUES ($1, 'chat_notifications', $2, 'preference')
ON CONFLICT (user_id, setting_key)
DO UPDATE SET setting_value_encrypted = $2, last_used_at = CURRENT_TIMESTAMP`,
[userId, valueJson]
);
await notifyInvalidate({ user_id: userId });
return success(res, { prefs: v.value });
} catch (err) {
apiLogger.error('chat/notification-prefs/personal PUT error:', err);
return error(res, 'PUT_PERSONAL_ERROR', err.message, 500);
}
});
// GET /chat/notification-prefs/conversation/:id — raw per-chat override
// (or null if not set). Used by the per-chat overrides modal to show the
// current state instead of opening in an all-Inherit default.
router.get('/notification-prefs/conversation/:id', requireAuth, async (req, res) => {
try {
const userId = req.user.userId;
const conversationId = parseInt(req.params.id, 10);
if (!conversationId) return badRequest(res, 'conversation_id required');
const row = await dbGet(
`SELECT notification_overrides AS value
FROM conversation_participants
WHERE user_id = $1 AND conversation_id = $2`,
[userId, conversationId]
);
if (!row) return forbidden(res, 'Not a participant in this conversation');
const prefs = typeof row.value === 'string' ? safeJsonParse(row.value, null) : (row.value ?? null);
return success(res, { prefs });
} catch (err) {
apiLogger.error('chat/notification-prefs/conversation GET error:', err);
return error(res, 'GET_CONV_ERROR', err.message, 500);
}
});
// PUT /chat/notification-prefs/conversation/:id
router.put('/notification-prefs/conversation/:id', requireAuth, async (req, res) => {
try {
const userId = req.user.userId;
const conversationId = parseInt(req.params.id, 10);
if (!conversationId) return badRequest(res, 'conversation_id required');
const v = validatePrefsShape(req.body?.prefs);
if (!v.ok) return badRequest(res, v.reason);
if (!(await assertParticipant(userId, conversationId))) {
return forbidden(res, 'Not a participant in this conversation');
}
const valueJson = JSON.stringify(v.value || {});
await dbRun(
`UPDATE conversation_participants
SET notification_overrides = $1::jsonb
WHERE user_id = $2 AND conversation_id = $3`,
[valueJson, userId, conversationId]
);
await notifyInvalidate({ user_id: userId, conversation_id: conversationId });
return success(res, { prefs: v.value });
} catch (err) {
apiLogger.error('chat/notification-prefs/conversation PUT error:', err);
return error(res, 'PUT_CONV_ERROR', err.message, 500);
}
});
// DELETE /chat/notification-prefs/conversation/:id — clear the override
router.delete('/notification-prefs/conversation/:id', requireAuth, async (req, res) => {
try {
const userId = req.user.userId;
const conversationId = parseInt(req.params.id, 10);
if (!conversationId) return badRequest(res, 'conversation_id required');
if (!(await assertParticipant(userId, conversationId))) {
return forbidden(res, 'Not a participant in this conversation');
}
await dbRun(
`UPDATE conversation_participants
SET notification_overrides = NULL
WHERE user_id = $1 AND conversation_id = $2`,
[userId, conversationId]
);
await notifyInvalidate({ user_id: userId, conversation_id: conversationId });
return success(res, { cleared: true });
} catch (err) {
apiLogger.error('chat/notification-prefs/conversation DELETE error:', err);
return error(res, 'DELETE_CONV_ERROR', err.message, 500);
}
});
}
// Exported for tests
export { validatePrefsShape };