Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
259 lines
11 KiB
JavaScript
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 };
|