> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Notifications

> In-app notifications, their email copies, and the approver auto-add in api-global, and the shell's notifications bell

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](#notifications-bell).

## Schema

`notifications` holds one row per organisation, recipient, and event:

| Column | Notes |
| - | - |
| `id` | Primary key. |
| `organisation_id`, `user_id` | The recipient. The row is deleted with their organisation membership. |
| `kind` | See [Notification kinds](#notification-kinds). |
| `actor_user_id` | Who acted, or null. Set to null when the user is deleted. |
| `project_id` | Set to null when the project is deleted. |
| `title`, `body` | Written at creation and never rewritten. |
| `link_path` | An in-app path: starts with one `/`, at most 500 characters, no backslash or whitespace. |
| `source_key` | Idempotency key. `UNIQUE (organisation_id, user_id, source_key)`, so a retried event writes nothing. |
| `created_at`, `read_at` | `read_at` is null until the recipient marks it read. |
| `deleted_at` | Not used by the routes yet; the organisation purge hard-deletes rows. |
| `email_state` | `pending` (default), `sending`, `sent`, `skipped`, or `failed`. |
| `email_attempts`, `email_claimed_at`, `email_sent_at`, `email_error` | The email drain's bookkeeping. |

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

| Function | Purpose |
| - | - |
| `notification_business_person(p_user, p_org)` | True when the person is an active first-party member of the business, not a contractor guest. |
| `notification_insert(...)` | The single writer. Idempotent on the source key; returns `(id, created)`, or `(null, false)` when the recipient is not an active member. |
| `notification_target(p_project, p_recipient, p_actor)` | Reads the project's name, the actor's name, and whether the recipient is active in the business and on the project team, before a peer-raised notification is written. |
| `project_approver_candidates(p_project, p_all)` | The approver picker: business people only, never contractors, active only, sorted by name. `p_all = false` is the project team, `true` everyone in the business. The caller must be on the project (`42501`). Returns `user_id`, `name`, `email`, `on_project`, `is_you`. |
| `project_add_approver(p_project, p_user)` | Adds a business person to the project as a `member`, with the same lock and audit row as `project_set_member` and `reason: 'approver'`. Returns false and changes nothing when they are already on it. A join request they had waiting is answered by the add. Raises `project_not_found`, `person_not_found`, or `42501` when the actor is not on the project. |

The `project_access_event_notify` trigger (after insert on `project_access_events`) raises the project access notifications for every membership path:

| Event | Notification | Recipient |
| - | - | - |
| Added, with a join request approved in the same transaction | `project_access_decided`: "X let you into Y" | The new member |
| Added directly, or as an approver | `project_added`: "X added you to Y", with "They'd like you to approve its tender." for an approver | The new member |
| Asked to join | `project_access_requested`: "X asked to join Y" | The project's active leads, or the business's admins when it has none |
| Request approved | `project_access_decided`: "X let you into Y" | The person who asked |
| Request declined | `project_access_decided`: "X declined your request to join Y" | The person who asked |

Role changes and self-joins raise nothing.

## Notification kinds

| Kind | Raised by | Recipient |
| - | - | - |
| `project_added`, `project_access_requested`, `project_access_decided` | The trigger | See above |
| `tender_approval_requested` | api-estimate, on Send for approval | The approver |
| `tender_approval_decided` | api-estimate, on Approve or Send back | The person who asked |
| `tender_approval_withdrawn` | api-estimate, on Withdraw request | The approver |
| `tender_link_first_opened` | api-estimate, the first time a private tender link shows the tender | The person who made the link |
| `tender_review_finished` | api-estimate, when a Tender review finishes | The person who started it |

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`):

| Method and path | Purpose |
| - | - |
| `GET /notifications` | The caller's notifications, newest first. Keyset paging with `cursor` and `limit` (default 30, at most 50). Returns `{ items, unreadCount, nextCursor }`. An unreadable cursor is `400 invalid_cursor`. `Cache-Control: no-store`. |
| `GET /notifications/unread-count` | `{ unreadCount }`. `Cache-Control: no-store`. |
| `POST /notifications/:id/read` | Mark one read. `204`, also when it was already read; `404 notification_not_found` for an unknown id or someone else's. |
| `POST /notifications/read-all` | Mark every unread one read. Returns `{ updated }`. |
| `GET /projects/:id/approver-candidates` | The approver picker. `scope=project` (default) or `scope=all`. Returns `{ items: [{ userId, name, email, onProject, isYou }] }`. The caller must be on the project (`403 project_access_required`). |

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](https://help.stralos.app/internal/service-token-auth.md#service-only-endpoints). 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.