docs: user notifications design (owner decisions 2026-10-13) #8

Merged
KofTwentyTwo merged 1 commits from feature/notifications-spec into develop 2026-10-13 15:03:46 +00:00
2 changed files with 249 additions and 0 deletions

No files matched your search

+2
View File
@@ -8,3 +8,5 @@ Deviations from the design, and why:
- **Heartbeats** are stored in an extra table, `alerts_heartbeat`.
- **Claims** are leases, not atomic conditional updates (see README.md, Known limits).
- **Email threading headers** wait for core messaging support.
User notifications (announcements, job failures, shares, security notices, releases and marketing), the recipient model and the Next UI notification center are designed in [NOTIFICATIONS.md](NOTIFICATIONS.md), with the owner decisions of 2026-10-13.
+247
View File
@@ -0,0 +1,247 @@
# QBit Alerts: user notifications (design)
**Status:** approved design, built in phases (section 11). It extends the alerts model in
[QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090) (`docs/specs/2026-10-12-qbit-alerts-design.md`); it does not add a parallel system.
## 0. Summary
The Alerts qbit also carries ordinary user notifications: announcements an admin writes, a user's own
failed jobs, alerts they subscribe to, things shared or assigned to them, security notices, and release or
marketing messages. Every notification is a row in a standard QQQ table, and every person it reaches gets
one recipient row. The Next UI bell, which today shows only alerts, becomes a notification center: an
unread badge, a popover, mark read, dismiss, a "View all" page with filters, and a banner for sticky
critical items. Admins create, edit, schedule, send, retract and delete notifications in the admin
through standard record screens and record actions.
**Owner requirement (verbatim, 2026-10-13):** "the alerts qbit should work for normal notifications etc
too. and if installed and working, the next UI should support it via the 'bell' in the top nav. it should
show you notifications, allow you to mark them as read etc. in the admin system, the system admins should
be able to create notifications, edit, remove, etc. we should be able to send notifications to all users in
a smart way, or just some etc. and a users own alerts or errors or warnings should work through that
system as well"
## 1. Owner decisions (2026-10-13)
1. **Channels.** In-app (the bell and the notifications page) always. Email is opt-in per user, by
severity or as a daily digest. Slack stays a group or monitor channel only, never per user.
2. **Audience.** Dynamic audiences: all users, groups or roles, holders of a permission, or named users.
The audience is resolved at send time and materialized as one recipient row per user, so read,
unread and dismissed are tracked per user.
3. **Automatic sources into a user's bell:**
- their failed or partial jobs (processes, ETL, imports and exports they started), linked to the run;
- alerts they subscribe to;
- things assigned or shared to them (a notebook share, a record lock, a pending approval);
- security notices (new sign-in, API key created or expiring, permission change);
- application releases and marketing notifications (authored by admins or emitted by apps, such as
"new version released" or a marketing-qbit campaign), sent to an audience.
**Owner decisions (2026-10-12): all recommendations accepted.** Every open question below (section 12)
is decided as recommended.
## 2. How it fits the alerts model
| Before (0.1.0-RC.1) | After |
|---|---|
| `alerts_notification` = the delivery log (one row per send attempt per channel) | Renamed `alerts_delivery` ("Delivery Log"). Same columns, same role. |
| `alerts_inbox_item` = one alert in one user's bell | Retired. In-app alert delivery writes an `alerts_notification` (category ALERT, linked to the alert) and a recipient row. |
| The bell shows alerts only | The bell shows every notification category, alerts included. |
| `alerts_user_preference` (quiet hours, digest hour, time zone) | Kept. Notification email settings per category live in `alerts_notification_preference`, and reuse its time zone and digest hour. |
| Producer SPI: `QAlerts.raise/resolve/heartbeat` | Plus `QAlerts.notify(NotificationSignal)` (section 6). |
The rename and the retirement break the changelog's additive-only rule once, before GA. Nobody runs
0.1.0-RC.1 in production. Changeset 16 renames the table, its key and its indexes, and changeset 17 drops
`alerts_inbox_item` (it holds only bell state). Both are guarded by preconditions, so they are safe on a
fresh schema and on an RC.1 schema.
## 3. Tables
All are standard QQQ tables with record screens, the API, audit and ESB events, secured by permissions
and record locks, never hidden.
### 3.1 `alerts_notification` (Notifications)
| Field | Type | Notes |
|---|---|---|
| `title` | string(200), required | Plain text |
| `body` | text | Markdown, rendered safely (no raw HTML) |
| `severity` | INFO / SUCCESS / WARNING / ERROR / CRITICAL | Default INFO |
| `category` | SYSTEM / JOB / SECURITY / RELEASE / MARKETING / SHARE / ALERT | Default SYSTEM |
| `sourceName` | string | Who emitted it: `admin`, `qbit-alerts`, a qbit or app name |
| `sourceKey` | string | Dedup key from producers (for example `process:<uuid>`). A second notify with the same key and audience updates the first. |
| `sourceTableName`, `sourceRecordId` | string | The record it is about (an alert, a run, a share) |
| `linkUrl`, `linkLabel` | string | A deep link (a record, a table filter, a run, or an https URL) |
| `audienceType` | ALL_USERS / ROLES / PERMISSION / USERS | |
| `audienceValue` | text | Comma-separated role names, one permission, or user ids |
| `publishAt`, `expireAt` | timestamp | Not shown before publishAt; hidden and purged after expireAt |
| `sticky` | boolean | A top banner until the user dismisses it (meant for CRITICAL) |
| `status` | DRAFT / SCHEDULED / SENT / RETRACTED | Default DRAFT |
| `authorUserId` | string | Set on insert |
| `sentAt`, `retractedAt`, `recipientCount`, `readCount` | | Kept by the qbit |
Record screen: identity (title, severity, category, status), Message (body, link), Audience, Schedule,
the Recipients child list, and System.
Edits after sending change what recipients see (the bell reads the notification). Retracting hides it
everywhere. Deleting it deletes its recipient rows.
### 3.2 `alerts_notification_recipient` (Notification Recipients)
A child of the notification: one row per user, unique (`notificationId`, `userId`).
`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus`
(NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED).
Users see and change only their own rows (owner lock on `userId`). Only `readAt` and `dismissedAt` are
editable; inserts and deletes are the qbit's.
### 3.3 `alerts_notification_preference` (My Notification Settings)
One row per user and category, unique (`userId`, `category`); a missing row means the defaults.
`userId`, `category`, `inAppMuted` (boolean; refused for SECURITY), `emailMode` (OFF / IMMEDIATE /
DAILY_DIGEST, default OFF), `emailMinSeverity` (default WARNING). It is owner-locked on `userId`. The
digest hour and time zone come from `alerts_user_preference`.
## 4. Audiences
`AlertsAudienceResolver` resolves an audience to user ids at send time:
| Audience | Resolved to |
|---|---|
| ALL_USERS | Every active user in the directory |
| ROLES | Directory users with any of the named roles or groups |
| PERMISSION | Directory users whose session holds the permission (`createAutomatedSessionForUser`) |
| USERS | The named user ids, as given |
**The user directory** is an SPI, `AlertsUserDirectoryInterface` (`listUsers()` returning id, label,
email and roles), set with `AlertsQBitConfig.withUserDirectory(QCodeReference)`. Without one, the directory
is every user who has used Alerts (a row in `alerts_user_preference`, created the first time a user opens
the bell). That is "all users who sign in", with no host code.
Resolution is capped by `maxAudienceSize` (default 50,000). Recipient rows are inserted in batches of 500.
## 5. Admin
The Notifications table is a main nav entry in the Alerts app (it needs `alerts.notification.admin`).
Admins use the standard create, edit and delete screens. Record actions are streamed-ETL processes with the
standard Review and Result summary lines, never nav entries:
| Action | On | Review, then Result |
|---|---|---|
| **Preview audience** | DRAFT or SCHEDULED (one) | "Would reach 42 users", plus up to 10 sample names. Writes nothing. |
| **Send now** | DRAFT or SCHEDULED (bulk) | "Will send 2 notifications to 57 recipients (1 already sent, skipped)", then "Sent" |
| **Schedule** | DRAFT (bulk), needing a future `publishAt` | Sets SCHEDULED. The delivery job sends it when `publishAt` passes. |
| **Retract** | SENT (bulk) | Sets RETRACTED, which hides it from every bell |
The Alerts home gets one link-only line, "Notifications: 3 scheduled, 12 sent this week", for
notification admins. The home stays the only Alerts dashboard.
## 6. Producer SPI
It extends the core alerts SPI (`com.kingsrook.qqq.backend.core.alerts`) through a core PR to qqq
`develop`, for the next 4.1 RC:
```java
QAlerts.notify(new NotificationSignal("Your export failed")
.withBody("**Orders export** stopped after 1,200 rows: the file server refused the upload.")
.withSeverity(NotificationSeverity.ERROR)
.withCategory(NotificationCategory.JOB)
.withSourceKey("process:" + processUUID)
.withLink(AlertLink.toUrl("/app/orders", "Orders"))
.withAudience(NotificationAudience.user(userId)));
```
- `AlertSinkInterface` gets a default method, `notify(NotificationSignal)`, that logs; existing sinks keep
working. `LoggingAlertSink` logs at INFO. The qbit's sink queues the signal like an alert signal.
- The guarantees match `raise`: it never throws or blocks, it waits for the commit when a transaction is
open, and bad input is counted.
- `NotificationAudience`: `allUsers()`, `roles(...)`, `permission(p)`, `users(...)`, `user(id)`.
- Until that RC ships, the qbit carries the same classes in its `spi` stand-in package, and hosts call
`AlertsRuntime.get(qInstance).sink().notify(...)`.
### 6.1 Automatic sources
| Source | How | Category |
|---|---|---|
| Failed or partial jobs | The qbit's process lifecycle listener: a run that fails, or completes with error summary lines, notifies the user who started it (the session user; system runs are skipped). The link is the run's table, or `jobRunLinkPattern` with `{processName}` and `{processUUID}`. Turn it off with `notifyFailedJobs(false)`. | JOB |
| Alerts they subscribe to | The router's in-app channel writes an ALERT notification per alert (sourceKey `alert:<id>`) and a recipient row per routed user. A repeat or reopen marks it unread again. | ALERT |
| Shares, record locks, approvals | Producers call `QAlerts.notify(... user(id))`. The first wiring is the SQL Workbench notebook share in kof22. | SHARE |
| Security notices | `AlertsSecurityNotices` (new sign-in, API key created, API key expiring, permission change). Core has no authentication events today, so hosts call it from their authentication customizer. A core hook is follow-up work (section 12, Q5). Security is never mutable in-app. | SECURITY |
| Releases and marketing | Admin-authored notifications, or producers with an audience | RELEASE, MARKETING |
## 7. Delivery
- **In-app:** recipient rows. The bell shows rows that are not dismissed, of SENT notifications whose
`publishAt` has passed and whose `expireAt` has not.
- **Email** (phase e): through the QQQ messaging provider when configured; otherwise inert. IMMEDIATE
sends from the delivery job. DAILY_DIGEST sends one email per user at their digest hour, listing their
unread notifications since the last digest. A muted category is skipped. Email never includes a body
for SECURITY beyond the title and a link.
- **Slack** stays a channel for alert groups and monitors only.
## 8. Next UI bell and page
Gating is unchanged from #85: no bell and zero requests unless `metaData.apps.alerts` is present.
- **Badge:** the unread count (99+), red when an unread item is CRITICAL. The poller is tiny and lives in the
main bundle. It polls every `pollSeconds` (60), backs off to 5 minutes on errors, and pauses while the tab
is hidden. QQQ has no SSE today.
- **Popover** (lazy chunk): newest first, a severity icon, the title, the source, a relative time, and a
link. You can mark one read or unread, or dismiss it, and the footer has "Mark all read" and "View all".
- **Page** `/notifications` (lazy route): filters (All or Unread, category, severity), paging, a markdown
body, and the same actions.
- **Banner:** sticky, undismissed, unexpired items show above the page content until dismissed.
- **Budget:** total JS 1100 KB. The badge adds well under 1 KB to first load; the popover and the page are
lazy chunks with their own `lazyChunks` budget entry.
### 8.1 API (`/qqq/v1/alerts`, session-authenticated, `alerts.view`)
| Method | Path | Body or query | Returns |
|---|---|---|---|
| GET | `/inbox/summary` | | `{unreadCount, highestUnreadSeverity, items[≤10], banners[], pollSeconds, notificationsHref}` |
| GET | `/inbox` | `?status=all\|unread&category=&severity=&offset=0&limit=25` | `{items[], total, offset, limit}` |
| POST | `/inbox/read` | `{id}` or `{ids:[..]}` or `{all:true}` (`{alertId}` still works) | `{updated}` |
| POST | `/inbox/unread` | `{id}` | `{updated}` |
| POST | `/inbox/dismiss` | `{id}` or `{ids:[..]}` or `{all:true}` | `{updated}` |
An item is `{id, notificationId, title, body, severity, category, source, href, linkLabel,
deliveredAt, age, unread, sticky}`. `id` is the recipient row id. `body` appears only in `/inbox` and in
banners. `highestUnreadSeverity` is one of the five severities. Every call works in the caller's
session, so the record locks apply.
## 9. Security
- `alerts.notification.admin`: author, send, schedule, retract and delete notifications, and read every
recipient row (the `alerts_user` all-access key).
- `alerts.view`: the bell, the page, their own recipient rows (read and edit) and their own notification
settings.
- Recipient and preference rows are owner-locked on `userId`. Notification bodies reach a user only
through their own recipient rows.
- Markdown is rendered without raw HTML. Links must be relative app paths or https.
## 10. Retention
`expireAt` hides a notification at once. The delivery job's purge step deletes notifications (and their
recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED notifications older than
`notificationRetentionDays` (default 180).
## 11. Phases
| Phase | Scope |
|---|---|
| a | Tables, the rename and retirement, admin screens and actions, audiences and recipients, the bell API, ALERT notifications |
| b | Next UI: badge, popover, page, banner |
| c | Producer SPI (core PR plus the stand-in), job failures |
| d | Security notices, shares, release and marketing producers |
| e | Email: immediate and daily digest |
## 12. Questions, with the recommendations the owner accepted
1. Rename the delivery log rather than pick another name? **Yes**, before GA.
2. Copy notification content per recipient? **No**: recipients read the notification, so edits and
retractions apply at once.
3. Users added after a send? **They don't get it.** Resolution happens at send time, per decision 2.
4. Default email? **Off** for every category until the user opts in.
5. Core authentication events? **Later.** Hosts call `AlertsSecurityNotices` from their authentication
customizer until core offers a sign-in event.
6. Server-sent events? **Later.** Polling with backoff is enough at 60 s.