Notifications API
In-app notification lists, preferences, reads, and rerouting
Notifications are database rows displayed by the bell UI. They are not external email. Every read/list operation scopes by the current user's ID, while rerouting is a government operation that recalculates submitted records.
GET /api/notifications
Auth: Any signed-in user. Success: 200.
| Query field | Type | Required? | Constraints/default |
|---|---|---|---|
page | integer | No | Minimum 1; default 1. |
pageSize | integer | No | 1–50; default 20. |
unread | enum | No | "true" or "false". |
curl 'http://localhost:3000/api/notifications?page=1&pageSize=20&unread=true' \
-H 'cookie: next-auth.session-token=SESSION'Example:
{"data":{"items":[{"id":"clx-notification","kind":"SUBMISSION_MATCH","title":"New submission routed: Autonomous UUV navigation","body":"Pelagic Dynamics submitted...","linkUrl":"/gov/organizations/clx-pelagic?submission=clx-submission","read":false,"createdAt":"2026-02-01T12:00:01.000Z"}],"total":1,"page":1,"pageSize":20}}Errors: 401, 400 invalid query, 500. Pagination is applied after the current user's filter.
linkUrl deep links are consumed, not decorative
notifyGovByTags builds submission-match links as /gov/organizations/<orgId>?submission=<submissionId> (src/lib/matching.ts), and the organization page reads that parameter: it keeps the value only when the ID belongs to one of that organization's submissions, then highlights the row, offsets the scroll target by the sticky header height, and gives the row tabIndex={-1} so SubmissionDeepLink can move focus to it after paint. Motion is suppressed under prefers-reduced-motion.
A parameter that does not match one of the organization's submissions is discarded silently and the page renders normally — no highlight, no error. Any new deep-linked notification kind must add the same target-side handling; a query parameter no page reads is a broken link that looks fine in the API response.
GET /api/notifications/unread-count
Auth: Any signed-in user. Success: 200.
curl http://localhost:3000/api/notifications/unread-count \
-H 'cookie: next-auth.session-token=SESSION'Response: { "data": { "count": 3 } }. Errors: 401 or 500.
POST /api/notifications/read
Auth: Any signed-in user. The body is a union, so send exactly one form.
| Body | Type | Meaning |
|---|---|---|
id | string 1–64 | Mark this user's notification read. |
all | literal true | Mark all of this user's notifications read. |
curl -X POST http://localhost:3000/api/notifications/read \
-H 'content-type: application/json' -H 'cookie: next-auth.session-token=SESSION' \
-d '{"id":"clx-notification"}'Or { "all": true }. Success: { "data": { "updated": 1 } }; errors 401, malformed JSON 400, schema 400, and 500. An ID belonging to another user does not update another user's row.
GET /api/notifications/settings
Auth: Any signed-in user. Success: 200.
curl http://localhost:3000/api/notifications/settings \
-H 'cookie: next-auth.session-token=SESSION'Response shape:
{"data":{"settings":[{"kind":"SUBMISSION_MATCH","enabled":true,"techTags":""},{"kind":"ANNOUNCEMENT","enabled":true,"techTags":""},{"kind":"CALL_OPENED","enabled":true,"techTags":""},{"kind":"ENGAGEMENT_UPDATE","enabled":true,"techTags":""},{"kind":"SYSTEM","enabled":true,"techTags":""}]}}Missing database rows are returned as enabled defaults. Errors: 401 or 500.
PUT /api/notifications/settings
Auth: Any signed-in user.
| Field | Type | Required? | Constraints |
|---|---|---|---|
settings | array | Yes | 1 through all notification kinds. |
settings[].kind | enum | Yes | Every NotificationKind value. |
settings[].enabled | boolean | Yes | Explicit true/false. |
The implementation also accepts the optional tag filter where the settings UI sends it; tags are normalized lowercase and persisted in NotificationSetting.techTags.
curl -X PUT http://localhost:3000/api/notifications/settings \
-H 'content-type: application/json' -H 'cookie: next-auth.session-token=SESSION' \
-d '{"settings":[{"kind":"SUBMISSION_MATCH","enabled":true,"techTags":"uuv,autonomy"},{"kind":"ANNOUNCEMENT","enabled":false},{"kind":"CALL_OPENED","enabled":true},{"kind":"ENGAGEMENT_UPDATE","enabled":true},{"kind":"SYSTEM","enabled":true}]}'Success: { "data": { "ok": true } }. Errors: 401, 400 for a malformed body or unknown kinds, and 500. Duplicate kinds are accepted; the last occurrence wins because settings are upserted in array order. A disabled kind suppresses future rows; it does not delete old notifications.
POST /api/notifications/reroute
Auth: admin-tier government session (GOV_ADMIN or GOV_SYSTEM_ADMIN). Success: 200.
curl -X POST http://localhost:3000/api/notifications/reroute \
-H 'cookie: next-auth.session-token=ADMIN_SESSION'The route finds SUBMITTED submissions and calls routeSubmission for each. Response: { "data": { "routed": 4, "totalMatches": 7 } }. Errors: 401, 403, 500. Reroute preserves match replacement behavior and notification de-duplication.
Notification kinds
| Kind | Typical producer | Typical recipient |
|---|---|---|
SUBMISSION_MATCH | routeSubmission | Government POC or matching profile user. |
ANNOUNCEMENT | Government announcement publisher | Industry users or configured recipients. |
CALL_OPENED | Government call publisher | Industry users. |
ENGAGEMENT_UPDATE | Government engagement workflow | Configured users when produced. |
SYSTEM | Administrative/system path | User-specific system recipient. |
Read semantics
Marking a notification read is not deletion. The row, title, body, link, kind, and timestamp remain available in the paginated list. Mark-all updates rows belonging to the current user only. The unread count is a count query, so a stale browser can refresh it without downloading all notification bodies.
Preference semantics
A missing setting row behaves as enabled. An existing disabled row suppresses the matching future event. A nonempty tag filter is applied only when the event carries tags; it is lowercased and compared by exact comma-separated token after normalization. Changing a preference does not retroactively delete notifications or mark them read.
Safe client behavior
Treat notification IDs as opaque strings. A client should send only the ID or mark-all instruction supported by the route and should not attempt to update another user's row. The server derives the user ID from the session, so adding a userId property to a request cannot broaden the operation.
After a mutation, refresh the list and unread count rather than assuming every local badge update succeeded. A network retry is safe for an idempotent read; for a write, follow the route's documented body and inspect the response before retrying.
Example preference update
await fetch('/api/notifications/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
settings: [
{ kind: 'SUBMISSION_MATCH', enabled: true, techTags: 'ai, autonomy' },
{ kind: 'CALL_OPENED', enabled: false, techTags: '' }
]
})
});The array must contain at least one setting and cannot exceed the number of NotificationKind enum values. Tag filters reject control and injection-relevant characters before normalization. A successful update writes notification_settings_updated to the audit log.
