Skip to main content
In-app notifications live in 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’s estimate-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 the kind, recipientUserId, actorUserId, projectId, linkPath, a sourceKey (up to 200 characters), and typed details. 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). 201 when created, 200 with created: false when 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 rides x-forwarded-user (403 forwarded_user_required), so the add is recorded as them. Answers { userId, added }; 404 person_not_found for anyone who is not an active first-party member.
See Service token authentication. api-estimate enqueues its notifications in the same transaction as the change and sends them after it commits (src/notifications/outbox.ts).

Email

Every row starts pending. The once-a-minute event flusher (src/event-flush-lambda.ts) runs drainNotificationEmails (src/notifications/email.ts) on the owner pool:
  1. Claim up to 10 pending rows, or sending rows whose claim is more than 10 minutes old, under FOR UPDATE SKIP LOCKED, and add one attempt.
  2. Send up to 3 at a time through SES, from admin-api’s identity.
  3. Finish each as sent, skipped, pending (a transient failure, retried), or failed (a permanent refusal, an uncertain send, or the fifth attempt).
A row is skipped when the person is no longer active in the business, or when it is more than 24 hours old. An uncertain send is marked failed and not retried: a missing email is a smaller harm than a duplicate, because the in-app row already exists. The drain runs at most 10 rounds per invocation and stops with less than 15 seconds left. Without 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/):
  • useUnreadCount polls GET /notifications/unread-count every 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.