From 63bdc9313dde4f4bf6b75843aae8c2c61091c44e Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 19:35:29 -0500 Subject: [PATCH 1/2] feat(preferences): Notifications section in user Preferences Registers a core Preferences section 'notifications' backed by alerts_user_preference, alerts_notification_preference and the user's own alerts_subscription rows: pause, digest time and time zone, alert email and in-app switches, per-category in-app/email settings (security can't be muted) and per-subscription overrides. Adds in_app_min_severity (changeset qbit-alerts:21), honours it for in-app delivery, and skips non-security emails while paused. The inbox summary returns preferencesSection. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 5 +- docs/NOTIFICATIONS.md | 55 ++- .../qbits/alerts/AlertsQBitConfig.java | 22 ++ .../qbits/alerts/AlertsQBitProducer.java | 33 ++ .../kingsrook/qbits/alerts/api/AlertsApi.java | 2 + .../AlertsNotificationCustomizer.java | 12 +- .../metadata/AlertsTableMetaDataHelper.java | 2 +- .../model/AlertsNotificationPreference.java | 31 ++ .../alerts/model/AlertsPossibleValues.java | 2 +- .../notifications/NotificationEmailer.java | 77 +++-- .../notifications/NotificationService.java | 12 +- ...AlertsNotificationPreferencesProvider.java | 314 ++++++++++++++++++ .../qbit-alerts/alerts.changelog.sql | 4 + .../qbits/alerts/AlertsQBitProducerTest.java | 4 +- .../qbits/alerts/demo/AlertsDemoServer.java | 4 + .../NotificationEmailAndNoticesTest.java | 81 +++++ .../notifications/NotificationsTest.java | 35 +- .../NotificationPreferencesTest.java | 222 +++++++++++++ src/test/resources/alerts-h2.sql | 1 + 20 files changed, 871 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java create mode 100644 src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ada6c..84ba1e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project are documented here. The format follows Keep ## [0.1.0] - Unreleased ### Added +- Notifications user-preferences section over the existing settings and subscription tables, with user/client-channel audits, category 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:21`); SECURITY always arrives in-app. Notification email pauses now defer non-security immediate and digest delivery without losing older pending items. - Optional external channels: - Slack (an Incoming Webhook, Block Kit) - Microsoft Teams (a Workflows webhook, an Adaptive Card) diff --git a/README.md b/README.md index 0a1cfe7..f0e28e3 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,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 settings, and the user's own subscriptions. It uses existing tables and FIELD audits; SECURITY cannot be muted in-app. Requires core's user-preferences contract (currently the local `4.1.0-PREFS-SNAPSHOT`); 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. @@ -99,7 +100,7 @@ Once the RC ships, producers call `QAlerts.raise(...)` from core and need no dep | 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 `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 26df034..13832fc 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -98,10 +98,59 @@ 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). 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 and by email.” 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), + and Email from. Missing category rows use table defaults; edits upsert only that user's category. + 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" } }], + "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 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 severity settings but bypasses the pause. Existing alert routing continues to use the same pause +and alert channel switches. + +Schema migration: `qbit-alerts:21` adds nullable `in_app_min_severity VARCHAR(16)`. +Until the core contract is released, build with the locally installed snapshot: +`./mvnw -o -q verify -Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT`. +The POM's default QQQ version stays unchanged until the shipping RC is selected. + ## 4. Audiences `AlertsAudienceResolver` resolves an audience to user ids at send time: @@ -198,7 +247,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, 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}` | @@ -208,6 +257,8 @@ 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. +`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/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java index ebd6015..2df4613 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java @@ -95,6 +95,7 @@ public class AlertsQBitConfig implements QBitConfig private String alertmanagerIngestTokenSecretRef; + private Boolean preferencesSectionEnabled = true; private Boolean registerRoutes = true; private Boolean synchronousSink = false; @@ -793,4 +794,25 @@ public class AlertsQBitConfig implements QBitConfig this.jobRunLinkPattern = jobRunLinkPattern; return (this); } + + + + /******************************************************************************* + ** 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); + } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java index fc45f4b..b7d406a 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 "roles or groups"; - case PERMISSION -> "permission"; - default -> "user ids"; - } + " in Audience Members.")); + { + case ROLES -> "roles or groups"; + case PERMISSION -> "permission"; + default -> "user ids"; + } + " in Audience Members.")); } } } @@ -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 10d167f..c67a9de 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java @@ -449,7 +449,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"))) + .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "inAppMinSeverity", "emailMode", "emailMinSeverity"))) .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 f01ffa3..7430cbe 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationPreference.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationPreference.java @@ -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; @@ -299,4 +302,32 @@ public class AlertsNotificationPreference extends QRecordEntity this.modifyDate = modifyDate; return (this); } + + + /******************************************************************************* + ** getInAppMinSeverity + *******************************************************************************/ + public String getInAppMinSeverity() + { + return (this.inAppMinSeverity); + } + + + /******************************************************************************* + ** setInAppMinSeverity + *******************************************************************************/ + public void setInAppMinSeverity(String inAppMinSeverity) + { + this.inAppMinSeverity = inAppMinSeverity; + } + + + /******************************************************************************* + ** withInAppMinSeverity + *******************************************************************************/ + public AlertsNotificationPreference withInAppMinSeverity(String inAppMinSeverity) + { + this.inAppMinSeverity = inAppMinSeverity; + return (this); + } } 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 f4600cb..85dbfb7 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java @@ -312,7 +312,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 f874d10..0ec40d9 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java @@ -22,7 +22,6 @@ import java.time.DateTimeException; import java.time.Instant; import java.time.ZoneId; import java.time.ZoneOffset; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -49,6 +48,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; @@ -129,45 +129,60 @@ 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) { - boolean ok = send(address(settings.get(userId)), notification.getValueString("title"), List.of(notification)); - mark(recipient, ok ? EmailStatus.SENT : EmailStatus.FAILED, ok ? now : null); - if(ok) + 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"))) { - sent++; + mark(recipient, EmailStatus.SKIPPED, null); + skipped++; } - else + 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)) { - failed++; + // Leave the recipient pending so a later run can deliver it. + skipped++; + } + else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode"))) + { + boolean ok = send(address(settings.get(userId)), notification.getValueString("title"), List.of(notification)); + mark(recipient, ok ? EmailStatus.SENT : EmailStatus.FAILED, ok ? now : null); + if(ok) + { + sent++; + } + else + { + failed++; + } + } + else if(recipient.getValue("readAt") == null) + { + digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification}); } - } - 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 67bc1bb..a8391c8 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java @@ -170,7 +170,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()) @@ -530,16 +530,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/preferences/AlertsNotificationPreferencesProvider.java b/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java new file mode 100644 index 0000000..4f10578 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java @@ -0,0 +1,314 @@ +/* + * 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.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.qbits.alerts.spi.NotificationCategory; +import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer; +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) + { + 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)); + UserPreferencesMetaData.ofOrWithNew(instance).withSection(new UserPreferenceSectionMetaData() + .withName(SECTION_NAME).withLabel("Notifications").withIcon(new QIcon("notifications")).withOrder(20) + .withDescription("What reaches you in the app and by email.").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(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"))) + .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")))); + } + + + + /******************************************************************************* + ** 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)); + } + 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", null); + 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" : 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 9dfaec9..7772f9c 100644 --- a/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql @@ -427,3 +427,7 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference ( CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id), CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category) ); + +-- changeset qbit-alerts:21 +-- 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/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java index 7a18230..804df6a 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java @@ -182,7 +182,9 @@ class AlertsQBitProducerTest extends BaseAlertsTest QTableMetaData table = qInstance.getTable(tableName); for(QFieldMetaData field : table.getFields().values()) { - assertThat(body).as(tableName + "." + field.getName()).contains(" " + QInstanceEnricher.inferBackendName(field.getName()) + " "); + String column = QInstanceEnricher.inferBackendName(field.getName()); + assertThat(body.contains(" " + column + " ") || changelog.contains("ALTER TABLE " + tableName + " ADD COLUMN " + column + " ")) + .as(tableName + "." + field.getName() + " is created or added by a migration").isTrue(); } } } 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 33e9fde..3f46be9 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 1411c77..26e5b53 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java @@ -18,12 +18,15 @@ 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; @@ -34,10 +37,12 @@ import com.kingsrook.qbits.alerts.spi.NotificationAudience; import com.kingsrook.qbits.alerts.spi.NotificationCategory; import com.kingsrook.qbits.alerts.spi.NotificationSeverity; import com.kingsrook.qbits.alerts.spi.NotificationSignal; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; 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; @@ -217,4 +222,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/NotificationsTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java index 108c214..6c00e33 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java @@ -329,7 +329,7 @@ class NotificationsTest extends BaseAlertsTest assertThat(NotificationService.linkUrl(AlertLink.toUrl("https://example.com/x", "x"))).isEqualTo("https://example.com/x"); assertThat(NotificationService.linkUrl(AlertLink.toPath("/app/alerts", "x"))).isEqualTo("/app/alerts"); assertThat(NotificationService.safeLink("//evil.example.com")).isNull(); - for(String bad : List.of("/\\evil.com", "\\\\evil.com", "javascript:alert(1)", "data:text/html,x", "http://x.example.com", "https:///nohost", "/a b", "/a b", "https://")) + for(String bad : List.of("/\\evil.com", "\\\\evil.com", "javascript:alert(1)", "data:text/html,x", "http://x.example.com", "https:///nohost", "/a b", "/a\tb", "https://")) { assertThat(NotificationService.safeLink(bad)).as(bad).isNull(); } @@ -449,4 +449,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..d124c63 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java @@ -0,0 +1,222 @@ +/* + * 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.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", null).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", null).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(); + } + + + + /******************************************************************************* + ** 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); + 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 b4f6813..091e8bc 100644 --- a/src/test/resources/alerts-h2.sql +++ b/src/test/resources/alerts-h2.sql @@ -366,6 +366,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), create_date TIMESTAMP, -- 2.54.0 From 3e5ab03b41337f63daf0b7287001ada1176e81de Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 20:38:20 -0500 Subject: [PATCH 2/2] fix(preferences): show an unset in-app threshold as Info (everything) Co-Authored-By: Claude Opus 5.5 --- .../AlertsNotificationPreferencesProvider.java | 11 ++++++++++- .../preferences/NotificationPreferencesTest.java | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java b/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java index 4f10578..3f07666 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java +++ b/src/main/java/com/kingsrook/qbits/alerts/preferences/AlertsNotificationPreferencesProvider.java @@ -32,6 +32,7 @@ import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; import com.kingsrook.qbits.alerts.model.AlertsSubscription; import com.kingsrook.qbits.alerts.model.AlertsUserPreference; import com.kingsrook.qbits.alerts.spi.NotificationCategory; +import com.kingsrook.qbits.alerts.spi.NotificationSeverity; import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; @@ -153,13 +154,21 @@ public class AlertsNotificationPreferencesProvider implements UserPreferenceProv { 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()); + } 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", null); + values.put("inAppMinSeverity", NotificationSeverity.INFO.name()); row.withReadOnlyFieldNames(List.of("inApp", "inAppMinSeverity")).withReadOnlyReason(SECURITY_REASON); } categories.add(row); diff --git a/src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java b/src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java index d124c63..ff223fa 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/preferences/NotificationPreferencesTest.java @@ -81,7 +81,7 @@ class NotificationPreferencesTest extends BaseAlertsTest 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", null).containsEntry("emailMode", "OFF").containsEntry("emailMinSeverity", "WARNING"); + 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", @@ -102,7 +102,7 @@ class NotificationPreferencesTest extends BaseAlertsTest 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", null).containsEntry("inApp", false)); + .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"); -- 2.54.0