feat(notifications): per-user Slack delivery (qbit-slack routing) #31
No files matched your search
@@ -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:<id>`) with no qbit-slack dependency. Pause and quiet hours hold them (CRITICAL bypasses), SECURITY carries title and link only, a user without a Slack connection is skipped, and failures never fail the notification. RC.6 has no idempotency key or `isAvailable`, so `slackStatus` guards resends (STAND-IN core#1194, removeBy RC.8) and availability checks the instance's provider.
|
||||
- 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.
|
||||
|
||||
+21
-5
@@ -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:<userId>`, 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:<notificationId>:<userId>: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
|
||||
|
||||
|
||||
@@ -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
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<Integer, QRecord> notifications(List<QRecord> recipients, Instant now) throws QException
|
||||
static Map<Integer, QRecord> notifications(List<QRecord> recipients, Instant now) throws QException
|
||||
{
|
||||
Map<Integer, QRecord> live = new HashMap<>();
|
||||
List<Integer> 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<String, QRecord> userSettings(java.util.Set<String> userIds) throws QException
|
||||
static Map<String, QRecord> userSettings(java.util.Set<String> userIds) throws QException
|
||||
{
|
||||
Map<String, QRecord> settings = new HashMap<>();
|
||||
for(QRecord setting : AlertsDb.query(AlertsUserPreference.TABLE_NAME, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(userIds))))
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:<id>", 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<String, Map<String, QRecord>> 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<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
|
||||
.withCriteria("slackStatus", QCriteriaOperator.IN, List.of(SlackStatus.NONE.name(), SlackStatus.PENDING.name()))
|
||||
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS))
|
||||
.withLimit(NotificationEmailer.SCAN));
|
||||
Map<Integer, QRecord> notifications = NotificationEmailer.notifications(recipients, now);
|
||||
Map<String, QRecord> settings = NotificationEmailer.userSettings(preferences.keySet());
|
||||
boolean available = isAvailable();
|
||||
|
||||
int sent = 0;
|
||||
int held = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
Map<String, List<QRecord[]>> 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<String, List<QRecord[]>> digest : digests.entrySet())
|
||||
{
|
||||
QRecord setting = settings.get(digest.getKey());
|
||||
if(!NotificationEmailer.isDigestHour(setting, now))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
List<QRecord> 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:<notificationId>:<userId>:slack), so the
|
||||
** recipient row's slackStatus is what prevents a resend.
|
||||
*******************************************************************************/
|
||||
private SlackStatus sendExternal(String userId, String subject, List<QRecord> 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()));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<SendMessageInput> 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:<id> 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user