// 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 registries = require('../../../modules/registries') const teamPrefs = require('../../../model/teams/teamNotify.model') const { isAllowedEndpoint } = require('../../../utils/pushDispatch') 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/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' }) } } module.exports = { registerDevice, listDevices, removeDevice, getStreams, getSubscriptions, putSubscriptions, getTeamPrefs, putTeamPrefs, }