feat(notifications): per-user Slack delivery (qbit-slack routing) #31

Merged
KofTwentyTwo merged 2 commits from feature/slack-routing into develop 2026-10-14 16:30:57 +00:00
16 changed files with 855 additions and 14 deletions

No files matched your search

+1
View File
@@ -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
View File
@@ -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);
}
}
+5
View File
@@ -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),