stralos-api-global (migration drizzle/0101_notifications.sql). Project access events and tender workflow events each raise a notification for the person affected, and every notification is also sent by email. A notification row is written once and its text is never rewritten, so it reads as a log even if a project is renamed later.
The shell shows them in the notifications bell.
Schema
notifications holds one row per organisation, recipient, and event:
Indexes:
notifications_inbox_idx (the inbox keyset), notifications_unread_idx (the unread count), and notifications_email_due_idx (the email drain).
FORCE RLS is on. The request role stralos_runtime_global may only SELECT its own rows and UPDATE (read_at) on them (organisation_id = project_org(), user_id = project_actor(), not deleted). It has no INSERT or DELETE. stralos_platform_global may select, update, and delete for the organisation purge. Rows are written only by the SECURITY DEFINER functions below.
Database functions
The
project_access_event_notify trigger (after insert on project_access_events) raises the project access notifications for every membership path:
Role changes and self-joins raise nothing.
Notification kinds
api-global writes the title and body for the tender kinds (
src/notifications/compose.ts), so all notification copy lives in one place.
User routes
All under/api/global, for a signed-in member with an organisation (src/routes/notifications.ts):
Each item carries
id, kind, title, body, linkPath, projectId, actor (id and name), createdAt, and readAt.
Service routes
api-estimate’sestimate-notify service token calls two routes, each needing its own scope. Both check that :orgId is the token’s own organisation (403 organisation_mismatch).
POST /api/global/service/organisations/:orgId/notifications(global:notifications-write) writes one notification. The body names thekind,recipientUserId,actorUserId,projectId,linkPath, asourceKey(up to 200 characters), and typeddetails. The project access kinds are refused (400 kind_not_allowed). The recipient must be an active member (404 recipient_not_found) and on the project team (409 recipient_cannot_open_project).201when created,200withcreated: falsewhen the source key already exists.POST /api/global/service/organisations/:orgId/projects/:projectId/approvers(global:project-approver-add) puts the chosen approver on the project before the request is written. The estimator’s token ridesx-forwarded-user(403 forwarded_user_required), so the add is recorded as them. Answers{ userId, added };404 person_not_foundfor anyone who is not an active first-party member.
src/notifications/outbox.ts).
pending. The once-a-minute event flusher (src/event-flush-lambda.ts) runs drainNotificationEmails (src/notifications/email.ts) on the owner pool:
- Claim up to 10 pending rows, or
sendingrows whose claim is more than 10 minutes old, underFOR UPDATE SKIP LOCKED, and add one attempt. - Send up to 3 at a time through SES, from admin-api’s identity.
- Finish each as
sent,skipped,pending(a transient failure, retried), orfailed(a permanent refusal, an uncertain send, or the fifth attempt).
NOTIFICATION_EMAIL_FROM_ADDRESS (local, CI) nothing is claimed and rows stay pending.
The email’s subject is the title. The body carries the title, the notification body, a link into the app, and a line saying the person gets it because they use Stralos at their business, in HTML and plain text.
Notifications bell
stralos-shell mounts the bell in the header, between the command palette launcher and the user menu, on every signed-in route (src/notifications/):
useUnreadCountpollsGET /notifications/unread-countevery 60 seconds while the tab is visible. The interval stops while the tab is hidden; becoming visible again, or the window regaining focus, polls at once.- The badge shows the unread count, or 9+ above 9. The bell’s accessible name carries the count, and a polite live region announces a rising count (not the first count on load).
- The panel lists notifications newest first, with Load more for the next page, Mark all as read, and Try again when the list fails. Selecting a notification marks it read and opens its link. The count and list update locally after a mark-read, without waiting for the next poll.

