From a2e7f9d9e540a4e9f2431b91549d61f78d0324fc Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:27:35 -0500 Subject: [PATCH 1/2] feat(notifications): Slack preference and recipient columns (changeset 28) slackMode and slackMinSeverity on alerts_notification_preference; slackStatus and slackMessageId on alerts_notification_recipient. Co-Authored-By: Claude Opus 5.5 --- .../metadata/AlertsTableMetaDataHelper.java | 7 +- .../model/AlertsNotificationPreference.java | 66 +++++++++++++++++++ .../model/AlertsNotificationRecipient.java | 66 +++++++++++++++++++ .../alerts/model/AlertsPossibleValues.java | 16 ++++- .../notifications/NotificationService.java | 2 +- .../qbit-alerts/alerts.changelog.sql | 10 +++ .../qbits/alerts/delivery/RenderingTest.java | 2 +- src/test/resources/alerts-h2.sql | 5 ++ 8 files changed, 168 insertions(+), 6 deletions(-) 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 fb4a437..80487ad 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java @@ -445,6 +445,7 @@ public final class AlertsTableMetaDataHelper .withRecordSecurityLock(AlertsSecurity.recipientLock("userId")) .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notificationId", "userId", "deliveredAt", "readAt", "dismissedAt"))) .withSection(new QFieldSection("email", "Email", new QIcon("mail"), Tier.T2, List.of("emailStatus", "emailedAt"))) + .withSection(new QFieldSection("slack", "Slack", new QIcon("chat"), Tier.T2, List.of("slackStatus", "slackMessageId"))) .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate"))); table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_DELETE); return (notificationCustomizer(table, true)); @@ -463,12 +464,14 @@ 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", "emailMode", "emailMinSeverity", "slackMode", "slackMinSeverity"))) .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("userId", "createDate", "modifyDate"))); table.getField("category").setDefaultValue(NotificationCategory.SYSTEM.name()); table.getField("inAppMuted").setDefaultValue(false); table.getField("emailMode").setDefaultValue(AlertsPossibleValues.EmailMode.OFF.name()); table.getField("emailMinSeverity").setDefaultValue(NotificationSeverity.WARNING.name()); + table.getField("slackMode").setDefaultValue(AlertsPossibleValues.EmailMode.OFF.name()); + table.getField("slackMinSeverity").setDefaultValue(NotificationSeverity.WARNING.name()); return (notificationCustomizer(table, false)); } @@ -628,7 +631,7 @@ public final class AlertsTableMetaDataHelper widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(JOIN_TEAM_MEMBER)).withLabel("Members").withCanAddChildRecord(true).withMaxRows(100) .withOnlyIncludeFieldNames(List.of("userId", "role")).getWidgetMetaData()); widgets.add(childList(joins.get(JOIN_CHANNEL_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt"))); - widgets.add(childList(joins.get(JOIN_NOTIFICATION_RECIPIENT), "Recipients", 25, new QFilterOrderBy("id", true), List.of("userId", "deliveredAt", "readAt", "dismissedAt", "emailStatus"))); + widgets.add(childList(joins.get(JOIN_NOTIFICATION_RECIPIENT), "Recipients", 25, new QFilterOrderBy("id", true), List.of("userId", "deliveredAt", "readAt", "dismissedAt", "emailStatus", "slackStatus"))); return (widgets); } 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..d3e747b 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationPreference.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationPreference.java @@ -52,6 +52,12 @@ public class AlertsNotificationPreference extends QRecordEntity @QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "Email From Severity") private String emailMinSeverity; + @QField(maxLength = 16, possibleValueSourceName = "alerts_email_mode", label = "Slack") + private String slackMode; + + @QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "Slack From Severity") + private String slackMinSeverity; + @QField(isEditable = false) private Instant createDate; @@ -299,4 +305,64 @@ public class AlertsNotificationPreference extends QRecordEntity this.modifyDate = modifyDate; return (this); } + + + /******************************************************************************* + ** Getter for slackMode + *******************************************************************************/ + public String getSlackMode() + { + return (this.slackMode); + } + + + + /******************************************************************************* + ** Setter for slackMode + *******************************************************************************/ + public void setSlackMode(String slackMode) + { + this.slackMode = slackMode; + } + + + + /******************************************************************************* + ** Fluent setter for slackMode + *******************************************************************************/ + public AlertsNotificationPreference withSlackMode(String slackMode) + { + this.slackMode = slackMode; + return (this); + } + + + /******************************************************************************* + ** Getter for slackMinSeverity + *******************************************************************************/ + public String getSlackMinSeverity() + { + return (this.slackMinSeverity); + } + + + + /******************************************************************************* + ** Setter for slackMinSeverity + *******************************************************************************/ + public void setSlackMinSeverity(String slackMinSeverity) + { + this.slackMinSeverity = slackMinSeverity; + } + + + + /******************************************************************************* + ** Fluent setter for slackMinSeverity + *******************************************************************************/ + public AlertsNotificationPreference withSlackMinSeverity(String slackMinSeverity) + { + this.slackMinSeverity = slackMinSeverity; + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationRecipient.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationRecipient.java index 477cb1f..10e34d4 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationRecipient.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsNotificationRecipient.java @@ -58,6 +58,12 @@ public class AlertsNotificationRecipient extends QRecordEntity @QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_email_status") private String emailStatus; + @QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_slack_status") + private String slackStatus; + + @QField(isEditable = false, maxLength = 100) + private String slackMessageId; + @QField(isEditable = false) private Instant createDate; @@ -361,4 +367,64 @@ public class AlertsNotificationRecipient extends QRecordEntity this.modifyDate = modifyDate; return (this); } + + + /******************************************************************************* + ** Getter for slackStatus + *******************************************************************************/ + public String getSlackStatus() + { + return (this.slackStatus); + } + + + + /******************************************************************************* + ** Setter for slackStatus + *******************************************************************************/ + public void setSlackStatus(String slackStatus) + { + this.slackStatus = slackStatus; + } + + + + /******************************************************************************* + ** Fluent setter for slackStatus + *******************************************************************************/ + public AlertsNotificationRecipient withSlackStatus(String slackStatus) + { + this.slackStatus = slackStatus; + return (this); + } + + + /******************************************************************************* + ** Getter for slackMessageId + *******************************************************************************/ + public String getSlackMessageId() + { + return (this.slackMessageId); + } + + + + /******************************************************************************* + ** Setter for slackMessageId + *******************************************************************************/ + public void setSlackMessageId(String slackMessageId) + { + this.slackMessageId = slackMessageId; + } + + + + /******************************************************************************* + ** Fluent setter for slackMessageId + *******************************************************************************/ + public AlertsNotificationRecipient withSlackMessageId(String slackMessageId) + { + this.slackMessageId = slackMessageId; + 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 ff76299..c9018f6 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java @@ -63,6 +63,7 @@ public final class AlertsPossibleValues public static final String AUDIENCE_TYPE = "alerts_audience_type"; public static final String EMAIL_STATUS = "alerts_email_status"; public static final String EMAIL_MODE = "alerts_email_mode"; + public static final String SLACK_STATUS = "alerts_slack_status"; @@ -268,7 +269,8 @@ public final class AlertsPossibleValues enumSource(NOTIFICATION_STATUS, NotificationStatus.values()), enumSource(AUDIENCE_TYPE, NotificationAudience.Type.values()), enumSource(EMAIL_STATUS, EmailStatus.values()), - enumSource(EMAIL_MODE, EmailMode.values()))); + enumSource(EMAIL_MODE, EmailMode.values()), + enumSource(SLACK_STATUS, SlackStatus.values()))); } @@ -294,7 +296,17 @@ public final class AlertsPossibleValues /******************************************************************************* - ** How a user wants a category by email. + ** Whether (and how) a recipient was sent a Slack direct message. + *******************************************************************************/ + public enum SlackStatus + { + NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED + } + + + + /******************************************************************************* + ** How a user wants a category by email (also used for Slack: same modes). *******************************************************************************/ public enum EmailMode { 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 a3eac36..03986ec 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java @@ -579,7 +579,7 @@ public class NotificationService *******************************************************************************/ private static QRecord newRecipient(Integer notificationId, String userId, Instant now) { - return (new QRecord().withValue("notificationId", notificationId).withValue("userId", userId).withValue("deliveredAt", now).withValue("emailStatus", "NONE")); + return (new QRecord().withValue("notificationId", notificationId).withValue("userId", userId).withValue("deliveredAt", now).withValue("emailStatus", "NONE").withValue("slackStatus", "NONE")); } 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 5c9460f..aaa2f62 100644 --- a/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql @@ -503,3 +503,13 @@ ALTER TABLE alerts_delivery ADD COLUMN digest_claim_key VARCHAR(240); ALTER TABLE alerts_delivery ADD COLUMN digest_last_item_id INTEGER; ALTER TABLE alerts_delivery ADD CONSTRAINT uq_alerts_delivery_digest_claim UNIQUE (digest_claim_key); CREATE INDEX ix_alerts_delivery_digest ON alerts_delivery (digest_key, channel_id, status); + +-- changeset qbit-alerts:28 +-- comment: Per-user Slack direct messages for notification categories: the Slack mode and minimum severity, and each recipient's Slack status and message id. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_notification_preference' AND LOWER(column_name) = 'slack_mode' +ALTER TABLE alerts_notification_preference ADD COLUMN slack_mode VARCHAR(16); +ALTER TABLE alerts_notification_preference ADD COLUMN slack_min_severity VARCHAR(16); +ALTER TABLE alerts_notification_recipient ADD COLUMN slack_status VARCHAR(16); +ALTER TABLE alerts_notification_recipient ADD COLUMN slack_message_id VARCHAR(100); +CREATE INDEX ix_alerts_notification_recipient_slack_status ON alerts_notification_recipient (slack_status); diff --git a/src/test/java/com/kingsrook/qbits/alerts/delivery/RenderingTest.java b/src/test/java/com/kingsrook/qbits/alerts/delivery/RenderingTest.java index cd5eda2..2357feb 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/delivery/RenderingTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/delivery/RenderingTest.java @@ -160,6 +160,6 @@ class RenderingTest assertThat(AlertsPossibleValues.label("IDP_GROUP")).isEqualTo("IdP Group"); assertThat(AlertsPossibleValues.label("IN_APP")).isEqualTo("In App"); assertThat(AlertsPossibleValues.label(null)).isNull(); - assertThat(AlertsPossibleValues.buildEnumSources()).hasSize(25); + assertThat(AlertsPossibleValues.buildEnumSources()).hasSize(26); } } diff --git a/src/test/resources/alerts-h2.sql b/src/test/resources/alerts-h2.sql index 7f3df25..fe5d361 100644 --- a/src/test/resources/alerts-h2.sql +++ b/src/test/resources/alerts-h2.sql @@ -361,6 +361,8 @@ CREATE TABLE IF NOT EXISTS alerts_notification_recipient ( dismissed_at TIMESTAMP, emailed_at TIMESTAMP, email_status VARCHAR(16), + slack_status VARCHAR(16), + slack_message_id VARCHAR(100), create_date TIMESTAMP, modify_date TIMESTAMP, CONSTRAINT pk_alerts_notification_recipient PRIMARY KEY (id), @@ -368,6 +370,7 @@ CREATE TABLE IF NOT EXISTS alerts_notification_recipient ( ); CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_user_id_read_at ON alerts_notification_recipient (user_id, read_at); CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_email_status ON alerts_notification_recipient (email_status); +CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_slack_status ON alerts_notification_recipient (slack_status); CREATE TABLE IF NOT EXISTS alerts_notification_preference ( id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL, @@ -376,6 +379,8 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference ( in_app_muted BOOLEAN, email_mode VARCHAR(16), email_min_severity VARCHAR(16), + slack_mode VARCHAR(16), + slack_min_severity VARCHAR(16), create_date TIMESTAMP, modify_date TIMESTAMP, CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id), -- 2.54.0 From 718e13c2362c8d3ad273b8e32ebd608d2773cdc2 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:27:35 -0500 Subject: [PATCH 2/2] feat(notifications): deliver notifications to Slack per user preference NotificationSlackSender runs after the emailer: core SendMessageAction to the configured provider (default "slack") with user:, no qbit-slack import. Pause and quiet hours apply (CRITICAL bypasses quiet hours), SECURITY sends title and link only, daily digest DMs, SKIPPED without a provider or on a user-facing refusal. Supersedes the 2026-10-13 "Slack never per user" decision (owner, 2026-10-14; QRun-IO/qbit-slack DESIGN section 10). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/NOTIFICATIONS.md | 26 +- .../qbits/alerts/AlertsQBitConfig.java | 24 ++ .../qbits/alerts/engine/AlertRouter.java | 2 +- .../notifications/NotificationEmailer.java | 14 +- .../NotificationSlackSender.java | 272 ++++++++++++++ .../processes/AlertsScheduledSteps.java | 2 + .../notifications/NotificationSlackTest.java | 354 ++++++++++++++++++ 8 files changed, 687 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java create mode 100644 src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d5006..2943790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to this project are documented here. The format follows Keep - Webhook signing delegates to core `WebhookSigner`/`WebhookSecret` (qbit-alerts#15, F11). Headers, `v1,` signatures and `whsec_` encoding are unchanged. A thin adapter preserves legacy keys shorter than core's 16-byte minimum using equivalent HMAC zero-padding. ### Added +- Send notifications to Slack per user and category (QRun-IO/qbit-slack docs/DESIGN.md section 10; supersedes "Slack never per user"). `alerts_notification_preference` gets `slackMode` (OFF/IMMEDIATE/DAILY_DIGEST, default OFF) and `slackMinSeverity` (default WARNING); recipients get `slackStatus` and `slackMessageId` (guarded changeset 28). IMMEDIATE and daily-digest DMs go through core `SendMessageAction` (provider `AlertsQBitConfig.withSlackProviderName`, default `slack`, address `user:`) with no qbit-slack dependency. Pause and quiet hours hold them (CRITICAL bypasses), SECURITY carries title and link only, a user without a Slack connection is skipped, and failures never fail the notification. RC.6 has no idempotency key or `isAvailable`, so `slackStatus` guards resends (STAND-IN core#1194, removeBy RC.8) and availability checks the instance's provider. - Apply reminders and escalation (#27, part 2): a minute-based hidden job sends due reminders, advances ordered USER/TEAM/CHANNEL escalation steps, and caps final-step repeats. Acknowledgement, snooze, resolution and mutes stop both; reopening restarts escalation. Delivery, events and progress commit together, with a persisted same-minute checkpoint. - Add admin escalation policy and step tables, child step lists, and type/group policy links in guarded Liquibase changesets 23–26. Type policies override group policies. CRITICAL reminders default to 60 minutes; explicit zero turns them off. - Keep EXPIRED reserved and remove it from selectable resolution values because the design specifies no expiration rule. diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 9182f20..912f83f 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -23,7 +23,9 @@ system as well" ## 1. Owner decisions (2026-10-13) 1. **Channels.** In-app (the bell and the notifications page) always. Email is opt-in per user, by - severity or as a daily digest. Slack stays a group or monitor channel only, never per user. + severity or as a daily digest. Slack is also a per-user channel, chosen per category (owner decision + 2026-10-14, superseding the 2026-10-13 "Slack stays a group or monitor channel only, never per user"; + alert-group and monitor Slack channels are unchanged). 2. **Audience.** Dynamic audiences: all users, groups or roles, holders of a permission, or named users. The audience is resolved at send time and materialized as one recipient row per user, so read, unread and dismissed are tracked per user. @@ -88,8 +90,9 @@ everywhere. Deleting it deletes its recipient rows. A child of the notification: one row per user, unique (`notificationId`, `userId`). -`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus` -(NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED). +`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus`, +`slackStatus`, `slackMessageId` (each status is NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED; +`slackMessageId` is filled when the Slack provider reports one). Users see and change only their own rows (owner lock on `userId`). Only `readAt` and `dismissedAt` are editable; inserts and deletes are the qbit's. @@ -99,7 +102,8 @@ 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 / -DAILY_DIGEST, default OFF), `emailMinSeverity` (default WARNING). It is owner-locked on `userId`. The +DAILY_DIGEST, default OFF), `emailMinSeverity` (default WARNING), `slackMode` (OFF / IMMEDIATE / DAILY_DIGEST, default OFF), +`slackMinSeverity` (default WARNING). It is owner-locked on `userId`. The digest hour and time zone come from `alerts_user_preference`. ## 4. Audiences @@ -191,7 +195,19 @@ QAlerts.notify(new NotificationSignal("Your export failed") sends from the delivery job. DAILY_DIGEST sends one email per user at their digest hour, listing their unread notifications since the last digest. A muted category is skipped. Email never includes a body for SECURITY beyond the title and a link. -- **Slack** stays a channel for alert groups and monitors only. +- **Slack** (per user, opt-in per category through the Slack column of My Notification Settings): sent by + `NotificationSlackSender` from the same delivery job, only through core `SendMessageAction` (provider + `slackProviderName`, default `slack`; address `user:`, the recipient as owner, so the DM goes + through that user's own Slack connection; qbit-slack is never imported). IMMEDIATE sends one DM per + notification at or above `slackMinSeverity`; DAILY_DIGEST sends one DM per user at their digest hour, + listing their unread notifications, and marks the rows DIGESTED. Slack is a push channel, so a user's pause + and quiet hours (`alerts_user_preference`) hold it, with the same CRITICAL bypass as other push channels; + the bell is never held. ALERT notifications are not sent (alert channels already cover them) and SECURITY + carries the title and link only. With no provider registered under that name the rows are SKIPPED. A user + without a Slack connection is SKIPPED with no retry; any other error is FAILED; neither fails the + notification. Idempotency key `notify:::slack`; until core adds an idempotency + field (STAND-IN core#1194, remove by RC.8) the recipient's `slackStatus` prevents a resend. +- **Alert-group and monitor Slack channels** (incoming webhooks) work as before. ## 8. Next UI bell and page diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java index 6b266a5..37f1e95 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java @@ -82,6 +82,7 @@ public class AlertsQBitConfig implements QBitConfig private String schedulerName; private String messagingProviderName; private String fromAddress; + private String slackProviderName = "slack"; private String publicBaseUrl; private Integer reopenWindowMinutes = 30; @@ -341,6 +342,29 @@ public class AlertsQBitConfig implements QBitConfig + /******************************************************************************* + ** Getter for slackProviderName + *******************************************************************************/ + public String getSlackProviderName() + { + return (this.slackProviderName); + } + + + + /******************************************************************************* + ** Fluent setter for slackProviderName: the core messaging provider that + ** sends per-user Slack direct messages (default "slack", registered by + ** qbit-slack). Without a provider of that name Slack is skipped. + *******************************************************************************/ + public AlertsQBitConfig withSlackProviderName(String slackProviderName) + { + this.slackProviderName = slackProviderName; + return (this); + } + + + /******************************************************************************* ** Getter for fromAddress *******************************************************************************/ 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 c7d74c3..0e40ca4 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java @@ -765,7 +765,7 @@ public class AlertRouter /******************************************************************************* ** Whether the user paused notifications (vacation) until after now. *******************************************************************************/ - private static boolean isPaused(QRecord preference, Instant now) + public static boolean isPaused(QRecord preference, Instant now) { return (preference != null && preference.getValueInstant("pausedUntil") != null && preference.getValueInstant("pausedUntil").isAfter(now)); } 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 ba2b4f0..1568ac7 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java @@ -421,6 +421,16 @@ public class NotificationEmailer ** A link made absolute with the public base URL (relative app paths). *******************************************************************************/ private String absolute(String link) + { + return (absolute(runtime, link)); + } + + + + /******************************************************************************* + ** A link made absolute with the public base URL (relative app paths). + *******************************************************************************/ + static String absolute(AlertsRuntime runtime, String link) { String base = runtime.getConfig().getPublicBaseUrl(); if(link == null || !link.startsWith("/") || !StringUtils.hasContent(base)) @@ -445,7 +455,7 @@ public class NotificationEmailer /******************************************************************************* ** The live (SENT, published, not expired) notifications for the rows. *******************************************************************************/ - private static Map notifications(List recipients, Instant now) throws QException + static Map notifications(List recipients, Instant now) throws QException { Map live = new HashMap<>(); List ids = recipients.stream().map(recipient -> recipient.getValueInteger("notificationId")).distinct().toList(); @@ -470,7 +480,7 @@ public class NotificationEmailer /******************************************************************************* ** My Alert Settings rows (address, time zone, digest hour) by user. *******************************************************************************/ - private static Map userSettings(java.util.Set userIds) throws QException + static Map userSettings(java.util.Set userIds) throws QException { Map settings = new HashMap<>(); for(QRecord setting : AlertsDb.query(AlertsUserPreference.TABLE_NAME, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(userIds)))) diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java new file mode 100644 index 0000000..3b60b7d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackSender.java @@ -0,0 +1,272 @@ +/* + * 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.notifications; + + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.engine.AlertRouter; +import com.kingsrook.qbits.alerts.engine.AlertTiming; +import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; +import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SlackStatus; +import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; +import com.kingsrook.qqq.backend.core.model.actions.messaging.ContentRole; +import com.kingsrook.qqq.backend.core.model.actions.messaging.Party; +import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Slack direct messages for user notifications (docs/NOTIFICATIONS.md section + ** 7), opt-in per user and category in My Notification Settings, alongside + ** the bell and email: + ** - IMMEDIATE: one DM per notification at or above slackMinSeverity; + ** - DAILY_DIGEST: one DM per user at their digest hour listing their unread + ** notifications. + ** Sent only through core SendMessageAction (provider slackProviderName, + ** address "user:", the recipient as owner, so the DM travels through that + ** user's own Slack connection); qbit-slack is never imported. Slack is a push + ** channel: pause and quiet hours hold it (CRITICAL bypasses quiet hours), + ** never the bell. ALERT notifications are not sent here. SECURITY DMs carry + ** the title and link only. A user without a Slack connection (the provider + ** throws a QUserFacingException) is SKIPPED with no retry; other failures are + ** FAILED. Never fails the notification itself. Runs as the system user from + ** the delivery job. + *******************************************************************************/ +public class NotificationSlackSender +{ + private static final QLogger LOG = QLogger.getLogger(NotificationSlackSender.class); + + private final AlertsRuntime runtime; + + + + /******************************************************************************* + ** What one run did. + *******************************************************************************/ + public record Result(int sent, int digests, int held, int skipped, int failed) + { + } + + + + /******************************************************************************* + ** Constructor + *******************************************************************************/ + public NotificationSlackSender(AlertsRuntime runtime) + { + this.runtime = runtime; + } + + + + /******************************************************************************* + ** Send what is due: immediate DMs, and digest DMs for users at their hour. + *******************************************************************************/ + public Result run() throws QException + { + Instant now = runtime.now(); + return (runtime.asSystem(() -> + { + Map> preferences = new HashMap<>(); + for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter() + .withCriteria("slackMode", QCriteriaOperator.IN, List.of(EmailMode.IMMEDIATE.name(), EmailMode.DAILY_DIGEST.name())))) + { + preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference); + } + if(preferences.isEmpty()) + { + return (new Result(0, 0, 0, 0, 0)); + } + + List recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter() + .withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet())) + .withCriteria("slackStatus", QCriteriaOperator.IN, List.of(SlackStatus.NONE.name(), SlackStatus.PENDING.name())) + .withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)) + .withLimit(NotificationEmailer.SCAN)); + Map notifications = NotificationEmailer.notifications(recipients, now); + Map settings = NotificationEmailer.userSettings(preferences.keySet()); + boolean available = isAvailable(); + + int sent = 0; + int held = 0; + int skipped = 0; + int failed = 0; + Map> digests = new LinkedHashMap<>(); + for(QRecord recipient : recipients) + { + QRecord notification = notifications.get(recipient.getValueInteger("notificationId")); + String userId = recipient.getValueString("userId"); + QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category")); + if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category")) + || !NotificationEmailer.atLeast(notification.getValueString("severity"), preference.getValueString("slackMinSeverity")) || !available) + { + mark(recipient, SlackStatus.SKIPPED); + skipped++; + } + else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("slackMode"))) + { + if(isHeld(settings.get(userId), notification, now)) + { + held++; + continue; + } + SlackStatus status = sendExternal(userId, notification.getValueString("title"), List.of(notification), notification.getValueInteger("id")); + mark(recipient, status); + sent += status == SlackStatus.SENT ? 1 : 0; + skipped += status == SlackStatus.SKIPPED ? 1 : 0; + failed += status == SlackStatus.FAILED ? 1 : 0; + } + else if(recipient.getValue("readAt") == null) + { + digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification}); + } + } + + int digestCount = 0; + for(Map.Entry> digest : digests.entrySet()) + { + QRecord setting = settings.get(digest.getKey()); + if(!NotificationEmailer.isDigestHour(setting, now)) + { + continue; + } + List items = digest.getValue().stream().map(pair -> pair[1]).toList(); + if(items.stream().allMatch(item -> isHeld(setting, item, now))) + { + held++; + continue; + } + SlackStatus status = sendExternal(digest.getKey(), items.size() == 1 ? "1 new notification" : items.size() + " new notifications", items, null); + for(QRecord[] pair : digest.getValue()) + { + mark(pair[0], status == SlackStatus.SENT ? SlackStatus.DIGESTED : status); + } + digestCount += status == SlackStatus.SENT ? 1 : 0; + failed += status == SlackStatus.FAILED ? 1 : 0; + } + return (new Result(sent, digestCount, held, skipped, failed)); + })); + } + + + + /******************************************************************************* + ** Whether the Slack messaging provider is registered in this instance. + ** RC.6 core has no SendMessageAction.isAvailable, so ask the instance. + *******************************************************************************/ + private boolean isAvailable() + { + String name = runtime.getConfig().getSlackProviderName(); + return (StringUtils.hasContent(name) && QContext.getQInstance().getMessagingProvider(name) != null); + } + + + + /******************************************************************************* + ** Whether a push is held for now: the user paused notifications, or it is + ** their quiet hours (CRITICAL bypasses). The bell is never held. + *******************************************************************************/ + private static boolean isHeld(QRecord setting, QRecord notification, Instant now) + { + return (AlertRouter.isPaused(setting, now) + || AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now)); + } + + + + /******************************************************************************* + ** The external notification channel step: one Slack DM to the user (the + ** recipient is the owner of the connection that sends it) with the + ** notification titles, bodies (not for SECURITY) and links. SENT; SKIPPED + ** when the provider says the user can't be reached (no Slack connection), + ** with no retry; FAILED for anything else. + ** + ** STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6 SendMessageInput has no + ** idempotency key (notify:::slack), so the + ** recipient row's slackStatus is what prevents a resend. + *******************************************************************************/ + private SlackStatus sendExternal(String userId, String subject, List items, Integer notificationId) + { + StringBuilder text = new StringBuilder(); + for(QRecord item : items) + { + String body = NotificationCategory.SECURITY.name().equals(item.getValueString("category")) ? null : item.getValueString("body"); + String link = NotificationEmailer.absolute(runtime, NotificationService.safeLink(item.getValueString("linkUrl"))); + text.append(items.size() > 1 ? "- " : "").append(item.getValueString("title")).append("\n"); + if(StringUtils.hasContent(body)) + { + text.append(body).append("\n"); + } + if(link != null) + { + text.append(link).append("\n"); + } + } + String key = notificationId == null ? null : "notify:" + notificationId + ":" + userId + ":slack"; + try + { + new SendMessageAction().execute(new SendMessageInput() + .withMessagingProviderName(runtime.getConfig().getSlackProviderName()) + .withTo(new Party().withAddress("user:" + userId)) + .withSubject(subject) + .withContentList(List.of(new Content().withContentRole(ContentRole.Default.DEFAULT).withBody(text.toString().stripTrailing())))); + return (SlackStatus.SENT); + } + catch(QUserFacingException e) + { + LOG.info("Slack could not reach a user; skipped", logPair("userId", userId), logPair("key", key), logPair("reason", e.getMessage())); + return (SlackStatus.SKIPPED); + } + catch(Exception e) + { + LOG.warn("Could not send a Slack notification", e, logPair("userId", userId), logPair("key", key)); + return (SlackStatus.FAILED); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void mark(QRecord recipient, SlackStatus status) throws QException + { + AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, new QRecord().withValue("id", recipient.getValue("id")).withValue("slackStatus", status.name())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java index 236df0d..102be3a 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java +++ b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java @@ -21,6 +21,7 @@ import com.kingsrook.qbits.alerts.AlertsRuntime; import com.kingsrook.qbits.alerts.delivery.DigestWorker; import com.kingsrook.qbits.alerts.engine.EscalationAndReminder; import com.kingsrook.qbits.alerts.notifications.NotificationEmailer; +import com.kingsrook.qbits.alerts.notifications.NotificationSlackSender; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; @@ -79,6 +80,7 @@ public final class AlertsScheduledSteps runtime.getNotifications().sendDue(); runtime.getNotifications().purge(); new NotificationEmailer(runtime).run(); + new NotificationSlackSender(runtime).run(); output.addValue(OUTPUT_COUNT, runtime.getDeliveryWorker().deliverDue()); } } diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java new file mode 100644 index 0000000..aa62476 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationSlackTest.java @@ -0,0 +1,354 @@ +/* + * 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.notifications; + + +import java.time.Instant; +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.engine.AlertsDb; +import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; +import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; +import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +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.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData; +import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface; +import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Per-user Slack notifications (immediate and daily digest) through a fake + ** core messaging provider registered as "slack". + *******************************************************************************/ +class NotificationSlackTest extends BaseAlertsTest +{ + static final List SENT = new CopyOnWriteArrayList<>(); + + static volatile String failureMode = "NONE"; + + + + /******************************************************************************* + ** Records inputs instead of sending them; can be told to fail. + *******************************************************************************/ + public static class FakeSlackProvider implements MessagingProviderInterface + { + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public String getType() + { + return ("alerts-slack-capture"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public SendMessageOutput sendMessage(SendMessageInput sendMessageInput) throws QException + { + if("NO_CONNECTION".equals(failureMode)) + { + throw (new QUserFacingException("Connect Slack first.")); + } + if("BOOM".equals(failureMode)) + { + throw (new IllegalStateException("Slack is down")); + } + SENT.add(sendMessageInput); + return (new SendMessageOutput()); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected void customizeConfig(AlertsQBitConfig config) + { + QMessagingProviderDispatcher.registerMessagingProvider(new FakeSlackProvider()); + qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("slack").withType("alerts-slack-capture")); + SENT.clear(); + failureMode = "NONE"; + } + + + + /******************************************************************************* + ** A user's settings (UTC, digest hour 12, and any extra settings) and one + ** category preference for Slack. + *******************************************************************************/ + private void slackSettings(String userId, String category, String mode, String minSeverity, QRecord extra) throws QException + { + AlertsPreferences.ensure(runtime, loginAs(userId)); + runtime.asSystem(() -> + { + QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); + QRecord update = new QRecord().withValue("id", settings.getValue("id")).withValue("timeZone", "UTC").withValue("digestHour", 12); + if(extra != null) + { + extra.getValues().forEach(update::setValue); + } + AlertsDb.update(AlertsUserPreference.TABLE_NAME, update); + AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", userId).withValue("category", category) + .withValue("slackMode", mode).withValue("slackMinSeverity", minSeverity).withValue("inAppMuted", false)); + return (null); + }); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private String slackStatus(String userId) throws QException + { + return (runtime.asSystem(() -> AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId))).getValueString("slackStatus")); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String text(SendMessageInput input) + { + return (input.getContentList().stream().map(Content::getBody).reduce("", String::concat)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void notifyJob(String title, NotificationSeverity severity, String... userIds) + { + QAlerts.notify(new NotificationSignal(title).withBody("Disk full").withSeverity(severity).withCategory(NotificationCategory.JOB) + .withLink(AlertLink.toPath("/app/widget", "Widgets")).withAudience(NotificationAudience.users(userIds))); + } + + + + /******************************************************************************* + ** IMMEDIATE sends one DM to user: through the Slack provider; below + ** the minimum severity is skipped; OFF and users without a row are left + ** alone; running again does not send twice. + *******************************************************************************/ + @Test + void testImmediate() throws Exception + { + slackSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING", null); + slackSettings("viewer-2", "JOB", "IMMEDIATE", "ERROR", null); + slackSettings("viewer-3", "JOB", "OFF", "INFO", null); + notifyJob("Export failed", NotificationSeverity.WARNING, "viewer-1", "viewer-2", "viewer-3", "manager-1"); + + NotificationSlackSender.Result result = new NotificationSlackSender(runtime).run(); + assertThat(result.sent()).isEqualTo(1); + assertThat(result.skipped()).isEqualTo(1); + assertThat(SENT).singleElement().satisfies(message -> + { + assertThat(message.getMessagingProviderName()).isEqualTo("slack"); + assertThat(message.getTo().getAddress()).isEqualTo("user:viewer-1"); + assertThat(message.getSubject()).isEqualTo("Export failed"); + assertThat(text(message)).contains("Export failed").contains("Disk full").contains("https://admin.example.com/app/widget"); + }); + assertThat(slackStatus("viewer-1")).isEqualTo("SENT"); + assertThat(slackStatus("viewer-2")).isEqualTo("SKIPPED"); + assertThat(slackStatus("viewer-3")).isEqualTo("NONE"); + assertThat(slackStatus("manager-1")).isEqualTo("NONE"); + + assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(0); + assertThat(SENT).hasSize(1); + } + + + + /******************************************************************************* + ** A category muted in-app gets no recipient row, so no Slack message. + *******************************************************************************/ + @Test + void testMuted() throws Exception + { + slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", null); + runtime.asSystem(() -> AlertsDb.update(AlertsNotificationPreference.TABLE_NAME, new QRecord() + .withValue("id", AlertsDb.first(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-1")).getValue("id")).withValue("inAppMuted", true))); + notifyJob("Export failed", NotificationSeverity.ERROR, "viewer-1"); + + assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(0); + assertThat(SENT).isEmpty(); + } + + + + /******************************************************************************* + ** Quiet hours hold the DM (the bell is untouched); CRITICAL bypasses; it + ** goes out once the quiet hours end. + *******************************************************************************/ + @Test + void testQuietHours() throws Exception + { + clock.set(Instant.parse("2026-10-12T23:00:00Z")); + slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", new QRecord().withValue("quietHoursStart", "22:00").withValue("quietHoursEnd", "07:00")); + notifyJob("Export failed", NotificationSeverity.ERROR, "viewer-1"); + notifyJob("Database down", NotificationSeverity.CRITICAL, "viewer-1"); + + NotificationSlackSender.Result result = new NotificationSlackSender(runtime).run(); + assertThat(result.sent()).isEqualTo(1); + assertThat(result.held()).isEqualTo(1); + assertThat(SENT).singleElement().satisfies(message -> assertThat(message.getSubject()).isEqualTo("Database down")); + assertThat(runtime.asSystem(() -> AlertsDb.count(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "viewer-1")))).isEqualTo(2); + + clock.set(Instant.parse("2026-10-13T07:05:00Z")); + assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1); + assertThat(SENT).hasSize(2); + } + + + + /******************************************************************************* + ** A paused user gets nothing until the pause ends (even CRITICAL). + *******************************************************************************/ + @Test + void testPaused() throws Exception + { + clock.set(Instant.parse("2026-10-12T10:00:00Z")); + slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", new QRecord().withValue("pausedUntil", Instant.parse("2026-10-14T10:00:00Z"))); + notifyJob("Database down", NotificationSeverity.CRITICAL, "viewer-1"); + + assertThat(new NotificationSlackSender(runtime).run().held()).isEqualTo(1); + assertThat(SENT).isEmpty(); + assertThat(slackStatus("viewer-1")).isEqualTo("NONE"); + } + + + + /******************************************************************************* + ** No provider under the configured name: SKIPPED, nothing thrown. + *******************************************************************************/ + @Test + void testNoProvider() throws Exception + { + runtime.getConfig().withSlackProviderName("not-registered"); + slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", null); + notifyJob("Export failed", NotificationSeverity.ERROR, "viewer-1"); + + assertThat(new NotificationSlackSender(runtime).run().skipped()).isEqualTo(1); + assertThat(SENT).isEmpty(); + assertThat(slackStatus("viewer-1")).isEqualTo("SKIPPED"); + } + + + + /******************************************************************************* + ** A user-facing error (no Slack connection) is SKIPPED and not retried; + ** any other error is FAILED. + *******************************************************************************/ + @Test + void testErrors() throws Exception + { + slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", null); + slackSettings("viewer-2", "JOB", "IMMEDIATE", "INFO", null); + + failureMode = "NO_CONNECTION"; + notifyJob("Export failed", NotificationSeverity.ERROR, "viewer-1"); + NotificationSlackSender.Result first = new NotificationSlackSender(runtime).run(); + assertThat(first.skipped()).isEqualTo(1); + assertThat(slackStatus("viewer-1")).isEqualTo("SKIPPED"); + + failureMode = "BOOM"; + notifyJob("Another failure", NotificationSeverity.ERROR, "viewer-2"); + NotificationSlackSender.Result second = new NotificationSlackSender(runtime).run(); + assertThat(second.failed()).isEqualTo(1); + assertThat(slackStatus("viewer-2")).isEqualTo("FAILED"); + + failureMode = "NONE"; + assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(0); + assertThat(SENT).isEmpty(); + } + + + + /******************************************************************************* + ** SECURITY sends the title and link, never the body. + *******************************************************************************/ + @Test + void testSecurityHasNoBody() throws Exception + { + slackSettings("viewer-1", "SECURITY", "IMMEDIATE", "INFO", null); + QAlerts.notify(new NotificationSignal("New sign-in").withBody("Firefox on Windows, please change your password").withCategory(NotificationCategory.SECURITY) + .withSeverity(NotificationSeverity.WARNING).withLink(AlertLink.toPath("/app/widget", "Review")).withAudience(NotificationAudience.user("viewer-1"))); + + assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1); + assertThat(SENT).singleElement().satisfies(message -> + { + assertThat(text(message)).contains("New sign-in").contains("https://admin.example.com/app/widget"); + assertThat(text(message)).doesNotContain("Firefox").doesNotContain("password"); + }); + } + + + + /******************************************************************************* + ** The digest waits for the user's hour, then sends one DM listing their + ** unread notifications and marks them DIGESTED; email-only users get none. + *******************************************************************************/ + @Test + void testDigest() throws Exception + { + clock.set(Instant.parse("2026-10-12T09:00:00Z")); + slackSettings("viewer-1", "JOB", "DAILY_DIGEST", "INFO", null); + notifyJob("Export failed", NotificationSeverity.WARNING, "viewer-1"); + notifyJob("Import failed", NotificationSeverity.ERROR, "viewer-1"); + + assertThat(new NotificationSlackSender(runtime).run().digests()).isEqualTo(0); + assertThat(SENT).isEmpty(); + clock.set(Instant.parse("2026-10-12T12:05:00Z")); + assertThat(new NotificationSlackSender(runtime).run().digests()).isEqualTo(1); + assertThat(SENT).singleElement().satisfies(message -> + { + assertThat(message.getTo().getAddress()).isEqualTo("user:viewer-1"); + assertThat(message.getSubject()).isEqualTo("2 new notifications"); + assertThat(text(message)).contains("Export failed").contains("Import failed"); + }); + assertThat(runtime.asSystem(() -> AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "viewer-1")))) + .allSatisfy(recipient -> assertThat(recipient.getValueString("slackStatus")).isEqualTo("DIGESTED")); + assertThat(new NotificationSlackSender(runtime).run().digests()).isEqualTo(0); + assertThat(SENT).hasSize(1); + } +} -- 2.54.0