From 8c6e4dadfab7c9885915f472fb510f1925cbab1b Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 11:02:08 -0500 Subject: [PATCH] feat(routing): apply quiet hours, user pause and digests Quiet hours, digest hour and pause were editable in My Alert Settings but never applied, and DIGEST_HOURLY / DIGEST_DAILY subscriptions sent immediately. - The router suppresses push deliveries (email, lists, webhooks) to a user during their quiet hours (time zone, windows across midnight, quiet days; severities at or above quietBypassSeverity, default CRITICAL, break through) as QUIET_HOURS, and while paused as USER_PAUSED. The in-app bell is never suppressed by either. A channel below its minimum severity is now logged as MIN_SEVERITY. - Digest subscriptions queue DIGEST deliveries per user/channel and window. The hidden hourly alertsSendDigests job sends one grouped message per window (hourly, or daily at the user's digest hour in their time zone) through the existing email path or one in-app notification, rechecks visibility, skips empty digests and never sends a window twice. Failed or oversized windows stay visible. - Changeset 27 adds the durable digest dispatch columns. Refs #27 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 18 +- docs/DESIGN.md | 12 +- .../qbits/alerts/delivery/AlertMessage.java | 13 +- .../alerts/delivery/AlertMessageRenderer.java | 39 ++ .../qbits/alerts/delivery/DeliveryWorker.java | 1 + .../qbits/alerts/delivery/DigestWorker.java | 297 +++++++++ .../qbits/alerts/delivery/EmailChannel.java | 20 +- .../qbits/alerts/email/AlertsEmailSender.java | 16 + .../qbits/alerts/engine/AlertRouter.java | 50 +- .../qbits/alerts/engine/AlertTiming.java | 144 ++++ .../AlertsProcessesMetaDataProducer.java | 7 +- .../metadata/AlertsTableMetaDataHelper.java | 3 +- .../qbits/alerts/model/AlertsDelivery.java | 102 +++ .../alerts/model/AlertsPossibleValues.java | 2 +- .../notifications/NotificationService.java | 24 + .../processes/AlertsScheduledSteps.java | 20 +- .../qbit-alerts/alerts.changelog.sql | 10 + .../qbits/alerts/AlertsQBitProducerTest.java | 46 +- .../alerts/email/EmailQBitSendingTest.java | 36 + .../engine/QuietHoursAndDigestsTest.java | 621 ++++++++++++++++++ src/test/resources/alerts-h2.sql | 5 + 22 files changed, 1465 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/alerts/delivery/DigestWorker.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/engine/AlertTiming.java create mode 100644 src/test/java/com/kingsrook/qbits/alerts/engine/QuietHoursAndDigestsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f336bd..b4baa0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project are documented here. The format follows Keep ## [0.1.0] - Unreleased ### Changed +- Apply user pause and local quiet hours to personal push channels (email, email lists and other non-IN_APP channels), including team members, with logged suppression reasons and a configurable quiet-hours severity bypass. The in-app bell remains unaffected, including digest delivery. Channel subscriptions are unaffected. +- Queue hourly and daily alert subscription digests and send them through the hidden hourly `alertsSendDigests` process. Group linked alert summaries into one message per user/channel/window, use the existing email integration, and keep durable dispatch claims in the Delivery Log (guarded changeset 27). Reruns cannot send a window twice; failed, interrupted and oversized windows remain visible for review. - Switch the optional qbit-email integration to its published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.2`, preserving templates, categories, idempotency keys, linked records and outcome mapping. Narrow the architecture allowance to that API package while retaining all guardrails. - Email goes through qbit-email when it is produced in the instance (optional dependency, `qbit-email.version` 0.1.0-RC.2): alert channel emails (template `alerts_alert`), notification emails (`alerts_notification`) and digests (`alerts_digest`), all NOTIFICATION, one recipient per message, with idempotency keys and the alert or notification as the linked record. `messagingProviderName` stays as the fallback when qbit-email is absent, and is not required when it is present. - Target released QQQ `4.1.0-RC.6`; verify normally with `./mvnw -B verify` (no version override). diff --git a/README.md b/README.md index 202063f..4b82e99 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,18 @@ How each one sends: - **Responses:** 2xx is sent. 408, 425, 429, 5xx and network errors are retried. Any other 4xx fails permanently. - **URLs** must be https, unless the channel's settings say `"allowInsecureHttp": true`, which is meant for local receivers. +## Quiet hours, pause and alert digests + +My Alert Settings applies to user subscriptions, manual team members and record followers. Pause suppresses personal push channels (email, email lists and other non-IN_APP channels) until `pausedUntil`. Quiet hours also apply only to push channels; neither setting suppresses the in-app bell. Quiet hours use `timeZone` (UTC when missing or invalid), start inclusively and end exclusively. For a window such as 22:00-07:00, `quietDays` names the day the window starts: Monday includes Tuesday morning. Empty days mean every day; use comma-separated ISO day numbers (1=Monday), full English names or MON/TUE/etc. Missing, invalid or equal start/end times disable quiet hours. Severities at or above `quietBypassSeverity` (CRITICAL by default) bypass quiet hours, but never pause. Channel subscriptions remain independent of these personal settings; their digest cadence still applies, using UTC and 08:00 for daily delivery. Suppressions are recorded in the Delivery Log; channel severity filtering now records `MIN_SEVERITY` too. + +Subscriptions with `DIGEST_HOURLY` or `DIGEST_DAILY` queue `DIGEST` delivery rows with status `PENDING`, a window key and a due time. Immediate delivery skips them. The hidden `alertsSendDigests` process runs hourly on `schedulerName`; without a scheduler it can be invoked explicitly. Hourly windows close on the user's local hour. Daily windows close at their `digestHour` (08:00 by default), using local calendar days across daylight saving changes. A late scheduler catches up completed windows. Settings changes affect newly queued windows; the original boundary stays with existing items. + +Each user/channel/window produces one message with the window's open alerts ordered by severity, new alerts and resolved alerts, each linked to its record. Multiple events for an alert produce one entry per applicable section. In-app gets one notification; email uses the existing `alerts_digest` template through qbit-email's public API, or the configured messaging provider. Pause and quiet hours are checked again for push channels at dispatch; visibility is checked for every channel. No empty message is sent. There is no `DIGESTS` feature switch in this configuration. + +Changeset 21 adds the digest fields and a unique dispatch claim to `alerts_delivery`. The base key is `alerts:digest::`; alert digests append `::channel:` to distinguish overlapping hourly/daily windows, channels and the existing notification digests. Channel-only digests use `alerts:channel-digest::::channel:`. Email keys also append a recipient hash so email-list addresses each receive their copy. A persisted claim prevents concurrent or repeated runs from sending twice. Its item cutoff freezes the contents; late items for a claimed window are logged as `DUPLICATE`, not reported as sent. Included items become `SENT`, or `FAILED` with the error. Digest transport failures are not automatically retried; an interrupted claim fails after ten minutes rather than risking another send through a provider without idempotency support. The Delivery Log retains that uncertainty for review. + +Digest queries and rendering are bounded: up to 100 candidate items are scanned per run, and a message accepts at most 500 queued events. Larger windows fail visibly with guidance to narrow the subscription or use hourly digests; they are never sent partially. Digest messages use MINIMAL detail and contain a snapshot of content visible at dispatch. Protected alerts require verifiable user visibility even for an in-app digest. + ## Email through qbit-email When qbit-email is produced in the same instance (and on the classpath; it is an optional dependency), alert channel emails, notification emails and daily digests go through the published `com.kingsrook.qbits.email.api.EmailApi` in qbit-email `0.1.0-RC.2` instead of core `SendMessageAction`. Template registration, sends and installation detection use only the public API; templates, categories, idempotency keys, linked records and outcome mapping are unchanged: @@ -169,12 +181,12 @@ When qbit-email is produced in the same instance (and on the classpath; it is an ## Known limits (v1) -- **Claims are best-effort.** Delivery and monitor claims use a lease, not an atomic conditional update. With several scheduler nodes, run the two scheduled processes on one node, or accept at-least-once delivery. +- **Claims are best-effort.** Delivery and monitor claims use a lease, not an atomic conditional update. With several scheduler nodes, run the delivery and monitor processes on one node, or accept at-least-once delivery. Digest dispatch instead uses a unique database claim as described above. - **Email threading headers.** `Message-ID` and `In-Reply-To` need core messaging support for custom headers, so they are not set. -- **Recipient visibility.** It is checked through `createAutomatedSessionForUser`. If a host's authentication module can't build a session with permissions for a user, email goes out only for alerts that have no visibility permission and no subject table. In-app delivery is always filtered by the record locks. +- **Recipient visibility.** It is checked through `createAutomatedSessionForUser`. If a host's authentication module can't build a session with permissions for a user, email goes out only for alerts that have no visibility permission and no subject table. Individual in-app alerts are filtered by record locks. Digest snapshots are checked before dispatch. - **Outbound HTTP** blocks plain http, but has no full SSRF guard. Channels are admin-only. Core's `SafeHttpClient` (QRun-IO/qqq#1109) replaces the client when it is released. - **Alertmanager sink:** open alerts are sent with an `endsAt` a week out, not re-posted. -- **Not built yet:** digests, quiet hours, reminders, Micrometer metrics, ROLE and IdP-group team resolvers, escalation, mirrored lock columns and a configurable table namespace. These are the rest of design phases P4 to P6. +- **Not built yet:** reminders, Micrometer metrics, ROLE and IdP-group team resolvers, escalation, mirrored lock columns and a configurable table namespace. These are the rest of design phases P4 to P6. ## Build diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f730937..728916e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # QBit Alerts: design -The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). This repository implements it. Phases P1 to P3 are built (see README.md); P4 to P6 remain. +The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). This repository implements it. Phases P1 to P3 are built (see README.md); Quiet hours and subscription digests from P4 are also built; the remaining work is listed in README.md. Deviations from the design, and why: - **UI API and record following:** the RC.6 alerts extension, collaboration ownership of follow, and machine webhook migration are specified in [UI-API.md](UI-API.md). Existing RECORD watch subscriptions remain readable without migration. @@ -12,3 +12,13 @@ Deviations from the design, and why: - **The delivery log is `alerts_delivery`** (the design calls it `alerts_notification`), and **`alerts_inbox_item` is retired**: user notifications took those roles (NOTIFICATIONS.md section 2). User notifications (announcements, job failures, shares, security notices, releases and marketing), the recipient model and the Next UI notification center are designed in [NOTIFICATIONS.md](NOTIFICATIONS.md), with the owner decisions of 2026-10-13. + +## Quiet hours and digest delivery (#27, part 1) + +- Quiet days identify the start day of the window. Missing/invalid time zones use UTC; equal endpoints disable quiet hours. Pause and quiet hours apply only to personal push channels (email, email lists and other non-IN_APP channels), never to the in-app bell. +- Existing delivery statuses are sufficient: pending `DIGEST` items are excluded from the ordinary worker. Changeset 21 adds window keys, a unique channel/window claim and the claim's last included item id to the standard `alerts_delivery` table, mirrored in its entity and H2 DDL. +- Digest keys extend the documented user/window prefix with cadence and channel. This avoids collisions between hourly and daily subscriptions with the same start, different channels, and general notification digests. Channel-only digest keys use the separate `alerts:channel-digest` prefix and UTC windows; email adds a recipient hash for list channels. The unique claim is atomic even across schedulers; immediate-delivery leases retain their existing behavior. +- Delivery has at-most-once dispatch semantics. A crash between claiming and recording a provider result cannot be resolved safely for every supported channel. Such claims fail after ten minutes and are not automatically resent. Transport errors are FAILED; late items after the frozen cutoff are SUPPRESSED/DUPLICATE. The shared email API also receives the durable key. +- Digests summarize alerts represented by events in their window, rather than resending the complete outstanding-alert inventory on every run. Rendering uses MINIMAL detail, checks recipient visibility again (failing closed for unverifiable protected content), and rechecks pause/quiet hours for push channels only. In-app summaries are snapshots of the content visible at dispatch. +- To bound memory, each message accepts 500 source events; overflow fails the window with an actionable error instead of silently dropping items or sending several messages. Each run scans at most 100 due candidates; result updates use pages of 200. This is an explicit v1 limit. +- There is no feature-switch collection or DIGESTS switch in the current config. Reminders, escalation and team member sources are unchanged. diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/AlertMessage.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/AlertMessage.java index 32f2465..908f443 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/delivery/AlertMessage.java +++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/AlertMessage.java @@ -34,9 +34,20 @@ import java.util.List; ** @param entries one structured entry per alert, for channels that send ** one event per alert (PagerDuty, Alertmanager, webhooks); ** already redacted and capped by the detail level + ** @param idempotencyKey durable digest dispatch key, null for immediate messages *******************************************************************************/ -public record AlertMessage(String subject, String text, String html, String severity, String kind, List alertIds, String link, List entries) +public record AlertMessage(String subject, String text, String html, String severity, String kind, List alertIds, String link, List entries, String idempotencyKey) { + /******************************************************************************* + ** A message without a durable dispatch key. + *******************************************************************************/ + public AlertMessage(String subject, String text, String html, String severity, String kind, List alertIds, String link, List entries) + { + this(subject, text, html, severity, kind, alertIds, link, entries, null); + } + + + /******************************************************************************* ** A message without structured entries. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/AlertMessageRenderer.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/AlertMessageRenderer.java index 2559cb8..939f2ec 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/delivery/AlertMessageRenderer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/AlertMessageRenderer.java @@ -167,6 +167,45 @@ public final class AlertMessageRenderer + /******************************************************************************* + ** A bounded digest: open alerts by severity, plus new and resolved events + ** in this window. Every entry has its own link and uses the normal redactor. + *******************************************************************************/ + public static AlertMessage renderDigest(List items, String publicBaseUrl, Instant now, String key) + { + List ordered = items.stream().sorted((a, b) -> AlertSeverity.fromLabel(b.alert().getValueString("severity"), AlertSeverity.WARNING) + .compareTo(AlertSeverity.fromLabel(a.alert().getValueString("severity"), AlertSeverity.WARNING))).toList(); + AlertMessage base = render(ordered, DeliveryKind.DIGEST, publicBaseUrl, now); + StringBuilder text = new StringBuilder(); + StringBuilder html = new StringBuilder(); + for(String section : List.of("Open alerts by severity", "New since the last digest", "Resolved since the last digest")) + { + text.append(section).append('\n'); + html.append("

").append(section).append("

"); + for(int i = 0; i < ordered.size(); i++) + { + QRecord alert = ordered.get(i).alert(); + boolean included = switch(section) + { + case "Open alerts by severity" -> !"RESOLVED".equals(alert.getValueString("status")); + case "New since the last digest" -> Boolean.TRUE.equals(alert.getValueBoolean("digestNew")); + default -> Boolean.TRUE.equals(alert.getValueBoolean("digestResolved")); + }; + if(included) + { + AlertMessage.Entry entry = base.entries().get(i); + String line = "[" + entry.severity() + "] " + entry.title(); + text.append(line).append(" - [Open alert](").append(entry.link()).append(")\n"); + html.append("

").append(HomeHtml.escape(line)).append("

"); + } + } + text.append('\n'); + } + return (new AlertMessage("Alert digest: " + items.size() + " alerts", text.toString(), html.toString(), base.severity(), "DIGEST", base.alertIds(), base.link(), base.entries(), key)); + } + + + /******************************************************************************* ** The group all items share, or null. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java index cfadf9c..8c07078 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java +++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java @@ -102,6 +102,7 @@ public class DeliveryWorker Instant now = runtime.now(); List due = AlertsDb.query(AlertsDelivery.TABLE_NAME, new QQueryFilter() .withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.RETRYING.name())) + .withCriteria("kind", QCriteriaOperator.NOT_EQUALS, DeliveryKind.DIGEST.name()) .withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now) .withOrderBy(new QFilterOrderBy("id")) .withLimit(BATCH_LIMIT)); diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/DigestWorker.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/DigestWorker.java new file mode 100644 index 0000000..b111b0e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/DigestWorker.java @@ -0,0 +1,297 @@ +/* + * 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.delivery; + + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.engine.AlertTiming; +import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.model.AlertsAlert; +import com.kingsrook.qbits.alerts.model.AlertsChannel; +import com.kingsrook.qbits.alerts.model.AlertsDelivery; +import com.kingsrook.qbits.alerts.model.AlertsEvent; +import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.alerts.DetailLevel; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Sends completed subscription windows. A unique, durable delivery-log claim + ** prevents concurrent schedulers or restarts from sending a window twice. + ** An interrupted send is failed after its lease; uncertain sends are never + ** automatically repeated. Messages and database reads have explicit bounds. + *******************************************************************************/ +public class DigestWorker +{ + static final int MAX_ITEMS = 500; + private final AlertsRuntime runtime; + + + + /******************************************************************************* + ** Constructor. + *******************************************************************************/ + public DigestWorker(AlertsRuntime runtime) + { + this.runtime = runtime; + } + + + + /******************************************************************************* + ** Send up to 100 due windows. Returns the number of messages sent. + *******************************************************************************/ + public int sendDue() throws QException + { + return (runtime.asSystem(() -> + { + Instant now = runtime.now(); + List due = AlertsDb.query(AlertsDelivery.TABLE_NAME, AlertsDb.eq("kind", "DIGEST") + .withCriteria("status", QCriteriaOperator.EQUALS, "PENDING") + .withCriteria("alertId", QCriteriaOperator.IS_NOT_BLANK) + .withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now) + .withOrderBy(new QFilterOrderBy("id")).withLimit(100)); + Set seen = new HashSet<>(); + int sent = 0; + for(QRecord first : due) + { + String claimKey = first.getValueString("digestKey") + ":channel:" + first.getValue("channelId"); + if(seen.add(claimKey)) + { + sent += sendWindow(first, claimKey, now); + } + } + return (sent); + })); + } + + + + /******************************************************************************* + ** Claim before the side effect, then record the result on every item. + *******************************************************************************/ + private int sendWindow(QRecord first, String claimKey, Instant now) throws QException + { + QRecord claim = AlertsDb.first(AlertsDelivery.TABLE_NAME, AlertsDb.eq("digestClaimKey", claimKey)); + if(claim != null) + { + if("SENDING".equals(claim.getValueString("status"))) + { + if(claim.getValueInstant("claimedUntil") != null && claim.getValueInstant("claimedUntil").isAfter(now)) + { + return (0); + } + claim = finish(claim, DeliveryResult.failed("Digest send was interrupted; delivery is uncertain. It will not be sent again automatically."), now); + } + if(!List.of("SENT", "FAILED", "DEAD", "SUPPRESSED").contains(claim.getValueString("status"))) + { + claim = finish(claim, DeliveryResult.failed("A digest claim cannot be retried because its original delivery may already have succeeded."), now); + } + finishItems(first, claim); + return (0); + } + QRecord last = AlertsDb.first(AlertsDelivery.TABLE_NAME, items(first).withOrderBy(new QFilterOrderBy("id", false))); + if(last == null) + { + return (0); + } + Integer lastItemId = last.getValueInteger("id"); + try + { + claim = AlertsDb.insert(AlertsDelivery.TABLE_NAME, new QRecord() + .withValue("kind", "DIGEST").withValue("status", "SENDING").withValue("attempts", 1) + .withValue("digestKey", first.getValue("digestKey")).withValue("digestClaimKey", claimKey) + .withValue("channelId", first.getValue("channelId")).withValue("recipientUserId", first.getValue("recipientUserId")) + .withValue("recipientLabel", first.getValue("recipientLabel")).withValue("digestLastItemId", lastItemId).withValue("claimedUntil", now.plus(Duration.ofMinutes(10)))); + } + catch(QException e) + { + if(AlertsDb.first(AlertsDelivery.TABLE_NAME, AlertsDb.eq("digestClaimKey", claimKey)) != null) + { + return (0); + } + throw (e); + } + + DeliveryResult result; + try + { + List rows = AlertsDb.query(AlertsDelivery.TABLE_NAME, items(first).withCriteria("id", QCriteriaOperator.LESS_THAN_OR_EQUALS, lastItemId).withLimit(MAX_ITEMS + 1)); + result = rows.size() > MAX_ITEMS ? DeliveryResult.failed("Digest exceeds the 500 item message limit. Narrow the subscription or use hourly digests.") + : deliver(first, rows, claimKey, now); + } + catch(Exception e) + { + result = DeliveryResult.failed(AlertRedactor.redact(e.getClass().getSimpleName() + ": " + e.getMessage())); + } + claim = finish(claim, result, now); + finishItems(first, claim); + return ("SENT".equals(claim.getValueString("status")) ? 1 : 0); + } + + + + /******************************************************************************* + ** Render each distinct alert once. Event rows preserve raised/resolved + ** sections even when the alert has subsequently changed state. + *******************************************************************************/ + private DeliveryResult deliver(QRecord first, List rows, String claimKey, Instant now) throws QException + { + QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, first.getValue("channelId")); + if(channel == null || Boolean.FALSE.equals(channel.getValueBoolean("enabled"))) + { + return (DeliveryResult.failed("The digest channel is missing or disabled.")); + } + String userId = first.getValueString("recipientUserId"); + QRecord preference = userId == null ? null : AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); + boolean inApp = "IN_APP".equals(channel.getValueString("channelType")); + Map alerts = new LinkedHashMap<>(); + for(QRecord row : rows) + { + Integer id = row.getValueInteger("alertId"); + QRecord alert = alerts.get(id); + if(alert == null) + { + alert = AlertsDb.byId(AlertsAlert.TABLE_NAME, id); + if(alert == null) + { + AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")) + .withValue("status", "FAILED").withValue("lastError", "The alert no longer exists.")); + continue; + } + String reason = null; + if(userId != null && !runtime.getRouter().canReceiveDigest(userId, alert)) + { + reason = "NOT_VISIBLE"; + } + else if(!inApp && preference != null && preference.getValueInstant("pausedUntil") != null && preference.getValueInstant("pausedUntil").isAfter(now)) + { + reason = "USER_PAUSED"; + } + else if(!inApp && AlertTiming.isQuiet(preference, AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), now)) + { + reason = "QUIET_HOURS"; + } + if(reason != null) + { + AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")) + .withValue("status", "SUPPRESSED").withValue("suppressedReason", reason)); + continue; + } + alerts.put(id, alert); + } + QRecord event = AlertsDb.byId(AlertsEvent.TABLE_NAME, row.getValue("alertEventId")); + String kind = event == null ? "" : event.getValueString("eventType"); + if(List.of("RAISED", "REOPENED").contains(kind)) + { + alert.setValue("digestNew", true); + } + if("RESOLVED".equals(kind)) + { + alert.setValue("digestResolved", true); + } + } + if(alerts.isEmpty()) + { + return (DeliveryResult.failed("No alerts remain in this digest window.")); + } + List rendered = new ArrayList<>(); + for(QRecord alert : alerts.values()) + { + rendered.add(new AlertMessageRenderer.Item(alert, alert.getValueString("alertTypeName"), null, DetailLevel.MINIMAL)); + } + AlertMessage message = AlertMessageRenderer.renderDigest(rendered, runtime.getConfig().getPublicBaseUrl(), now, claimKey); + if(inApp) + { + runtime.getNotifications().deliverDigest(userId, message, now); + return (DeliveryResult.sent(null)); + } + AlertChannelInterface implementation = runtime.getChannels().forType(channel.getValueString("channelType")); + if(implementation == null) + { + return (DeliveryResult.failed("No implementation for the digest channel.")); + } + String address = preference == null ? null : preference.getValueString("emailAddress"); + return (implementation.deliver(message, channel, address, runtime.getChannels().getSecrets())); + } + + + + /******************************************************************************* + ** Pending source items, excluding the claim row. + *******************************************************************************/ + private QQueryFilter items(QRecord first) + { + return (AlertsDb.eq("digestKey", first.getValue("digestKey")) + .withCriteria("channelId", QCriteriaOperator.EQUALS, first.getValue("channelId")) + .withCriteria("status", QCriteriaOperator.EQUALS, "PENDING") + .withCriteria("alertId", QCriteriaOperator.IS_NOT_BLANK)); + } + + + + /******************************************************************************* + ** A digest has one attempt; retryable channel errors are recorded as FAILED. + *******************************************************************************/ + private QRecord finish(QRecord claim, DeliveryResult result, Instant now) throws QException + { + return (AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", claim.getValue("id")) + .withValue("digestLastItemId", claim.getValue("digestLastItemId")) + .withValue("status", result.outcome() == DeliveryResult.Outcome.SENT ? "SENT" : "FAILED") + .withValue("sentAt", result.outcome() == DeliveryResult.Outcome.SENT ? now : null).withValue("claimedUntil", null) + .withValue("providerMessageId", result.providerMessageId()) + .withValue("lastError", StringUtils.safeTruncate(AlertRedactor.redact(result.error()), 1900)))); + } + + + + /******************************************************************************* + ** Page through the entire window, including overflow and late source rows. + *******************************************************************************/ + private void finishItems(QRecord first, QRecord claim) throws QException + { + List page; + do + { + page = AlertsDb.query(AlertsDelivery.TABLE_NAME, items(first).withLimit(200)); + for(QRecord row : page) + { + boolean late = claim.getValueInteger("digestLastItemId") != null && row.getValueInteger("id") > claim.getValueInteger("digestLastItemId"); + AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")) + .withValue("status", late ? "SUPPRESSED" : claim.getValue("status")).withValue("attempts", late ? 0 : 1) + .withValue("suppressedReason", late ? "DUPLICATE" : null).withValue("sentAt", late ? null : claim.getValue("sentAt")) + .withValue("lastError", late ? "The digest window was already claimed." : claim.getValue("lastError")) + .withValue("providerMessageId", late ? null : claim.getValue("providerMessageId"))); + } + } + while(!page.isEmpty()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/EmailChannel.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/EmailChannel.java index da247f8..375a7c0 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/delivery/EmailChannel.java +++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/EmailChannel.java @@ -182,7 +182,10 @@ public class EmailChannel implements AlertChannelInterface { for(String address : addresses) { - AlertsEmailSender.Result result = AlertsEmailSender.sendAlert(address, process, message.subject(), message.severity(), message.kind(), items, message.link(), + AlertsEmailSender.Result result = "DIGEST".equals(message.kind()) + ? AlertsEmailSender.sendAlertDigest(address, AlertsProcessesMetaDataProducer.SEND_DIGESTS, message.idempotencyKey(), message.alertIds().get(0), message.alertIds().size(), + digestItems(message), base + "/app/alerts_subscription") + : AlertsEmailSender.sendAlert(address, process, message.subject(), message.severity(), message.kind(), items, message.link(), base + "/app/alerts_subscription", message.alertIds(), now); firstId = firstId == null ? result.messageId() : firstId; if(result.status() == AlertsEmailSender.Status.REFUSED && problem == null) @@ -209,6 +212,21 @@ public class EmailChannel implements AlertChannelInterface + /******************************************************************************* + ** Keep the same sectioned, redacted content in the shared digest template. + *******************************************************************************/ + private static List> digestItems(AlertMessage message) + { + Map item = new LinkedHashMap<>(); + item.put("title", message.subject()); + item.put("body", message.text()); + item.put("link", message.link()); + item.put("linkLabel", "Open alerts"); + return (List.of(item)); + } + + + /******************************************************************************* ** Put a value in a template item unless it is null. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmailSender.java b/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmailSender.java index 6ab65a5..79ac4c9 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmailSender.java +++ b/src/main/java/com/kingsrook/qbits/alerts/email/AlertsEmailSender.java @@ -199,6 +199,22 @@ public final class AlertsEmailSender + /******************************************************************************* + ** An alert subscription digest uses the shared digest template and links + ** to an alert. Its channel-qualified key cannot collide with notification + ** digests or another delivery channel. + *******************************************************************************/ + public static Result sendAlertDigest(String to, String process, String key, Integer alertId, int count, List> items, String settingsUrl) throws QException + { + ensure(TEMPLATE_DIGEST); + EmailApiRequest request = base(to, process, key + ":recipient:" + hash(to)).withTemplateName(TEMPLATE_DIGEST) + .withVariable("count", count).withVariable("items", (Serializable) items).withLinkedRecord(AlertsAlert.TABLE_NAME, alertId); + withUrl(request, "settings_url", settingsUrl); + return (result(EmailApi.send(request))); + } + + + /******************************************************************************* ** The parts every message has: one recipient, the sender and the key. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java index 36b3f91..a5f5bd7 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java @@ -75,7 +75,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; ** 2. expand team subscriptions to their members; keep channel subscriptions; ** 3. check each person can see the alert (section 7.2); ** 4. apply, in order: the type's notify switch, mutes, flapping, the user's - ** pause, channel enabled / minimum severity / address / rate limit, and a + ** pause, quiet hours, channel enabled / minimum severity / address / rate limit, and a ** 10-minute duplicate check; ** 5. write an alerts_delivery row per destination (SUPPRESSED rows keep ** their reason, so "why didn't I get it?" is answered by a table), and an @@ -96,7 +96,7 @@ public class AlertRouter /******************************************************************************* ** One destination for one notification. *******************************************************************************/ - record Destination(String userId, QRecord channel, Integer subscriptionId, String minSeverity, QRecord preference) + record Destination(String userId, QRecord channel, Integer subscriptionId, String minSeverity, QRecord preference, String deliveryMode) { } @@ -166,6 +166,7 @@ public class AlertRouter int queued = 0; for(Destination destination : destinations) { + boolean digest = List.of("DIGEST_HOURLY", "DIGEST_DAILY").contains(destination.deliveryMode() == null ? "IMMEDIATE" : destination.deliveryMode()); SuppressedReason reason = null; String channelType = destination.channel().getValueString("channelType"); boolean inApp = ChannelType.IN_APP.name().equals(channelType); @@ -182,10 +183,6 @@ public class AlertRouter continue; } AlertSeverity channelMin = AlertSeverity.fromLabel(destination.channel().getValueString("minSeverity"), null); - if(channelMin != null && !severity.isAtLeast(channelMin) && !inApp) - { - continue; - } if(silent) { @@ -208,6 +205,14 @@ public class AlertRouter { reason = SuppressedReason.USER_PAUSED; } + else if(destination.userId() != null && !inApp && AlertTiming.isQuiet(destination.preference(), severity, now)) + { + reason = SuppressedReason.QUIET_HOURS; + } + else if(channelMin != null && !severity.isAtLeast(channelMin) && !inApp) + { + reason = SuppressedReason.MIN_SEVERITY; + } else if(Boolean.FALSE.equals(destination.channel().getValueBoolean("enabled"))) { reason = SuppressedReason.CHANNEL_DISABLED; @@ -220,7 +225,7 @@ public class AlertRouter { reason = SuppressedReason.RATE_LIMITED; } - else if(isDuplicate(alert, destination, kind, now)) + else if(isDuplicate(alert, destination, digest ? DeliveryKind.DIGEST : kind, now, digest ? eventId : null)) { reason = SuppressedReason.DUPLICATE; } @@ -241,6 +246,15 @@ public class AlertRouter notification.setValue("suppressedReason", reason.name()); AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification); } + else if(digest) + { + Instant end = AlertTiming.digestEnd(destination.deliveryMode(), destination.preference(), now); + Instant start = AlertTiming.digestStart(destination.deliveryMode(), destination.preference(), end); + String recipientKey = destination.userId() == null ? "alerts:channel-digest:" + destination.channel().getValue("id") : "alerts:digest:" + destination.userId(); + notification.withValue("kind", DeliveryKind.DIGEST.name()).withValue("status", DeliveryStatus.PENDING.name()) + .withValue("digestKey", recipientKey + ":" + start + ":" + destination.deliveryMode()).withValue("nextAttemptAt", end); + AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification); + } else if(inApp) { notification.setValue("status", DeliveryStatus.SENT.name()); @@ -333,7 +347,7 @@ public class AlertRouter QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, subscription.getValue("channelId")); if(channel != null) { - destinations.putIfAbsent("channel:" + channel.getValue("id"), new Destination(null, channel, subscriptionId, minName, null)); + destinations.putIfAbsent("channel:" + channel.getValue("id"), new Destination(null, channel, subscriptionId, minName, null, subscription.getValueString("deliveryMode"))); } continue; } @@ -356,7 +370,7 @@ public class AlertRouter QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); for(QRecord channel : userChannels(subscription, preference)) { - destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, subscriptionId, minName, preference)); + destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, subscriptionId, minName, preference, subscription.getValueString("deliveryMode"))); } } } @@ -374,7 +388,7 @@ public class AlertRouter QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); for(QRecord channel : userChannels(null, preference)) { - destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference)); + destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference, "IMMEDIATE")); } } } @@ -541,6 +555,16 @@ public class AlertRouter + /******************************************************************************* + ** Recheck queued digest content before sending it to a user. + *******************************************************************************/ + public boolean canReceiveDigest(String userId, QRecord alert) + { + return (knownVisibility(userId, alert).orElse(alert.getValue("visibilityPermission") == null && alert.getValue("subjectTableName") == null)); + } + + + /******************************************************************************* ** Followers must be able to read the source record, including its record locks. ** Reuse recipient sessions; unknown access fails closed before adding channels. @@ -622,13 +646,17 @@ public class AlertRouter /******************************************************************************* ** The same alert, channel, recipient and kind within 10 minutes. *******************************************************************************/ - private boolean isDuplicate(QRecord alert, Destination destination, DeliveryKind kind, Instant now) throws QException + private boolean isDuplicate(QRecord alert, Destination destination, DeliveryKind kind, Instant now, Integer eventId) throws QException { var filter = AlertsDb.eq("alertId", alert.getValue("id")) .withCriteria("channelId", QCriteriaOperator.EQUALS, destination.channel().getValue("id")) .withCriteria("kind", QCriteriaOperator.EQUALS, kind.name()) .withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.SENDING.name(), DeliveryStatus.SENT.name(), DeliveryStatus.RETRYING.name())) .withCriteria("createDate", QCriteriaOperator.GREATER_THAN, now.minus(DUPLICATE_WINDOW_MINUTES, ChronoUnit.MINUTES)); + if(kind == DeliveryKind.DIGEST) + { + filter.withCriteria("alertEventId", QCriteriaOperator.EQUALS, eventId); + } if(destination.userId() != null) { filter.withCriteria("recipientUserId", QCriteriaOperator.EQUALS, destination.userId()); diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertTiming.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertTiming.java new file mode 100644 index 0000000..4ead4e1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertTiming.java @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.alerts.engine; + + +import java.time.DateTimeException; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Locale; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** User-local quiet windows and subscription digest boundaries. + *******************************************************************************/ +public final class AlertTiming +{ + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AlertTiming() + { + } + + + + /******************************************************************************* + ** Missing or invalid zones use UTC, independently of the server's zone. + *******************************************************************************/ + public static ZoneId zone(QRecord preference) + { + try + { + String name = preference == null ? null : preference.getValueString("timeZone"); + return (StringUtils.hasContent(name) ? ZoneId.of(name) : ZoneOffset.UTC); + } + catch(DateTimeException e) + { + return (ZoneOffset.UTC); + } + } + + + + /******************************************************************************* + ** The start is inclusive, the end exclusive. Overnight windows belong to + ** their starting day. Days accept ISO numbers (1=Monday), names or MON etc. + ** Missing, invalid or equal endpoints disable quiet hours. + *******************************************************************************/ + public static boolean isQuiet(QRecord preference, AlertSeverity severity, Instant now) + { + if(preference == null || severity.isAtLeast(AlertSeverity.fromLabel(preference.getValueString("quietBypassSeverity"), AlertSeverity.CRITICAL))) + { + return (false); + } + String startValue = preference.getValueString("quietHoursStart"); + String endValue = preference.getValueString("quietHoursEnd"); + if(!StringUtils.hasContent(startValue) || !StringUtils.hasContent(endValue)) + { + return (false); + } + try + { + LocalTime start = LocalTime.parse(startValue); + LocalTime end = LocalTime.parse(endValue); + ZonedDateTime local = now.atZone(zone(preference)); + LocalTime time = local.toLocalTime(); + boolean overnight = start.isAfter(end); + boolean inside = overnight ? !time.isBefore(start) || time.isBefore(end) : !time.isBefore(start) && time.isBefore(end); + if(!inside) + { + return (false); + } + var day = (overnight && time.isBefore(end) ? local.minusDays(1) : local).getDayOfWeek(); + String days = preference.getValueString("quietDays"); + return (!StringUtils.hasContent(days) || Arrays.stream(days.toUpperCase(Locale.ROOT).split("[,\\s]+")) + .anyMatch(value -> value.equals(day.name()) || value.equals(day.name().substring(0, 3)) || value.equals(String.valueOf(day.getValue())))); + } + catch(DateTimeException e) + { + return (false); + } + } + + + + /******************************************************************************* + ** A daily window ends at the next local digest hour (default 08:00). + ** Local date arithmetic preserves the hour across daylight saving changes. + *******************************************************************************/ + public static Instant digestEnd(String mode, QRecord preference, Instant now) + { + ZonedDateTime local = now.atZone(zone(preference)); + if("DIGEST_DAILY".equals(mode)) + { + int hour = digestHour(preference); + ZonedDateTime end = local.toLocalDate().atTime(hour, 0).atZone(local.getZone()); + return ((end.toInstant().isAfter(now) ? end : local.toLocalDate().plusDays(1).atTime(hour, 0).atZone(local.getZone())).toInstant()); + } + return (local.truncatedTo(ChronoUnit.HOURS).plusHours(1).toInstant()); + } + + + + /******************************************************************************* + ** Stable window identity stored with each queued item. + *******************************************************************************/ + public static Instant digestStart(String mode, QRecord preference, Instant end) + { + return ("DIGEST_DAILY".equals(mode) ? end.atZone(zone(preference)).toLocalDate().minusDays(1).atTime(digestHour(preference), 0).atZone(zone(preference)).toInstant() : end.minus(1, ChronoUnit.HOURS)); + } + + + + /******************************************************************************* + ** A valid configured hour, or the default 08:00. + *******************************************************************************/ + private static int digestHour(QRecord preference) + { + Integer configured = preference == null ? null : preference.getValueInteger("digestHour"); + return (configured == null || configured < 0 || configured > 23 ? 8 : configured); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsProcessesMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsProcessesMetaDataProducer.java index 5147c95..10e8886 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsProcessesMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsProcessesMetaDataProducer.java @@ -46,13 +46,15 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith ** - the record and table actions (AlertsAction), each a standard streamed ** ETL process on its table: optional input, Review summary lines, Result ** summary lines; - ** - two hidden scheduled processes: alertsEvaluateMonitors (monitors, plus - ** snooze / quiet-period / flapping upkeep) and alertsDeliverNotifications. + ** - three hidden scheduled processes: alertsEvaluateMonitors (monitors, plus + ** snooze / quiet-period / flapping upkeep), alertsDeliverNotifications and + ** alertsSendDigests. ** They exist without a scheduler and run on one only when the config names ** it. *******************************************************************************/ public class AlertsProcessesMetaDataProducer extends QBitComponentMetaDataProducer { + public static final String SEND_DIGESTS = "alertsSendDigests"; public static final String EVALUATE_MONITORS = "alertsEvaluateMonitors"; public static final String DELIVER_NOTIFICATIONS = "alertsDeliverNotifications"; @@ -87,6 +89,7 @@ public class AlertsProcessesMetaDataProducer extends QBitComponentMetaDataProduc } processes.add(scheduled(config, EVALUATE_MONITORS, "Evaluate alert monitors", AlertsScheduledSteps.EvaluateMonitors.class, config.getEvaluateEverySeconds())); processes.add(scheduled(config, DELIVER_NOTIFICATIONS, "Deliver alert notifications", AlertsScheduledSteps.DeliverNotifications.class, config.getDeliverEverySeconds())); + processes.add(scheduled(config, SEND_DIGESTS, "Send alert digests", AlertsScheduledSteps.SendDigests.class, 3600)); return (processes); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java index b9d3098..8d76d5a 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java @@ -380,11 +380,12 @@ public final class AlertsTableMetaDataHelper public static QTableMetaData buildDeliveryTable(AlertsQBitConfig config) throws QException { QTableMetaData table = base(config, AlertsDelivery.TABLE_NAME, "Delivery Log", "send", AlertsDelivery.class) + .withUniqueKey(new UniqueKey("digestClaimKey")) .withRecordLabelFormat("%s %s") .withRecordLabelFields("kind", "recipientLabel") .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "alertId", "kind", "status", "channelId", "recipientLabel"))) .withSection(new QFieldSection("delivery", "Delivery", new QIcon("send"), Tier.T2, List.of("suppressedReason", "attempts", "nextAttemptAt", "sentAt", "lastError", "providerMessageId"))) - .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("recipientUserId", "subscriptionId", "alertEventId", "claimedUntil", "createDate", "modifyDate"))); + .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("recipientUserId", "subscriptionId", "alertEventId", "digestKey", "digestClaimKey", "digestLastItemId", "claimedUntil", "createDate", "modifyDate"))); return (readOnly(table)); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsDelivery.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsDelivery.java index 109098d..1d403cb 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsDelivery.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsDelivery.java @@ -66,6 +66,15 @@ public class AlertsDelivery extends QRecordEntity @QField(isEditable = false, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_suppressed_reason") private String suppressedReason; + @QField(isEditable = false, maxLength = 200) + private String digestKey; + + @QField(isEditable = false, maxLength = 240) + private String digestClaimKey; + + @QField(isEditable = false) + private Integer digestLastItemId; + @QField(isEditable = false) private Integer attempts; @@ -666,4 +675,97 @@ public class AlertsDelivery extends QRecordEntity this.modifyDate = modifyDate; return (this); } + + + + /******************************************************************************* + ** Getter for digestKey + *******************************************************************************/ + public String getDigestKey() + { + return (this.digestKey); + } + + + + /******************************************************************************* + ** Setter for digestKey + *******************************************************************************/ + public void setDigestKey(String digestKey) + { + this.digestKey = digestKey; + } + + + + /******************************************************************************* + ** Fluent setter for digestKey + *******************************************************************************/ + public AlertsDelivery withDigestKey(String digestKey) + { + this.digestKey = digestKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for digestClaimKey + *******************************************************************************/ + public String getDigestClaimKey() + { + return (this.digestClaimKey); + } + + + + /******************************************************************************* + ** Setter for digestClaimKey + *******************************************************************************/ + public void setDigestClaimKey(String digestClaimKey) + { + this.digestClaimKey = digestClaimKey; + } + + + + /******************************************************************************* + ** Fluent setter for digestClaimKey + *******************************************************************************/ + public AlertsDelivery withDigestClaimKey(String digestClaimKey) + { + this.digestClaimKey = digestClaimKey; + return (this); + } + + + + /******************************************************************************* + ** Getter for digestLastItemId + *******************************************************************************/ + public Integer getDigestLastItemId() + { + return (this.digestLastItemId); + } + + + + /******************************************************************************* + ** Setter for digestLastItemId + *******************************************************************************/ + public void setDigestLastItemId(Integer digestLastItemId) + { + this.digestLastItemId = digestLastItemId; + } + + + + /******************************************************************************* + ** Fluent setter for digestLastItemId + *******************************************************************************/ + public AlertsDelivery withDigestLastItemId(Integer digestLastItemId) + { + this.digestLastItemId = digestLastItemId; + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java index 89616a0..fc543d7 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java @@ -223,7 +223,7 @@ public final class AlertsPossibleValues *******************************************************************************/ public enum SuppressedReason { - QUIET_HOURS, MUTED, FLAPPING, RATE_LIMITED, NOT_VISIBLE, CHANNEL_DISABLED, DUPLICATE, USER_PAUSED, TYPE_SILENT, NO_ADDRESS + QUIET_HOURS, MUTED, FLAPPING, RATE_LIMITED, NOT_VISIBLE, CHANNEL_DISABLED, DUPLICATE, USER_PAUSED, TYPE_SILENT, NO_ADDRESS, MIN_SEVERITY } diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java index 3ddff2c..a3eac36 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Locale; import java.util.Set; import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.delivery.AlertMessage; import com.kingsrook.qbits.alerts.engine.AlertsDb; import com.kingsrook.qbits.alerts.model.AlertsAlert; import com.kingsrook.qbits.alerts.model.AlertsNotification; @@ -381,6 +382,29 @@ public class NotificationService + /******************************************************************************* + ** One bell notification for a claimed digest, with all its alert links. + ** ALERT category keeps the general notification emailer from emailing it. + *******************************************************************************/ + public void deliverDigest(String userId, AlertMessage message, Instant now) throws QException + { + QRecord existing = AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", message.idempotencyKey())); + if(existing != null) + { + return; + } + QRecord notification = AlertsDb.insert(AlertsNotification.TABLE_NAME, new QRecord() + .withValue("title", message.subject()).withValue("body", message.text()).withValue("severity", message.severity()) + .withValue("category", NotificationCategory.ALERT.name()).withValue("status", STATUS_SENT) + .withValue("sourceName", "alerts").withValue("sourceKey", message.idempotencyKey()) + .withValue("linkUrl", "/app/" + AlertsAlert.TABLE_NAME).withValue("linkLabel", "Open alerts") + .withValue("audienceType", NotificationAudience.Type.USERS.name()).withValue("audienceValue", userId) + .withValue("publishAt", now).withValue("sentAt", now).withValue("recipientCount", 1)); + AlertsDb.insert(AlertsNotificationRecipient.TABLE_NAME, newRecipient(notification.getValueInteger("id"), userId, now)); + } + + + /******************************************************************************* ** Recount a notification's readers (after people mark it read or unread). *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java index 6018eb2..9ffabf5 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java +++ b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java @@ -18,6 +18,7 @@ package com.kingsrook.qbits.alerts.processes; import com.kingsrook.qbits.alerts.AlertsRuntime; +import com.kingsrook.qbits.alerts.delivery.DigestWorker; import com.kingsrook.qbits.alerts.notifications.NotificationEmailer; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -27,7 +28,7 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp /******************************************************************************* ** The hidden scheduled processes' steps: evaluate due monitors (plus snooze, - ** quiet-period and flapping upkeep), and deliver due notifications. + ** quiet-period and flapping upkeep), deliver due notifications and send digests. *******************************************************************************/ public final class AlertsScheduledSteps { @@ -80,4 +81,21 @@ public final class AlertsScheduledSteps output.addValue(OUTPUT_COUNT, runtime.getDeliveryWorker().deliverDue()); } } + + + + /******************************************************************************* + ** alertsSendDigests + *******************************************************************************/ + public static class SendDigests implements BackendStep + { + /*************************************************************************** + ** Send completed digest windows through the configured channels. + ***************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + output.addValue(OUTPUT_COUNT, new DigestWorker(AlertsRuntime.get()).sendDue()); + } + } } diff --git a/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql b/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql index 9dfaec9..d6969c4 100644 --- a/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql @@ -427,3 +427,13 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference ( CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id), CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category) ); + +-- changeset qbit-alerts:27 +-- comment: Digest items share a window key. A separate log row claims each channel/window exactly once. +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_delivery' AND LOWER(column_name) = 'digest_key' +ALTER TABLE alerts_delivery ADD COLUMN digest_key VARCHAR(200); +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); diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java index eea5685..7d0fdcc 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java @@ -19,6 +19,10 @@ package com.kingsrook.qbits.alerts; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -182,13 +186,53 @@ class AlertsQBitProducerTest extends BaseAlertsTest QTableMetaData table = qInstance.getTable(tableName); for(QFieldMetaData field : table.getFields().values()) { - assertThat(body).as(tableName + "." + field.getName()).contains(" " + QInstanceEnricher.inferBackendName(field.getName()) + " "); + String column = QInstanceEnricher.inferBackendName(field.getName()); + boolean declared = body.contains(" " + column + " ") || changelog.contains("ALTER TABLE " + tableName + " ADD COLUMN " + column + " "); + assertThat(declared).as(tableName + "." + field.getName() + " is created or added by a migration").isTrue(); } } } + /******************************************************************************* + ** The real migration SQL builds a fresh schema and guards an already + ** upgraded schema. Its unique claim is enforced by the database itself. + *******************************************************************************/ + @Test + void testDigestMigration() throws Exception + { + String changelog; + try(InputStream in = getClass().getClassLoader().getResourceAsStream("db/liquibase/qbit-alerts/alerts.changelog.sql")) + { + changelog = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + String sql = changelog.lines().filter(line -> !line.stripLeading().startsWith("--")).collect(Collectors.joining("\n")); + try(Connection connection = DriverManager.getConnection("jdbc:h2:mem:alerts_digest_migration"); Statement statement = connection.createStatement()) + { + for(String command : sql.split(";")) + { + if(!command.isBlank()) + { + statement.execute(command); + } + } + String guard = changelog.substring(changelog.indexOf("-- changeset qbit-alerts:27")).lines() + .filter(line -> line.startsWith("-- precondition-sql-check")).findFirst().orElseThrow(); + try(ResultSet result = statement.executeQuery(guard.substring(guard.indexOf("SELECT")))) + { + assertThat(result.next()).isTrue(); + assertThat(result.getInt(1)).as("an upgraded database skips changeset 27").isEqualTo(1); + } + statement.executeUpdate("INSERT INTO alerts_delivery (kind, status, digest_key, digest_claim_key, digest_last_item_id) VALUES ('DIGEST', 'SENDING', 'window', 'claim', 12)"); + assertThatThrownBy(() -> statement.executeUpdate("INSERT INTO alerts_delivery (digest_claim_key) VALUES ('claim')")) + .isInstanceOf(java.sql.SQLException.class); + statement.executeUpdate("INSERT INTO alerts_delivery (digest_key) VALUES ('window'), ('window')"); + } + } + + + /******************************************************************************* ** Permissions: the three alerts permissions are listed; role lists nest. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/alerts/email/EmailQBitSendingTest.java b/src/test/java/com/kingsrook/qbits/alerts/email/EmailQBitSendingTest.java index 974f884..268c25e 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/email/EmailQBitSendingTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/email/EmailQBitSendingTest.java @@ -29,10 +29,12 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig; import com.kingsrook.qbits.alerts.BaseAlertsTest; import com.kingsrook.qbits.alerts.delivery.AlertMessage; import com.kingsrook.qbits.alerts.delivery.DeliveryResult; +import com.kingsrook.qbits.alerts.delivery.DigestWorker; import com.kingsrook.qbits.alerts.delivery.EmailChannel; import com.kingsrook.qbits.alerts.engine.AlertsDb; import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType; +import com.kingsrook.qbits.alerts.model.AlertsSubscription; import com.kingsrook.qbits.alerts.model.AlertsUserPreference; import com.kingsrook.qbits.alerts.notifications.NotificationEmailer; import com.kingsrook.qbits.email.EmailQBitConfig; @@ -40,6 +42,8 @@ import com.kingsrook.qbits.email.EmailQBitProducer; import com.kingsrook.qbits.email.api.EmailApi; import com.kingsrook.qbits.email.api.EmailApiMessage; import com.kingsrook.qqq.backend.core.alerts.AlertLink; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; @@ -392,6 +396,38 @@ class EmailQBitSendingTest extends BaseAlertsTest + /******************************************************************************* + ** Alert subscription digests use the public EmailApi path, the digest + ** template, real per-alert links, and a durable cadence/channel key. + *******************************************************************************/ + @Test + void testAlertSubscriptionDigest() throws Exception + { + addProvider(); + runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", ADMIN) + .withValue("emailEnabled", true).withValue("inAppEnabled", false).withValue("emailAddress", "ops@example.com"))); + runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER") + .withValue("userId", ADMIN).withValue("scopeType", "ALL").withValue("deliveryMode", "DIGEST_HOURLY").withValue("enabled", true))); + QRecord first = runtime.getEngine().raise(new AlertSignal("test.digest").withDedupKey("first").withTitle("First failed").withSeverity(AlertSeverity.ERROR)); + QRecord second = runtime.getEngine().raise(new AlertSignal("test.digest").withDedupKey("second").withTitle("Second failed").withSeverity(AlertSeverity.CRITICAL)); + assertThat(runtime.getDeliveryWorker().deliverDue()).isZero(); + assertThat(messages()).isEmpty(); + clock.advance(java.time.Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + QRecord message = messages().get(0); + assertThat(templateName(message)).isEqualTo("alerts_digest"); + assertThat(message.getValueString("subject")).isEqualTo("2 new notifications"); + assertThat(message.getValueString("senderProcess")).isEqualTo("alertsSendDigests"); + assertThat(message.getValueString("linkedTableName")).isEqualTo("alerts_alert"); + assertThat(message.getValueString("idempotencyKey")).startsWith("alerts:digest:" + ADMIN + ":2026-10-12T12:00:00Z:DIGEST_HOURLY:channel:"); + assertThat(message.getValueString("htmlBody")).contains("Open alerts by severity", "New since the last digest", "First failed", "Second failed", + "href=\"https://admin.example.com/app/alerts_alert/" + first.getValue("id") + "\"", "href=\"https://admin.example.com/app/alerts_alert/" + second.getValue("id") + "\""); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(messages()).hasSize(1); + } + + + /******************************************************************************* ** Recipient hashes are stable, case-insensitive and hide the address. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/alerts/engine/QuietHoursAndDigestsTest.java b/src/test/java/com/kingsrook/qbits/alerts/engine/QuietHoursAndDigestsTest.java new file mode 100644 index 0000000..dabc96a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/engine/QuietHoursAndDigestsTest.java @@ -0,0 +1,621 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.alerts.engine; + + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.alerts.AlertsQBitConfig; +import com.kingsrook.qbits.alerts.BaseAlertsTest; +import com.kingsrook.qbits.alerts.TestSteps; +import com.kingsrook.qbits.alerts.delivery.DeliveryResult; +import com.kingsrook.qbits.alerts.delivery.DigestWorker; +import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer; +import com.kingsrook.qbits.alerts.model.AlertsChannel; +import com.kingsrook.qbits.alerts.model.AlertsDelivery; +import com.kingsrook.qbits.alerts.model.AlertsNotification; +import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution; +import com.kingsrook.qbits.alerts.model.AlertsSubscription; +import com.kingsrook.qbits.alerts.model.AlertsTeam; +import com.kingsrook.qbits.alerts.model.AlertsTeamMember; +import com.kingsrook.qbits.alerts.model.AlertsUserPreference; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** User-local routing and durable subscription digest delivery on H2. + *******************************************************************************/ +class QuietHoursAndDigestsTest extends BaseAlertsTest +{ + private static final String TYPE = "test.digest"; + private QRecord preference; + + + + /******************************************************************************* + ** Use a recording channel through the real channel registry. + *******************************************************************************/ + @Override + protected void customizeConfig(AlertsQBitConfig config) + { + config.withChannelType(new QCodeReference(TestSteps.RecordingChannel.class)); + config.withChannelType(new QCodeReference(RecordingEmailChannel.class)); + } + + + + /******************************************************************************* + ** One user's settings, and a clean transport recorder. + *******************************************************************************/ + @BeforeEach + void prepare() throws Exception + { + TestSteps.SENT.clear(); + TestSteps.nextOutcome = DeliveryResult.Outcome.SENT; + preference = runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord() + .withValue("userId", ADMIN).withValue("inAppEnabled", true).withValue("emailEnabled", false).withValue("timeZone", "UTC"))); + } + + + + /******************************************************************************* + ** Personal subscriptions use the standard in-app default or an override. + *******************************************************************************/ + private QRecord subscribe(String mode, Integer channelId) throws QException + { + return (runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord() + .withValue("subscriberType", "USER").withValue("userId", ADMIN).withValue("scopeType", "ALL") + .withValue("minSeverity", "WARNING").withValue("events", "RAISED,RESOLVED").withValue("enabled", true) + .withValue("deliveryMode", mode).withValue("channelOverrideIds", channelId == null ? null : channelId.toString())))); + } + + + + /******************************************************************************* + ** Update settings through the table actions. + *******************************************************************************/ + private void settings(QRecord values) throws QException + { + runtime.asSystem(() -> AlertsDb.update(AlertsUserPreference.TABLE_NAME, values.withValue("id", preference.getValue("id")))); + } + + + + /******************************************************************************* + ** Raise a distinct alert at the test clock. + *******************************************************************************/ + private QRecord raise(String key, AlertSeverity severity) throws QException + { + return (runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey(key).withTitle(key).withSeverity(severity))); + } + + + + /******************************************************************************* + ** Source delivery items, excluding digest claim rows. + *******************************************************************************/ + private List items() throws QException + { + return (where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", ADMIN).withCriteria("alertId", QCriteriaOperator.IS_NOT_BLANK))); + } + + + + /******************************************************************************* + ** Inclusive start, exclusive end, and the default critical bypass. + *******************************************************************************/ + @Test + void testQuietBoundariesAndBypass() throws Exception + { + settings(new QRecord().withValue("inAppEnabled", false).withValue("emailEnabled", true).withValue("emailAddress", "admin@example.com")); + subscribe("IMMEDIATE", null); + settings(new QRecord().withValue("quietHoursStart", "12:00").withValue("quietHoursEnd", "14:00")); + raise("at start", AlertSeverity.WARNING); + raise("critical", AlertSeverity.CRITICAL); + clock.set(Instant.parse("2026-10-12T13:59:59Z")); + raise("inside", AlertSeverity.ERROR); + clock.set(Instant.parse("2026-10-12T14:00:00Z")); + raise("at end", AlertSeverity.ERROR); + assertThat(items()).extracting(r -> r.getValueString("suppressedReason")).containsExactly("QUIET_HOURS", null, "QUIET_HOURS", null); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).isEmpty(); + } + + + + /******************************************************************************* + ** Overnight days belong to the start day, in the user's time zone. + *******************************************************************************/ + @Test + void testOvernightDaysAndTimeZone() throws Exception + { + settings(new QRecord().withValue("inAppEnabled", false).withValue("emailEnabled", true).withValue("emailAddress", "admin@example.com")); + subscribe("IMMEDIATE", null); + settings(new QRecord().withValue("timeZone", "America/Chicago").withValue("quietHoursStart", "22:00") + .withValue("quietHoursEnd", "07:00").withValue("quietDays", "MON")); + clock.set(Instant.parse("2026-10-13T03:00:00Z")); + raise("Monday night", AlertSeverity.ERROR); + clock.set(Instant.parse("2026-10-13T11:59:59Z")); + raise("Tuesday morning", AlertSeverity.ERROR); + clock.set(Instant.parse("2026-10-14T03:00:00Z")); + raise("Tuesday night", AlertSeverity.ERROR); + assertThat(items()).extracting(r -> r.getValueString("suppressedReason")).containsExactly("QUIET_HOURS", "QUIET_HOURS", null); + } + + + + /******************************************************************************* + ** Pause precedes quiet hours for push channels, including critical alerts. + ** A channel subscription is independent of personal settings. + *******************************************************************************/ + @Test + void testPauseAndChannelExemption() throws Exception + { + settings(new QRecord().withValue("inAppEnabled", false).withValue("emailEnabled", true).withValue("emailAddress", "admin@example.com")); + subscribe("IMMEDIATE", null); + QRecord channel = channel(); + runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord() + .withValue("subscriberType", "CHANNEL").withValue("channelId", channel.getValue("id")).withValue("scopeType", "ALL") + .withValue("deliveryMode", "IMMEDIATE").withValue("enabled", true))); + settings(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(60)).withValue("quietHoursStart", "11:00").withValue("quietHoursEnd", "13:00")); + raise("paused", AlertSeverity.CRITICAL); + assertThat(items()).singleElement().matches(r -> "USER_PAUSED".equals(r.getValueString("suppressedReason"))); + assertThat(runtime.getDeliveryWorker().deliverDue()).isEqualTo(1); + assertThat(TestSteps.SENT).hasSize(1); + clock.advance(Duration.ofSeconds(60)); + raise("pause expired", AlertSeverity.CRITICAL); + assertThat(items().get(1).getValueString("status")).isEqualTo("PENDING"); + raise("quiet user", AlertSeverity.ERROR); + assertThat(items().get(2).getValueString("suppressedReason")).isEqualTo("QUIET_HOURS"); + assertThat(runtime.getDeliveryWorker().deliverDue()).isEqualTo(3); + } + + + + /******************************************************************************* + ** Expanded manual team members have the same personal quiet windows. + *******************************************************************************/ + @Test + void testTeamQuietHours() throws Exception + { + settings(new QRecord().withValue("inAppEnabled", false).withValue("emailEnabled", true).withValue("emailAddress", "admin@example.com")); + QRecord team = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", "ops").withValue("label", "Ops"))); + runtime.asSystem(() -> AlertsDb.insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", team.getValue("id")).withValue("userId", ADMIN))); + runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "TEAM") + .withValue("teamId", team.getValue("id")).withValue("scopeType", "ALL").withValue("enabled", true))); + settings(new QRecord().withValue("quietHoursStart", "11:00").withValue("quietHoursEnd", "13:00").withValue("quietDays", "1,3")); + raise("quiet team", AlertSeverity.ERROR); + assertThat(items()).singleElement().matches(r -> "QUIET_HOURS".equals(r.getValueString("suppressedReason"))); + } + + + + /******************************************************************************* + ** Invalid settings are safe; an explicit lower bypass is respected. + *******************************************************************************/ + @Test + void testTimingDefaults() throws Exception + { + QRecord settings = new QRecord().withValue("timeZone", "bad/zone").withValue("quietHoursStart", "11:00").withValue("quietHoursEnd", "13:00"); + assertThat(AlertTiming.isQuiet(settings, AlertSeverity.ERROR, clock.instant())).isTrue(); + settings.setValue("quietBypassSeverity", "ERROR"); + assertThat(AlertTiming.isQuiet(settings, AlertSeverity.ERROR, clock.instant())).isFalse(); + settings.setValue("quietHoursStart", "oops"); + assertThat(AlertTiming.isQuiet(settings, AlertSeverity.WARNING, clock.instant())).isFalse(); + settings.setValue("quietHoursStart", "13:00"); + assertThat(AlertTiming.isQuiet(settings, AlertSeverity.WARNING, clock.instant())).isFalse(); + assertThat(AlertTiming.isQuiet(null, AlertSeverity.WARNING, clock.instant())).isFalse(); + } + + + + /******************************************************************************* + ** Hourly items never reach the immediate worker. One message has severity + ** order, new and resolved sections, links, and no resend after restart. + *******************************************************************************/ + @Test + void testHourlyGroupingAndDurableRerun() throws Exception + { + QRecord channel = channel(); + subscribe("DIGEST_HOURLY", channel.getValueInteger("id")); + QRecord first = raise("First", AlertSeverity.WARNING); + QRecord second = raise("Second", AlertSeverity.CRITICAL); + runtime.getEngine().resolve(TYPE, "First", Resolution.PRODUCER, null, "Done"); + assertThat(items()).hasSize(3).allSatisfy(r -> + { + assertThat(r.getValueString("kind")).isEqualTo("DIGEST"); + assertThat(r.getValueString("status")).isEqualTo("PENDING"); + assertThat(r.getValueString("digestKey")).isEqualTo("alerts:digest:" + ADMIN + ":2026-10-12T12:00:00Z:DIGEST_HOURLY"); + }); + assertThat(runtime.getDeliveryWorker().deliverDue()).isZero(); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + assertThat(TestSteps.SENT).singleElement().satisfies(message -> + { + assertThat(message.alertIds()).containsExactly(second.getValueInteger("id"), first.getValueInteger("id")); + assertThat(message.text()).contains("Open alerts by severity", "New since the last digest", "Resolved since the last digest", "/app/alerts_alert/" + first.getValue("id")); + assertThat(message.kind()).isEqualTo("DIGEST"); + }); + assertThat(items()).allSatisfy(r -> assertThat(r.getValueString("status")).isEqualTo("SENT")); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(TestSteps.SENT).hasSize(1); + } + + + + /******************************************************************************* + ** Daily delivery follows the recipient's local hour and groups in-app + ** alerts into one notification. The next day's window remains separate. + *******************************************************************************/ + @Test + void testDailyInAppAndScheduledProcess() throws Exception + { + subscribe("DIGEST_DAILY", null); + settings(new QRecord().withValue("timeZone", "America/Chicago").withValue("digestHour", 8)); + raise("One", AlertSeverity.ERROR); + raise("Two", AlertSeverity.WARNING); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).isEmpty(); + clock.set(Instant.parse("2026-10-12T12:59:59Z")); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + clock.set(Instant.parse("2026-10-12T13:00:00Z")); + assertThat(new RunProcessAction().execute(new RunProcessInput().withProcessName(AlertsProcessesMetaDataProducer.SEND_DIGESTS)).getValues().get("count")).isEqualTo(1); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).hasSize(1); + assertThat(all(AlertsNotification.TABLE_NAME)).singleElement().matches(r -> r.getValueString("body").contains("One") && r.getValueString("body").contains("Two")); + raise("Tomorrow", AlertSeverity.ERROR); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + clock.advance(Duration.ofDays(1)); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).hasSize(2); + } + + + + /******************************************************************************* + ** A late scheduler catches up; daily windows preserve local time over DST. + *******************************************************************************/ + @Test + void testDaylightSavingBoundariesAndCatchup() throws Exception + { + QRecord setting = new QRecord().withValue("timeZone", "America/Chicago").withValue("digestHour", 8); + Instant end = AlertTiming.digestEnd("DIGEST_DAILY", setting, Instant.parse("2026-10-31T14:00:00Z")); + assertThat(end).isEqualTo(Instant.parse("2026-11-01T14:00:00Z")); + assertThat(Duration.between(AlertTiming.digestStart("DIGEST_DAILY", setting, end), end)).isEqualTo(Duration.ofHours(25)); + end = AlertTiming.digestEnd("DIGEST_DAILY", setting, Instant.parse("2026-03-07T15:00:00Z")); + assertThat(Duration.between(AlertTiming.digestStart("DIGEST_DAILY", setting, end), end)).isEqualTo(Duration.ofHours(23)); + subscribe("DIGEST_DAILY", null); + raise("Delayed", AlertSeverity.ERROR); + clock.advance(Duration.ofDays(3)); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + } + + + + /******************************************************************************* + ** Empty digests do not send, and transport failures fail all source rows. + *******************************************************************************/ + @Test + void testEmptyAndFailedDigest() throws Exception + { + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + QRecord channel = channel(); + subscribe("DIGEST_HOURLY", channel.getValueInteger("id")); + raise("Failed one", AlertSeverity.ERROR); + raise("Failed two", AlertSeverity.ERROR); + TestSteps.nextOutcome = DeliveryResult.Outcome.RETRY; + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(items()).hasSize(2).allSatisfy(r -> + { + assertThat(r.getValueString("status")).isEqualTo("FAILED"); + assertThat(r.getValueString("lastError")).contains("503"); + }); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(TestSteps.SENT).hasSize(1); + } + + + + /******************************************************************************* + ** The same source event is queued only once; resolve is a distinct event. + *******************************************************************************/ + @Test + void testDigestEventDeduplication() throws Exception + { + subscribe("DIGEST_HOURLY", null); + QRecord alert = raise("Duplicate", AlertSeverity.ERROR); + Integer eventId = items().get(0).getValueInteger("alertEventId"); + runtime.asSystem(() -> + { + runtime.getRouter().route(alert, EventType.RAISED, eventId); + return (null); + }); + assertThat(items()).hasSize(2); + assertThat(items().get(1).getValueString("suppressedReason")).isEqualTo("DUPLICATE"); + } + + + + /******************************************************************************* + ** An expired durable claim is failed, never resent after a crash. + *******************************************************************************/ + @Test + void testInterruptedClaim() throws Exception + { + QRecord channel = channel(); + subscribe("DIGEST_HOURLY", channel.getValueInteger("id")); + raise("Interrupted", AlertSeverity.ERROR); + QRecord item = items().get(0); + runtime.asSystem(() -> AlertsDb.insert(AlertsDelivery.TABLE_NAME, new QRecord().withValue("kind", "DIGEST").withValue("status", "SENDING") + .withValue("digestClaimKey", item.getValueString("digestKey") + ":channel:" + channel.getValue("id")) + .withValue("claimedUntil", clock.instant().plus(Duration.ofHours(2))))); + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(items().get(0).getValueString("status")).isEqualTo("PENDING"); + clock.advance(Duration.ofHours(2)); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(items().get(0).getValueString("lastError")).contains("interrupted"); + assertThat(TestSteps.SENT).isEmpty(); + } + + + + /******************************************************************************* + ** Settings edited after fan-out are honored at digest dispatch too. + *******************************************************************************/ + @Test + void testPauseAndQuietAtDispatch() throws Exception + { + settings(new QRecord().withValue("inAppEnabled", false).withValue("emailEnabled", true).withValue("emailAddress", "admin@example.com")); + subscribe("DIGEST_HOURLY", null); + raise("Paused while waiting", AlertSeverity.ERROR); + settings(new QRecord().withValue("pausedUntil", clock.instant().plus(Duration.ofHours(2)))); + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(items().get(0).getValueString("suppressedReason")).isEqualTo("USER_PAUSED"); + settings(new QRecord().withValue("pausedUntil", null)); + raise("Quiet while waiting", AlertSeverity.WARNING); + raise("Critical breaks through", AlertSeverity.CRITICAL); + settings(new QRecord().withValue("quietHoursStart", "14:00").withValue("quietHoursEnd", "16:00")); + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + assertThat(items().get(1).getValueString("suppressedReason")).isEqualTo("QUIET_HOURS"); + assertThat(items().get(2).getValueString("status")).isEqualTo("SENT"); + assertThat(TestSteps.SENT).singleElement().matches(message -> !message.text().contains("Quiet while waiting")); + } + + + + /******************************************************************************* + ** Scheduler metadata is hidden and repeats hourly on the chosen scheduler. + *******************************************************************************/ + @Test + void testScheduleAndMissingHour() throws Exception + { + var process = AlertsProcessesMetaDataProducer.buildProcesses(new AlertsQBitConfig().withSchedulerName("jobs")).stream() + .filter(p -> AlertsProcessesMetaDataProducer.SEND_DIGESTS.equals(p.getName())).findFirst().orElseThrow(); + assertThat(process.getIsHidden()).isTrue(); + assertThat(process.getSchedule().getSchedulerName()).isEqualTo("jobs"); + assertThat(process.getSchedule().getRepeatSeconds()).isEqualTo(3600); + QRecord setting = new QRecord().withValue("timeZone", "America/Chicago").withValue("digestHour", 2); + Instant end = AlertTiming.digestEnd("DIGEST_DAILY", setting, Instant.parse("2026-03-07T15:00:00Z")); + assertThat(end).isEqualTo(Instant.parse("2026-03-08T08:00:00Z")); + assertThat(AlertTiming.digestStart("DIGEST_DAILY", setting, end)).isEqualTo(Instant.parse("2026-03-07T08:00:00Z")); + assertThat(AlertTiming.digestEnd("DIGEST_DAILY", null, clock.instant())).isEqualTo(Instant.parse("2026-10-13T08:00:00Z")); + } + + + + /******************************************************************************* + ** Hourly and daily windows with identical starts must not mix future items. + *******************************************************************************/ + @Test + void testDifferentCadencesAndRecipients() throws Exception + { + QRecord channel = channel(); + QRecord subscription = subscribe("DIGEST_HOURLY", channel.getValueInteger("id")); + settings(new QRecord().withValue("digestHour", 12)); + raise("Hourly", AlertSeverity.ERROR); + runtime.asSystem(() -> AlertsDb.update(AlertsSubscription.TABLE_NAME, new QRecord().withValue("id", subscription.getValue("id")).withValue("deliveryMode", "DIGEST_DAILY"))); + raise("Daily", AlertSeverity.ERROR); + assertThat(items()).extracting(r -> r.getValueString("digestKey")).doesNotHaveDuplicates(); + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + assertThat(TestSteps.SENT.get(0).text()).contains("Hourly").doesNotContain("Daily"); + assertThat(items().get(1).getValueString("status")).isEqualTo("PENDING"); + clock.advance(Duration.ofDays(1)); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + assertThat(TestSteps.SENT.get(1).text()).contains("Daily").doesNotContain("Hourly"); + } + + + + /******************************************************************************* + ** Oversized windows fail visibly instead of sending a partial digest or + ** allocating an unbounded message. Every source item records the error. + *******************************************************************************/ + @Test + void testBoundedDigest() throws Exception + { + subscribe("DIGEST_HOURLY", null); + raise("Many", AlertSeverity.ERROR); + QRecord first = items().get(0); + runtime.asSystem(() -> + { + for(int i = 0; i < 500; i++) + { + AlertsDb.insert(AlertsDelivery.TABLE_NAME, new QRecord().withValue("alertId", first.getValue("alertId")) + .withValue("channelId", first.getValue("channelId")).withValue("recipientUserId", ADMIN) + .withValue("kind", "DIGEST").withValue("status", "PENDING").withValue("digestKey", first.getValue("digestKey")) + .withValue("nextAttemptAt", first.getValue("nextAttemptAt"))); + } + return (null); + }); + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(items()).hasSize(501).allSatisfy(r -> + { + assertThat(r.getValueString("status")).isEqualTo("FAILED"); + assertThat(r.getValueString("lastError")).contains("500 item"); + }); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).isEmpty(); + } + + + + /******************************************************************************* + ** Persisted claims freeze item membership and reject duplicate claims. + ** Requeueing a claim cannot turn a terminal result into an endless loop. + *******************************************************************************/ + @Test + void testLateItemsAndRetriedClaim() throws Exception + { + QRecord channel = channel(); + subscribe("DIGEST_HOURLY", channel.getValueInteger("id")); + raise("Claimed", AlertSeverity.ERROR); + QRecord first = items().get(0); + clock.advance(Duration.ofHours(1)); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + QRecord claim = where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("digestKey", first.getValue("digestKey")) + .withCriteria("digestClaimKey", QCriteriaOperator.IS_NOT_BLANK)).get(0); + assertThat(claim.getValueInteger("digestLastItemId")).isEqualTo(first.getValueInteger("id")); + runtime.asSystem(() -> AlertsDb.insert(AlertsDelivery.TABLE_NAME, new QRecord().withValue("alertId", first.getValue("alertId")) + .withValue("channelId", first.getValue("channelId")).withValue("recipientUserId", ADMIN).withValue("kind", "DIGEST") + .withValue("status", "PENDING").withValue("digestKey", first.getValue("digestKey")).withValue("nextAttemptAt", first.getValue("nextAttemptAt")))); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(items().get(1).getValueString("status")).isEqualTo("SUPPRESSED"); + assertThat(items().get(1).getValueString("suppressedReason")).isEqualTo("DUPLICATE"); + assertThat(items().get(1).getValueInstant("sentAt")).isNull(); + assertThatThrownBy(() -> runtime.asSystem(() -> AlertsDb.insert(AlertsDelivery.TABLE_NAME, + new QRecord().withValue("kind", "DIGEST").withValue("status", "SENDING").withValue("digestClaimKey", claim.getValue("digestClaimKey"))))) + .isInstanceOf(QException.class); + runtime.asSystem(() -> AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", claim.getValue("id")).withValue("status", "PENDING"))); + runtime.asSystem(() -> AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", first.getValue("id")).withValue("status", "PENDING"))); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + assertThat(items().get(0).getValueString("status")).isEqualTo("FAILED"); + assertThat(TestSteps.SENT).hasSize(1); + } + + + + /******************************************************************************* + ** Channel subscriptions honor their cadence without personal quiet/pause + ** settings. With no user time zone, daily channels use 08:00 UTC. + *******************************************************************************/ + @Test + void testChannelDigest() throws Exception + { + QRecord channel = channel(); + runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "CHANNEL") + .withValue("channelId", channel.getValue("id")).withValue("scopeType", "ALL").withValue("deliveryMode", "DIGEST_DAILY").withValue("enabled", true))); + settings(new QRecord().withValue("pausedUntil", clock.instant().plus(Duration.ofDays(3)))); + raise("Channel digest", AlertSeverity.ERROR); + QRecord row = all(AlertsDelivery.TABLE_NAME).get(0); + assertThat(row.getValueString("kind")).isEqualTo("DIGEST"); + assertThat(row.getValueInstant("nextAttemptAt")).isEqualTo(Instant.parse("2026-10-13T08:00:00Z")); + assertThat(runtime.getDeliveryWorker().deliverDue()).isZero(); + clock.set(Instant.parse("2026-10-13T08:00:00Z")); + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + assertThat(TestSteps.SENT).singleElement().matches(message -> message.text().contains("Channel digest")); + assertThat(new DigestWorker(runtime).sendDue()).isZero(); + } + + + + /******************************************************************************* + ** The silent bell receives immediate alerts and digests during quiet hours + ** and during a pause, while the same user's email is suppressed. + *******************************************************************************/ + @ParameterizedTest + @ValueSource(strings = {"IMMEDIATE", "DIGEST_HOURLY"}) + void testBellIgnoresQuietHoursAndPause(String mode) throws Exception + { + subscribe(mode, null); + settings(new QRecord().withValue("emailEnabled", true).withValue("emailAddress", "admin@example.com") + .withValue("quietHoursStart", "11:00").withValue("quietHoursEnd", "16:00")); + raise("Quiet bell", AlertSeverity.WARNING); + clock.advance(Duration.ofHours(1)); + if("DIGEST_HOURLY".equals(mode)) + { + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + } + settings(new QRecord().withValue("quietHoursStart", null).withValue("quietHoursEnd", null) + .withValue("pausedUntil", clock.instant().plus(Duration.ofHours(2)))); + raise("Paused bell", AlertSeverity.WARNING); + clock.advance(Duration.ofHours(1)); + if("DIGEST_HOURLY".equals(mode)) + { + assertThat(new DigestWorker(runtime).sendDue()).isEqualTo(1); + } + Integer inAppId = runtime.getChannels().defaultChannel(ChannelType.IN_APP).getValueInteger("id"); + assertThat(items()).filteredOn(r -> inAppId.equals(r.getValueInteger("channelId"))).hasSize(2).allSatisfy(r -> + { + assertThat(r.getValueString("suppressedReason")).isNull(); + assertThat(r.getValueString("status")).isEqualTo("SENT"); + }); + assertThat(items()).filteredOn(r -> !inAppId.equals(r.getValueInteger("channelId"))) + .extracting(r -> r.getValueString("suppressedReason")).containsExactly("QUIET_HOURS", "USER_PAUSED"); + assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).hasSize(2); + } + + + + /******************************************************************************* + ** Record email dispatch without an external provider. + *******************************************************************************/ + public static class RecordingEmailChannel extends TestSteps.RecordingChannel + { + /*************************************************************************** + ** Use the standard personal email destination. + ***************************************************************************/ + @Override + public String getType() + { + return ("EMAIL"); + } + } + + + + /******************************************************************************* + ** A standard channel backed by the test transport. + *******************************************************************************/ + private QRecord channel() throws QException + { + return (runtime.asSystem(() -> AlertsDb.insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "chat").withValue("label", "Chat") + .withValue("channelType", "SLACK").withValue("enabled", true).withValue("detailLevel", "STANDARD")))); + } +} diff --git a/src/test/resources/alerts-h2.sql b/src/test/resources/alerts-h2.sql index b4f6813..338624a 100644 --- a/src/test/resources/alerts-h2.sql +++ b/src/test/resources/alerts-h2.sql @@ -296,8 +296,13 @@ CREATE TABLE IF NOT EXISTS alerts_delivery ( last_error VARCHAR(2000), create_date TIMESTAMP, modify_date TIMESTAMP, + digest_key VARCHAR(200), + digest_claim_key VARCHAR(240), + digest_last_item_id INTEGER, + CONSTRAINT uq_alerts_delivery_digest_claim UNIQUE (digest_claim_key), CONSTRAINT pk_alerts_delivery PRIMARY KEY (id) ); +CREATE INDEX IF NOT EXISTS ix_alerts_delivery_digest ON alerts_delivery (digest_key, channel_id, status); CREATE INDEX IF NOT EXISTS ix_alerts_delivery_status_next_attempt_at ON alerts_delivery (status, next_attempt_at); CREATE INDEX IF NOT EXISTS ix_alerts_delivery_alert_id ON alerts_delivery (alert_id); CREATE INDEX IF NOT EXISTS ix_alerts_delivery_channel_id_status ON alerts_delivery (channel_id, status); -- 2.54.0