diff --git a/docs/DESIGN.md b/docs/DESIGN.md index dd2f89b..a8d7f68 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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. diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md new file mode 100644 index 0000000..3b03152 --- /dev/null +++ b/docs/NOTIFICATIONS.md @@ -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:`). 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:`) 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.