diff --git a/CHANGELOG.md b/CHANGELOG.md index 94329d7..191a6b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`) 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:`) 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. diff --git a/README.md b/README.md index dd386f6..ae5764d 100644 --- a/README.md +++ b/README.md @@ -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": [..]}` | diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 912f83f..872e384 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -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 diff --git a/docs/UI-API.md b/docs/UI-API.md index 5c637b8..ffe0fc8 100644 --- a/docs/UI-API.md +++ b/docs/UI-API.md @@ -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. diff --git a/pom.xml b/pom.xml index c534691..cb46feb 100644 --- a/pom.xml +++ b/pom.xml @@ -204,7 +204,7 @@ true - 4.1.0-RC.6 + 4.1.0-RC.7 diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java index 6e3d02c..4405740 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java @@ -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. diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java index 54ba329..6f06395 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java @@ -209,6 +209,11 @@ public class AlertsQBitProducer implements QBitMetaDataProducer items, List banners, String notificationsHref, String inboxHref, String alertsHref, Integer pollSeconds) implements ToSchema + public record AlertsInboxSummaryV1(Integer unreadCount, String highestUnreadSeverity, List items, List 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)))))); diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java index d70a255..b54b012 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java @@ -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)); diff --git a/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java b/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java index 4ed286f..2feddfb 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java @@ -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.")); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java index 80487ad..55facb3 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java @@ -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); diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationPreference.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationPreference.java index d3e747b..1900c3c 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationPreference.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationPreference.java @@ -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 *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java index c9018f6..fb799ee 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java @@ -324,7 +324,7 @@ public final class AlertsPossibleValues List> 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) diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java index 1568ac7..d9f6fac 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java @@ -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 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 notifications = notifications(recipients, now); - Map settings = userSettings(preferences.keySet()); - - int sent = 0; - int skipped = 0; - int failed = 0; + Map settings = userSettings(preferences.keySet()); + int sent = 0; + int skipped = 0; + int failed = 0; Map> 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 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 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}); + } } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java index 03986ec..b861d1a 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java @@ -173,7 +173,7 @@ public class NotificationService AlertsAudienceResolver.Resolved resolved = resolver.resolve(audienceOf(notification)); Set existing = recipientUserIds(notificationId); - Set muted = mutedUsers(notification.getValueString("category")); + Set muted = mutedUsers(notification.getValueString("category"), notification.getValueString("severity")); List 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 mutedUsers(String category) throws QException + private Set mutedUsers(String category, String severity) throws QException { Set 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); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java index 3b60b7d..8632579 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java @@ -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 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 notifications = NotificationEmailer.notifications(recipients, now); - Map settings = NotificationEmailer.userSettings(preferences.keySet()); - boolean available = isAvailable(); + Map settings = NotificationEmailer.userSettings(preferences.keySet()); + boolean available = isAvailable(); int sent = 0; int held = 0; int skipped = 0; int failed = 0; Map> 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 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 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)); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java b/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java new file mode 100644 index 0000000..2799c4c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java @@ -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 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> 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 preferences = new LinkedHashMap<>(); + for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId()))) + { + preferences.put(preference.getValueString("category"), preference); + } + List categories = new ArrayList<>(); + for(NotificationCategory category : NotificationCategory.values()) + { + QRecord preference = preferences.get(category.name()); + Map 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 subscriptions = new ArrayList<>(); + for(QRecord subscription : ownSubscriptions(context)) + { + Map 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 validate(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException + { + List 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 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 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 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; + }); + } +} diff --git a/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql b/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql index aaa2f62..f17b9cf 100644 --- a/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql @@ -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); diff --git a/src/main/resources/openapi/qqq-v1/alerts.yaml b/src/main/resources/openapi/qqq-v1/alerts.yaml index 5de6d48..8b1d190 100644 --- a/src/main/resources/openapi/qqq-v1/alerts.yaml +++ b/src/main/resources/openapi/qqq-v1/alerts.yaml @@ -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: diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java index e0b9c30..9821507 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java @@ -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/"); } diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java index b1e2356..82747a6 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java @@ -200,7 +200,7 @@ class AlertsApiHttpTest extends BaseAlertsTest start(); String base = "/qqq/v1/alerts"; Map 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> items = (List>) summary.get("items"); assertThat(items).hasSize(1); Map item = items.get(0); diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java index 8797128..e89296a 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java @@ -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. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/alerts/demo/AlertsDemoServer.java b/src/test/java/com/kingsrook/qbits/alerts/demo/AlertsDemoServer.java index 8d3727c..e0b3e10 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/demo/AlertsDemoServer.java +++ b/src/test/java/com/kingsrook/qbits/alerts/demo/AlertsDemoServer.java @@ -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)); diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java index fbcc92a..2959f5e 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java @@ -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 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 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")); + } } diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java index aa62476..3d2ad9b 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java @@ -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)); } diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java index cacae89..ab7dc8c 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java @@ -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); + } } diff --git a/src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java b/src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java new file mode 100644 index 0000000..24d351c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java @@ -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 otherSettings = new HashMap<>(all(AlertsUserPreference.TABLE_NAME).get(0).getValues()); + Map 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 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 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 row(String key, Map 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")))); + } +} diff --git a/src/test/resources/alerts-h2.sql b/src/test/resources/alerts-h2.sql index fe5d361..b0e99a9 100644 --- a/src/test/resources/alerts-h2.sql +++ b/src/test/resources/alerts-h2.sql @@ -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),