From 63bdc9313dde4f4bf6b75843aae8c2c61091c44e Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 19:35:29 -0500 Subject: [PATCH 1/6] 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/6] 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 From 684a2157b8c20735d5efba42c929cc8a81977c0c Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 16:52:47 -0500 Subject: [PATCH 3/6] fix(routing): use a group's owner team and a user's default channels ownerTeamId on a group and defaultChannelIds on a user were stored but never read. An alert that no subscription matches (WARNING and above) now goes to the owner team of its group (or the nearest parent with one); default channels decide where a user is reached when a subscription names none. Types, groups and default monitors also sync on the first bell request. Co-Authored-By: Claude Opus 5.5 --- .../kingsrook/qbits/alerts/api/AlertsApi.java | 5 +- .../qbits/alerts/engine/AlertRouter.java | 117 ++++++++- .../qbits/alerts/engine/AlertsWiringTest.java | 223 ++++++++++++++++++ 3 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/kingsrook/qbits/alerts/engine/AlertsWiringTest.java diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java index 8718310..60065af 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java @@ -102,7 +102,9 @@ public class AlertsApi /******************************************************************************* - ** Check alerts.view and set up the session (keys, settings). + ** Check alerts.view and set up the session (keys, settings, and the first-visit + ** sync of alert types, groups and default monitors, so those tables are never + ** empty on a running install). *******************************************************************************/ public String requireViewer() throws QException { @@ -113,6 +115,7 @@ public class AlertsApi } AlertsSecurity.ensureSessionKeys(runtime.getQInstance(), session); AlertsPreferences.ensure(runtime, session); + runtime.getTypeSync().syncIfNeeded(); return (AlertsSecurity.userId(session)); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java index 0e40ca4..e85efe3 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java @@ -415,11 +415,14 @@ public class AlertRouter ** Every destination for an alert: subscribed users (directly or through a ** team) with their in-app and email channels (or the subscription's ** chosen channels), subscribed channels, and readable record followers. + ** An alert that no subscription matches (at WARNING or above) goes to the + ** members of its group's owner team, so an unowned problem is not silent. *******************************************************************************/ List destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) throws QException { Map> groupNames = new HashMap<>(); Map destinations = new LinkedHashMap<>(); + boolean subscribed = false; for(QRecord subscription : AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("enabled", true))) { AlertSeverity min = AlertSeverity.fromLabel(subscription.getValueString("minSeverity"), AlertSeverity.WARNING); @@ -429,6 +432,7 @@ public class AlertRouter continue; } + subscribed = true; Integer subscriptionId = subscription.getValueInteger("id"); String minName = subscription.getValueString("minSeverity"); String subscriberType = subscription.getValueString("subscriberType"); @@ -465,6 +469,10 @@ public class AlertRouter } } } + if(!subscribed && severity.isAtLeast(AlertSeverity.WARNING)) + { + addOwnerTeam(destinations, target); + } for(String recordKey : kind == DeliveryKind.REMINDER ? java.util.Set.of() : target.recordKeys()) { String[] parts = recordKey.split(":", 2); @@ -488,6 +496,60 @@ public class AlertRouter + /******************************************************************************* + ** The fallback for an alert nobody subscribed to: the members of the owner + ** team of its group (or of the nearest parent group that has one), through + ** their normal channels and the usual suppression checks. + *******************************************************************************/ + private void addOwnerTeam(Map destinations, AlertTarget target) throws QException + { + Integer teamId = ownerTeamId(target.groupId()); + if(teamId == null) + { + return; + } + for(String userId : teamMembers(teamId)) + { + if(!StringUtils.hasContent(userId)) + { + continue; + } + QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); + for(QRecord channel : userChannels(null, preference)) + { + destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference, "IMMEDIATE")); + } + } + } + + + + /******************************************************************************* + ** The owner team of a group, or of the nearest parent that has one (null: + ** none). + *******************************************************************************/ + Integer ownerTeamId(Integer groupId) throws QException + { + Integer current = groupId; + int depth = 0; + while(current != null && depth++ < 10) + { + QRecord group = AlertsDb.byId(AlertsGroup.TABLE_NAME, current); + if(group == null) + { + return (null); + } + if(group.getValueInteger("ownerTeamId") != null) + { + return (group.getValueInteger("ownerTeamId")); + } + current = group.getValueInteger("parentGroupId"); + } + return (null); + } + + + /******************************************************************************* ** Explicit members plus the team's optional host-managed source. Missing ** or failed resolvers contribute nobody and leave an actionable log entry. @@ -544,8 +606,9 @@ public class AlertRouter /******************************************************************************* - ** A user's channels for a subscription: the ones it names, else in-app - ** (unless turned off) and email (when enabled in their settings). + ** A user's channels for a subscription: the ones it names, else the user's + ** own default channels (My Alert Settings), else in-app (unless turned off) + ** and email (when enabled in their settings). *******************************************************************************/ private List userChannels(QRecord subscription, QRecord preference) throws QException { @@ -553,18 +616,17 @@ public class AlertRouter String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds"); if(StringUtils.hasContent(overrides)) { - for(String id : overrides.split("[,\\s]+")) + return (channelsById(overrides)); + } + + String defaults = preference == null ? null : preference.getValueString("defaultChannelIds"); + if(StringUtils.hasContent(defaults)) + { + channels.addAll(channelsById(defaults)); + if(!channels.isEmpty()) { - if(!id.isBlank()) - { - QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, Integer.valueOf(id.trim())); - if(channel != null) - { - channels.add(channel); - } - } + return (channels); } - return (channels); } if(preference == null || !Boolean.FALSE.equals(preference.getValueBoolean("inAppEnabled"))) @@ -580,6 +642,37 @@ public class AlertRouter + /******************************************************************************* + ** The channel rows for a list of ids ("3, 5"); ids that are not numbers or + ** no longer exist are skipped. + *******************************************************************************/ + private List channelsById(String ids) throws QException + { + List channels = new ArrayList<>(); + for(String id : ids.split("[,\\s]+")) + { + if(id.isBlank()) + { + continue; + } + try + { + QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, Integer.valueOf(id.trim())); + if(channel != null) + { + channels.add(channel); + } + } + catch(NumberFormatException e) + { + LOG.debug("Ignoring a channel id that is not a number", logPair("id", id)); + } + } + return (channels); + } + + + /******************************************************************************* ** Whether a subscription or mute scope covers the target. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsWiringTest.java b/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsWiringTest.java new file mode 100644 index 0000000..4bd8343 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsWiringTest.java @@ -0,0 +1,223 @@ +/* + * 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.engine; + + +import java.util.List; +import com.kingsrook.qbits.alerts.AlertsQBitConfig; +import com.kingsrook.qbits.alerts.BaseAlertsTest; +import com.kingsrook.qbits.alerts.TestSteps; +import com.kingsrook.qbits.alerts.delivery.DeliveryResult; +import com.kingsrook.qbits.alerts.model.AlertsChannel; +import com.kingsrook.qbits.alerts.model.AlertsDelivery; +import com.kingsrook.qbits.alerts.model.AlertsGroup; +import com.kingsrook.qbits.alerts.model.AlertsSubscription; +import com.kingsrook.qbits.alerts.model.AlertsTeam; +import com.kingsrook.qbits.alerts.model.AlertsTeamMember; +import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData; +import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData; +import com.kingsrook.qqq.backend.core.exceptions.QException; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Two settings that used to be stored but never read, now enforced by the + ** router: a group's owner team (the fallback audience for an alert nobody + ** subscribed to) and a user's default channels. + *******************************************************************************/ +class AlertsWiringTest extends BaseAlertsTest +{ + static final String TYPE = "test.system.down"; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected void customizeConfig(AlertsQBitConfig config) + { + config.withChannelType(new QCodeReference(TestSteps.RecordingChannel.class)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected void customizeInstance(QInstance qInstance) + { + AlertsInstanceMetaData.ofOrWithNew(qInstance) + .withAlertType(new QAlertTypeMetaData().withName(TYPE).withLabel("System down").withGroupName("infrastructure").withDefaultSeverity(AlertSeverity.CRITICAL)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @BeforeEach + void reset() + { + TestSteps.SENT.clear(); + TestSteps.nextOutcome = DeliveryResult.Outcome.SENT; + } + + + + /******************************************************************************* + ** A team with these members; returns its id. + *******************************************************************************/ + private Integer team(String name, String... members) throws QException + { + Integer teamId = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", name).withValue("label", name)).getValueInteger("id")); + for(String member : members) + { + runtime.asSystem(() -> AlertsDb.insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", teamId).withValue("userId", member))); + } + return (teamId); + } + + + + /******************************************************************************* + ** The group row for the test type (synced on first use). + *******************************************************************************/ + private QRecord group(String name) throws QException + { + runtime.getTypeSync().syncIfNeeded(); + return (runtime.asSystem(() -> AlertsDb.first(AlertsGroup.TABLE_NAME, AlertsDb.eq("name", name)))); + } + + + + /******************************************************************************* + ** The users who were sent an alert, sorted. + *******************************************************************************/ + private List recipients() throws QException + { + return (all(AlertsDelivery.TABLE_NAME).stream().map(n -> n.getValueString("recipientUserId")).distinct().sorted().toList()); + } + + + + /******************************************************************************* + ** A user subscription to everything at WARNING and worse. + *******************************************************************************/ + private void subscribe(String userId) throws QException + { + runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", userId) + .withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("events", "RAISED").withValue("deliveryMode", "IMMEDIATE").withValue("enabled", true))); + } + + + + /******************************************************************************* + ** An alert nobody subscribed to goes to its group's owner team; once a + ** subscription matches, the owner team is not added. + *******************************************************************************/ + @Test + void ownerTeamIsTheFallbackAudience() throws Exception + { + Integer teamId = team("infra", "owner-1", "owner-2"); + QRecord group = group("infrastructure"); + runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId))); + + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("a").withTitle("Down")); + assertThat(recipients()).containsExactly("owner-1", "owner-2"); + assertThat(all(AlertsDelivery.TABLE_NAME)).allMatch(n -> "SENT".equals(n.getValueString("status"))); + + List before = all(AlertsDelivery.TABLE_NAME).stream().map(n -> n.getValue("id")).toList(); + subscribe("subscriber"); + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("b").withTitle("Down again")); + List second = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !before.contains(n.getValue("id"))).toList(); + assertThat(second).extracting(n -> n.getValueString("recipientUserId")).containsExactly("subscriber"); + } + + + + /******************************************************************************* + ** The nearest parent group with an owner team is used; a group with no + ** owner anywhere leaves the alert unrouted, as before. + *******************************************************************************/ + @Test + void ownerTeamComesFromTheNearestParentGroup() throws Exception + { + QRecord group = group("infrastructure"); + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("none").withTitle("Down")); + assertThat(recipients()).as("no owner team anywhere: nobody is notified").isEmpty(); + + Integer teamId = team("platform", "parent-owner"); + Integer parent = runtime.asSystem(() -> AlertsDb.insert(AlertsGroup.TABLE_NAME, new QRecord().withValue("name", "platform").withValue("label", "Platform") + .withValue("ownerTeamId", teamId)).getValueInteger("id")); + runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("parentGroupId", parent))); + + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("parent").withTitle("Down")); + assertThat(recipients()).containsExactly("parent-owner"); + } + + + + /******************************************************************************* + ** The owner fallback is for alerts that matter: an INFO alert is not sent + ** to the owner team. + *******************************************************************************/ + @Test + void ownerTeamIsNotToldAboutInfo() throws Exception + { + Integer teamId = team("infra", "owner-1"); + QRecord group = group("infrastructure"); + runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId))); + + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("info").withSeverity(AlertSeverity.INFO).withTitle("FYI")); + assertThat(recipients()).isEmpty(); + } + + + + /******************************************************************************* + ** A user's default channels replace the built-in in-app and email choice + ** for subscriptions that do not name channels. + *******************************************************************************/ + @Test + void defaultChannelsDecideWhereAUserIsReached() throws Exception + { + QRecord chat = runtime.asSystem(() -> AlertsDb.insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "chat").withValue("label", "Chat") + .withValue("channelType", "SLACK").withValue("enabled", true).withValue("detailLevel", "STANDARD").withValue("rateLimitPerHour", 60) + .withValue("healthStatus", "HEALTHY").withValue("consecutiveFailures", 0))); + subscribe("u1"); + + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("plain").withTitle("Down")); + List plain = all(AlertsDelivery.TABLE_NAME); + assertThat(plain).singleElement().matches(n -> !String.valueOf(chat.getValue("id")).equals(String.valueOf(n.getValue("channelId"))), "went to the built-in in-app channel"); + + runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "u1").withValue("userLabel", "One") + .withValue("inAppEnabled", true).withValue("emailEnabled", false).withValue("defaultChannelIds", "not-a-number, " + chat.getValue("id") + ", 99999"))); + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("chosen").withTitle("Down again")); + List chosen = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !n.getValue("id").equals(plain.get(0).getValue("id"))).toList(); + assertThat(chosen).singleElement().matches(n -> String.valueOf(chat.getValue("id")).equals(String.valueOf(n.getValue("channelId"))), "went to the user's default channel"); + } +} -- 2.54.0 From 4077e8a725dc904ea81ef2d1cf561afddcd5290d Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 16:52:48 -0500 Subject: [PATCH 4/6] feat: starter defaults, setup card and table help so no config table starts empty Adds alerts-defaults.changelog.sql (in-app and email channels, alerts and other groups; insert-only, guarded, tested through real Liquibase), a help bundle for the app and every table, a Setup card on the home, guardrail tests, and docs. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 + README.md | 14 + UPGRADE.md | 16 + pom.xml | 7 + .../qbits/alerts/AlertsQBitProducer.java | 2 + .../qbits/alerts/home/AlertsHome.java | 30 ++ .../qbits/alerts/home/AlertsSetupStatus.java | 179 ++++++++++ .../qbits/alerts/metadata/AlertsHelp.java | 127 +++++++ .../AlertsHomeWidgetsMetaDataProducer.java | 6 +- .../qbit-alerts/alerts-defaults.changelog.sql | 58 ++++ src/main/resources/help/qbit-alerts.yaml | 108 ++++++ .../alerts/AlertsDefaultsChangelogTest.java | 325 ++++++++++++++++++ .../alerts/AlertsTablesGuardrailTest.java | 204 +++++++++++ .../qbits/alerts/home/AlertsSetupTest.java | 105 ++++++ 14 files changed, 1190 insertions(+), 2 deletions(-) create mode 100644 UPGRADE.md create mode 100644 src/main/java/com/kingsrook/qbits/alerts/home/AlertsSetupStatus.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHelp.java create mode 100644 src/main/resources/db/liquibase/qbit-alerts/alerts-defaults.changelog.sql create mode 100644 src/main/resources/help/qbit-alerts.yaml create mode 100644 src/test/java/com/kingsrook/qbits/alerts/AlertsDefaultsChangelogTest.java create mode 100644 src/test/java/com/kingsrook/qbits/alerts/AlertsTablesGuardrailTest.java create mode 100644 src/test/java/com/kingsrook/qbits/alerts/home/AlertsSetupTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 94329d7..e019898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ All notable changes to this project are documented here. The format follows Keep ### Fixed - Record-view child lists (e.g. a notification's Recipients) failed with "Error in JSON Serialization": the child table's meta-data includes the qbit config, and `AlertsQBitConfig.getRuntime()` points at a runtime that points back at the config, so Jackson recursed to its 1000-level nesting limit. `getRuntime()` and `getAudienceResolver()` are now `@JsonIgnore`, as in the other qbits' configs. A regression test renders and serializes every child-list widget. +### Added +- Starter defaults changelog `alerts-defaults.changelog.sql` (insert-only, guarded): the `in-app` and `email` channels and the `alerts` and `other` groups, equal to what the runtime creates on first use. Tested through real Liquibase on H2. +- Help for the Alerts app and every `alerts_*` table (`help/qbit-alerts.yaml`, STAND-IN core#1198): what the table is for and how its rows get created, shown when it is empty. +- A Setup card first on the Alerts home (alerts admins): each configuration table, its state and the next step. +- `AlertsTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help. + +### Fixed +- A group's `ownerTeamId` was stored but never read. An alert that no subscription matches (WARNING and above) now goes to the owner team of its group, or of the nearest parent group that has one. +- A user's `defaultChannelIds` was stored but never read. It now decides the channels for subscriptions that name none. +- Alert types, groups and default monitors also sync on the first bell request, so those tables are not empty on a running install. + ### Changed - Keep reminders and escalations immediate for digest subscribers while applying pause and quiet hours to their push channels. Preserve team audience resolution, mute scopes, both scheduled jobs and changesets 21-27. - 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. diff --git a/README.md b/README.md index dd386f6..ce1525b 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,20 @@ new AlertsQBitProducer() 4. **Optional: ESB and Quick Search.** `AlertsIntegrations` names the main tables and searchable fields for the host's ESB and Quick Search configuration. +## Starter defaults and setup + +After the schema, include the starter defaults. They are insert-only (a row is added only when its name is absent, so edits survive and nothing is ever updated), hold no secrets, and are guarded so a host without the tables skips them: + +```yaml +- include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql } +``` + +They create the `in-app` and `email` channels and the `alerts` and `other` groups, exactly the rows the runtime would otherwise create on first use. Everything else starts empty for a reason the table's own help gives: alert types and default monitors are declared in code by the installed qbits and synced on the first visit; teams, escalation policies and subscriptions name your people; the rest is runtime data and user content. + +Every table has help that says what it is for and **how its rows get created**, shown on the table screen and when it is empty (`help/qbit-alerts.yaml`). Alerts admins also get a **Setup** card first on the home that lists each configuration table, whether it is set up, and the next step. + +Routing details worth knowing: an alert that no subscription matches goes to the owner team of its group (or the nearest parent group that has one), at WARNING and above; a person's **Default Channels** (My Alert Settings) are used for subscriptions that name no channels. + ## Raising alerts Use the producer SPI from `com.kingsrook.qqq.backend.core.alerts`, released in QQQ 4.1.0-RC.5 ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)). `AlertsQBitProducer` registers the qbit's sink through core's `AlertsInstanceMetaData`, so host producers need only core: diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..fdad7e6 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,16 @@ +# Upgrading + +## Unreleased: starter defaults, setup card, table help + +1. **Add the defaults changelog** after `alerts.changelog.sql` in the host's master changelog: + + ```yaml + - include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql } + ``` + + It is insert-only and guarded (row exists by name: no change; table missing: skipped), so it is safe on an installed host. It creates the `in-app` and `email` channels and the `alerts` and `other` groups only, the same rows the runtime creates on first use. +2. **Behavior changes to know about.** + - A group's **Owner Team** is now used: an alert at WARNING or above that no subscription matches is sent to that team's members. Hosts that never set an owner team see no change. + - **Default Channels** on My Alert Settings is now used for subscriptions that name no channels. A non-empty list of channel ids replaces the built-in in-app and email choice for that person; ids that do not exist are ignored. Hosts that never set it see no change. + - Alert types, groups and default monitors now also sync on the first request of the bell, not only on the home or the first signal. +3. Nothing else is required. The help bundle and the Setup card need no configuration. diff --git a/pom.xml b/pom.xml index c534691..b67e92d 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,13 @@ test + + + org.liquibase + liquibase-core + 4.33.0 + test + org.junit.jupiter junit-jupiter diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java index 54ba329..f15f8da 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java @@ -24,6 +24,7 @@ import com.kingsrook.qbits.alerts.engine.AlertsProcessListener; import com.kingsrook.qbits.alerts.engine.AlertsSinkAdapter; import com.kingsrook.qbits.alerts.engine.MonitorEvaluator; import com.kingsrook.qbits.alerts.engine.QbitAlertsSink; +import com.kingsrook.qbits.alerts.metadata.AlertsHelp; import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper; import com.kingsrook.qbits.alerts.model.AlertsChannel; import com.kingsrook.qbits.alerts.model.AlertsMonitor; @@ -173,6 +174,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer setup(runtime, now); case KIND_SEVERITY -> severity(runtime, now); case KIND_MINE -> mine(now); case KIND_ACTION -> needsAction(now); @@ -136,6 +138,34 @@ public final class AlertsHome + /******************************************************************************* + ** Setup (alerts admins): each configuration table, whether it is set up, and + ** the next step, linked to its table. + *******************************************************************************/ + static QWidgetData setup(AlertsRuntime runtime, Instant now) throws QException + { + List items = AlertsSetupStatus.load(runtime, now); + long todo = items.stream().filter(item -> item.state() == AlertsSetupStatus.State.TODO).count(); + StringBuilder html = new StringBuilder(HomeHtml.note(todo == 0 ? "Everything Alerts needs is set up." : HomeHtml.plural(todo, "item") + (todo == 1 ? " needs" : " need") + " attention.")); + for(AlertsSetupStatus.Item item : items) + { + String badge = switch(item.state()) + { + case OK -> HomeHtml.badge("Set up", HomeHtml.OK_COLOR); + case TODO -> HomeHtml.badge("Needs setup", HomeHtml.BAD_COLOR); + case DEFAULT -> HomeHtml.badge("Using defaults", HomeHtml.WARN_COLOR); + case OPTIONAL -> HomeHtml.badge("Optional", HomeHtml.OFF_COLOR); + }; + String label = item.tableName() == null ? HomeHtml.escape(item.label()) : HomeHtml.link("/app/" + item.tableName(), item.label()); + html.append("
") + .append(HomeHtml.card("" + label + " " + badge, HomeHtml.escape(item.detail()), item.nextStep() == null ? null : "Next: " + HomeHtml.escape(item.nextStep()))) + .append("
"); + } + return (new RawHTML(null, html.toString())); + } + + + /******************************************************************************* ** Open by severity: four tiles, each with the unacknowledged count and the ** oldest age, linking to the filtered list. diff --git a/src/main/java/com/kingsrook/qbits/alerts/home/AlertsSetupStatus.java b/src/main/java/com/kingsrook/qbits/alerts/home/AlertsSetupStatus.java new file mode 100644 index 0000000..4d8f7f5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/home/AlertsSetupStatus.java @@ -0,0 +1,179 @@ +/* + * 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.home; + + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.model.AlertsChannel; +import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy; +import com.kingsrook.qbits.alerts.model.AlertsEscalationStep; +import com.kingsrook.qbits.alerts.model.AlertsGroup; +import com.kingsrook.qbits.alerts.model.AlertsMonitor; +import com.kingsrook.qbits.alerts.model.AlertsMute; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus; +import com.kingsrook.qbits.alerts.model.AlertsSubscription; +import com.kingsrook.qbits.alerts.model.AlertsTeam; +import com.kingsrook.qbits.alerts.model.AlertsType; +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.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + + +/******************************************************************************* + ** The Setup card on the Alerts home: each configuration table, whether it is + ** set up, and the next step. Reads only the qbit's own small configuration + ** tables, in the signed-in administrator's session. + *******************************************************************************/ +public final class AlertsSetupStatus +{ + /******************************************************************************* + ** OK: working. TODO: needs attention. DEFAULT: working on built-in defaults. + ** OPTIONAL: a feature that is off until set up. + *******************************************************************************/ + public enum State + { + OK, TODO, DEFAULT, OPTIONAL + } + + + + /******************************************************************************* + ** One line of the card. tableName is null for an item with no table. + *******************************************************************************/ + public record Item(String label, String tableName, State state, String detail, String nextStep) + { + } + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AlertsSetupStatus() + { + } + + + + /******************************************************************************* + ** Build the items. + *******************************************************************************/ + public static List load(AlertsRuntime runtime, Instant now) throws QException + { + List items = new ArrayList<>(); + + ///////////// + // channels // + ///////////// + List channels = AlertsDb.query(AlertsChannel.TABLE_NAME, new QQueryFilter()); + long external = channels.stream().filter(c -> !List.of(ChannelType.IN_APP.name(), ChannelType.EMAIL.name()).contains(c.getValueString("channelType"))).count(); + long unhealthy = channels.stream().filter(c -> HealthStatus.UNHEALTHY.name().equals(c.getValueString("healthStatus"))).count(); + if(channels.isEmpty()) + { + items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.DEFAULT, "No channel rows yet: the bell and email channels are created when the first alert is routed.", + "Include alerts-defaults.changelog.sql to create them now, then add Slack, Teams or a webhook if you want them.")); + } + else if(unhealthy > 0) + { + items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.TODO, unhealthy + (unhealthy == 1 ? " channel is" : " channels are") + " failing.", + "Open it, check its settings and secret reference, then Send test notification.")); + } + else + { + items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.OK, plural(channels.size(), "channel") + (external == 0 ? ": the bell and email only." : ", " + external + " of them outside the bell and email."), + external == 0 ? "Optional: add Slack, Teams, a webhook or PagerDuty." : null)); + } + + /////////////////////// + // types and monitors // + /////////////////////// + int types = AlertsDb.count(AlertsType.TABLE_NAME, AlertsDb.eq("installed", true)); + items.add(types == 0 + ? new Item("Alert types", AlertsType.TABLE_NAME, State.TODO, "No installed alert type.", "Types are declared in code by the installed qbits (QAlertTypeMetaData); open the Alerts home once so they sync.") + : new Item("Alert types", AlertsType.TABLE_NAME, State.OK, plural(types, "type") + " declared by the installed qbits.", null)); + + int monitors = AlertsDb.count(AlertsMonitor.TABLE_NAME, AlertsDb.eq("enabled", true)); + items.add(monitors == 0 + ? new Item("Monitors", AlertsMonitor.TABLE_NAME, State.OPTIONAL, "No monitor is turned on: alerts come only from code that raises them.", "On an alert type, choose Create monitor, or add one with New.") + : new Item("Monitors", AlertsMonitor.TABLE_NAME, State.OK, plural(monitors, "monitor") + " turned on.", null)); + + ///////////////////////////////// + // groups, teams and owner teams // + ///////////////////////////////// + List groups = AlertsDb.query(AlertsGroup.TABLE_NAME, new QQueryFilter()); + long ownedGroups = groups.stream().filter(g -> g.getValueInteger("ownerTeamId") != null).count(); + items.add(ownedGroups == 0 + ? new Item("Groups", AlertsGroup.TABLE_NAME, State.OPTIONAL, plural(groups.size(), "group") + ", none with an owner team.", "Give a group an Owner Team so alerts that nobody subscribed to still reach someone.") + : new Item("Groups", AlertsGroup.TABLE_NAME, State.OK, plural(groups.size(), "group") + ", " + ownedGroups + " with an owner team.", null)); + + int teams = AlertsDb.count(AlertsTeam.TABLE_NAME, new QQueryFilter()); + items.add(teams == 0 + ? new Item("Teams", AlertsTeam.TABLE_NAME, State.OPTIONAL, "No team.", "Add a team and its members to subscribe people together or to own a group.") + : new Item("Teams", AlertsTeam.TABLE_NAME, State.OK, plural(teams, "team") + ".", null)); + + ///////////////////////////////////////////////////// + // subscriptions: nobody hears anything without one // + ///////////////////////////////////////////////////// + int subscriptions = AlertsDb.count(AlertsSubscription.TABLE_NAME, AlertsDb.eq("enabled", true)); + if(subscriptions > 0) + { + items.add(new Item("Subscriptions", AlertsSubscription.TABLE_NAME, State.OK, plural(subscriptions, "subscription") + " turned on.", null)); + } + else + { + items.add(new Item("Subscriptions", AlertsSubscription.TABLE_NAME, ownedGroups > 0 ? State.DEFAULT : State.TODO, "No subscription: " + (ownedGroups > 0 ? "alerts reach only the owner teams of their groups." : "alerts reach nobody."), + "Use Subscribe me on a type, group or monitor, or add one with New. An alerts admin gets an in-app subscription to errors on first visit.")); + } + + /////////////////////////////////////// + // escalation, maintenance, scheduler // + /////////////////////////////////////// + int policies = AlertsDb.count(AlertsEscalationPolicy.TABLE_NAME, AlertsDb.eq("enabled", true)); + int steps = AlertsDb.count(AlertsEscalationStep.TABLE_NAME, new QQueryFilter()); + items.add(policies == 0 + ? new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.OPTIONAL, "None: alerts are reminded, not escalated.", "Add a policy and its steps, then pick it on a type or group.") + : steps == 0 + ? new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.TODO, plural(policies, "policy") + ", but no steps, so nothing escalates.", "Add steps to the policy.") + : new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.OK, plural(policies, "policy") + ", " + plural(steps, "step") + ".", null)); + + int mutes = AlertsDb.count(AlertsMute.TABLE_NAME, AlertsDb.eq("enabled", true).withCriteria("endsAt", QCriteriaOperator.GREATER_THAN, now)); + items.add(new Item("Mutes and maintenance", AlertsMute.TABLE_NAME, State.OPTIONAL, mutes == 0 ? "Nothing is muted." : plural(mutes, "mute") + " active or scheduled.", + mutes == 0 ? "Add a window before planned maintenance." : null)); + + items.add(runtime.getConfig().getSchedulerName() == null + ? new Item("Scheduler", null, State.TODO, "No scheduler is configured, so monitors, delivery, digests and escalation only run when started by hand.", "Set schedulerName in the Alerts config.") + : new Item("Scheduler", null, State.OK, "Monitors, delivery, digests and escalation run on the " + runtime.getConfig().getSchedulerName() + " scheduler.", null)); + return (items); + } + + + + /******************************************************************************* + ** "1 channel", "2 channels". + *******************************************************************************/ + private static String plural(long count, String noun) + { + return (count + " " + noun + (count == 1 ? "" : "s")); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHelp.java b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHelp.java new file mode 100644 index 0000000..fff67f0 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHelp.java @@ -0,0 +1,127 @@ +/* + * 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.metadata; + + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.kingsrook.qbits.alerts.AlertsQBitConfig; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Applies help/qbit-alerts.yaml (the QQQ help-bundle format) to the + ** Alerts app and its alerts_* tables: what each table is for and how + ** its rows get created, shown on the table screen and when it is empty. + ** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned + ** QQQ release includes it; the bundle file stays the same. + *******************************************************************************/ +public final class AlertsHelp +{ + public static final String RESOURCE = "/help/qbit-alerts.yaml"; + public static final String SLOT = "help"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AlertsHelp() + { + } + + + + /******************************************************************************* + ** The bundle's entries by key, validated (fails startup if malformed). + *******************************************************************************/ + public static Map entries() throws QException + { + try(InputStream stream = AlertsHelp.class.getResourceAsStream(RESOURCE)) + { + if(stream == null) + { + throw (new IOException("missing resource")); + } + JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream); + if(!"qbit-alerts".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText())) + { + throw (new IOException("invalid bundle header")); + } + Map entries = new LinkedHashMap<>(); + for(JsonNode entry : bundle.path("entries")) + { + String key = entry.path("key").asText(); + String summary = entry.path("summary").asText(); + if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null) + { + throw (new IOException("invalid or duplicate entry: " + key)); + } + } + return (entries); + } + catch(IOException | RuntimeException e) + { + throw (new QException("Could not load " + RESOURCE, e)); + } + } + + + + /******************************************************************************* + ** Apply the help to the app and every alerts_* table in the instance. + ** A host's own help on a table is kept (the bundle only fills the slot). + *******************************************************************************/ + public static void apply(QInstance qInstance) throws QException + { + Map entries = entries(); + for(QTableMetaData table : qInstance.getTables().values()) + { + JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT); + if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT))) + { + table.withHelpContent(SLOT, content(entry)); + } + } + QAppMetaData app = qInstance.getApp(AlertsQBitConfig.APP_NAME); + JsonNode appEntry = entries.get("app:" + AlertsQBitConfig.APP_NAME + ";slot:" + SLOT); + if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT))) + { + app.withHelpContent(SLOT, content(appEntry)); + } + } + + + + /******************************************************************************* + ** Summary as the first paragraph, then the content, as markdown. + *******************************************************************************/ + static QHelpContent content(JsonNode entry) + { + return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHomeWidgetsMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHomeWidgetsMetaDataProducer.java index 8f0f5c3..89f67d6 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHomeWidgetsMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsHomeWidgetsMetaDataProducer.java @@ -33,11 +33,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaData /******************************************************************************* ** The Alerts home dashboard (design section 6.2), in page order, all full - ** width: open by severity, my alerts, needs action, by group, trend, noisy + ** width: setup (admins), open by severity, my alerts, needs action, by group, trend, noisy ** types, delivery health (admins) and recently resolved. *******************************************************************************/ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProducer { + public static final String SETUP = "alertsHomeSetup"; public static final String SEVERITY = "alertsHomeOpenBySeverity"; public static final String MINE = "alertsHomeMyAlerts"; public static final String ACTION = "alertsHomeNeedsAction"; @@ -55,7 +56,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd *******************************************************************************/ public static List homeWidgetNames() { - return (List.of(SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED)); + return (List.of(SETUP, SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED)); } @@ -70,6 +71,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd String table = WidgetType.TABLE.getType(); MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput(); + output.add(widget(SETUP, "Setup", html, AlertsHome.KIND_SETUP, AlertsQBitConfig.PERMISSION_ADMIN)); output.add(widget(SEVERITY, "Open Alerts", html, AlertsHome.KIND_SEVERITY, AlertsQBitConfig.PERMISSION_VIEW)); output.add(widget(MINE, "My Alerts", table, AlertsHome.KIND_MINE, AlertsQBitConfig.PERMISSION_VIEW)); output.add(widget(ACTION, "Needs Action", table, AlertsHome.KIND_ACTION, AlertsQBitConfig.PERMISSION_VIEW)); diff --git a/src/main/resources/db/liquibase/qbit-alerts/alerts-defaults.changelog.sql b/src/main/resources/db/liquibase/qbit-alerts/alerts-defaults.changelog.sql new file mode 100644 index 0000000..bb3d1fc --- /dev/null +++ b/src/main/resources/db/liquibase/qbit-alerts/alerts-defaults.changelog.sql @@ -0,0 +1,58 @@ +-- liquibase formatted sql + +-- qbit-alerts starter defaults: the two channels every install needs, and the +-- two groups the qbit itself files alerts under, so Delivery health and the +-- group lists are not empty before the first alert. +-- Include it from the host's master changelog AFTER alerts.changelog.sql: +-- +-- - include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql } +-- +-- Rules for this file: +-- * insert-only: every row is inserted only when its natural key (the name) is +-- absent, so an administrator's edits always survive and nothing is updated; +-- * no secrets: the channels hold settings only, never a secret; +-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS; +-- * every changeset has a precondition on its table, so a host that has not +-- created a table yet simply skips it (MARK_RAN). +-- The runtime creates the same rows when they are missing (AlertChannels.defaultChannel, +-- AlertTypeSync.ensureGroup); this file only makes them exist from the first deploy. +-- Alert types, monitors, escalation policies, teams and subscriptions are NOT seeded +-- here: types and default monitors are declared by the installed qbits and synced at +-- startup of the Alerts home, and the rest is the host's own people and policy. +-- AlertsDefaultsChangelogTest runs this file twice against a fresh schema. + +-- changeset qbit-alerts:defaults-1 +-- preconditions onFail:MARK_RAN onError:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_channel WHERE name = 'in-app' +-- comment: The in-app channel (the bell): every user has it, full detail, no rate limit. +INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date) +SELECT 'in-app', 'In-App (Bell)', 'IN_APP', TRUE, 'FULL', 'INFO', 0, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'in-app'); +-- rollback DELETE FROM alerts_channel WHERE name = 'in-app'; + +-- changeset qbit-alerts:defaults-2 +-- preconditions onFail:MARK_RAN onError:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_channel WHERE name = 'email' +-- comment: The personal email channel: a user's own address (My Alert Settings), minimal detail, 60 an hour. +INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date) +SELECT 'email', 'Email (Personal Address)', 'EMAIL', TRUE, 'MINIMAL', 'INFO', 60, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'email'); +-- rollback DELETE FROM alerts_channel WHERE name = 'email'; + +-- changeset qbit-alerts:defaults-3 +-- preconditions onFail:MARK_RAN onError:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_group WHERE name = 'alerts' +-- comment: The group the qbit files its own alerts under (a monitor that cannot run, a failing channel, dropped signals). +INSERT INTO alerts_group (name, label, create_date, modify_date) +SELECT 'alerts', 'Alerts', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'alerts'); +-- rollback DELETE FROM alerts_group WHERE name = 'alerts'; + +-- changeset qbit-alerts:defaults-4 +-- preconditions onFail:MARK_RAN onError:MARK_RAN +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_group WHERE name = 'other' +-- comment: The catch-all group for alert types that name no group (and the unregistered type). +INSERT INTO alerts_group (name, label, create_date, modify_date) +SELECT 'other', 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'other'); +-- rollback DELETE FROM alerts_group WHERE name = 'other'; diff --git a/src/main/resources/help/qbit-alerts.yaml b/src/main/resources/help/qbit-alerts.yaml new file mode 100644 index 0000000..7e43e00 --- /dev/null +++ b/src/main/resources/help/qbit-alerts.yaml @@ -0,0 +1,108 @@ +bundle: qbit-alerts +version: 1 +layer: QBIT +# Help for the Alerts app and every alerts_* table, in the QQQ help-bundle format +# (qqq#1198). Each table entry says what the table is for and how its rows get +# created, so an empty table explains itself. AlertsHelp applies it until the core +# HelpBundles registry ships in a QQQ release (STAND-IN(core=#1198)). +# Summaries stay under 120 characters (AlertsTablesGuardrailTest). +entries: + - key: app:alerts;slot:help + summary: "Manage by exception: alerts, who hears about them, how they are delivered, and the checks that raise them." + content: | + The Setup card on the home (alerts admins) lists every part of Alerts with its status and the next step. A new installation starts with the in-app and email channels and two groups; alert types and their default monitors appear as soon as the first person opens Alerts, because the installed qbits declare them. Nobody is notified until someone subscribes (the first alerts admin gets an in-app subscription to errors and worse automatically) or a group has an owner team. + + - key: table:alerts_alert;slot:help + summary: "One problem, open or closed, identified by its type and dedup key. Repeats update it; they never duplicate it." + content: | + **How rows get created:** never by hand. A producer (QAlerts.raise), a monitor that finds its condition true, a failed process, or an Alertmanager webhook raises the alert; the same condition updates the same row. People change its state with the actions Acknowledge, Snooze, Resolve, Reopen, Assign, Comment and Mute. Empty means nothing is wrong right now, or nothing is reporting yet: check Monitors and Alert Types. + + - key: table:alerts_type;slot:help + summary: "A kind of problem a source can report (such as cdn.purge.dead): its severity, group, quiet period and escalation." + content: | + **How rows get created:** from the alert types that the installed qbits and the host declare in code (QAlertTypeMetaData). They are synced the first time someone opens Alerts after a start, and when the first alert of a type is raised. You tune a row here (turn it off, override its severity, move it to a group, set a reminder interval or escalation policy); you do not add types by hand. Empty means the first sync has not run yet: open the Alerts home. + + - key: table:alerts_group;slot:help + summary: "A category of alerts (Backups, CDN), optionally nested: used to organize the home, subscriptions and owners." + content: | + **How rows get created:** the qbit seeds Alerts and Other, and every alert type that names a group creates it on first sync. Add your own to organize types, and nest one inside another. Set an Owner Team on a group: an alert in that group that no subscription matches goes to the owner team's members, so a problem with an owner is never silent. Set an escalation policy on a group to apply it to every type in it that has none. + + - key: table:alerts_monitor;slot:help + summary: "A configured check that raises alerts of a type: a table query, a threshold, a heartbeat or a failed process." + content: | + **How rows get created:** qbits ship default monitors for their alert types; they are added on first sync and can be tuned or turned off, not deleted. To add your own, use Create monitor on an alert type, or New here. Run check now tests a monitor on demand. If a monitor cannot run three times in a row, an alert says so. Empty means no installed qbit ships a default monitor; alerts then come only from code that raises them. + + - key: table:alerts_record_ref;slot:help + summary: "A record an alert is about, so the alert shows on that record and is hidden from people who cannot see it." + content: | + **How rows get created:** written by the engine when a signal names records (AlertRecordRef) or a table-query monitor finds rows. Never edited by hand. Empty means no alert has been about a specific record yet. + + - key: table:alerts_event;slot:help + summary: "The alert timeline: raised, repeated, acknowledged, snoozed, resolved, reopened, notified, commented." + content: | + **How rows get created:** written by the engine and the record actions whenever an alert changes. Read-only history. Empty means no alert exists yet. + + - key: table:alerts_subscription;slot:help + summary: "Who hears about what: a user, team or channel, scoped to everything, a group, type, monitor, table or record." + content: | + **How rows get created:** use Subscribe me on an alert type, group or monitor, or New here (pick who, the scope, the minimum severity, which events and how to deliver). The first alerts admin who opens Alerts gets one in-app subscription to errors and worse. Without a subscription an alert reaches nobody, unless its group has an owner team. A subscription can name channels; otherwise the person's default channels apply. + + - key: table:alerts_user_preference;slot:help + summary: "Your alert settings: email address, quiet hours, digest hour, time zone, default channels and a vacation pause." + content: | + **How rows get created:** one row per person, created automatically with defaults the first time they open Alerts (in-app on, email on once an address is added). Edit yours here. Quiet hours and the pause hold email and other push channels, never the bell; an alert at or above your bypass severity (CRITICAL by default) still gets through. Default Channels, when set, replace the built-in in-app and email choice for subscriptions that name no channels. + + - key: table:alerts_team;slot:help + summary: "A named set of people who can be subscribed together, or own a group of alerts." + content: | + **How rows get created:** an administrator adds teams with New, then adds Team Members, or sets a member source (a role or IdP group, which needs the host's audience resolver). Subscribe a team to a scope, or make it the Owner Team of an alert group so unsubscribed alerts reach it. Empty is normal until you decide who answers for what; the Setup card says what is missing. + + - key: table:alerts_team_member;slot:help + summary: "One person in a team, with an optional role. Teams combine these with any role or IdP-group source." + content: | + **How rows get created:** add members from a team's Members list, or New here. A team with no members and no member source notifies nobody. Empty means no team has members yet. + + - key: table:alerts_channel;slot:help + summary: "A configured destination for alerts: the bell, email, an email list, Slack, Teams, a webhook, PagerDuty." + content: | + **How rows get created:** the qbit seeds In-App (the bell) and Email (a person's own address) so alerts can be delivered from the first alert. Add Slack, Teams, a webhook or PagerDuty with New: its settings go in the row and its secret stays in your secret store, referenced by name (Secret Ref), never typed here. Use Send test notification to check a channel; Health shows whether recent deliveries worked. + + - key: table:alerts_mute;slot:help + summary: "A maintenance window or mute that silences notifications, or suppresses alerts, for a scope and a time." + content: | + **How rows get created:** use Mute on an alert, an alert type, a group or a monitor, or New here for a planned maintenance window (scope, start, end, effect). Silence notifications keeps recording alerts; suppress alerts stops raising them. Empty means nothing is muted, which is normal. + + - key: table:alerts_delivery;slot:help + summary: "The delivery log: one attempt per person and channel, with status, retries and, when held back, the reason." + content: | + **How rows get created:** written by the router when an alert is sent, and updated by the delivery worker (retries, failures). Suppressed deliveries are logged too, with why (muted, quiet hours, not visible, no address). Retry delivery re-queues a failed one. Empty means no alert has been routed to anyone yet: check Subscriptions. + + - key: table:alerts_notification;slot:help + summary: "A message for people: an announcement an admin writes, or one a producer sends (failed job, share, release)." + content: | + **How rows get created:** an administrator writes one with New, previews the audience, and sends or schedules it; producers create them with QAlerts.notify (failed jobs, shares, security notices, releases). Each reaches people through the bell and, by their settings, email or Slack. Empty means nothing has been announced and no producer has sent one. + + - key: table:alerts_notification_recipient;slot:help + summary: "One notification for one person: when it reached them and whether they read, dismissed or were emailed it." + content: | + **How rows get created:** written when a notification is sent, one per person it reaches. Read-only; people change read and dismissed from the bell. Empty means no notification has been sent. + + - key: table:alerts_notification_preference;slot:help + summary: "Your settings for one notification category: mute it in-app, and email or Slack off, immediate or daily digest." + content: | + **How rows get created:** each person adds a row per category (job, share, security, release...) from My Notification Settings when they want to change the default. Without a row the defaults apply: in-app on, email and Slack off. Security notices cannot be muted in-app. Empty means everyone is using the defaults. + + - key: table:alerts_heartbeat;slot:help + summary: "The last time each dead-man's switch beat, watched by heartbeat monitors." + content: | + **How rows get created:** written when code calls QAlerts.heartbeat(key) or a watched process completes. A Heartbeat monitor raises an alert when a key stops beating. Empty means nothing has beaten yet, which is fine until you add a heartbeat monitor. + + - key: table:alerts_escalation_policy;slot:help + summary: "A reusable escalation plan: how long to wait, who to tell next, and how often to repeat." + content: | + **How rows get created:** an administrator adds a policy with New, adds ordered Escalation Steps, then picks it on an alert type or group (a type's policy overrides its group's). Policies name real people, teams and channels, so the qbit cannot seed them. Empty means alerts are not escalated, only reminded. + + - key: table:alerts_escalation_step;slot:help + summary: "One step of an escalation policy: after how many minutes, tell this user, team or channel." + content: | + **How rows get created:** add steps from a policy's Steps list. Each has an order, a delay in minutes and one target (a user, a team or a channel). Acknowledging, snoozing or resolving an alert stops the steps. Empty means no policy has steps yet. diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsDefaultsChangelogTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsDefaultsChangelogTest.java new file mode 100644 index 0000000..a454680 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsDefaultsChangelogTest.java @@ -0,0 +1,325 @@ +/* + * 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; + + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType; +import liquibase.Liquibase; +import liquibase.database.Database; +import liquibase.database.DatabaseFactory; +import liquibase.database.jvm.JdbcConnection; +import liquibase.resource.ClassLoaderResourceAccessor; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The shipped starter defaults (db/liquibase/qbit-alerts/alerts-defaults. + ** changelog.sql), run through real Liquibase on H2 (PostgreSQL mode) against + ** the qbit's test schema: the rows appear, a second update runs nothing, an + ** edited row survives, a host without the tables marks the changesets ran, + ** and the seeded rows are the ones the runtime would create itself. + *******************************************************************************/ +class AlertsDefaultsChangelogTest extends BaseAlertsTest +{ + static final String CHANGELOG = "db/liquibase/qbit-alerts/alerts-defaults.changelog.sql"; + + /** the tables the changelog seeds. */ + static final Set SEEDED_TABLES = Set.of("alerts_channel", "alerts_group"); + + + + /******************************************************************************* + ** Fresh schema, update twice: same rows; an edit survives. + *******************************************************************************/ + @Test + void seedsAFreshSchemaInsertOnly() throws Exception + { + try(Connection connection = freshDatabase(true)) + { + update(connection); + assertThat(names(connection, "alerts_channel")).containsExactlyInAnyOrder("in-app", "email"); + assertThat(names(connection, "alerts_group")).containsExactlyInAnyOrder("alerts", "other"); + + try(Statement statement = connection.createStatement()) + { + statement.execute("UPDATE alerts_channel SET label = 'Bell', enabled = FALSE WHERE name = 'in-app'"); + statement.execute("DELETE FROM alerts_group WHERE name = 'other'"); + } + connection.commit(); + update(connection); + assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'in-app'")).isEqualTo("Bell"); + assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel")).isEqualTo("2"); + assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'EXECUTED'")).isEqualTo("4"); + } + } + + + + /******************************************************************************* + ** A host that already has a row by that name (its own settings) keeps it + ** untouched, and no second one is added. + *******************************************************************************/ + @Test + void keepsAHostsExistingRows() throws Exception + { + try(Connection connection = freshDatabase(true); Statement statement = connection.createStatement()) + { + statement.execute("INSERT INTO alerts_channel (name, label, channel_type, enabled, rate_limit_per_hour) VALUES ('email', 'Ops mail', 'EMAIL', FALSE, 5)"); + update(connection); + assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'email'")).isEqualTo("Ops mail"); + assertThat(scalar(connection, "SELECT rate_limit_per_hour FROM alerts_channel WHERE name = 'email'")).isEqualTo("5"); + assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel WHERE name = 'email'")).isEqualTo("1"); + } + } + + + + /******************************************************************************* + ** A host that has not created the tables yet skips every changeset (marked + ** ran), rather than failing its deploy. + *******************************************************************************/ + @Test + void aMissingTableMarksTheChangesetsRan() throws Exception + { + try(Connection connection = freshDatabase(false)) + { + update(connection); + assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("4"); + } + } + + + + /******************************************************************************* + ** Every changeset guards itself and uses only precondition forms that parse + ** in Liquibase 4 and 5. + *******************************************************************************/ + @Test + void everyChangesetIsGuarded() throws Exception + { + String text = changelogText(); + assertThat(text).doesNotContain("precondition-table-exists"); + String[] changesets = text.split("(?m)^(?=-- changeset )"); + List parts = new ArrayList<>(); + for(String part : changesets) + { + if(part.startsWith("-- changeset ")) + { + parts.add(part); + } + } + assertThat(parts).hasSize(4); + for(String part : parts) + { + assertThat(part).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("precondition-sql-check expectedResult:0").contains("-- rollback ").contains("NOT EXISTS"); + } + for(String table : SEEDED_TABLES) + { + assertThat(text).contains("INSERT INTO " + table + " "); + } + } + + + + /******************************************************************************* + ** The seeded rows equal what the runtime creates by itself when they are + ** missing, so a deploy never changes behavior. + *******************************************************************************/ + @Test + void seededRowsEqualTheRuntimeDefaults() throws Exception + { + QRecordPair in = runtimeChannel(ChannelType.IN_APP); + QRecordPair email = runtimeChannel(ChannelType.EMAIL); + Integer alertsGroup = runtime.asSystem(() -> runtime.getTypeSync().ensureGroup("alerts")); + Integer otherGroup = runtime.asSystem(() -> runtime.getTypeSync().ensureGroup(com.kingsrook.qbits.alerts.engine.AlertTypeSync.DEFAULT_GROUP)); + + try(Connection connection = freshDatabase(true)) + { + update(connection); + for(QRecordPair channel : List.of(in, email)) + { + for(Map.Entry column : channel.columns.entrySet()) + { + assertThat(scalar(connection, "SELECT " + column.getKey() + " FROM alerts_channel WHERE name = '" + channel.name + "'")).as(channel.name + " " + column.getKey()).isEqualTo(column.getValue()); + } + } + assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'alerts'")).isEqualTo(groupLabel(alertsGroup)); + assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'other'")).isEqualTo(groupLabel(otherGroup)); + } + } + + + + /******************************************************************************* + ** The channel the runtime makes when missing, as comparable column values. + *******************************************************************************/ + private QRecordPair runtimeChannel(ChannelType type) throws Exception + { + return (runtime.asSystem(() -> + { + var row = runtime.getChannels().defaultChannel(type); + QRecordPair pair = new QRecordPair(row.getValueString("name")); + pair.columns.put("label", row.getValueString("label")); + pair.columns.put("channel_type", row.getValueString("channelType")); + pair.columns.put("detail_level", row.getValueString("detailLevel")); + pair.columns.put("min_severity", row.getValueString("minSeverity")); + pair.columns.put("rate_limit_per_hour", String.valueOf(row.getValueInteger("rateLimitPerHour"))); + pair.columns.put("health_status", row.getValueString("healthStatus")); + return (pair); + })); + } + + + + /******************************************************************************* + ** The label of a group the runtime created. + *******************************************************************************/ + private String groupLabel(Integer id) throws Exception + { + return (runtime.asSystem(() -> com.kingsrook.qbits.alerts.engine.AlertsDb.byId("alerts_group", id).getValueString("label"))); + } + + + + /******************************************************************************* + ** A name and its expected column values. + *******************************************************************************/ + private static final class QRecordPair + { + private final String name; + private final Map columns = new java.util.LinkedHashMap<>(); + + + + /******************************************************************************* + ** + *******************************************************************************/ + private QRecordPair(String name) + { + this.name = name; + } + } + + + + /******************************************************************************* + ** A fresh in-memory H2 (PostgreSQL mode), with or without the qbit's tables. + *******************************************************************************/ + static Connection freshDatabase(boolean withTables) throws Exception + { + Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH"); + if(withTables) + { + String script; + try(InputStream in = AlertsDefaultsChangelogTest.class.getClassLoader().getResourceAsStream("alerts-h2.sql")) + { + script = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + try(Statement statement = connection.createStatement()) + { + StringBuilder current = new StringBuilder(); + for(String line : script.split("\n")) + { + if(line.trim().startsWith("--")) + { + continue; + } + current.append(line).append('\n'); + if(line.trim().endsWith(";")) + { + statement.execute(current.toString().trim().replaceAll(";$", "")); + current.setLength(0); + } + } + } + } + return (connection); + } + + + + /******************************************************************************* + ** Run the changelog with real Liquibase. + *******************************************************************************/ + static void update(Connection connection) throws Exception + { + Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection)); + ////////////////////////////////////////////////////////////////// + // not closed: closing Liquibase would close the test connection // + ////////////////////////////////////////////////////////////////// + new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).update(""); + connection.commit(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static String changelogText() throws Exception + { + try(InputStream stream = AlertsDefaultsChangelogTest.class.getClassLoader().getResourceAsStream(CHANGELOG)) + { + assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull(); + return (new String(stream.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static List names(Connection connection, String table) throws Exception + { + List names = new ArrayList<>(); + try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT name FROM " + table)) + { + while(rs.next()) + { + names.add(rs.getString(1)); + } + } + return (names); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String scalar(Connection connection, String sql) throws Exception + { + try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) + { + rs.next(); + return (rs.getString(1)); + } + } + +} diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsTablesGuardrailTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsTablesGuardrailTest.java new file mode 100644 index 0000000..79f0ee0 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsTablesGuardrailTest.java @@ -0,0 +1,204 @@ +/* + * 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; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import com.kingsrook.qbits.alerts.metadata.AlertsHelp; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Guardrail (owner requirement 2026-10-14: "all tables should be populated + ** and the qbit should be fully using them"). For every alerts_* table: + ** - runtime code (not just its model, metadata, config or home screen) reads + ** or writes it, so no table exists only on paper; + ** - it is either seeded by the shipped defaults changelog, or listed here + ** with why it starts empty; + ** - it has bundle help that says what it is for and how its rows get created. + ** A new table fails this test until it is wired, seeded or explained. + *******************************************************************************/ +class AlertsTablesGuardrailTest extends BaseAlertsTest +{ + private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "alerts"); + + /******************************************************************************* + ** Tables that legitimately start empty, and where their rows come from. + *******************************************************************************/ + static final Map STARTS_EMPTY = Map.ofEntries( + Map.entry("alerts_type", "synced from the alert types the installed qbits declare in code, on the first visit (AlertTypeSync)"), + Map.entry("alerts_monitor", "default monitors are shipped by the declaring qbits and seeded by AlertTypeSync; admins add the rest"), + Map.entry("alerts_alert", "runtime: a producer or monitor raises an alert"), + Map.entry("alerts_record_ref", "runtime: written with an alert that names records"), + Map.entry("alerts_event", "runtime: the alert timeline"), + Map.entry("alerts_subscription", "people's own choices; the first alerts admin gets one by the bootstrap rule (AlertsPreferences)"), + Map.entry("alerts_user_preference", "user content: created per person on their first visit (AlertsPreferences)"), + Map.entry("alerts_team", "the host's own people (kof22 seeds its team)"), + Map.entry("alerts_team_member", "the host's own people"), + Map.entry("alerts_mute", "user content: maintenance windows and mutes people create"), + Map.entry("alerts_delivery", "runtime log: every delivery attempt"), + Map.entry("alerts_notification", "user content and producer messages"), + Map.entry("alerts_notification_recipient", "runtime: one per person a notification reaches"), + Map.entry("alerts_notification_preference", "user content: a person's per-category choice; defaults apply without a row"), + Map.entry("alerts_heartbeat", "runtime: written by QAlerts.heartbeat and completed processes"), + Map.entry("alerts_escalation_policy", "host-specific: names real people, teams and channels"), + Map.entry("alerts_escalation_step", "host-specific: names real people, teams and channels")); + + + + /******************************************************************************* + ** Every table is used by runtime code. + *******************************************************************************/ + @Test + void everyTableIsUsedAtRuntime() throws Exception + { + Map entityByTable = entityByTable(); + List runtimeSource; + try(Stream files = Files.walk(MAIN)) + { + runtimeSource = files.filter(p -> p.toString().endsWith(".java")) + .filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("home")) && !p.endsWith("AlertsQBitConfig.java")) + .map(AlertsTablesGuardrailTest::read).toList(); + } + for(String table : alertsTables()) + { + String entity = entityByTable.get(table); + assertThat(entity).as("entity class for " + table).isNotNull(); + Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)"); + assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find())) + .as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue(); + } + } + + + + /******************************************************************************* + ** Every table is seeded or explained. + *******************************************************************************/ + @Test + void everyTableIsSeededOrExplained() throws Exception + { + String changelog = AlertsDefaultsChangelogTest.changelogText(); + for(String table : alertsTables()) + { + boolean seeded = AlertsDefaultsChangelogTest.SEEDED_TABLES.contains(table); + assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue(); + assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse(); + assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded); + } + assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance.getTable(table) != null, "STARTS_EMPTY names only real tables"); + } + + + + /******************************************************************************* + ** Every table and the app have bundle help that says how rows get created. + *******************************************************************************/ + @Test + void everyTableHasEmptyStateHelp() throws Exception + { + assertThat(AlertsHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120)); + for(String table : alertsTables()) + { + List help = qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(AlertsHelp.SLOT); + assertThat(help).as(table + " table help").isNotEmpty(); + assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created"); + } + assertThat(qInstance.getApp(AlertsQBitConfig.APP_NAME).getHelpContent()).containsKey(AlertsHelp.SLOT); + assertThat(AlertsHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null, + "help names only real tables"); + } + + + + /******************************************************************************* + ** A host's own help on a table is kept; the bundle fills only empty slots. + *******************************************************************************/ + @Test + void hostHelpIsNotOverridden() throws Exception + { + QTableMetaData table = qInstance.getTable("alerts_channel"); + table.getHelpContent().remove(AlertsHelp.SLOT); + table.withHelpContent(AlertsHelp.SLOT, new QHelpContent().withContentAsMarkdown("host text")); + AlertsHelp.apply(qInstance); + assertThat(table.getHelpContent().get(AlertsHelp.SLOT)).singleElement().matches(c -> "host text".equals(c.getContent())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List alertsTables() + { + List tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("alerts_")).sorted().toList(); + assertThat(tables).hasSizeGreaterThan(15); + return (tables); + } + + + + /******************************************************************************* + ** model/X.java declares TABLE_NAME = "x". + *******************************************************************************/ + private static Map entityByTable() throws Exception + { + Map rs = new HashMap<>(); + Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\""); + try(Stream files = Files.list(MAIN.resolve("model"))) + { + for(Path file : files.toList()) + { + Matcher m = p.matcher(read(file)); + if(m.find()) + { + rs.put(m.group(1), file.getFileName().toString().replace(".java", "")); + } + } + } + return (rs); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String read(Path path) + { + try + { + return (Files.readString(path, StandardCharsets.UTF_8)); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } + +} diff --git a/src/test/java/com/kingsrook/qbits/alerts/home/AlertsSetupTest.java b/src/test/java/com/kingsrook/qbits/alerts/home/AlertsSetupTest.java new file mode 100644 index 0000000..22afcaf --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/home/AlertsSetupTest.java @@ -0,0 +1,105 @@ +/* + * 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.home; + + +import java.util.List; +import com.kingsrook.qbits.alerts.BaseAlertsTest; +import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.home.AlertsSetupStatus.Item; +import com.kingsrook.qbits.alerts.home.AlertsSetupStatus.State; +import com.kingsrook.qbits.alerts.model.AlertsChannel; +import com.kingsrook.qbits.alerts.model.AlertsGroup; +import com.kingsrook.qbits.alerts.model.AlertsSubscription; +import com.kingsrook.qbits.alerts.model.AlertsTeam; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The Setup card: states follow the real configuration, and the home widget + ** renders for admins only. + *******************************************************************************/ +class AlertsSetupTest extends BaseAlertsTest +{ + + /******************************************************************************* + ** + *******************************************************************************/ + private Item item(String label) throws Exception + { + return (AlertsSetupStatus.load(runtime, clock.instant()).stream().filter(i -> i.label().equals(label)).findFirst().orElseThrow()); + } + + + + /******************************************************************************* + ** A fresh install: defaults channels, nobody subscribed, no scheduler. + *******************************************************************************/ + @Test + void freshInstallNeedsSubscriptionsAndAScheduler() throws Exception + { + runtime.getTypeSync().syncIfNeeded(); + assertThat(item("Channels").state()).isEqualTo(State.DEFAULT); + assertThat(item("Alert types").state()).isEqualTo(State.OK); + assertThat(item("Subscriptions").state()).isEqualTo(State.TODO); + assertThat(item("Scheduler").state()).isEqualTo(State.TODO); + assertThat(item("Teams").state()).isEqualTo(State.OPTIONAL); + assertThat(item("Groups").state()).isEqualTo(State.OPTIONAL); + assertThat(AlertsSetupStatus.load(runtime, clock.instant())).allMatch(i -> i.tableName() == null || i.tableName().startsWith("alerts_")); + } + + + + /******************************************************************************* + ** Configuring a part moves its line. + *******************************************************************************/ + @Test + void configuringMovesTheState() throws Exception + { + runtime.getTypeSync().syncIfNeeded(); + runtime.getChannels().ensureDefaults(); + assertThat(item("Channels").state()).isEqualTo(State.OK); + + runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", "u") + .withValue("scopeType", "ALL").withValue("minSeverity", "ERROR").withValue("enabled", true))); + assertThat(item("Subscriptions").state()).isEqualTo(State.OK); + + Integer team = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", "t").withValue("label", "T")).getValueInteger("id")); + QRecord group = runtime.asSystem(() -> AlertsDb.first(AlertsGroup.TABLE_NAME, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter())); + runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", team))); + assertThat(item("Groups").state()).isEqualTo(State.OK); + assertThat(item("Teams").state()).isEqualTo(State.OK); + + runtime.asSystem(() -> AlertsDb.update(AlertsChannel.TABLE_NAME, new QRecord().withValue("id", 1).withValue("healthStatus", "UNHEALTHY"))); + assertThat(item("Channels").state()).isEqualTo(State.TODO); + } + + + + /******************************************************************************* + ** The widget renders the card for an admin, with links to the tables. + *******************************************************************************/ + @Test + void widgetRendersForAdmins() throws Exception + { + runtime.getTypeSync().syncIfNeeded(); + String html = ((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) AlertsHome.load(AlertsHome.KIND_SETUP, runtime)).getHtml(); + assertThat(html).contains("Needs setup").contains("href=\"/app/alerts_subscription\"").contains("Next: ").contains("attention"); + } +} -- 2.54.0 From 35c3b00d6f1b138e0f8d6f7cd8515367ea19a050 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 17:13:22 -0500 Subject: [PATCH 5/6] fix(defaults): empty rollback, rollback test, include order Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../qbit-alerts/alerts-defaults.changelog.sql | 9 ++++--- .../alerts/AlertsDefaultsChangelogTest.java | 27 +++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ce1525b..ae14706 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ new AlertsQBitProducer() ## Starter defaults and setup -After the schema, include the starter defaults. They are insert-only (a row is added only when its name is absent, so edits survive and nothing is ever updated), hold no secrets, and are guarded so a host without the tables skips them: +After the schema, include the starter defaults. Include order: the schema first, then the defaults. They are insert-only (a row is added only when its name is absent, so edits survive and nothing is ever updated), hold no secrets, and are guarded so a host without the tables skips them: ```yaml - include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql } diff --git a/src/main/resources/db/liquibase/qbit-alerts/alerts-defaults.changelog.sql b/src/main/resources/db/liquibase/qbit-alerts/alerts-defaults.changelog.sql index bb3d1fc..77ac3c2 100644 --- a/src/main/resources/db/liquibase/qbit-alerts/alerts-defaults.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-alerts/alerts-defaults.changelog.sql @@ -7,6 +7,7 @@ -- -- - include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql } -- +-- Rollback is empty on purpose: a name-based delete would also remove rows the host already had. -- Rules for this file: -- * insert-only: every row is inserted only when its natural key (the name) is -- absent, so an administrator's edits always survive and nothing is updated; @@ -28,7 +29,7 @@ INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date) SELECT 'in-app', 'In-App (Bell)', 'IN_APP', TRUE, 'FULL', 'INFO', 0, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'in-app'); --- rollback DELETE FROM alerts_channel WHERE name = 'in-app'; +-- rollback empty -- changeset qbit-alerts:defaults-2 -- preconditions onFail:MARK_RAN onError:MARK_RAN @@ -37,7 +38,7 @@ WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'in-app'); INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date) SELECT 'email', 'Email (Personal Address)', 'EMAIL', TRUE, 'MINIMAL', 'INFO', 60, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'email'); --- rollback DELETE FROM alerts_channel WHERE name = 'email'; +-- rollback empty -- changeset qbit-alerts:defaults-3 -- preconditions onFail:MARK_RAN onError:MARK_RAN @@ -46,7 +47,7 @@ WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'email'); INSERT INTO alerts_group (name, label, create_date, modify_date) SELECT 'alerts', 'Alerts', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'alerts'); --- rollback DELETE FROM alerts_group WHERE name = 'alerts'; +-- rollback empty -- changeset qbit-alerts:defaults-4 -- preconditions onFail:MARK_RAN onError:MARK_RAN @@ -55,4 +56,4 @@ WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'alerts'); INSERT INTO alerts_group (name, label, create_date, modify_date) SELECT 'other', 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'other'); --- rollback DELETE FROM alerts_group WHERE name = 'other'; +-- rollback empty diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsDefaultsChangelogTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsDefaultsChangelogTest.java index a454680..7bec268 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsDefaultsChangelogTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsDefaultsChangelogTest.java @@ -99,6 +99,33 @@ class AlertsDefaultsChangelogTest extends BaseAlertsTest + /******************************************************************************* + ** Rolling the seeds back (Liquibase also rolls back changesets it only marked + ** as ran) never deletes rows the host already had. + *******************************************************************************/ + @Test + void rollbackKeepsHostOwnedRows() throws Exception + { + try(Connection connection = freshDatabase(true)) + { + try(Statement statement = connection.createStatement()) + { + statement.execute("INSERT INTO alerts_channel (name, label, channel_type) VALUES ('in-app', 'Mine', 'IN_APP')"); + statement.execute("INSERT INTO alerts_group (name, label) VALUES ('other', 'Mine')"); + } + connection.commit(); + update(connection); + Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection)); + new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).rollback(4, ""); + connection.commit(); + assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'in-app'")).isEqualTo("Mine"); + assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'other'")).isEqualTo("Mine"); + assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel")).isEqualTo("2"); + } + } + + + /******************************************************************************* ** A host that has not created the tables yet skips every changeset (marked ** ran), rather than failing its deploy. -- 2.54.0 From 5c545dc8e14b9d3277f9200ea1671d90781bd629 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 17:23:04 -0500 Subject: [PATCH 6/6] fix(routing): review findings on owner team, sync and default channels - coverage is decided by scope and severity, so a subscription with fewer events still counts; follow-ups go to the owner team only after it was told of the raise - the first-visit type sync is best effort and never breaks the bell - default channels keep the bell and replace only the built-in email choice - KIND_SETUP in AlertsHome.kinds(); help marker reworded Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- UPGRADE.md | 4 +- .../kingsrook/qbits/alerts/api/AlertsApi.java | 15 ++- .../qbits/alerts/engine/AlertRouter.java | 67 ++++++++++--- .../qbits/alerts/home/AlertsHome.java | 2 +- src/main/resources/help/qbit-alerts.yaml | 4 +- .../qbits/alerts/engine/AlertsWiringTest.java | 95 +++++++++++++++++-- 8 files changed, 165 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e019898..518bc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to this project are documented here. The format follows Keep ### Fixed - A group's `ownerTeamId` was stored but never read. An alert that no subscription matches (WARNING and above) now goes to the owner team of its group, or of the nearest parent group that has one. -- A user's `defaultChannelIds` was stored but never read. It now decides the channels for subscriptions that name none. +- A user's `defaultChannelIds` was stored but never read. It now adds the chosen external channels to the bell (unless In-App is off) for subscriptions that name none. - Alert types, groups and default monitors also sync on the first bell request, so those tables are not empty on a running install. ### Changed diff --git a/README.md b/README.md index ae14706..2652115 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ They create the `in-app` and `email` channels and the `alerts` and `other` group Every table has help that says what it is for and **how its rows get created**, shown on the table screen and when it is empty (`help/qbit-alerts.yaml`). Alerts admins also get a **Setup** card first on the home that lists each configuration table, whether it is set up, and the next step. -Routing details worth knowing: an alert that no subscription matches goes to the owner team of its group (or the nearest parent group that has one), at WARNING and above; a person's **Default Channels** (My Alert Settings) are used for subscriptions that name no channels. +Routing details worth knowing: an alert that no subscription matches goes to the owner team of its group (or the nearest parent group that has one), at WARNING and above; a person's **Default Channels** (My Alert Settings) replace the built-in email choice for subscriptions that name no channels (the bell stays unless turned off). Follow-ups go to the owner team only if it was told about the raise. ## Raising alerts diff --git a/UPGRADE.md b/UPGRADE.md index fdad7e6..3189b3a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -10,7 +10,7 @@ It is insert-only and guarded (row exists by name: no change; table missing: skipped), so it is safe on an installed host. It creates the `in-app` and `email` channels and the `alerts` and `other` groups only, the same rows the runtime creates on first use. 2. **Behavior changes to know about.** - - A group's **Owner Team** is now used: an alert at WARNING or above that no subscription matches is sent to that team's members. Hosts that never set an owner team see no change. - - **Default Channels** on My Alert Settings is now used for subscriptions that name no channels. A non-empty list of channel ids replaces the built-in in-app and email choice for that person; ids that do not exist are ignored. Hosts that never set it see no change. + - A group's **Owner Team** is now used: an alert at WARNING or above that no subscription covers (by scope and severity) is sent to that team's members; follow-ups (resolved, reminders) go only to members who were told about the raise. Hosts that never set an owner team see no change. + - **Default Channels** on My Alert Settings is now used for subscriptions that name no channels. The bell stays whenever In-App is not turned off; the list replaces the built-in email choice with the channels you chose (unknown ids are ignored). Hosts that never set it see no change. - Alert types, groups and default monitors now also sync on the first request of the bell, not only on the home or the first signal. 3. Nothing else is required. The help bundle and the Setup card need no configuration. diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java index 60065af..66879ce 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsApi.java @@ -55,6 +55,7 @@ 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.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; @@ -75,6 +76,8 @@ import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExten *******************************************************************************/ public class AlertsApi { + private static final QLogger LOG = QLogger.getLogger(AlertsApi.class); + static final int INBOX_ITEMS = 10; static final int MAX_BANNERS = 3; static final int SCAN_LIMIT = 1000; @@ -115,7 +118,17 @@ public class AlertsApi } AlertsSecurity.ensureSessionKeys(runtime.getQInstance(), session); AlertsPreferences.ensure(runtime, session); - runtime.getTypeSync().syncIfNeeded(); + try + { + runtime.getTypeSync().syncIfNeeded(); + } + catch(Exception e) + { + //////////////////////////////////////////////////////////////////////// + // best effort: the bell must work even if the sync fails; next call retries // + //////////////////////////////////////////////////////////////////////// + LOG.warn("Alert type sync failed on the first visit; will retry on the next request", e); + } return (AlertsSecurity.userId(session)); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java index e85efe3..4482b28 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java @@ -148,7 +148,7 @@ public class AlertRouter try { deliver(alert, eventType, eventId, kind, destinations(AlertTarget.of(alert), - AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), kind)); + AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), kind, alert.getValue("id"))); } catch(Exception e) { @@ -164,7 +164,7 @@ public class AlertRouter public int remind(QRecord alert) throws QException { return (deliver(alert, null, null, DeliveryKind.REMINDER, destinations(AlertTarget.of(alert), - AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), DeliveryKind.REMINDER))); + AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), DeliveryKind.REMINDER, alert.getValue("id")))); } @@ -418,7 +418,7 @@ public class AlertRouter ** An alert that no subscription matches (at WARNING or above) goes to the ** members of its group's owner team, so an unowned problem is not silent. *******************************************************************************/ - List destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) throws QException + List destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind, Object alertId) throws QException { Map> groupNames = new HashMap<>(); Map destinations = new LinkedHashMap<>(); @@ -426,13 +426,21 @@ public class AlertRouter for(QRecord subscription : AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("enabled", true))) { AlertSeverity min = AlertSeverity.fromLabel(subscription.getValueString("minSeverity"), AlertSeverity.WARNING); - if(!severity.isAtLeast(min) || !includesKind(subscription.getValueString("events"), kind) + if(!severity.isAtLeast(min) || !scopeMatches(subscription.getValueString("scopeType"), subscription.getValueString("scopeValue"), subscription.getValueString("scopeRecordId"), target, groupNames)) { continue; } + ////////////////////////////////////////////////////////////////////////////// + // covered by scope and severity alone: a subscription that leaves out an // + // event kind (events=RAISED) still means someone owns this alert // + ////////////////////////////////////////////////////////////////////////////// subscribed = true; + if(!includesKind(subscription.getValueString("events"), kind)) + { + continue; + } Integer subscriptionId = subscription.getValueInteger("id"); String minName = subscription.getValueString("minSeverity"); String subscriberType = subscription.getValueString("subscriberType"); @@ -471,7 +479,7 @@ public class AlertRouter } if(!subscribed && severity.isAtLeast(AlertSeverity.WARNING)) { - addOwnerTeam(destinations, target); + addOwnerTeam(destinations, target, kind, alertId); } for(String recordKey : kind == DeliveryKind.REMINDER ? java.util.Set.of() : target.recordKeys()) { @@ -501,7 +509,7 @@ public class AlertRouter ** team of its group (or of the nearest parent group that has one), through ** their normal channels and the usual suppression checks. *******************************************************************************/ - private void addOwnerTeam(Map destinations, AlertTarget target) throws QException + private void addOwnerTeam(Map destinations, AlertTarget target, DeliveryKind kind, Object alertId) throws QException { Integer teamId = ownerTeamId(target.groupId()); if(teamId == null) @@ -514,6 +522,13 @@ public class AlertRouter { continue; } + if((kind == DeliveryKind.RESOLVED || kind == DeliveryKind.REMINDER) && !wasToldOfRaise(alertId, userId)) + { + /////////////////////////////////////////////////////////////////////////// + // a follow-up only goes to owners who were told about the alert itself // + /////////////////////////////////////////////////////////////////////////// + continue; + } QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); for(QRecord channel : userChannels(null, preference)) { @@ -524,6 +539,18 @@ public class AlertRouter + /******************************************************************************* + ** Whether the user was sent (not suppressed) the RAISED notice for this alert. + *******************************************************************************/ + private boolean wasToldOfRaise(Object alertId, String userId) throws QException + { + return (alertId != null && AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("alertId", (java.io.Serializable) alertId) + .withCriteria("recipientUserId", QCriteriaOperator.EQUALS, userId).withCriteria("kind", QCriteriaOperator.EQUALS, DeliveryKind.RAISED.name()) + .withCriteria("status", QCriteriaOperator.NOT_EQUALS, DeliveryStatus.SUPPRESSED.name())) > 0); + } + + + /******************************************************************************* ** The owner team of a group, or of the nearest parent that has one (null: ** none). @@ -606,9 +633,10 @@ public class AlertRouter /******************************************************************************* - ** A user's channels for a subscription: the ones it names, else the user's - ** own default channels (My Alert Settings), else in-app (unless turned off) - ** and email (when enabled in their settings). + ** A user's channels for a subscription: the ones it names, else in-app + ** (unless turned off) plus the user's own default channels (My Alert + ** Settings) in place of the built-in email, else in-app and email (when + ** enabled in their settings). *******************************************************************************/ private List userChannels(QRecord subscription, QRecord preference) throws QException { @@ -619,12 +647,27 @@ public class AlertRouter return (channelsById(overrides)); } - String defaults = preference == null ? null : preference.getValueString("defaultChannelIds"); + boolean inApp = preference == null || !Boolean.FALSE.equals(preference.getValueBoolean("inAppEnabled")); + String defaults = preference == null ? null : preference.getValueString("defaultChannelIds"); if(StringUtils.hasContent(defaults)) { - channels.addAll(channelsById(defaults)); - if(!channels.isEmpty()) + List chosen = channelsById(defaults); + if(!chosen.isEmpty()) { + ///////////////////////////////////////////////////////////////////////// + // the bell stays (unless turned off); the list replaces external ones // + ///////////////////////////////////////////////////////////////////////// + if(inApp) + { + channels.add(runtime.getChannels().defaultChannel(ChannelType.IN_APP)); + } + for(QRecord channel : chosen) + { + if(!ChannelType.IN_APP.name().equals(channel.getValueString("channelType"))) + { + channels.add(channel); + } + } return (channels); } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java b/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java index c247a23..3ea908f 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java +++ b/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java @@ -779,6 +779,6 @@ public final class AlertsHome *******************************************************************************/ static Set kinds() { - return (Set.of(KIND_SEVERITY, KIND_MINE, KIND_ACTION, KIND_BY_GROUP, KIND_TREND, KIND_NOISY, KIND_DELIVERY, KIND_RESOLVED)); + return (Set.of(KIND_SETUP, KIND_SEVERITY, KIND_MINE, KIND_ACTION, KIND_BY_GROUP, KIND_TREND, KIND_NOISY, KIND_DELIVERY, KIND_RESOLVED)); } } diff --git a/src/main/resources/help/qbit-alerts.yaml b/src/main/resources/help/qbit-alerts.yaml index 7e43e00..384bd4f 100644 --- a/src/main/resources/help/qbit-alerts.yaml +++ b/src/main/resources/help/qbit-alerts.yaml @@ -4,7 +4,7 @@ layer: QBIT # Help for the Alerts app and every alerts_* table, in the QQQ help-bundle format # (qqq#1198). Each table entry says what the table is for and how its rows get # created, so an empty table explains itself. AlertsHelp applies it until the core -# HelpBundles registry ships in a QQQ release (STAND-IN(core=#1198)). +# HelpBundles registry ships in a QQQ release (a stand-in until core #1198 ships). # Summaries stay under 120 characters (AlertsTablesGuardrailTest). entries: - key: app:alerts;slot:help @@ -50,7 +50,7 @@ entries: - key: table:alerts_user_preference;slot:help summary: "Your alert settings: email address, quiet hours, digest hour, time zone, default channels and a vacation pause." content: | - **How rows get created:** one row per person, created automatically with defaults the first time they open Alerts (in-app on, email on once an address is added). Edit yours here. Quiet hours and the pause hold email and other push channels, never the bell; an alert at or above your bypass severity (CRITICAL by default) still gets through. Default Channels, when set, replace the built-in in-app and email choice for subscriptions that name no channels. + **How rows get created:** one row per person, created automatically with defaults the first time they open Alerts (in-app on, email on once an address is added). Edit yours here. Quiet hours and the pause hold email and other push channels, never the bell; an alert at or above your bypass severity (CRITICAL by default) still gets through. Default Channels, when set, replace the built-in email choice for subscriptions that name no channels; the bell stays unless you turn In-App off. - key: table:alerts_team;slot:help summary: "A named set of people who can be subscribed together, or own a group of alerts." diff --git a/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsWiringTest.java b/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsWiringTest.java index 4bd8343..3624dcd 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsWiringTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsWiringTest.java @@ -25,6 +25,7 @@ import com.kingsrook.qbits.alerts.delivery.DeliveryResult; import com.kingsrook.qbits.alerts.model.AlertsChannel; import com.kingsrook.qbits.alerts.model.AlertsDelivery; import com.kingsrook.qbits.alerts.model.AlertsGroup; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution; import com.kingsrook.qbits.alerts.model.AlertsSubscription; import com.kingsrook.qbits.alerts.model.AlertsTeam; import com.kingsrook.qbits.alerts.model.AlertsTeamMember; @@ -199,8 +200,8 @@ class AlertsWiringTest extends BaseAlertsTest /******************************************************************************* - ** A user's default channels replace the built-in in-app and email choice - ** for subscriptions that do not name channels. + ** A user's default channels replace the built-in email choice, but the bell + ** stays unless the user turned it off. *******************************************************************************/ @Test void defaultChannelsDecideWhereAUserIsReached() throws Exception @@ -208,16 +209,98 @@ class AlertsWiringTest extends BaseAlertsTest QRecord chat = runtime.asSystem(() -> AlertsDb.insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "chat").withValue("label", "Chat") .withValue("channelType", "SLACK").withValue("enabled", true).withValue("detailLevel", "STANDARD").withValue("rateLimitPerHour", 60) .withValue("healthStatus", "HEALTHY").withValue("consecutiveFailures", 0))); + String chatId = String.valueOf(chat.getValue("id")); subscribe("u1"); runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("plain").withTitle("Down")); List plain = all(AlertsDelivery.TABLE_NAME); - assertThat(plain).singleElement().matches(n -> !String.valueOf(chat.getValue("id")).equals(String.valueOf(n.getValue("channelId"))), "went to the built-in in-app channel"); + assertThat(plain).singleElement().matches(n -> !chatId.equals(String.valueOf(n.getValue("channelId"))), "went to the built-in in-app channel"); - runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "u1").withValue("userLabel", "One") - .withValue("inAppEnabled", true).withValue("emailEnabled", false).withValue("defaultChannelIds", "not-a-number, " + chat.getValue("id") + ", 99999"))); + QRecord preference = runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "u1").withValue("userLabel", "One") + .withValue("inAppEnabled", true).withValue("emailEnabled", false).withValue("defaultChannelIds", "not-a-number, " + chatId + ", 99999"))); runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("chosen").withTitle("Down again")); List chosen = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !n.getValue("id").equals(plain.get(0).getValue("id"))).toList(); - assertThat(chosen).singleElement().matches(n -> String.valueOf(chat.getValue("id")).equals(String.valueOf(n.getValue("channelId"))), "went to the user's default channel"); + assertThat(chosen).hasSize(2); + assertThat(chosen).anyMatch(n -> chatId.equals(String.valueOf(n.getValue("channelId")))); + assertThat(chosen).anyMatch(n -> !chatId.equals(String.valueOf(n.getValue("channelId")))); + + runtime.asSystem(() -> AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", preference.getValue("id")).withValue("inAppEnabled", false))); + List before = all(AlertsDelivery.TABLE_NAME).stream().map(n -> (Object) n.getValue("id")).toList(); + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("no-bell").withTitle("Down a third time")); + List third = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !before.contains(n.getValue("id"))).toList(); + assertThat(third).singleElement().matches(n -> chatId.equals(String.valueOf(n.getValue("channelId"))), "only the chosen channel when the bell is off"); + } + + + + /******************************************************************************* + ** A subscription that covers the alert by scope and severity, but only for + ** some events, still counts as ownership: RESOLVED does not fall through to + ** the owner team. + *******************************************************************************/ + @Test + void aSubscriptionWithFewerEventsStillCoversTheAlert() throws Exception + { + Integer teamId = team("infra", "owner-1"); + QRecord group = group("infrastructure"); + runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId))); + subscribe("subscriber"); + + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("a").withTitle("Down")); + runtime.getEngine().resolve(TYPE, "a", Resolution.PRODUCER, null, "fixed"); + assertThat(recipients()).containsExactly("subscriber"); + } + + + + /******************************************************************************* + ** Follow-ups (resolved, reminders) go to the owner team only when it was + ** told about the raise. + *******************************************************************************/ + @Test + void ownerTeamGetsFollowUpsOnlyAfterTheRaise() throws Exception + { + Integer teamId = team("infra", "owner-1"); + QRecord group = group("infrastructure"); + + ///////////////////////////////////////////////////////////////// + // raised with no owner team: nobody is told, so nobody is told // + // about the resolution even though a team is named afterwards // + ///////////////////////////////////////////////////////////////// + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("early").withTitle("Down")); + runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId))); + runtime.getEngine().resolve(TYPE, "early", Resolution.PRODUCER, null, "fixed"); + assertThat(recipients()).isEmpty(); + + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("late").withTitle("Down")); + assertThat(recipients()).containsExactly("owner-1"); + runtime.getEngine().resolve(TYPE, "late", Resolution.PRODUCER, null, "fixed"); + assertThat(all(AlertsDelivery.TABLE_NAME).stream().filter(n -> "RESOLVED".equals(n.getValueString("kind")))).hasSize(1); + } + + + + /******************************************************************************* + ** A failing type sync (a unique-key race between replicas, say) never breaks + ** the bell; the next request retries. + *******************************************************************************/ + @Test + void aFailingSyncDoesNotBreakTheBell() throws Exception + { + try(java.sql.Connection connection = com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.getConnection((com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); + java.sql.Statement statement = connection.createStatement()) + { + statement.execute("ALTER TABLE alerts_type RENAME TO alerts_type_gone"); + try + { + assertThat(new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).requireViewer()).isEqualTo(ADMIN); + } + finally + { + statement.execute("ALTER TABLE alerts_type_gone RENAME TO alerts_type"); + } + } + assertThat(new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).requireViewer()).isEqualTo(ADMIN); + assertThat(all("alerts_type")).isNotEmpty(); } } -- 2.54.0