feat(preferences): Notifications section in user Preferences #18

Merged
KofTwentyTwo merged 3 commits from feature/notification-preferences-section into develop 2026-10-14 21:43:57 +00:00
29 changed files with 1100 additions and 91 deletions

No files matched your search

+6 -3
View File
@@ -12,10 +12,10 @@ All notable changes to this project are documented here. The format follows Keep
- Apply user pause and local quiet hours to personal push channels (email, email lists and other non-IN_APP channels), including team members, with logged suppression reasons and a configurable quiet-hours severity bypass. The in-app bell remains unaffected, including digest delivery. Channel subscriptions are unaffected.
- Queue hourly and daily alert subscription digests and send them through the hidden hourly `alertsSendDigests` process. Group linked alert summaries into one message per user/channel/window, use the existing email integration, and keep durable dispatch claims in the Delivery Log (guarded changeset 27). Reruns cannot send a window twice; failed, interrupted and oversized windows remain visible for review.
- Team subscriptions now combine explicit members with ROLE and IDP_GROUP users from the optional public `AlertAudienceResolver` configured through `withAudienceResolver`. Recipients are deduplicated and retain per-user visibility checks. Missing resolvers resolve to nobody, log the reason, and show configuration-aware help on team/member screens.
- Fix the alert Mute action to target its subject record, or its type and dedup key when no subject exists. Add the separate "Mute this alert type" action. RECORD mutes now compare table and record ID. Guarded changesets 21–22 add `scope_record_id` and allow full ALERT keys; existing type-wide mutes retain their scope.
- Fix the alert Mute action to target its subject record, or its type and dedup key when no subject exists. Add the separate "Mute this alert type" action. RECORD mutes now compare table and record ID. Guarded changesets 21–22 add `scope_record_id` and allow full ALERT keys; existing type-wide mutes retain their scope.
- Switch the optional qbit-email integration to its published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.2`, preserving templates, categories, idempotency keys, linked records and outcome mapping. Narrow the architecture allowance to that API package while retaining all guardrails.
- Email goes through qbit-email when it is produced in the instance (optional dependency, `qbit-email.version` 0.1.0-RC.2): alert channel emails (template `alerts_alert`), notification emails (`alerts_notification`) and digests (`alerts_digest`), all NOTIFICATION, one recipient per message, with idempotency keys and the alert or notification as the linked record. `messagingProviderName` stays as the fallback when qbit-email is absent, and is not required when it is present.
- Target released QQQ `4.1.0-RC.6`; verify normally with `./mvnw -B verify` (no version override).
- Target released QQQ `4.1.0-RC.7`; verify offline with `./mvnw -o -q verify` (no version override).
- Adopt `qbit-build-parent:2.1.0` with architecture guardrail enforcement and strict UI API routes enabled for tests. Dependency, extension contract and stand-in checks run in the normal build.
- Add record followers to alert routing through core `RecordFollowers.get(instance, ref, FollowEvent.ALERT)` (#16, QRun-IO/qqq#1164). Deduplicate by user/channel with subscriptions and other record refs; require source-record read access under the follower's session, including record locks. Followers use the existing alert visibility, preferences and suppression pipeline without a dependency on qbit-collaboration. Missing/disabled/failing providers preserve subscription routing.
- Register six JSON UI specs as extension `alerts` at the established `/alerts` prefix, preserving methods, paths and existing payload fields. Publish the OpenAPI fragment with a regeneration/contract test and strict server coverage (#13, F2).
@@ -29,7 +29,10 @@ All notable changes to this project are documented here. The format follows Keep
- Webhook signing delegates to core `WebhookSigner`/`WebhookSecret` (qbit-alerts#15, F11). Headers, `v1,` signatures and `whsec_` encoding are unchanged. A thin adapter preserves legacy keys shorter than core's 16-byte minimum using equivalent HMAC zero-padding.
### Added
- Send notifications to Slack per user and category (QRun-IO/qbit-slack docs/DESIGN.md section 10; supersedes "Slack never per user"). `alerts_notification_preference` gets `slackMode` (OFF/IMMEDIATE/DAILY_DIGEST, default OFF) and `slackMinSeverity` (default WARNING); recipients get `slackStatus` and `slackMessageId` (guarded changeset 28). IMMEDIATE and daily-digest DMs go through core `SendMessageAction` (provider `AlertsQBitConfig.withSlackProviderName`, default `slack`, address `user:<id>`) with no qbit-slack dependency. Pause and quiet hours hold them (CRITICAL bypasses), SECURITY carries title and link only, a user without a Slack connection is skipped, and failures never fail the notification. RC.6 has no idempotency key or `isAvailable`, so `slackStatus` guards resends (STAND-IN core#1194, removeBy RC.8) and availability checks the instance's provider.
- Alerts UI API extension 1.1.0 adds nullable `preferencesSection` to the typed inbox summary alongside `inboxHref`, with a regenerated OpenAPI fragment. Slack category controls appear in Notifications preferences when the configured per-user Slack provider is registered; SECURITY Slack controls remain editable.
- Notifications user-preferences section over the existing settings and subscription tables, with user/client-channel audits, category in-app/email/Slack severity controls, ownership validation, and an inbox `preferencesSection` shortcut. Enabled by default when core preferences metadata is available; configurable with `withPreferencesSectionEnabled`.
- Nullable in-app notification severity threshold (`qbit-alerts:29`); SECURITY always arrives in-app. Notification email and per-user Slack pauses now defer non-security immediate and digest delivery without losing older pending items.
- Send notifications to Slack per user and category (QRun-IO/qbit-slack docs/DESIGN.md section 10; supersedes "Slack never per user"). `alerts_notification_preference` gets `slackMode` (OFF/IMMEDIATE/DAILY_DIGEST, default OFF) and `slackMinSeverity` (default WARNING); recipients get `slackStatus` and `slackMessageId` (guarded changeset 28). IMMEDIATE and daily-digest DMs go through core `SendMessageAction` (provider `AlertsQBitConfig.withSlackProviderName`, default `slack`, address `user:<id>`) with no qbit-slack dependency. Pause holds non-SECURITY messages; quiet hours hold them (CRITICAL bypasses), SECURITY carries title and link only, a user without a Slack connection is skipped, and failures never fail the notification. RC.6 has no idempotency key or `isAvailable`, so `slackStatus` guards resends (STAND-IN core#1194, removeBy RC.8) and availability checks the instance's provider.
- Apply reminders and escalation (#27, part 2): a minute-based hidden job sends due reminders, advances ordered USER/TEAM/CHANNEL escalation steps, and caps final-step repeats. Acknowledgement, snooze, resolution and mutes stop both; reopening restarts escalation. Delivery, events and progress commit together, with a persisted same-minute checkpoint.
- Add admin escalation policy and step tables, child step lists, and type/group policy links in guarded Liquibase changesets 23–26. Type policies override group policies. CRITICAL reminders default to 60 minutes; explicit zero turns them off.
- Keep EXPIRED reserved and remove it from selectable resolution values because the design specifies no expiration rule.
+6 -5
View File
@@ -60,7 +60,7 @@ shared rules, not personal notification preferences.
RECORD mutes store the table in `scopeValue` and its record ID in `scopeRecordId`.
ALERT mutes store the engine's full `type|dedupKey` in `scopeValue`. Apply guarded
Liquibase changesets 21–22 before running this version: they add the record ID
Liquibase changesets 21–22 before running this version: they add the record ID
column and widen mute scope values to 520 characters. Existing type-wide mutes
remain type-wide; historical RECORD mutes need their intended record ID filled
in because previous versions did not store it.
@@ -76,7 +76,8 @@ The qbit also carries ordinary notifications for people (design: [docs/NOTIFICAT
- **Email** is opt-in per user and category (IMMEDIATE, or DAILY_DIGEST at the digest hour in My Alert Settings), through the configured messaging provider; without one it is off. ALERT notifications are not emailed here (alert subscriptions already do), and SECURITY emails carry only the title and link.
- **Failed and partial jobs** notify the person who started them (`notifyFailedJobs`, `jobRunLinkPattern`).
- **Links** must be app paths with one leading slash, or https URLs; backslashes, whitespace and control characters are refused.
- **People** see them in the Next UI bell and notifications page, and choose per-category settings in My Notification Settings (security notices can't be muted).
- **Preferences:** the Notifications section combines pause, digest time and zone, alert switches, per-category in-app/email/Slack settings, and the user's own subscriptions. It uses existing tables and FIELD audits; SECURITY cannot be muted in-app. Slack controls appear when the configured per-user Slack provider is registered before Alerts is produced; SECURITY Slack settings stay editable. Uses core's released user-preferences contract in QQQ `4.1.0-RC.7`; disable with `withPreferencesSectionEnabled(false)`. See [Notification settings in Preferences](docs/NOTIFICATIONS.md#34-notification-settings-in-preferences).
- **People** see them in the Next UI bell and notifications page, and choose settings in Preferences > Notifications or the standard My Notification Settings table (security notices can't be muted).
Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions include their own recipient rows and settings.
@@ -104,7 +105,7 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano
## Requirements
- Java 21.
- QQQ `4.1.0-RC.6` (released on Maven Central).
- QQQ `4.1.0-RC.7` (released on Maven Central).
- An RDBMS backend.
- A scheduler, for monitors and email delivery.
- For email, either [qbit-email](https://github.com/QRun-IO/qbit-email) produced in the same instance, or a messaging provider.
@@ -113,7 +114,7 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano
The build uses `qbit-build-parent:2.1.0` with architecture guardrails and strict UI API route
tests enabled. Run `./mvnw -B verify` (`.\mvnw.cmd -B verify` on Windows), without a snapshot
override. The default-active `qqq-snapshot` profile retains its historical name but selects
the released RC.6 BOM ahead of the parent's QQQ 4.0 BOM.
the released RC.7 BOM ahead of the parent's QQQ 4.0 BOM.
## Install
@@ -161,7 +162,7 @@ User notifications use core `QAlerts.notify(NotificationSignal)` (RC.6, QRun-IO/
| Method | Path | What |
|---|---|---|
| GET | `/qqq/v1/alerts/inbox/summary` | Unread count, highest unread severity, the latest 10 notifications and sticky banners (the bell) |
| GET | `/qqq/v1/alerts/inbox/summary` | Unread count, highest unread severity, the latest 10 notifications and sticky banners (the bell), plus `inboxHref` and `preferencesSection` (`"notifications"` when registered, otherwise null; see [API contract](docs/NOTIFICATIONS.md#81-api-qqqv1alerts-session-authenticated-alertsview)) |
| GET | `/qqq/v1/alerts/inbox` | The notifications page: `status` (all, unread), `category`, `severity`, `offset`, `limit` |
| POST | `/qqq/v1/alerts/inbox/read` | `{"id": n}`, `{"ids": [..]}`, `{"all": true}`, or `{"alertId": n}` |
| POST | `/qqq/v1/alerts/inbox/unread` | `{"id": n}` or `{"ids": [..]}` |
+58 -3
View File
@@ -101,11 +101,62 @@ editable; inserts and deletes are the qbit's.
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 /
`userId`, `category`, `inAppMuted` (boolean; refused for SECURITY), `inAppMinSeverity` (nullable; null means every severity, refused for SECURITY), `emailMode` (OFF / IMMEDIATE /
DAILY_DIGEST, default OFF), `emailMinSeverity` (default WARNING), `slackMode` (OFF / IMMEDIATE / DAILY_DIGEST, default OFF),
`slackMinSeverity` (default WARNING). It is owner-locked on `userId`. The
digest hour and time zone come from `alerts_user_preference`.
### 3.4 Notification settings in Preferences
With the core user-preferences contract installed, the qbit registers **Notifications** (`notifications`,
order 20, icon `notifications`): “What reaches you in the app, by email and on Slack.” It requires `alerts.view`
and has no app scope. Hosts can disable it with `AlertsQBitConfig.withPreferencesSectionEnabled(false)`.
Older core runtimes without preferences metadata omit the section.
The section reads and writes the existing owner-locked tables, through core table actions with FIELD
level audits retaining the signed-in user and client channel. It exposes:
- Pause notifications until (security notices still arrive), daily digest time (24 hourly choices),
time zone (a valid Java `ZoneId`), and the alert in-app and email switches from `alerts_user_preference`.
A missing user row gets the same defaults as the bell's first-visit setup.
- One category row per notification category: In-app, In-app from, Email (Off / Immediate / Daily digest),
Email from, Slack (Off / Immediate / Daily digest) and Slack from. Slack fields appear only when
the configured `slackProviderName` messaging provider is registered before Alerts is produced.
They use the same `alerts_email_mode` choices as the table. Missing category rows use table defaults;
edits upsert only that user's category. SECURITY Slack fields remain editable.
SECURITY's two in-app fields are read-only: “Security notices can't be muted.”
- Only the user's own USER subscriptions, labelled by scope, with Enabled, From severity and Delivery mode.
Other users' rules and team/channel rules cannot be edited here, including by administrators.
`GET /qqq/v1/preferences/notifications` loads the section and inline enum choices.
`PATCH /qqq/v1/preferences/notifications` accepts partial changes, for example:
```json
{
"values": { "digestHour": 8, "timeZone": "America/Chicago" },
"lists": {
"categories": [{ "key": "JOB", "values": { "inAppMinSeverity": "WARNING", "emailMode": "DAILY_DIGEST", "slack": "IMMEDIATE", "slackMinSeverity": "WARNING" } }],
"subscriptions": [{ "key": "42", "values": { "enabled": true, "minSeverity": "ERROR" } }]
}
}
```
Subscription keys are persisted row IDs. Invalid zones, foreign/team subscription keys and SECURITY
in-app changes return 400; a section hidden by permission or configuration returns 404.
The section has no user parameter and cannot change ownership. Administrators retain the standard
table screens for administering other users' settings.
In-app severity filters apply when recipients are materialized, just like category muting; existing
inbox items remain. SECURITY always reaches its in-app recipients. While `pausedUntil` is in the future,
non-SECURITY notification emails and per-user Slack DMs are deferred, including daily digests; recipients remain in the inbox
and eligible after the pause ends, even after more than a day. SECURITY keeps its category email mode
and Slack mode/severity settings but bypasses the pause. Existing alert routing continues to use the same pause
and alert channel switches.
Schema migration: `qbit-alerts:29` adds nullable `in_app_min_severity VARCHAR(16)`.
The migration uses `onFail:MARK_RAN` when the column already exists.
The POM targets released QQQ `4.1.0-RC.7`; build with `./mvnw -o -q verify`.
## 4. Audiences
`AlertsAudienceResolver` resolves an audience to user ids at send time:
@@ -201,7 +252,8 @@ QAlerts.notify(new NotificationSignal("Your export failed")
through that user's own Slack connection; qbit-slack is never imported). IMMEDIATE sends one DM per
notification at or above `slackMinSeverity`; DAILY_DIGEST sends one DM per user at their digest hour,
listing their unread notifications, and marks the rows DIGESTED. Slack is a push channel, so a user's pause
and quiet hours (`alerts_user_preference`) hold it, with the same CRITICAL bypass as other push channels;
holds non-SECURITY items; quiet hours (`alerts_user_preference`) hold items with the same CRITICAL
bypass as other push channels. SECURITY bypasses pause, and mixed digests include only eligible items;
the bell is never held. ALERT notifications are not sent (alert channels already cover them) and SECURITY
carries the title and link only. With no provider registered under that name the rows are SKIPPED. A user
without a Slack connection is SKIPPED with no retry; any other error is FAILED; neither fails the
@@ -228,7 +280,7 @@ Gating is unchanged from #85: no bell and zero requests unless `metaData.apps.al
| Method | Path | Body or query | Returns |
|---|---|---|---|
| GET | `/inbox/summary` | | `{unreadCount, highestUnreadSeverity, items[≤10], banners[], pollSeconds, notificationsHref}` |
| GET | `/inbox/summary` | | `{unreadCount, highestUnreadSeverity, items[≤10], banners[], pollSeconds, notificationsHref, inboxHref, alertsHref, preferencesSection}` |
| 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}` |
@@ -238,6 +290,9 @@ An item is `{id, notificationId, title, body, severity, category, source, href,
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.
The alerts extension is version `1.1.0`. `inboxHref` retains the canonical inbox link.
`preferencesSection` is `"notifications"` when that section is registered, otherwise JSON null.
Clients can use it for the bell's settings shortcut to the core Preferences section.
## 9. Security
+5 -5
View File
@@ -1,7 +1,7 @@
# Alerts UI API and webhook migration
The qbit targets QQQ **4.1.0-RC.6** (pending release). `AlertsQBitProducer` registers the
`alerts` `MiddlewareSpecExtension`, version `1.0.0`, with the established `/alerts` prefix.
`alerts` `MiddlewareSpecExtension`, version `1.1.0`, with the established `/alerts` prefix.
`withRegisterRoutes(false)` opts out of both the extension and webhook registration.
The six JSON specs extend `AbstractJsonEndpointSpec`; existing `AlertsApi` logic continues
to enforce caller permissions, recipient ownership and alert visibility.
@@ -27,6 +27,7 @@ Nullable fields remain serialized as JSON null.
## Next handoff: additive payload fields
- Summary adds nullable `preferencesSection`: the user Preferences section name (`notifications`) or null when not registered. Clients can use `/qqq/v1/preferences/{name}` for notification settings.
- Summary adds `inboxHref`, currently `/app/notifications`. Retain and honor the existing
`notificationsHref` and `alertsHref` fields for older clients.
- Every inbox item (including banners) adds `actions`: `name`, `label`, `operationId`.
@@ -126,13 +127,12 @@ The supplied Next `main` checkout still has no alerts client; the
## Verification
Use the Maven wrapper. The committed BOM version is RC.6; while it is unreleased, verify with
an already installed local core build:
Use the Maven wrapper with the released QQQ 4.1.0-RC.7 artifacts already installed locally:
```sh
./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
./mvnw -o -q verify
```
Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test`
(and the same local version override). Normal tests compare the fragment without rewriting it.
(with `-o` for an offline build). Normal tests compare the fragment without rewriting it.
Checkstyle uses `checkstyle/config.xml` and the repository's existing short Apache headers.
+1 -1
View File
@@ -204,7 +204,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -101,6 +101,7 @@ public class AlertsQBitConfig implements QBitConfig
private String alertmanagerIngestTokenSecretRef;
private Boolean preferencesSectionEnabled = true;
private Boolean registerRoutes = true;
private Boolean synchronousSink = false;
@@ -852,6 +853,27 @@ public class AlertsQBitConfig implements QBitConfig
/*******************************************************************************
** Whether to register Notifications in user preferences (default true).
*******************************************************************************/
public Boolean getPreferencesSectionEnabled()
{
return (preferencesSectionEnabled);
}
/*******************************************************************************
** Enable or disable the Notifications preferences section.
*******************************************************************************/
public AlertsQBitConfig withPreferencesSectionEnabled(Boolean enabled)
{
preferencesSectionEnabled = enabled;
return (this);
}
/*******************************************************************************
** Whether to expose the deprecated UI-path webhook alias (default false).
** Requires non-strict UI route mode; remove after one minor release.
@@ -209,6 +209,11 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
qInstance.withProcessLifecycleListener(new QCodeReference(AlertsProcessListener.class));
}
if(Boolean.TRUE.equals(qBitConfig.getPreferencesSectionEnabled()) && isPreferencesAvailable())
{
com.kingsrook.qbits.alerts.preferences.AlertsNotificationPreferencesProvider.register(qInstance, qBitConfig);
}
boolean routes = false;
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
{
@@ -290,4 +295,32 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
return (false);
}
}
/*******************************************************************************
** Older core versions can run without the optional preferences integration.
*******************************************************************************/
private static boolean isPreferencesAvailable()
{
try
{
Class.forName("com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData", false, AlertsQBitProducer.class.getClassLoader());
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
** The bell shortcut is present only when the section is registered.
*******************************************************************************/
public static String preferencesSectionName(QInstance instance)
{
return (isPreferencesAvailable() && com.kingsrook.qbits.alerts.preferences.AlertsNotificationPreferencesProvider.isRegistered(instance) ? "notifications" : null);
}
}
@@ -30,6 +30,7 @@ import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsPreferences;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsQBitProducer;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.delivery.AlertMessageRenderer;
@@ -156,6 +157,7 @@ public class AlertsApi
result.put("items", latest);
result.put("banners", banners);
result.put("notificationsHref", NOTIFICATIONS_HREF);
result.put("preferencesSection", AlertsQBitProducer.preferencesSectionName(QContext.getQInstance()));
result.put("inboxHref", NOTIFICATIONS_HREF);
result.put("alertsHref", "/app/" + AlertsQBitConfig.APP_NAME);
result.put("pollSeconds", POLL_SECONDS);
@@ -49,7 +49,7 @@ public final class AlertsSchemas
Map.entry("AlertsInboxItemV1", new AlertsInboxItemV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsPanelAlertV1", new AlertsPanelAlertV1(null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsFollowV1", new AlertsFollowV1(null).toSchema()),
Map.entry("AlertsInboxSummaryV1", new AlertsInboxSummaryV1(null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsInboxSummaryV1", new AlertsInboxSummaryV1(null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsInboxPageV1", new AlertsInboxPageV1(null, null, null, null).toSchema()),
Map.entry("AlertsRecordPanelV1", new AlertsRecordPanelV1(null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsMutationResultV1", new AlertsMutationResultV1(null).toSchema()),
@@ -161,7 +161,7 @@ public final class AlertsSchemas
/*******************************************************************************
** Bell summary, latest ten items and up to three sticky banners.
*******************************************************************************/
public record AlertsInboxSummaryV1(Integer unreadCount, String highestUnreadSeverity, List<AlertsInboxItemV1> items, List<AlertsInboxItemV1> banners, String notificationsHref, String inboxHref, String alertsHref, Integer pollSeconds) implements ToSchema
public record AlertsInboxSummaryV1(Integer unreadCount, String highestUnreadSeverity, List<AlertsInboxItemV1> items, List<AlertsInboxItemV1> banners, String notificationsHref, String inboxHref, String preferencesSection, String alertsHref, Integer pollSeconds) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
@@ -175,6 +175,7 @@ public final class AlertsSchemas
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("banners", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("notificationsHref", new Schema().withType(Type.STRING).withDescription("Existing notifications page link; retained for compatibility.")),
Map.entry("preferencesSection", new Schema().withType(Type.STRING).withNullable(true).withDescription("Name of the user Preferences section for notification settings (/qqq/v1/preferences/{name}), or null when not registered.")),
Map.entry("inboxHref", new Schema().withType(Type.STRING).withDescription("Canonical inbox navigation link.")),
Map.entry("alertsHref", new Schema().withType(Type.STRING)),
Map.entry("pollSeconds", new Schema().withType(Type.INTEGER))))));
@@ -52,7 +52,7 @@ public final class AlertsUiApi
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
.withSemanticVersion("1.0.0").withDescription("Alerts inbox and record panel")
.withSemanticVersion("1.1.0").withDescription("Alerts inbox and record panel")
.withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class)
.withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class)
.withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class));
@@ -236,7 +236,7 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
*******************************************************************************/
static void checkPreference(QRecord record)
{
if(NotificationCategory.SECURITY.name().equals(record.getValueString("category")) && Boolean.TRUE.equals(record.getValueBoolean("inAppMuted")))
if(NotificationCategory.SECURITY.name().equals(record.getValueString("category")) && (Boolean.TRUE.equals(record.getValueBoolean("inAppMuted")) || record.getValue("inAppMinSeverity") != null))
{
record.addError(new PermissionDeniedMessage("Security notices can't be muted in-app; you can still choose how they are emailed."));
}
@@ -464,7 +464,7 @@ public final class AlertsTableMetaDataHelper
.withUniqueKey(new UniqueKey("userId", "category"))
.withRecordSecurityLock(AlertsSecurity.ownerLock("userId"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "emailMode", "emailMinSeverity", "slackMode", "slackMinSeverity")))
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "inAppMinSeverity", "emailMode", "emailMinSeverity", "slackMode", "slackMinSeverity")))
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("userId", "createDate", "modifyDate")));
table.getField("category").setDefaultValue(NotificationCategory.SYSTEM.name());
table.getField("inAppMuted").setDefaultValue(false);
@@ -25,7 +25,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
/*******************************************************************************
** A user's settings for one notification category: muted in-app (never for security), and email off, immediate or in a daily digest, from a minimum severity.
** A user's settings for one notification category: in-app minimum severity (never for security), and email/Slack off, immediate or in a daily digest, from a minimum severity.
**
** Generated from the qbit-alerts schema (one definition for the entity,
** the Liquibase changelog and the test DDL).
@@ -46,6 +46,9 @@ public class AlertsNotificationPreference extends QRecordEntity
@QField(label = "Muted In-App")
private Boolean inAppMuted;
@QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "In-App From Severity")
private String inAppMinSeverity;
@QField(maxLength = 16, possibleValueSourceName = "alerts_email_mode", label = "Email")
private String emailMode;
@@ -307,6 +310,35 @@ public class AlertsNotificationPreference extends QRecordEntity
}
/*******************************************************************************
** Getter for inAppMinSeverity
*******************************************************************************/
public String getInAppMinSeverity()
{
return (this.inAppMinSeverity);
}
/*******************************************************************************
** Setter for inAppMinSeverity
*******************************************************************************/
public void setInAppMinSeverity(String inAppMinSeverity)
{
this.inAppMinSeverity = inAppMinSeverity;
}
/*******************************************************************************
** Fluent setter for inAppMinSeverity
*******************************************************************************/
public AlertsNotificationPreference withInAppMinSeverity(String inAppMinSeverity)
{
this.inAppMinSeverity = inAppMinSeverity;
return (this);
}
/*******************************************************************************
** Getter for slackMode
*******************************************************************************/
@@ -324,7 +324,7 @@ public final class AlertsPossibleValues
List<QPossibleValue<?>> possibleValues = new ArrayList<>();
for(Enum<?> value : values)
{
possibleValues.add(new QPossibleValue<>(value.name(), label(value.name())));
possibleValues.add(new QPossibleValue<>(value.name(), value == EmailMode.DAILY_DIGEST ? "Daily digest" : label(value.name())));
}
return (new QPossibleValueSource()
.withName(name)
@@ -54,6 +54,7 @@ import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -141,40 +142,55 @@ public class NotificationEmailer
return (new Result(0, 0, 0, 0));
}
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name()))
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS))
.withLimit(SCAN));
Map<Integer, QRecord> notifications = notifications(recipients, now);
Map<String, QRecord> settings = userSettings(preferences.keySet());
int sent = 0;
int skipped = 0;
int failed = 0;
Map<String, QRecord> settings = userSettings(preferences.keySet());
int sent = 0;
int skipped = 0;
int failed = 0;
Map<String, List<QRecord[]>> digests = new LinkedHashMap<>();
for(QRecord recipient : recipients)
int afterId = 0;
while(true)
{
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
String userId = recipient.getValueString("userId");
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|| !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name()))
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId)
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(SCAN));
if(recipients.isEmpty())
{
mark(recipient, EmailStatus.SKIPPED, null);
skipped++;
break;
}
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
Map<Integer, QRecord> notifications = notifications(recipients, now);
for(QRecord recipient : recipients)
{
EmailStatus status = sendImmediate(address(settings.get(userId)), notification);
mark(recipient, status, status == EmailStatus.SENT ? now : null);
sent += status == EmailStatus.SENT ? 1 : 0;
skipped += status == EmailStatus.SKIPPED ? 1 : 0;
failed += status == EmailStatus.FAILED ? 1 : 0;
}
else if(recipient.getValue("readAt") == null)
{
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
String userId = recipient.getValueString("userId");
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|| !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))
{
mark(recipient, EmailStatus.SKIPPED, null);
skipped++;
}
else if(!NotificationCategory.SECURITY.name().equals(notification.getValueString("category"))
&& settings.get(userId) != null && settings.get(userId).getValueInstant("pausedUntil") != null
&& settings.get(userId).getValueInstant("pausedUntil").isAfter(now))
{
// Leave the recipient pending so a later run can deliver it.
skipped++;
}
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
{
EmailStatus status = sendImmediate(address(settings.get(userId)), notification);
mark(recipient, status, status == EmailStatus.SENT ? now : null);
sent += status == EmailStatus.SENT ? 1 : 0;
skipped += status == EmailStatus.SKIPPED ? 1 : 0;
failed += status == EmailStatus.FAILED ? 1 : 0;
}
else if(recipient.getValue("readAt") == null)
{
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
}
}
}
@@ -173,7 +173,7 @@ public class NotificationService
AlertsAudienceResolver.Resolved resolved = resolver.resolve(audienceOf(notification));
Set<String> existing = recipientUserIds(notificationId);
Set<String> muted = mutedUsers(notification.getValueString("category"));
Set<String> muted = mutedUsers(notification.getValueString("category"), notification.getValueString("severity"));
List<QRecord> inserts = new ArrayList<>();
for(String userId : resolved.userIds())
@@ -602,16 +602,20 @@ public class NotificationService
/*******************************************************************************
** Users who muted a category in-app (never for SECURITY).
*******************************************************************************/
private Set<String> mutedUsers(String category) throws QException
private Set<String> mutedUsers(String category, String severity) throws QException
{
Set<String> userIds = new HashSet<>();
if(category == null || NotificationCategory.SECURITY.name().equals(category))
{
return (userIds);
}
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("category", category).withCriteria("inAppMuted", QCriteriaOperator.EQUALS, true)))
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("category", category)))
{
userIds.add(preference.getValueString("userId"));
String minimum = preference.getValueString("inAppMinSeverity");
if(Boolean.TRUE.equals(preference.getValueBoolean("inAppMuted")) || (minimum != null && !NotificationEmailer.atLeast(severity, minimum)))
{
userIds.add(preference.getValueString("userId"));
}
}
return (userIds);
}
@@ -19,7 +19,6 @@ package com.kingsrook.qbits.alerts.notifications;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -45,6 +44,7 @@ import com.kingsrook.qqq.backend.core.model.actions.messaging.ContentRole;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -114,47 +114,61 @@ public class NotificationSlackSender
return (new Result(0, 0, 0, 0, 0));
}
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("slackStatus", QCriteriaOperator.IN, List.of(SlackStatus.NONE.name(), SlackStatus.PENDING.name()))
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS))
.withLimit(NotificationEmailer.SCAN));
Map<Integer, QRecord> notifications = NotificationEmailer.notifications(recipients, now);
Map<String, QRecord> settings = NotificationEmailer.userSettings(preferences.keySet());
boolean available = isAvailable();
Map<String, QRecord> settings = NotificationEmailer.userSettings(preferences.keySet());
boolean available = isAvailable();
int sent = 0;
int held = 0;
int skipped = 0;
int failed = 0;
Map<String, List<QRecord[]>> digests = new LinkedHashMap<>();
for(QRecord recipient : recipients)
int afterId = 0;
while(true)
{
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
String userId = recipient.getValueString("userId");
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|| !NotificationEmailer.atLeast(notification.getValueString("severity"), preference.getValueString("slackMinSeverity")) || !available)
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("slackStatus", QCriteriaOperator.IN, List.of(SlackStatus.NONE.name(), SlackStatus.PENDING.name()))
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId)
.withOrderBy(new QFilterOrderBy("id", true))
.withLimit(NotificationEmailer.SCAN));
if(recipients.isEmpty())
{
mark(recipient, SlackStatus.SKIPPED);
skipped++;
break;
}
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("slackMode")))
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
Map<Integer, QRecord> notifications = NotificationEmailer.notifications(recipients, now);
for(QRecord recipient : recipients)
{
if(isHeld(settings.get(userId), notification, now))
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
String userId = recipient.getValueString("userId");
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|| !NotificationEmailer.atLeast(notification.getValueString("severity"), preference.getValueString("slackMinSeverity")) || !available)
{
mark(recipient, SlackStatus.SKIPPED);
skipped++;
}
else if(!NotificationCategory.SECURITY.name().equals(notification.getValueString("category")) && AlertRouter.isPaused(settings.get(userId), now))
{
held++;
continue;
}
SlackStatus status = sendExternal(userId, notification.getValueString("title"), List.of(notification), notification.getValueInteger("id"));
mark(recipient, status);
sent += status == SlackStatus.SENT ? 1 : 0;
skipped += status == SlackStatus.SKIPPED ? 1 : 0;
failed += status == SlackStatus.FAILED ? 1 : 0;
}
else if(recipient.getValue("readAt") == null)
{
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("slackMode")))
{
if(isHeld(settings.get(userId), notification, now))
{
held++;
continue;
}
SlackStatus status = sendExternal(userId, notification.getValueString("title"), List.of(notification), notification.getValueInteger("id"));
mark(recipient, status);
sent += status == SlackStatus.SENT ? 1 : 0;
skipped += status == SlackStatus.SKIPPED ? 1 : 0;
failed += status == SlackStatus.FAILED ? 1 : 0;
}
else if(recipient.getValue("readAt") == null)
{
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
}
}
}
@@ -200,11 +214,12 @@ public class NotificationSlackSender
/*******************************************************************************
** Whether a push is held for now: the user paused notifications, or it is
** their quiet hours (CRITICAL bypasses). The bell is never held.
** their quiet hours (CRITICAL bypasses). SECURITY bypasses the pause.
** The bell is never held.
*******************************************************************************/
private static boolean isHeld(QRecord setting, QRecord notification, Instant now)
{
return (AlertRouter.isPaused(setting, now)
return ((!NotificationCategory.SECURITY.name().equals(notification.getValueString("category")) && AlertRouter.isPaused(setting, now))
|| AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now));
}
@@ -0,0 +1,349 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.preferences;
import java.io.Serializable;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRowChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
/*******************************************************************************
** The session user's notification settings, stored in the existing Alerts tables.
*******************************************************************************/
public class AlertsNotificationPreferencesProvider implements UserPreferenceProviderInterface
{
public static final String SECTION_NAME = "notifications";
private static final String SECURITY_REASON = "Security notices can't be muted.";
private static final Map<String, String> SCALARS = Map.of("pausedUntil", "pausedUntil", "digestHour", "digestHour", "timeZone", "timeZone",
"alertInAppEnabled", "inAppEnabled", "alertEmailEnabled", "emailEnabled");
/*******************************************************************************
** Register once; no app scope, so bell users need only alerts.view.
*******************************************************************************/
public static void register(QInstance instance, AlertsQBitConfig config)
{
if(isRegistered(instance))
{
return;
}
List<QPossibleValue<?>> hours = new ArrayList<>();
for(int hour = 0; hour < 24; hour++)
{
hours.add(new QPossibleValue<>(hour, (hour % 12 == 0 ? 12 : hour % 12) + ":00 " + (hour < 12 ? "AM" : "PM")));
}
instance.addPossibleValueSource(new QPossibleValueSource().withName("alerts_digest_hour").withType(QPossibleValueSourceType.ENUM).withEnumValues(hours));
UserPreferenceListMetaData categories = new UserPreferenceListMetaData().withName("categories").withLabel("Categories").withRowLabel("Category")
.withField(new QFieldMetaData("inApp", QFieldType.BOOLEAN).withLabel("In-app"))
.withField(enumField("inAppMinSeverity", "In-app from", "alerts_notification_severity"))
.withField(enumField("emailMode", "Email", "alerts_email_mode"))
.withField(enumField("emailMinSeverity", "Email from", "alerts_notification_severity"));
if(slackConfigured(instance, config))
{
categories.withField(enumField("slack", "Slack", "alerts_email_mode"))
.withField(enumField("slackMinSeverity", "Slack from", "alerts_notification_severity"));
}
UserPreferencesMetaData.ofOrWithNew(instance).withSection(new UserPreferenceSectionMetaData()
.withName(SECTION_NAME).withLabel("Notifications").withIcon(new QIcon("notifications")).withOrder(20)
.withDescription("What reaches you in the app, by email and on Slack.").withPermissionName(AlertsQBitConfig.PERMISSION_VIEW)
.withProvider(new QCodeReference(AlertsNotificationPreferencesProvider.class))
.withField(new QFieldMetaData("pausedUntil", QFieldType.DATE_TIME).withLabel("Pause notifications until")
.withHelpContent(new QHelpContent("Security notices still arrive.")))
.withField(new QFieldMetaData("digestHour", QFieldType.INTEGER).withLabel("Daily digest time").withPossibleValueSourceName("alerts_digest_hour"))
.withField(new QFieldMetaData("timeZone", QFieldType.STRING).withLabel("Time zone").withMaxLength(64))
.withField(new QFieldMetaData("alertInAppEnabled", QFieldType.BOOLEAN).withLabel("Show alerts in the app"))
.withField(new QFieldMetaData("alertEmailEnabled", QFieldType.BOOLEAN).withLabel("Email me alerts"))
.withList(categories)
.withList(new UserPreferenceListMetaData().withName("subscriptions").withLabel("Subscriptions").withRowLabel("Alert rule")
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Enabled"))
.withField(enumField("minSeverity", "From severity", "alerts_severity"))
.withField(enumField("deliveryMode", "Delivery mode", "alerts_delivery_mode"))));
}
/*******************************************************************************
** Only expose Slack controls when the configured messaging provider exists.
*******************************************************************************/
private static boolean slackConfigured(QInstance instance, AlertsQBitConfig config)
{
String name = config.getSlackProviderName();
return (name != null && !name.isBlank() && instance.getMessagingProvider(name) != null);
}
/*******************************************************************************
** Whether the section is actually installed in this instance.
*******************************************************************************/
public static boolean isRegistered(QInstance instance)
{
UserPreferencesMetaData metadata = UserPreferencesMetaData.of(instance);
return (metadata != null && metadata.getSections().stream().anyMatch(section -> SECTION_NAME.equals(section.getName())));
}
/*******************************************************************************
** String enums use ordinary fields with an ENUM possible-value source.
*******************************************************************************/
private static QFieldMetaData enumField(String name, String label, String source)
{
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withPossibleValueSourceName(source));
}
/*******************************************************************************
** Load defaults and fixed category rows, plus only this user's subscriptions.
*******************************************************************************/
@Override
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
{
QRecord settings = userSettings(context);
UserPreferenceValues result = new UserPreferenceValues();
SCALARS.forEach((name, column) -> result.getValues().put(name, settings.getValue(column)));
Map<String, QRecord> preferences = new LinkedHashMap<>();
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId())))
{
preferences.put(preference.getValueString("category"), preference);
}
List<UserPreferenceRow> categories = new ArrayList<>();
for(NotificationCategory category : NotificationCategory.values())
{
QRecord preference = preferences.get(category.name());
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("inApp", preference == null || !Boolean.TRUE.equals(preference.getValueBoolean("inAppMuted")));
for(String field : List.of("inAppMinSeverity", "emailMode", "emailMinSeverity"))
{
values.put(field, preference == null ? QContext.getQInstance().getTable(AlertsNotificationPreference.TABLE_NAME).getField(field).getDefaultValue() : preference.getValue(field));
}
///////////////////////////////////////////////////////////////////////
// no in-app threshold means everything; show it as the lowest level //
///////////////////////////////////////////////////////////////////////
if(values.get("inAppMinSeverity") == null)
{
values.put("inAppMinSeverity", NotificationSeverity.INFO.name());
}
if(slackConfigured(QContext.getQInstance(), AlertsRuntime.get(QContext.getQInstance()).getConfig()))
{
for(String field : List.of("slackMode", "slackMinSeverity"))
{
values.put("slackMode".equals(field) ? "slack" : field, preference == null
? QContext.getQInstance().getTable(AlertsNotificationPreference.TABLE_NAME).getField(field).getDefaultValue() : preference.getValue(field));
}
}
String label = QContext.getQInstance().getPossibleValueSource("alerts_notification_category").getEnumValues().stream()
.filter(value -> category.name().equals(value.getId())).findFirst().orElseThrow().getLabel();
UserPreferenceRow row = new UserPreferenceRow().withKey(category.name()).withLabel(label).withValues(values);
if(category == NotificationCategory.SECURITY)
{
values.put("inApp", true);
values.put("inAppMinSeverity", NotificationSeverity.INFO.name());
row.withReadOnlyFieldNames(List.of("inApp", "inAppMinSeverity")).withReadOnlyReason(SECURITY_REASON);
}
categories.add(row);
}
result.getLists().put("categories", categories);
List<UserPreferenceRow> subscriptions = new ArrayList<>();
for(QRecord subscription : ownSubscriptions(context))
{
Map<String, Serializable> values = new LinkedHashMap<>();
for(String field : List.of("enabled", "minSeverity", "deliveryMode"))
{
values.put(field, subscription.getValue(field));
}
subscriptions.add(new UserPreferenceRow().withKey(subscription.getValueString("id")).withLabel(scopeLabel(subscription)).withValues(values));
}
result.getLists().put("subscriptions", subscriptions);
return (result);
}
/*******************************************************************************
** Validate domain rules; core invokes this outside its trusted store scope.
*******************************************************************************/
@Override
public List<String> validate(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
List<String> errors = new ArrayList<>();
Serializable zone = change.getValues().get("timeZone");
if(zone != null)
{
try
{
ZoneId.of(zone.toString());
}
catch(DateTimeException e)
{
errors.add("Time zone: must be a valid time zone, such as America/Chicago.");
}
}
for(UserPreferenceRowChange row : change.getLists().getOrDefault("categories", List.of()))
{
if("SECURITY".equals(row.getKey()) && (row.getValues().containsKey("inApp") || row.getValues().containsKey("inAppMinSeverity")))
{
errors.add(SECURITY_REASON);
}
}
if(!change.getLists().getOrDefault("subscriptions", List.of()).isEmpty())
{
Set<String> ownIds = CorePermissionEnforcer.runTrusted("validate session user's alert subscriptions", () -> ownSubscriptions(context).stream()
.map(row -> row.getValueString("id")).collect(Collectors.toSet()));
for(UserPreferenceRowChange row : change.getLists().get("subscriptions"))
{
if(!ownIds.contains(row.getKey()))
{
errors.add("Alert rule: isn't one of your subscriptions.");
}
}
}
return (errors);
}
/*******************************************************************************
** Partial updates through normal actions preserve auditing and customizers.
*******************************************************************************/
@Override
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
List<String> errors = validate(context, change);
if(!errors.isEmpty())
{
throw (new QBadRequestException(String.join("; ", errors)));
}
if(!change.getValues().isEmpty())
{
QRecord update = new QRecord().withValue("id", userSettings(context).getValue("id"));
change.getValues().forEach((field, value) -> update.setValue(SCALARS.get(field), value));
AlertsDb.update(AlertsUserPreference.TABLE_NAME, update);
}
for(UserPreferenceRowChange row : change.getLists().getOrDefault("categories", List.of()))
{
QRecord existing = AlertsDb.first(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId())
.withCriteria("category", QCriteriaOperator.EQUALS, row.getKey()));
QRecord update = new QRecord();
row.getValues().forEach((field, value) -> update.setValue("inApp".equals(field) ? "inAppMuted" : "slack".equals(field) ? "slackMode" : field,
"inApp".equals(field) ? Boolean.FALSE.equals(value) : value));
if(existing == null)
{
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, update.withValue("userId", context.getUserId()).withValue("category", row.getKey()));
}
else
{
AlertsDb.update(AlertsNotificationPreference.TABLE_NAME, update.withValue("id", existing.getValue("id")));
}
}
for(UserPreferenceRowChange row : change.getLists().getOrDefault("subscriptions", List.of()))
{
QRecord update = new QRecord().withValue("id", Integer.valueOf(row.getKey()));
row.getValues().forEach(update::setValue);
AlertsDb.update(AlertsSubscription.TABLE_NAME, update);
}
}
/*******************************************************************************
** Create the same defaults as AlertsPreferences.ensure, retaining the user.
*******************************************************************************/
private QRecord userSettings(UserPreferenceRequestContext context) throws QException
{
QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId()));
if(settings == null)
{
String label = context.getSession().getUser().getFullName();
settings = AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", context.getUserId())
.withValue("userLabel", label == null ? context.getUserId() : label).withValue("inAppEnabled", true).withValue("emailEnabled", true)
.withValue("quietBypassSeverity", "CRITICAL").withValue("digestHour", 8));
}
return (settings);
}
/*******************************************************************************
** Never expose team or channel rules, even to an administrator.
*******************************************************************************/
private List<QRecord> ownSubscriptions(UserPreferenceRequestContext context) throws QException
{
return (AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", context.getUserId()).withCriteria("subscriberType", QCriteriaOperator.EQUALS, "USER")));
}
/*******************************************************************************
** Readable scope without relying on access to the subscription's target table.
*******************************************************************************/
private String scopeLabel(QRecord subscription)
{
String value = subscription.getValueString("scopeValue");
return (switch(subscription.getValueString("scopeType"))
{
case "ALL" -> "All alerts";
case "TYPE" -> "Type: " + value;
case "GROUP" -> "Group: " + value;
case "RECORD" -> "Record: " + value + " #" + subscription.getValueString("scopeRecordId");
case "MONITOR" -> "Monitor: " + value;
default -> "Table: " + value;
});
}
}
@@ -513,3 +513,9 @@ ALTER TABLE alerts_notification_preference ADD COLUMN slack_min_severity VARCHAR
ALTER TABLE alerts_notification_recipient ADD COLUMN slack_status VARCHAR(16);
ALTER TABLE alerts_notification_recipient ADD COLUMN slack_message_id VARCHAR(100);
CREATE INDEX ix_alerts_notification_recipient_slack_status ON alerts_notification_recipient (slack_status);
-- changeset qbit-alerts:29
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_notification_preference' AND LOWER(column_name) = 'in_app_min_severity'
-- comment: Optional minimum severity for in-app notifications (security always arrives).
ALTER TABLE alerts_notification_preference ADD COLUMN in_app_min_severity VARCHAR(16);
@@ -115,6 +115,11 @@ components:
type: "string"
pollSeconds:
type: "integer"
preferencesSection:
description: "Name of the user Preferences section for notification settings\
\ (/qqq/v1/preferences/{name}), or null when not registered."
nullable: true
type: "string"
unreadCount:
type: "integer"
type: "object"
@@ -217,7 +222,7 @@ info:
email: "contact@kingsrook.com"
description: "Alerts inbox and record panel"
title: "QQQ Middleware API extension: alerts"
version: "1.0.0"
version: "1.1.0"
openapi: "3.0.3"
paths:
/qqq/v1/alerts/inbox/summary:
@@ -56,7 +56,8 @@ class AlertsApiContractTest extends BaseAlertsTest
"/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read",
"/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel");
assertThat(extension.getSpecs()).hasSize(6);
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "actions", "follow");
assertThat(extension.getSemanticVersion()).isEqualTo("1.1.0");
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "preferencesSection", "actions", "follow");
assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/");
}
@@ -200,7 +200,7 @@ class AlertsApiHttpTest extends BaseAlertsTest
start();
String base = "/qqq/v1/alerts";
Map<String, Object> summary = json(request("GET", base + "/inbox/summary", null, ADMIN, null));
assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications");
assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications").containsEntry("preferencesSection", "notifications");
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
assertThat(items).hasSize(1);
Map<String, Object> item = items.get(0);
@@ -309,6 +309,50 @@ class AlertsQBitProducerTest extends BaseAlertsTest
/*******************************************************************************
** Changeset 29 upgrades existing Slack preferences once and preserves their
** values; its MARK_RAN guard skips a database that already has the column.
*******************************************************************************/
@Test
void testInAppSeverityMigration() throws Exception
{
String changelog;
try(InputStream in = getClass().getClassLoader().getResourceAsStream("db/liquibase/qbit-alerts/alerts.changelog.sql"))
{
changelog = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
String block = changelog.substring(changelog.indexOf("-- changeset qbit-alerts:29"));
assertThat(block).contains("-- preconditions onFail:MARK_RAN onError:HALT");
String guard = block.lines().filter(line -> line.startsWith("-- precondition-sql-check")).findFirst().orElseThrow();
String sql = block.lines().filter(line -> line.startsWith("ALTER TABLE")).findFirst().orElseThrow();
try(Connection connection = DriverManager.getConnection("jdbc:h2:mem:alerts_preferences_migration"); Statement statement = connection.createStatement())
{
statement.execute("CREATE TABLE alerts_notification_preference (user_id VARCHAR(100), slack_mode VARCHAR(16), slack_min_severity VARCHAR(16))");
statement.execute("INSERT INTO alerts_notification_preference VALUES ('viewer-1', 'DAILY_DIGEST', 'ERROR')");
for(int pass = 0; pass < 2; pass++)
{
try(ResultSet result = statement.executeQuery(guard.substring(guard.indexOf("SELECT"))))
{
assertThat(result.next()).isTrue();
assertThat(result.getInt(1)).isEqualTo(pass);
}
if(pass == 0)
{
statement.execute(sql);
}
}
try(ResultSet result = statement.executeQuery("SELECT slack_mode, slack_min_severity, in_app_min_severity FROM alerts_notification_preference"))
{
assertThat(result.next()).isTrue();
assertThat(result.getString(1)).isEqualTo("DAILY_DIGEST");
assertThat(result.getString(2)).isEqualTo("ERROR");
assertThat(result.getString(3)).isNull();
}
}
}
/*******************************************************************************
** Permissions: the three alerts permissions are listed; role lists nest.
*******************************************************************************/
@@ -279,6 +279,10 @@ public class AlertsDemoServer extends AbstractQQQApplication
{
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", ADMIN));
AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", preference.getValue("id")).withValue("emailAddress", "dana@example.com"));
AlertsDb.insert("alerts_notification_preference", new QRecord().withValue("userId", ADMIN).withValue("category", "JOB")
.withValue("inAppMinSeverity", "WARNING").withValue("emailMode", "IMMEDIATE").withValue("emailMinSeverity", "ERROR"));
AlertsDb.insert("alerts_notification_preference", new QRecord().withValue("userId", ADMIN).withValue("category", "RELEASE")
.withValue("emailMode", "DAILY_DIGEST").withValue("emailMinSeverity", "INFO"));
QRecord list = AlertsDb.insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "ops-list").withValue("label", "Ops email list").withValue("channelType", "EMAIL_LIST")
.withValue("enabled", true).withValue("settings", JsonUtils.toJson(Map.of("addresses", List.of("ops@example.com")))).withValue("detailLevel", "MINIMAL")
.withValue("minSeverity", "ERROR").withValue("rateLimitPerHour", 60).withValue("healthStatus", "HEALTHY").withValue("consecutiveFailures", 0));
@@ -18,17 +18,21 @@
package com.kingsrook.qbits.alerts.notifications;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.kingsrook.qbits.alerts.AlertsPreferences;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.api.AlertsApi;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
@@ -39,6 +43,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
@@ -218,4 +223,80 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
assertThat(SENT).hasSize(4);
assertThat(SENT).noneMatch(message -> text(message).contains("change your password"));
}
/*******************************************************************************
** Pauses defer immediate and digest mail past a day without hiding the inbox;
** SECURITY still sends in both email modes.
*******************************************************************************/
@Test
void testPausedEmail() throws Exception
{
emailSettings("viewer-1", "JOB", "IMMEDIATE", "INFO");
emailSettings("viewer-1", "SECURITY", "IMMEDIATE", "INFO");
emailSettings("viewer-2", "JOB", "DAILY_DIGEST", "INFO");
emailSettings("viewer-2", "SECURITY", "DAILY_DIGEST", "INFO");
runtime.asSystem(() ->
{
for(QRecord setting : all(AlertsUserPreference.TABLE_NAME))
{
AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", setting.getValue("id"))
.withValue("pausedUntil", clock.instant().plus(Duration.ofDays(2))));
}
return (null);
});
for(NotificationCategory category : List.of(NotificationCategory.JOB, NotificationCategory.SECURITY))
{
runtime.getNotifications().notify(new NotificationSignal(category.name()).withCategory(category).withAudience(NotificationAudience.users("viewer-1", "viewer-2")));
}
NotificationEmailer.Result paused = new NotificationEmailer(runtime).run();
assertThat(paused.sent()).isEqualTo(1);
assertThat(paused.digests()).isEqualTo(1);
assertThat(paused.skipped()).isEqualTo(2);
assertThat(SENT).hasSize(2).allSatisfy(message -> assertThat(text(message)).contains("SECURITY").doesNotContain("JOB"));
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).filteredOn(row -> row.getValueInstant("emailedAt") == null).hasSize(2)
.allSatisfy(row -> assertThat(row.getValueString("emailStatus")).isEqualTo("NONE"));
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("unreadCount", 2);
clock.advance(Duration.ofDays(2));
NotificationEmailer.Result resumed = new NotificationEmailer(runtime).run();
assertThat(resumed.sent()).isEqualTo(1);
assertThat(resumed.digests()).isEqualTo(1);
assertThat(SENT).hasSize(4);
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).allSatisfy(row -> assertThat(row.getValueInstant("emailedAt")).isNotNull());
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
}
/*******************************************************************************
** A full scan page of paused recipients cannot starve a later security notice.
*******************************************************************************/
@Test
void testPausedBacklogDoesNotBlockSecurity() throws Exception
{
emailSettings(VIEWER, "JOB", "IMMEDIATE", "INFO");
emailSettings(VIEWER, "SECURITY", "IMMEDIATE", "INFO");
runtime.asSystem(() ->
{
QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER));
AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", settings.getValue("id"))
.withValue("pausedUntil", clock.instant().plusSeconds(3600)));
List<QRecord> notifications = new ArrayList<>();
for(int i = 0; i < NotificationEmailer.SCAN; i++)
{
notifications.add(new QRecord().withValue("title", "Paused job " + i).withValue("category", "JOB").withValue("status", "SENT"));
}
notifications = InsertAction.executeForRecords(
new InsertInput(AlertsNotification.TABLE_NAME).withRecords(notifications));
List<QRecord> recipients = notifications.stream().map(notification -> new QRecord().withValue("notificationId", notification.getValue("id"))
.withValue("userId", VIEWER).withValue("deliveredAt", clock.instant())).toList();
InsertAction.executeForRecords(
new InsertInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(recipients));
return (null);
});
runtime.getNotifications().notify(new NotificationSignal("Security after backlog").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user(VIEWER)));
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(SENT).singleElement().satisfies(message -> assertThat(message.getSubject()).isEqualTo("Security after backlog"));
}
}
@@ -18,6 +18,7 @@
package com.kingsrook.qbits.alerts.notifications;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@@ -254,6 +255,44 @@ class NotificationSlackTest extends BaseAlertsTest
assertThat(new NotificationSlackSender(runtime).run().held()).isEqualTo(1);
assertThat(SENT).isEmpty();
assertThat(slackStatus("viewer-1")).isEqualTo("NONE");
clock.advance(Duration.ofDays(2));
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1);
assertThat(slackStatus("viewer-1")).isEqualTo("SENT");
}
/*******************************************************************************
** Pauses hold JOB items in both Slack modes, even in a digest containing
** SECURITY. After two days every held item is delivered exactly once.
*******************************************************************************/
@Test
void testPausedSecurityAndMixedDigest() throws Exception
{
clock.set(Instant.parse("2026-10-12T12:05:00Z"));
for(String userId : List.of("viewer-1", "viewer-2"))
{
String mode = "viewer-1".equals(userId) ? "IMMEDIATE" : "DAILY_DIGEST";
QRecord paused = new QRecord().withValue("pausedUntil", clock.instant().plus(Duration.ofDays(2)));
slackSettings(userId, "JOB", mode, "INFO", paused);
slackSettings(userId, "SECURITY", mode, "INFO", paused);
}
for(NotificationCategory category : List.of(NotificationCategory.JOB, NotificationCategory.SECURITY))
{
QAlerts.notify(new NotificationSignal(category.name()).withCategory(category).withSeverity(NotificationSeverity.CRITICAL)
.withAudience(NotificationAudience.users("viewer-1", "viewer-2")));
}
NotificationSlackSender.Result paused = new NotificationSlackSender(runtime).run();
assertThat(paused.sent()).isEqualTo(1);
assertThat(paused.digests()).isEqualTo(1);
assertThat(paused.held()).isEqualTo(2);
assertThat(SENT).hasSize(2).allSatisfy(message -> assertThat(text(message)).contains("SECURITY").doesNotContain("JOB"));
clock.advance(Duration.ofDays(2));
NotificationSlackSender.Result resumed = new NotificationSlackSender(runtime).run();
assertThat(resumed.sent()).isEqualTo(1);
assertThat(resumed.digests()).isEqualTo(1);
assertThat(SENT).hasSize(4);
assertThat(new NotificationSlackSender(runtime).run()).isEqualTo(new NotificationSlackSender.Result(0, 0, 0, 0, 0));
}
@@ -534,4 +534,37 @@ class NotificationsTest extends BaseAlertsTest
new DeleteAction().execute(new DeleteInput(AlertsNotification.TABLE_NAME).withPrimaryKeys(List.of(other)));
assertThat(runtime.asSystem(() -> all(AlertsNotificationRecipient.TABLE_NAME))).isEmpty();
}
/*******************************************************************************
** In-app severity is inclusive; null means all, and SECURITY always arrives.
*******************************************************************************/
@Test
void testInAppMinimumSeverity() throws Exception
{
runtime.asSystem(() ->
{
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", VIEWER).withValue("category", "JOB").withValue("inAppMinSeverity", "ERROR"));
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", VIEWER).withValue("category", "SECURITY")
.withValue("inAppMuted", true).withValue("inAppMinSeverity", "CRITICAL"));
return (null);
});
for(NotificationSeverity severity : List.of(NotificationSeverity.WARNING, NotificationSeverity.ERROR, NotificationSeverity.CRITICAL))
{
runtime.getNotifications().notify(new NotificationSignal("Job " + severity).withSeverity(severity).withCategory(NotificationCategory.JOB)
.withAudience(NotificationAudience.users(VIEWER, "viewer-2")));
}
runtime.getNotifications().notify(new NotificationSignal("Security info").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user(VIEWER)));
assertThat(where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", VIEWER))).hasSize(3);
assertThat(where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "viewer-2"))).hasSize(3);
loginAs(VIEWER);
QRecord preference = where(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER)).stream()
.filter(row -> "JOB".equals(row.getValueString("category"))).findFirst().orElseThrow();
assertThat(new UpdateAction().execute(new UpdateInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord().withValue("id", preference.getValue("id"))
.withValue("category", "SECURITY"))).getRecords().get(0).getErrors()).isNotEmpty();
AlertsDb.update(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("id", preference.getValue("id")).withValue("inAppMinSeverity", null));
runtime.getNotifications().notify(new NotificationSignal("Job info").withCategory(NotificationCategory.JOB).withAudience(NotificationAudience.user(VIEWER)));
assertThat(where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", VIEWER))).hasSize(4);
}
}
@@ -0,0 +1,262 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.preferences;
import java.io.Serializable;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsQBitProducer;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.api.AlertsApi;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRowChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Real core action round trips, ownership, validation and field auditing.
*******************************************************************************/
class NotificationPreferencesTest extends BaseAlertsTest
{
private final UserPreferencesAction action = new UserPreferencesAction();
/*******************************************************************************
** Defaults, all settings, partial changes and isolation persist in real tables.
*******************************************************************************/
@Test
void roundTrip() throws Exception
{
QRecord mine = subscription(VIEWER, "USER", "ALL");
QRecord other = subscription("viewer-2", "USER", "GROUP");
subscription(VIEWER, "TEAM", "ALL");
loginAs("viewer-2");
action.patchSection("notifications", Map.of("values", Map.of("digestHour", 7), "lists", Map.of("categories", List.of(row("JOB", Map.of("emailMode", "IMMEDIATE"))))));
Map<String, Serializable> otherSettings = new HashMap<>(all(AlertsUserPreference.TABLE_NAME).get(0).getValues());
Map<String, Serializable> otherCategory = new HashMap<>(all(AlertsNotificationPreference.TABLE_NAME).get(0).getValues());
loginAs(VIEWER).setPermissions(Set.of(AlertsQBitConfig.PERMISSION_VIEW));
qInstance.setEnforcePermissionsInCoreActions(true);
var defaults = action.getSection("notifications");
assertThat(defaults.getSection().getAppName()).isNull();
assertThat(defaults.getValues().getValues()).containsEntry("digestHour", 8).containsEntry("alertInAppEnabled", true).containsEntry("alertEmailEnabled", true);
assertThat(defaults.getValues().getLists().get("categories")).hasSize(7).allSatisfy(category ->
{
assertThat(category.getValues()).containsEntry("inApp", true).containsEntry("inAppMinSeverity", "INFO").containsEntry("emailMode", "OFF").containsEntry("emailMinSeverity", "WARNING");
});
assertThat(defaults.getValues().getLists().get("subscriptions")).singleElement().satisfies(rule -> assertThat(rule.getLabel()).isEqualTo("All alerts"));
action.patchSection("notifications", Map.of("values", Map.of("pausedUntil", "2026-11-01T12:00:00Z", "digestHour", 22, "timeZone", "America/Chicago",
"alertInAppEnabled", false, "alertEmailEnabled", false), "lists", Map.of(
"categories", List.of(row("JOB", Map.of("inApp", false, "inAppMinSeverity", "ERROR", "emailMode", "DAILY_DIGEST", "emailMinSeverity", "CRITICAL"))),
"subscriptions", List.of(row(mine.getValueString("id"), Map.of("enabled", false, "minSeverity", "ERROR", "deliveryMode", "DIGEST_DAILY"))))));
var loaded = action.getSection("notifications").getValues();
assertThat(loaded.getValues()).containsEntry("digestHour", 22).containsEntry("timeZone", "America/Chicago")
.containsEntry("pausedUntil", Instant.parse("2026-11-01T12:00:00Z")).containsEntry("alertInAppEnabled", false).containsEntry("alertEmailEnabled", false);
assertThat(where(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER))).singleElement().satisfies(record ->
assertThat(record.getValues()).containsEntry("inAppEnabled", false).containsEntry("emailEnabled", false).containsEntry("quietBypassSeverity", "CRITICAL"));
assertThat(where(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER))).singleElement().satisfies(record ->
assertThat(record.getValues()).containsEntry("category", "JOB").containsEntry("inAppMuted", true).containsEntry("inAppMinSeverity", "ERROR")
.containsEntry("emailMode", "DAILY_DIGEST").containsEntry("emailMinSeverity", "CRITICAL"));
assertThat(where(AlertsSubscription.TABLE_NAME, AlertsDb.eq("id", mine.getValue("id")))).singleElement().satisfies(record ->
assertThat(record.getValues()).containsEntry("enabled", false).containsEntry("minSeverity", "ERROR").containsEntry("deliveryMode", "DIGEST_DAILY"));
Map<String, Object> clearMinimum = new HashMap<>();
clearMinimum.put("inAppMinSeverity", null);
action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(row("JOB", clearMinimum)))));
assertThat(action.getSection("notifications").getValues().getLists().get("categories")).filteredOn(row -> row.getKey().equals("JOB"))
.singleElement().satisfies(row -> assertThat(row.getValues()).containsEntry("inAppMinSeverity", "INFO").containsEntry("inApp", false));
assertThat(where(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-2")).get(0).getValues()).isEqualTo(otherSettings);
assertThat(where(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-2")).get(0).getValues()).isEqualTo(otherCategory);
loginAs("viewer-2");
assertThat(action.getSection("notifications").getValues().getLists().get("subscriptions")).singleElement()
.satisfies(row -> assertThat(row.getKey()).isEqualTo(other.getValueString("id")));
assertThat(where(AlertsSubscription.TABLE_NAME, AlertsDb.eq("id", other.getValue("id"))).get(0).getValueBoolean("enabled")).isTrue();
}
/*******************************************************************************
** Slack settings are available only with the configured provider, persist per
** user and category, and remain editable for SECURITY.
*******************************************************************************/
@Test
void slackPreferences() throws Exception
{
var absent = action.getSection("notifications");
assertThat(absent.getValues().getLists().get("categories")).allSatisfy(row ->
assertThat(row.getValues()).doesNotContainKeys("slack", "slackMinSeverity"));
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("lists", Map.of("categories",
List.of(row("JOB", Map.of("slack", "IMMEDIATE"))))))).isInstanceOf(QBadRequestException.class);
runtime.getConfig().withSlackProviderName("personal-slack");
qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("personal-slack").withType("test"));
UserPreferencesMetaData.of(qInstance).getSections().clear();
qInstance.getPossibleValueSources().remove("alerts_digest_hour");
AlertsNotificationPreferencesProvider.register(qInstance, runtime.getConfig());
loginAs(VIEWER).setPermissions(Set.of(AlertsQBitConfig.PERMISSION_VIEW));
qInstance.setEnforcePermissionsInCoreActions(true);
var defaults = action.getSection("notifications");
assertThat(defaults.getValues().getLists().get("categories")).allSatisfy(row ->
assertThat(row.getValues()).containsEntry("slack", "OFF").containsEntry("slackMinSeverity", "WARNING"));
action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(
row("JOB", Map.of("slack", "DAILY_DIGEST", "slackMinSeverity", "ERROR")),
row("SECURITY", Map.of("slack", "IMMEDIATE", "slackMinSeverity", "INFO"))))));
assertThat(action.getSection("notifications").getValues().getLists().get("categories"))
.filteredOn(row -> row.getKey().equals("JOB")).singleElement().satisfies(row ->
assertThat(row.getValues()).containsEntry("slack", "DAILY_DIGEST").containsEntry("slackMinSeverity", "ERROR"));
assertThat(where(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER)))
.filteredOn(row -> "SECURITY".equals(row.getValueString("category"))).singleElement().satisfies(row ->
assertThat(row.getValues()).containsEntry("slackMode", "IMMEDIATE").containsEntry("slackMinSeverity", "INFO"));
action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(row("SECURITY", Map.of("slack", "OFF"))))));
loginAs("viewer-2");
assertThat(action.getSection("notifications").getValues().getLists().get("categories")).allSatisfy(row ->
assertThat(row.getValues()).containsEntry("slack", "OFF").containsEntry("slackMinSeverity", "WARNING"));
}
/*******************************************************************************
** Invalid and foreign row keys fail even for admins with all-access keys.
*******************************************************************************/
@Test
void rejectsUnsafeChanges() throws Exception
{
QRecord other = subscription("viewer-2", "USER", "ALL");
QRecord team = subscription(ADMIN, "TEAM", "ALL");
for(String key : List.of(other.getValueString("id"), team.getValueString("id"), "99999", "bogus"))
{
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("lists", Map.of("subscriptions", List.of(row(key, Map.of("enabled", false)))))))
.isInstanceOf(QBadRequestException.class);
}
for(String field : List.of("inApp", "inAppMinSeverity"))
{
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(row("SECURITY", Map.of(field, field.equals("inApp") ? false : "ERROR")))))))
.isInstanceOf(QBadRequestException.class).hasMessageContaining("Security notices can't be muted.");
}
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("values", Map.of("timeZone", "Not/AZone")))).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("values", Map.of("digestHour", 24)))).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("userId", "viewer-2"))).isInstanceOf(QBadRequestException.class);
var context = new UserPreferenceRequestContext(action.getSection("notifications").getSection(), QContext.getQSession());
var provider = new AlertsNotificationPreferencesProvider();
assertThat(provider.validate(context, new UserPreferenceChange().withLists(Map.of("subscriptions", List.of(new UserPreferenceRowChange().withKey(other.getValueString("id"))))))).isNotEmpty();
assertThat(provider.validate(context, new UserPreferenceChange().withLists(Map.of("categories", List.of(new UserPreferenceRowChange().withKey("SECURITY").withValues(Map.of("inApp", false))))))).isNotEmpty();
loginAs("nobody");
assertThat(action.listSections()).isEmpty();
assertThatThrownBy(() -> action.getSection("notifications")).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> action.patchSection("notifications", Map.of())).isInstanceOf(QNotFoundException.class);
}
/*******************************************************************************
** Registration is idempotent, configurable, and drives the inbox shortcut.
*******************************************************************************/
@Test
void registrationAndSummary() throws Exception
{
AlertsNotificationPreferencesProvider.register(qInstance, runtime.getConfig());
assertThat(UserPreferencesMetaData.of(qInstance).getSections()).hasSize(1);
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("preferencesSection", "notifications");
assertThat(qInstance.getPossibleValueSource("alerts_digest_hour").getEnumValues()).hasSize(24);
assertThat(qInstance.getPossibleValueSource("alerts_digest_hour").getEnumValues().get(0).getLabel()).isEqualTo("12:00 AM");
assertThat(qInstance.getPossibleValueSource("alerts_digest_hour").getEnumValues().get(23).getLabel()).isEqualTo("11:00 PM");
UserPreferencesMetaData.of(qInstance).getSections().clear();
new AlertsQBitProducer().withQBitConfig(config.withPreferencesSectionEnabled(false)).postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("preferencesSection", null);
assertThat(action.listSections()).isEmpty();
}
/*******************************************************************************
** Standard FIELD audits retain both user identity and client channel.
*******************************************************************************/
@Test
void audits() throws Exception
{
new AuditsMetaDataProvider().withClientChannelField(true).defineAll(qInstance, BACKEND_NAME, null);
new QInstanceEnricher(qInstance).enrich();
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
{
for(String table : List.of("auditDetail", "audit", "auditUser", "auditTable"))
{
statement.execute("DROP TABLE IF EXISTS " + table);
}
statement.execute("CREATE TABLE auditUser (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE auditTable (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), label VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE audit (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditTableId INTEGER, auditUserId INTEGER, recordId INTEGER, message VARCHAR(250), timestamp TIMESTAMP, clientChannel VARCHAR(100))");
statement.execute("CREATE TABLE auditDetail (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditId BIGINT, message VARCHAR(250), fieldName VARCHAR(100), oldValue VARCHAR(250), newValue VARCHAR(250))");
}
QRecord rule = subscription(VIEWER, "USER", "ALL");
loginAs(VIEWER).withClientChannel("cli/test").setPermissions(Set.of(AlertsQBitConfig.PERMISSION_VIEW));
qInstance.setEnforcePermissionsInCoreActions(true);
action.patchSection("notifications", Map.of("values", Map.of("digestHour", 17), "lists", Map.of("categories", List.of(row("JOB", Map.of("emailMode", "IMMEDIATE"))),
"subscriptions", List.of(row(rule.getValueString("id"), Map.of("enabled", false))))));
action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(row("JOB", Map.of("emailMode", "DAILY_DIGEST"))))));
List<QRecord> audits = all("audit").stream().filter(record -> "cli/test".equals(record.getValueString("clientChannel"))).toList();
assertThat(audits).hasSize(5);
QRecord user = where("auditUser", AlertsDb.eq("name", "Name of " + VIEWER)).get(0);
assertThat(audits).allSatisfy(audit -> assertThat(audit.getValue("auditUserId")).isEqualTo(user.getValue("id")));
assertThat(all("auditDetail")).anyMatch(detail -> "digestHour".equals(detail.getValueString("fieldName")))
.anyMatch(detail -> "emailMode".equals(detail.getValueString("fieldName"))).anyMatch(detail -> "enabled".equals(detail.getValueString("fieldName")));
}
/*******************************************************************************
** A raw API row patch.
*******************************************************************************/
private Map<String, Object> row(String key, Map<String, ?> values)
{
return (Map.of("key", key, "values", values));
}
/*******************************************************************************
** Seed a subscription as the engine would.
*******************************************************************************/
private QRecord subscription(String userId, String type, String scope) throws Exception
{
return (runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("userId", userId)
.withValue("subscriberType", type).withValue("scopeType", scope).withValue("scopeValue", "widgets"))));
}
}
+1
View File
@@ -377,6 +377,7 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference (
user_id VARCHAR(100) NOT NULL,
category VARCHAR(16) NOT NULL,
in_app_muted BOOLEAN,
in_app_min_severity VARCHAR(16),
email_mode VARCHAR(16),
email_min_severity VARCHAR(16),
slack_mode VARCHAR(16),