// Self-service push-notification management for the logged-in user (any role). // Mounted under /auth/me behind requireAuth, so req.user is the fresh DB row. // Devices (endpoints) and stream subscriptions live here; the fan-out that // actually delivers is utils/pushDispatch. See docs/android/PLAN.md §11. const pushDevices = require('../../../model/pushDevices/pushDevices.model') const notificationSubs = require('../../../model/notificationSubs/notificationSubs.model') const channelPrefs = require('../../../model/notificationChannelPrefs/notificationChannelPrefs.model') const registries = require('../../../modules/registries') const teamPrefs = require('../../../model/teams/teamNotify.model') const { isAllowedEndpoint } = require('../../../utils/pushDispatch') const inbox = require('../../../model/userNotifications/userNotifications.db') const log = require('../../../utils/logger')('notifications') // POST /auth/me/devices — register (or refresh) a push endpoint for this user. async function registerDevice(req, res) { const { transport = 'unifiedpush', endpoint, platform } = req.body // SSRF guard: the endpoint is a URL the server will later POST to. Reject // anything that isn't an allowed HTTPS relay origin before storing it. if (!isAllowedEndpoint(endpoint)) { return res.status(400).json({ message: 'Endpoint is not an allowed push URL' }) } try { const device = await pushDevices.register({ userId: req.user.id, transport, endpoint, platform }) return res.status(201).json(device) } catch (err) { log.error('registerDevice', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /auth/me/devices — this user's registered devices. async function listDevices(req, res) { try { return res.json(await pushDevices.listForUser(req.user.id)) } catch (err) { log.error('listDevices', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // DELETE /auth/me/devices/:id — unregister a device (must belong to the caller). async function removeDevice(req, res) { try { const ok = await pushDevices.remove(Number(req.params.id), req.user.id) if (!ok) return res.status(404).json({ message: 'Not found' }) return res.json({ ok: true }) } catch (err) { log.error('removeDevice', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /auth/me/notifications/streams — the subscribable catalog: core's streams // plus every installed module's, in registration order. Fixed for the lifetime of // a process (registration is boot-time), not a static constant. function getStreams(req, res) { return res.json({ streams: registries.allStreams() }) } // GET /auth/me/notifications/subscriptions — the caller's opted-in stream ids. async function getSubscriptions(req, res) { try { return res.json({ streams: await notificationSubs.getForUser(req.user.id) }) } catch (err) { log.error('getSubscriptions', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // PUT /auth/me/notifications/subscriptions — replace the caller's stream set. // Unknown ids are dropped; the stored (cleaned) set is echoed back. async function putSubscriptions(req, res) { try { const streams = await notificationSubs.setForUser(req.user.id, req.body.streams) return res.json({ streams }) } catch (err) { log.error('putSubscriptions', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /auth/me/notifications/channels — the per-channel preferences surface // (ENGAGEMENT.md Phase 3): the channel registry's declarative half, plus one item // per subscribable id with its EFFECTIVE mode on each channel that applies. // // The superset of `/notifications/streams` + `/notifications/subscriptions`, // which stay exactly as they are for the shipped app. async function getChannelPrefs(req, res) { try { return res.json(await channelPrefs.getForUser(req.user.id, req.user)) } catch (err) { log.error('getChannelPrefs', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // PUT /auth/me/notifications/channels — apply a SPARSE set of preferences. // // Only the (id, channel) pairs in the body are written; every other pair is left // alone, so a screen that manages one channel need not know about the others. Off // is a mode, not an omission — which is also why this endpoint has no // empty-array case to get wrong, unlike its two neighbours. Entries naming an // unknown id, an inapplicable channel or a mode that channel does not accept are // dropped by the model; the full stored state is echoed back so the caller can // see what actually took. async function putChannelPrefs(req, res) { try { return res.json(await channelPrefs.applyForUser(req.user.id, req.body.prefs, req.user)) } catch (err) { log.error('putChannelPrefs', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /auth/me/notifications/teams — this user's per-Team preferences, one row // per Team they could be notified about whether or not they have ever set one. // // Not gated on `teams_forums_enabled`: two of the four streams (member joined, // leadership changed) have nothing to do with the forum, so a deployment with // forums switched off still has preferences worth showing. async function getTeamPrefs(req, res) { try { return res.json({ teams: await teamPrefs.listPrefs(req.user.id) }) } catch (err) { log.error('getTeamPrefs', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // PUT /auth/me/notifications/teams — replace the caller's whole preference set. // // PUT-the-whole-set, matching the subscriptions endpoint beside it, and the // `teams` array is REQUIRED even when empty — the Android gotcha in // docs/android/PLAN.md §11: a DTO field with a default is dropped by kotlinx when // it equals that default, so clearing the last entry would arrive as a body with // no array at all and 400. Entries naming a Team the caller is not in are dropped // by the model rather than refused here (an ordinary race, not a client bug). async function putTeamPrefs(req, res) { try { const { prefs } = await teamPrefs.replacePrefs(req.user.id, req.body.teams) return res.json({ teams: prefs }) } catch (err) { log.error('putTeamPrefs', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // ── The inbox (ENGAGEMENT.md §4.5 G17, Phase 7) ──────────────────────────── // // The in-app channel's read side. Everything above this line is a PREFERENCE — // which streams, which channels, which Teams — and everything below it is // CONTENT addressed to the caller. They share a path prefix because a person // calls both "notifications", and the shapes keep them apart: the preference // endpoints are whole-set GET/PUT pairs on named sub-paths, the inbox is a // paged GET on the bare path with POSTs that name a row. // // **`req.user.id` is the only user id any of these can name.** There is no route // parameter for a user and no query string that selects one, so the ownership // check is not something a caller can be forgetful about — it is the shape of // the API. The model then repeats it in the WHERE clause of every statement, so // "read someone else's notification" is a 404 twice over. // GET /auth/me/notifications — one page of the caller's inbox, newest first. async function getInbox(req, res) { try { const page = await inbox.list(req.user.id, { limit: req.query.limit, before: req.query.before, unreadOnly: req.query.unread === 'true' || req.query.unread === '1', }) // The unread count rides along on every page, so the bell and the list never // disagree: a client that renders both from one response cannot show "3 // unread" above a list in which the third was just marked read. return res.json({ ...page, unread: await inbox.unreadCount(req.user.id) }) } catch (err) { log.error('getInbox', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // GET /auth/me/notifications/unread-count — the badge, on its own. // // Its own route rather than a field of the list, because it is polled: a client // asking "is there anything new" every minute should not make the server // assemble thirty rows and their bodies to answer with one integer. async function getUnreadCount(req, res) { try { return res.json({ unread: await inbox.unreadCount(req.user.id) }) } catch (err) { log.error('getUnreadCount', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /auth/me/notifications/:id/read — mark one item read. Idempotent. // // 404 both when the row does not exist and when it belongs to somebody else, // which is the same answer on purpose: distinguishing them would turn this route // into a way to ask whether a given id is anybody's. async function markRead(req, res) { try { const found = await inbox.markRead(req.user.id, req.params.id) if (!found) return res.status(404).json({ message: 'Not Found' }) return res.json({ ok: true, unread: await inbox.unreadCount(req.user.id) }) } catch (err) { log.error('markRead', err) return res.status(500).json({ message: 'Internal Server Error' }) } } // POST /auth/me/notifications/read-all — mark the whole inbox read. async function markAllRead(req, res) { try { const changed = await inbox.markAllRead(req.user.id) return res.json({ ok: true, changed, unread: 0 }) } catch (err) { log.error('markAllRead', err) return res.status(500).json({ message: 'Internal Server Error' }) } } module.exports = { registerDevice, listDevices, removeDevice, getStreams, getSubscriptions, putSubscriptions, getChannelPrefs, putChannelPrefs, getTeamPrefs, putTeamPrefs, getInbox, getUnreadCount, markRead, markAllRead, }