Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ebea22a1
|
||
|
|
99a0547c4a | ||
|
|
718e13c236
|
||
|
|
a2e7f9d9e5
|
||
|
|
aca61324aa | ||
|
|
ebfb71c910
|
||
|
|
8c6e4dadfa
|
||
|
|
9de98746a4 | ||
|
|
af4695ac60 | ||
|
|
e2c5181782
|
||
|
|
28664d7d8b
|
||
|
|
62f484712e | ||
|
|
ea5f67933a
|
||
|
|
c942d90a2f | ||
|
|
96b7a20a7d |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
|
||||
@@ -4,7 +4,15 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- Record-view child lists (e.g. a notification's Recipients) failed with "Error in JSON Serialization": the child table's meta-data includes the qbit config, and `AlertsQBitConfig.getRuntime()` points at a runtime that points back at the config, so Jackson recursed to its 1000-level nesting limit. `getRuntime()` and `getAudienceResolver()` are now `@JsonIgnore`, as in the other qbits' configs. A regression test renders and serializes every child-list widget.
|
||||
|
||||
### Changed
|
||||
- Keep reminders and escalations immediate for digest subscribers while applying pause and quiet hours to their push channels. Preserve team audience resolution, mute scopes, both scheduled jobs and changesets 21-27.
|
||||
- Apply user pause and local quiet hours to personal push channels (email, email lists and other non-IN_APP channels), including team members, with logged suppression reasons and a configurable quiet-hours severity bypass. The in-app bell remains unaffected, including digest delivery. Channel subscriptions are unaffected.
|
||||
- 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.
|
||||
- Team subscriptions now combine explicit members with ROLE and IDP_GROUP users from the optional public `AlertAudienceResolver` configured through `withAudienceResolver`. Recipients are deduplicated and retain per-user visibility checks. Missing resolvers resolve to nobody, log the reason, and show configuration-aware help on team/member screens.
|
||||
- Fix the alert Mute action to target its subject record, or its type and dedup key when no subject exists. Add the separate "Mute this alert type" action. RECORD mutes now compare table and record ID. Guarded changesets 21–22 add `scope_record_id` and allow full ALERT keys; existing type-wide mutes retain their scope.
|
||||
- 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).
|
||||
@@ -21,6 +29,10 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- Webhook signing delegates to core `WebhookSigner`/`WebhookSecret` (qbit-alerts#15, F11). Headers, `v1,` signatures and `whsec_` encoding are unchanged. A thin adapter preserves legacy keys shorter than core's 16-byte minimum using equivalent HMAC zero-padding.
|
||||
|
||||
### Added
|
||||
- Send notifications to Slack per user and category (QRun-IO/qbit-slack docs/DESIGN.md section 10; supersedes "Slack never per user"). `alerts_notification_preference` gets `slackMode` (OFF/IMMEDIATE/DAILY_DIGEST, default OFF) and `slackMinSeverity` (default WARNING); recipients get `slackStatus` and `slackMessageId` (guarded changeset 28). IMMEDIATE and daily-digest DMs go through core `SendMessageAction` (provider `AlertsQBitConfig.withSlackProviderName`, default `slack`, address `user:<id>`) with no qbit-slack dependency. Pause and quiet hours hold them (CRITICAL bypasses), SECURITY carries title and link only, a user without a Slack connection is skipped, and failures never fail the notification. RC.6 has no idempotency key or `isAvailable`, so `slackStatus` guards resends (STAND-IN core#1194, removeBy RC.8) and availability checks the instance's provider.
|
||||
- Apply reminders and escalation (#27, part 2): a minute-based hidden job sends due reminders, advances ordered USER/TEAM/CHANNEL escalation steps, and caps final-step repeats. Acknowledgement, snooze, resolution and mutes stop both; reopening restarts escalation. Delivery, events and progress commit together, with a persisted same-minute checkpoint.
|
||||
- Add admin escalation policy and step tables, child step lists, and type/group policy links in guarded Liquibase changesets 23–26. Type policies override group policies. CRITICAL reminders default to 60 minutes; explicit zero turns them off.
|
||||
- Keep EXPIRED reserved and remove it from selectable resolution values because the design specifies no expiration rule.
|
||||
- Optional external channels:
|
||||
- Slack (an Incoming Webhook, Block Kit)
|
||||
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
|
||||
|
||||
@@ -31,6 +31,40 @@ Any qbit or app reports exceptional conditions ("a person should do something").
|
||||
|
||||
All data is in standard QQQ tables named `alerts_*`. People change alerts through record actions, never by editing rows.
|
||||
|
||||
## Team audiences and mutes
|
||||
|
||||
Teams keep their source in `alerts_team.memberSource` and `memberSourceValue`.
|
||||
Rows in `alerts_team_member` are explicit user memberships. A team's ROLE or
|
||||
IDP_GROUP source adds users to those explicit members at routing time. Register
|
||||
`com.kingsrook.qbits.alerts.AlertAudienceResolver` before producing the qbit:
|
||||
|
||||
```java
|
||||
config.withAudienceResolver((sourceType, sourceValue) -> hostDirectory.resolveUserIds(sourceType, sourceValue));
|
||||
```
|
||||
|
||||
The host implements that directory lookup through its role or identity-provider
|
||||
public API; this qbit has no dependency on either implementation. Return a
|
||||
`Set<String>` of user IDs (an empty set for an unknown source/value), bounded by
|
||||
`maxAudienceSize` (50,000 by default). Users are deduplicated across explicit
|
||||
members, resolved members and subscriptions, and each still passes the router's
|
||||
visibility checks. Missing or failing resolvers contribute no users and log a
|
||||
clear reason; explicit members still receive alerts. Team and member screens
|
||||
show the missing-resolver help text only when no resolver was configured.
|
||||
|
||||
The alert's **Mute** record action silences its subject table and record ID when
|
||||
there is one. Without a subject record, it silences only that alert's type and
|
||||
dedup key, including later occurrences of the same problem. **Mute this alert
|
||||
type** is a separate record action on alerts and alert types. Both use the usual
|
||||
Review and Result summaries and require a reason and a duration. Mutes remain
|
||||
shared rules, not personal notification preferences.
|
||||
|
||||
RECORD mutes store the table in `scopeValue` and its record ID in `scopeRecordId`.
|
||||
ALERT mutes store the engine's full `type|dedupKey` in `scopeValue`. Apply guarded
|
||||
Liquibase changesets 21–22 before running this version: they add the record ID
|
||||
column and widen mute scope values to 520 characters. Existing type-wide mutes
|
||||
remain type-wide; historical RECORD mutes need their intended record ID filled
|
||||
in because previous versions did not store it.
|
||||
|
||||
## User notifications
|
||||
|
||||
The qbit also carries ordinary notifications for people (design: [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md)):
|
||||
@@ -155,6 +189,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, explicit and host-resolved 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. REMINDER and ESCALATED deliveries are always immediate, even for digest subscriptions, and still respect pause and quiet hours on push channels. 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 27 adds the digest fields and a unique dispatch claim to `alerts_delivery`. The base key is `alerts:digest:<user>:<window start>`; alert digests append `:<delivery mode>:channel:<channel id>` to distinguish overlapping hourly/daily windows, channels and the existing notification digests. Channel-only digests use `alerts:channel-digest:<channel>:<window start>:<delivery mode>:channel:<channel id>`. 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:
|
||||
@@ -167,14 +213,57 @@ When qbit-email is produced in the same instance (and on the classpath; it is an
|
||||
- **Text** in the variables is the existing redacted, external-safe text. No record labels are passed.
|
||||
- **Config.** `messagingProviderName` and `fromAddress` are not needed (the From address comes from the email provider). Without qbit-email they work as before.
|
||||
|
||||
## Reminders and escalation
|
||||
|
||||
The hidden `alertsEscalateAndRemind` process runs every minute on `schedulerName`.
|
||||
Without a configured scheduler, hosts can invoke the process themselves. Apply
|
||||
Liquibase changesets 23–26 before running this version.
|
||||
|
||||
An OPEN, unacknowledged alert sends REMINDER deliveries to matching subscriptions
|
||||
whose events include REMINDER (an empty events list includes all kinds). Set the
|
||||
type's `reminderIntervalMinutes`: null defaults to 60 for CRITICAL alerts and off
|
||||
otherwise; zero disables reminders, including CRITICAL. The interval starts at
|
||||
the later of the latest raise and the latest successful notification. Delivery
|
||||
uses the normal permission, channel and suppression checks, writes NOTIFIED in
|
||||
the timeline when something is queued, and updates `lastNotifiedAt`.
|
||||
|
||||
Admins can create **Escalation Policies** in the Alerts app and add **Steps** on a
|
||||
policy's record screen. Attach `escalationPolicyId` to a type or group; a type's
|
||||
explicit policy takes precedence, including when that policy is disabled. Steps
|
||||
have a positive, unique `stepOrder`, a nonnegative `afterMinutes`, and exactly
|
||||
one USER (`userId`), TEAM (`teamId`) or CHANNEL (`channelId`) target. User and team
|
||||
targets use the users' normal in-app/email preferences. Steps apply only to OPEN
|
||||
alerts matching `onlyIfStatus` (default OPEN). Timing starts at the first raise
|
||||
or the latest reopen; repeated raises do not postpone escalation.
|
||||
|
||||
Each due step sends ESCALATED through the normal delivery checks, records an
|
||||
ESCALATED event with the previous and new levels, and advances `escalationLevel`.
|
||||
After the final step, positive `repeatAfterMinutes` and `maxRepeats` allow that
|
||||
many additional attempts at the final target. Null/zero disables repeats. Repeats
|
||||
leave the level unchanged and are counted in the timeline within the current
|
||||
open cycle. Reopening resets the level and repeat budget. One step runs per tick;
|
||||
a delayed scheduler catches up over subsequent minutes instead of sending a burst.
|
||||
Acknowledgement, snooze, resolution, mute, flapping, and a disabled or silent type
|
||||
stop timed notifications. Suppressed deliveries still count as escalation attempts.
|
||||
|
||||
Each alert's delivery rows, timeline and progress commit together. The persisted
|
||||
`timedNotificationCheckedAt` prevents duplicate attempts in the same minute,
|
||||
including when no recipient is eligible. Run this job on **one scheduler node**;
|
||||
there is no distributed lock between scheduler nodes. No ESCALATION feature
|
||||
switch exists in the current config; enable or disable individual policies.
|
||||
|
||||
`EXPIRED` remains a reserved resolution enum value. The design defines no alert
|
||||
expiration rule, so it is excluded from selectable resolution values and is never
|
||||
assigned automatically. The Resolve action continues to record MANUAL.
|
||||
|
||||
## 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:** Micrometer metrics, mirrored lock columns and a configurable table namespace. These are the rest of design phases P4 to P6.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
+15
-1
@@ -1,8 +1,12 @@
|
||||
# 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:
|
||||
- **Timed notifications (#27, part 2):** reminders and escalation are implemented ahead of the remaining P4–P6 features. The minute job uses `stepOrder` and typed `userId` / `teamId` / `channelId` fields instead of the design's generic `targetId`. Policy description and enabled fields make the standard admin records usable. Types override groups; only OPEN, unacknowledged alerts run, even if a step is configured for another status, because acknowledgement must stop escalation.
|
||||
- **Escalation progress:** existing ESCALATED events hold level transitions and final-step repeat attempts; repeats keep the same level. Reopen events define each cycle's start and repeat budget. One step runs per minute, including when several steps became overdue. Null/zero repeat settings disable repeats. Suppression consumes an escalation attempt, as the normal router records why no delivery was queued.
|
||||
- **Timed notification transactions:** `AlertsDb` passes the current QQQ transaction to table actions. Each alert's timed fan-out, events and progress commit together. A read-only `timedNotificationCheckedAt` field prevents repeated evaluation in the same minute, including attempts with no eligible recipients. Alert and team scans use bounded pages. Runtime ticks are serialized locally; hosts must run the job on one scheduler node, matching the existing single-node scheduling guidance.
|
||||
- **Reminders and EXPIRED:** null reminder interval means 60 minutes for CRITICAL and off otherwise; explicit zero is off. Reminders use subscription event filters and NOTIFIED timeline entries, without introducing a REMINDER event. Record followers do not receive scheduled reminders without a matching subscription. EXPIRED remains reserved but is excluded from the resolution possible values; the design defines no expiration behavior. No ESCALATION feature switch is present in the current config.
|
||||
- **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.
|
||||
- **Alerts and notifications use core's RC.6 producer API**, including sink registration in core instance metadata (QRun-IO/qqq#1108 and #1125). `AlertsSinkAdapter` delegates `raise`, `resolve`, `heartbeat` and `notify` to the runtime queue. No local SPI types remain; storage-specific audience and severity conversion belongs to `NotificationService`.
|
||||
- **Visibility checks 1 to 3 (section 7.2)** are enforced by record security locks on session keys (`alerts_permission`, `alerts_readable_table`), not by a pre-query customizer. QQQ has no pre-query customizer, and locks also apply to counts, exports and the API.
|
||||
@@ -12,3 +16,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.
|
||||
+21
-5
@@ -23,7 +23,9 @@ system as well"
|
||||
## 1. Owner decisions (2026-10-13)
|
||||
|
||||
1. **Channels.** In-app (the bell and the notifications page) always. Email is opt-in per user, by
|
||||
severity or as a daily digest. Slack stays a group or monitor channel only, never per user.
|
||||
severity or as a daily digest. Slack is also a per-user channel, chosen per category (owner decision
|
||||
2026-10-14, superseding the 2026-10-13 "Slack stays a group or monitor channel only, never per user";
|
||||
alert-group and monitor Slack channels are unchanged).
|
||||
2. **Audience.** Dynamic audiences: all users, groups or roles, holders of a permission, or named users.
|
||||
The audience is resolved at send time and materialized as one recipient row per user, so read,
|
||||
unread and dismissed are tracked per user.
|
||||
@@ -88,8 +90,9 @@ everywhere. Deleting it deletes its recipient rows.
|
||||
|
||||
A child of the notification: one row per user, unique (`notificationId`, `userId`).
|
||||
|
||||
`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus`
|
||||
(NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED).
|
||||
`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus`,
|
||||
`slackStatus`, `slackMessageId` (each status is NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED;
|
||||
`slackMessageId` is filled when the Slack provider reports one).
|
||||
|
||||
Users see and change only their own rows (owner lock on `userId`). Only `readAt` and `dismissedAt` are
|
||||
editable; inserts and deletes are the qbit's.
|
||||
@@ -99,7 +102,8 @@ editable; inserts and deletes are the qbit's.
|
||||
One row per user and category, unique (`userId`, `category`); a missing row means the defaults.
|
||||
|
||||
`userId`, `category`, `inAppMuted` (boolean; refused for SECURITY), `emailMode` (OFF / IMMEDIATE /
|
||||
DAILY_DIGEST, default OFF), `emailMinSeverity` (default WARNING). It is owner-locked on `userId`. The
|
||||
DAILY_DIGEST, default OFF), `emailMinSeverity` (default WARNING), `slackMode` (OFF / IMMEDIATE / DAILY_DIGEST, default OFF),
|
||||
`slackMinSeverity` (default WARNING). It is owner-locked on `userId`. The
|
||||
digest hour and time zone come from `alerts_user_preference`.
|
||||
|
||||
## 4. Audiences
|
||||
@@ -191,7 +195,19 @@ QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
sends from the delivery job. DAILY_DIGEST sends one email per user at their digest hour, listing their
|
||||
unread notifications since the last digest. A muted category is skipped. Email never includes a body
|
||||
for SECURITY beyond the title and a link.
|
||||
- **Slack** stays a channel for alert groups and monitors only.
|
||||
- **Slack** (per user, opt-in per category through the Slack column of My Notification Settings): sent by
|
||||
`NotificationSlackSender` from the same delivery job, only through core `SendMessageAction` (provider
|
||||
`slackProviderName`, default `slack`; address `user:<userId>`, the recipient as owner, so the DM goes
|
||||
through that user's own Slack connection; qbit-slack is never imported). IMMEDIATE sends one DM per
|
||||
notification at or above `slackMinSeverity`; DAILY_DIGEST sends one DM per user at their digest hour,
|
||||
listing their unread notifications, and marks the rows DIGESTED. Slack is a push channel, so a user's pause
|
||||
and quiet hours (`alerts_user_preference`) hold it, with the same CRITICAL bypass as other push channels;
|
||||
the bell is never held. ALERT notifications are not sent (alert channels already cover them) and SECURITY
|
||||
carries the title and link only. With no provider registered under that name the rows are SKIPPED. A user
|
||||
without a Slack connection is SKIPPED with no retry; any other error is FAILED; neither fails the
|
||||
notification. Idempotency key `notify:<notificationId>:<userId>:slack`; until core adds an idempotency
|
||||
field (STAND-IN core#1194, remove by RC.8) the recipient's `slackStatus` prevents a resend.
|
||||
- **Alert-group and monitor Slack channels** (incoming webhooks) work as before.
|
||||
|
||||
## 8. Next UI bell and page
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host SPI for ROLE and IDP_GROUP team membership. Implementations use the
|
||||
** host's public APIs and return user IDs, never display names. Return an empty
|
||||
** set for an unknown source or value. Results must be bounded by the configured
|
||||
** maxAudienceSize; routing still checks every user's alert visibility.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface AlertAudienceResolver
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve the team's memberSource and memberSourceValue to user IDs.
|
||||
*******************************************************************************/
|
||||
Set<String> resolve(String sourceType, String sourceValue) throws QException;
|
||||
}
|
||||
@@ -19,9 +19,12 @@ package com.kingsrook.qbits.alerts;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
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.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
@@ -80,6 +83,7 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
private String schedulerName;
|
||||
private String messagingProviderName;
|
||||
private String fromAddress;
|
||||
private String slackProviderName = "slack";
|
||||
private String publicBaseUrl;
|
||||
|
||||
private Integer reopenWindowMinutes = 30;
|
||||
@@ -106,10 +110,34 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
private Boolean notifyFailedJobs = true;
|
||||
private String jobRunLinkPattern;
|
||||
|
||||
private transient AlertAudienceResolver audienceResolver;
|
||||
|
||||
private transient AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional host resolver for ROLE and IDP_GROUP team members.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public AlertAudienceResolver getAudienceResolver()
|
||||
{
|
||||
return (audienceResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the host resolver before producing the qbit's metadata.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withAudienceResolver(AlertAudienceResolver audienceResolver)
|
||||
{
|
||||
this.audienceResolver = audienceResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend must be named and exist; numbers must be positive.
|
||||
*******************************************************************************/
|
||||
@@ -153,7 +181,7 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
*******************************************************************************/
|
||||
public static List<String> allTableNames()
|
||||
{
|
||||
return (List.of(AlertsAlert.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsSubscription.TABLE_NAME,
|
||||
return (List.of(AlertsEscalationPolicy.TABLE_NAME, AlertsEscalationStep.TABLE_NAME, AlertsAlert.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsSubscription.TABLE_NAME,
|
||||
AlertsUserPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsTeamMember.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME,
|
||||
AlertsDelivery.TABLE_NAME, AlertsEvent.TABLE_NAME, AlertsRecordRef.TABLE_NAME, AlertsHeartbeat.TABLE_NAME, AlertsNotification.TABLE_NAME,
|
||||
AlertsNotificationRecipient.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME));
|
||||
@@ -316,6 +344,29 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackProviderName
|
||||
*******************************************************************************/
|
||||
public String getSlackProviderName()
|
||||
{
|
||||
return (this.slackProviderName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackProviderName: the core messaging provider that
|
||||
** sends per-user Slack direct messages (default "slack", registered by
|
||||
** qbit-slack). Without a provider of that name Slack is skipped.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withSlackProviderName(String slackProviderName)
|
||||
{
|
||||
this.slackProviderName = slackProviderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fromAddress
|
||||
*******************************************************************************/
|
||||
@@ -624,8 +675,11 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runtime (built by the producer)
|
||||
** Getter for runtime (built by the producer). Never serialized: the runtime
|
||||
** points back at this config, and QQQ meta-data JSON (e.g. a child-list
|
||||
** widget's table meta-data) can include the qbit config.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public AlertsRuntime getRuntime()
|
||||
{
|
||||
return (this.runtime);
|
||||
|
||||
@@ -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<Integer> alertIds, String link, List<Entry> entries)
|
||||
public record AlertMessage(String subject, String text, String html, String severity, String kind, List<Integer> alertIds, String link, List<Entry> entries, String idempotencyKey)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A message without a durable dispatch key.
|
||||
*******************************************************************************/
|
||||
public AlertMessage(String subject, String text, String html, String severity, String kind, List<Integer> alertIds, String link, List<Entry> entries)
|
||||
{
|
||||
this(subject, text, html, severity, kind, alertIds, link, entries, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A message without structured entries.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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<Item> items, String publicBaseUrl, Instant now, String key)
|
||||
{
|
||||
List<Item> 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("<h3>").append(section).append("</h3>");
|
||||
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("<p><a href=\"").append(HomeHtml.escape(entry.link())).append("\">").append(HomeHtml.escape(line)).append("</a></p>");
|
||||
}
|
||||
}
|
||||
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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -102,6 +102,7 @@ public class DeliveryWorker
|
||||
Instant now = runtime.now();
|
||||
List<QRecord> 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));
|
||||
|
||||
@@ -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<QRecord> 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<String> 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<QRecord> 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<QRecord> 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<Integer, QRecord> 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<AlertMessageRenderer.Item> 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<QRecord> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, Serializable>> digestItems(AlertMessage message)
|
||||
{
|
||||
Map<String, Serializable> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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<Map<String, Serializable>> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -22,9 +22,12 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertAudienceResolver;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsEngine.AlertTarget;
|
||||
@@ -37,11 +40,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MemberSource;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SuppressedReason;
|
||||
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.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
@@ -75,7 +80,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 minimum severity / enabled / 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 +101,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)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -142,129 +147,8 @@ public class AlertRouter
|
||||
|
||||
try
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
AlertTarget target = AlertTarget.of(alert);
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
AlertSeverity severity = AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING);
|
||||
|
||||
List<Destination> destinations = destinations(target, severity, kind);
|
||||
if(destinations.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
boolean silent = type != null && Boolean.FALSE.equals(type.getValueBoolean("notify"));
|
||||
boolean muted = isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS);
|
||||
boolean flapping = Boolean.TRUE.equals(alert.getValueBoolean("flapping")) && eventType != EventType.FLAPPING_STARTED;
|
||||
if(muted && !Boolean.TRUE.equals(alert.getValueBoolean("muted")))
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("muted", true));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.MUTED, null, null, null, "A mute silenced notifications", now);
|
||||
}
|
||||
|
||||
Map<String, Optional<Boolean>> visibleByUser = new HashMap<>();
|
||||
int queued = 0;
|
||||
for(Destination destination : destinations)
|
||||
{
|
||||
SuppressedReason reason = null;
|
||||
String channelType = destination.channel().getValueString("channelType");
|
||||
boolean inApp = ChannelType.IN_APP.name().equals(channelType);
|
||||
|
||||
if(severity == AlertSeverity.INFO && !inApp && !AlertSeverity.INFO.name().equals(destination.minSeverity()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!inApp && "alerts.channel.failing".equals(alert.getValueString("alertTypeName")))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a broken channel is only ever reported in-app (no loops) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
AlertSeverity channelMin = AlertSeverity.fromLabel(destination.channel().getValueString("minSeverity"), null);
|
||||
if(channelMin != null && !severity.isAtLeast(channelMin) && !inApp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(silent)
|
||||
{
|
||||
reason = SuppressedReason.TYPE_SILENT;
|
||||
}
|
||||
else if(muted)
|
||||
{
|
||||
reason = SuppressedReason.MUTED;
|
||||
}
|
||||
else if(flapping)
|
||||
{
|
||||
reason = SuppressedReason.FLAPPING;
|
||||
}
|
||||
else if(destination.userId() != null && !visibleByUser.computeIfAbsent(destination.userId(), userId -> knownVisibility(userId, alert))
|
||||
.orElse(inApp || (alert.getValue("visibilityPermission") == null && alert.getValue("subjectTableName") == null)))
|
||||
{
|
||||
reason = SuppressedReason.NOT_VISIBLE;
|
||||
}
|
||||
else if(destination.userId() != null && !inApp && isPaused(destination.preference(), now))
|
||||
{
|
||||
reason = SuppressedReason.USER_PAUSED;
|
||||
}
|
||||
else if(Boolean.FALSE.equals(destination.channel().getValueBoolean("enabled")))
|
||||
{
|
||||
reason = SuppressedReason.CHANNEL_DISABLED;
|
||||
}
|
||||
else if(ChannelType.EMAIL.name().equals(channelType) && !StringUtils.hasContent(emailAddress(destination)))
|
||||
{
|
||||
reason = SuppressedReason.NO_ADDRESS;
|
||||
}
|
||||
else if(!inApp && isRateLimited(destination.channel(), now))
|
||||
{
|
||||
reason = SuppressedReason.RATE_LIMITED;
|
||||
}
|
||||
else if(isDuplicate(alert, destination, kind, now))
|
||||
{
|
||||
reason = SuppressedReason.DUPLICATE;
|
||||
}
|
||||
|
||||
QRecord notification = new QRecord()
|
||||
.withValue("alertId", alert.getValue("id"))
|
||||
.withValue("alertEventId", eventId)
|
||||
.withValue("subscriptionId", destination.subscriptionId())
|
||||
.withValue("channelId", destination.channel().getValue("id"))
|
||||
.withValue("recipientUserId", destination.userId())
|
||||
.withValue("recipientLabel", recipientLabel(destination))
|
||||
.withValue("kind", kind.name())
|
||||
.withValue("attempts", 0);
|
||||
|
||||
if(reason != null)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
|
||||
notification.setValue("suppressedReason", reason.name());
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
}
|
||||
else if(inApp)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SENT.name());
|
||||
notification.setValue("sentAt", now);
|
||||
QRecord inserted = AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
runtime.getNotifications().deliverAlert(destination.userId(), alert, now);
|
||||
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")));
|
||||
queued++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.PENDING.name());
|
||||
notification.setValue("nextAttemptAt", now);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
if(queued > 0)
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("lastNotifiedAt", now));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.NOTIFIED, null, null, String.valueOf(queued),
|
||||
queued == 1 ? "1 notification" : queued + " notifications", now);
|
||||
}
|
||||
deliver(alert, eventType, eventId, kind, destinations(AlertTarget.of(alert),
|
||||
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), kind));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -274,6 +158,226 @@ public class AlertRouter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A due reminder uses subscription event filters and the normal delivery checks.
|
||||
*******************************************************************************/
|
||||
public int remind(QRecord alert) throws QException
|
||||
{
|
||||
return (deliver(alert, null, null, DeliveryKind.REMINDER, destinations(AlertTarget.of(alert),
|
||||
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), DeliveryKind.REMINDER)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A policy target uses the same channel, permission and suppression checks.
|
||||
** Team members are read in bounded pages; no membership source is changed.
|
||||
*******************************************************************************/
|
||||
public void escalate(QRecord alert, QRecord step, Integer eventId) throws QException
|
||||
{
|
||||
String targetType = step.getValueString("targetType");
|
||||
if(SubscriberType.CHANNEL.name().equals(targetType))
|
||||
{
|
||||
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, step.getValue("channelId"));
|
||||
if(channel != null)
|
||||
{
|
||||
deliver(alert, EventType.ESCALATED, eventId, DeliveryKind.ESCALATED,
|
||||
List.of(new Destination(null, channel, null, null, null, "IMMEDIATE")));
|
||||
}
|
||||
}
|
||||
else if(SubscriberType.USER.name().equals(targetType))
|
||||
{
|
||||
escalateToUser(alert, step.getValueString("userId"), eventId);
|
||||
}
|
||||
else if(SubscriberType.TEAM.name().equals(targetType))
|
||||
{
|
||||
int lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> members = AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", step.getValue("teamId"))
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)
|
||||
.withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id", true)).withLimit(100));
|
||||
if(members.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord member : members)
|
||||
{
|
||||
escalateToUser(alert, member.getValueString("userId"), eventId);
|
||||
lastId = member.getValueInteger("id");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User and team targets use the user's normal destinations.
|
||||
*******************************************************************************/
|
||||
private void escalateToUser(QRecord alert, String userId, Integer eventId) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
for(QRecord channel : userChannels(null, preference))
|
||||
{
|
||||
deliver(alert, EventType.ESCALATED, eventId, DeliveryKind.ESCALATED,
|
||||
List.of(new Destination(userId, channel, null, null, preference, "IMMEDIATE")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared fan-out. Scheduled callers propagate failures so their transaction rolls back.
|
||||
*******************************************************************************/
|
||||
private int deliver(QRecord alert, EventType eventType, Integer eventId, DeliveryKind kind, List<Destination> destinations) throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
AlertTarget target = AlertTarget.of(alert);
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
AlertSeverity severity = AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING);
|
||||
|
||||
if(destinations.isEmpty())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
|
||||
boolean silent = type != null && Boolean.FALSE.equals(type.getValueBoolean("notify"));
|
||||
boolean muted = isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS);
|
||||
boolean flapping = Boolean.TRUE.equals(alert.getValueBoolean("flapping")) && eventType != EventType.FLAPPING_STARTED;
|
||||
if(muted && !Boolean.TRUE.equals(alert.getValueBoolean("muted")))
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("muted", true));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.MUTED, null, null, null, "A mute silenced notifications", now);
|
||||
}
|
||||
|
||||
Map<String, Optional<Boolean>> visibleByUser = new HashMap<>();
|
||||
int queued = 0;
|
||||
for(Destination destination : destinations)
|
||||
{
|
||||
boolean digest = kind != DeliveryKind.REMINDER && kind != DeliveryKind.ESCALATED
|
||||
&& 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);
|
||||
|
||||
if(severity == AlertSeverity.INFO && !inApp && !AlertSeverity.INFO.name().equals(destination.minSeverity()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!inApp && "alerts.channel.failing".equals(alert.getValueString("alertTypeName")))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a broken channel is only ever reported in-app (no loops) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
AlertSeverity channelMin = AlertSeverity.fromLabel(destination.channel().getValueString("minSeverity"), null);
|
||||
|
||||
if(silent)
|
||||
{
|
||||
reason = SuppressedReason.TYPE_SILENT;
|
||||
}
|
||||
else if(muted)
|
||||
{
|
||||
reason = SuppressedReason.MUTED;
|
||||
}
|
||||
else if(flapping)
|
||||
{
|
||||
reason = SuppressedReason.FLAPPING;
|
||||
}
|
||||
else if(destination.userId() != null && !visibleByUser.computeIfAbsent(destination.userId(), userId -> knownVisibility(userId, alert))
|
||||
.orElse(inApp || (alert.getValue("visibilityPermission") == null && alert.getValue("subjectTableName") == null)))
|
||||
{
|
||||
reason = SuppressedReason.NOT_VISIBLE;
|
||||
}
|
||||
else if(destination.userId() != null && !inApp && isPaused(destination.preference(), now))
|
||||
{
|
||||
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;
|
||||
}
|
||||
else if(ChannelType.EMAIL.name().equals(channelType) && !StringUtils.hasContent(emailAddress(destination)))
|
||||
{
|
||||
reason = SuppressedReason.NO_ADDRESS;
|
||||
}
|
||||
else if(!inApp && isRateLimited(destination.channel(), now))
|
||||
{
|
||||
reason = SuppressedReason.RATE_LIMITED;
|
||||
}
|
||||
else if(kind != DeliveryKind.REMINDER && isDuplicate(alert, destination, digest ? DeliveryKind.DIGEST : kind, eventId, now))
|
||||
{
|
||||
reason = SuppressedReason.DUPLICATE;
|
||||
}
|
||||
|
||||
QRecord notification = new QRecord()
|
||||
.withValue("alertId", alert.getValue("id"))
|
||||
.withValue("alertEventId", eventId)
|
||||
.withValue("subscriptionId", destination.subscriptionId())
|
||||
.withValue("channelId", destination.channel().getValue("id"))
|
||||
.withValue("recipientUserId", destination.userId())
|
||||
.withValue("recipientLabel", recipientLabel(destination))
|
||||
.withValue("kind", kind.name())
|
||||
.withValue("attempts", 0);
|
||||
|
||||
if(reason != null)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
|
||||
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());
|
||||
notification.setValue("sentAt", now);
|
||||
QRecord inserted = AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
runtime.getNotifications().deliverAlert(destination.userId(), alert, now);
|
||||
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")));
|
||||
queued++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.PENDING.name());
|
||||
notification.setValue("nextAttemptAt", now);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
if(queued > 0)
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("lastNotifiedAt", now));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.NOTIFIED, null, null, String.valueOf(queued),
|
||||
queued == 1 ? "1 notification" : queued + " notifications", now);
|
||||
}
|
||||
return (queued);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an active SUPPRESS_ALERTS maintenance window covers the target.
|
||||
*******************************************************************************/
|
||||
@@ -297,7 +401,7 @@ public class AlertRouter
|
||||
Map<Integer, List<String>> groupNames = new HashMap<>();
|
||||
for(QRecord mute : mutes)
|
||||
{
|
||||
if(scopeMatches(mute.getValueString("scopeType"), mute.getValueString("scopeValue"), null, target, groupNames))
|
||||
if(scopeMatches(mute.getValueString("scopeType"), mute.getValueString("scopeValue"), mute.getValueString("scopeRecordId"), target, groupNames))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
@@ -333,18 +437,15 @@ 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;
|
||||
}
|
||||
|
||||
List<String> userIds = new ArrayList<>();
|
||||
Set<String> userIds = new LinkedHashSet<>();
|
||||
if(SubscriberType.TEAM.name().equals(subscriberType))
|
||||
{
|
||||
for(QRecord member : AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", subscription.getValue("teamId"))))
|
||||
{
|
||||
userIds.add(member.getValueString("userId"));
|
||||
}
|
||||
userIds.addAll(teamMembers(subscription.getValueInteger("teamId")));
|
||||
}
|
||||
else if(StringUtils.hasContent(subscription.getValueString("userId")))
|
||||
{
|
||||
@@ -353,14 +454,18 @@ public class AlertRouter
|
||||
|
||||
for(String userId : userIds)
|
||||
{
|
||||
if(!StringUtils.hasContent(userId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String recordKey : target.recordKeys())
|
||||
for(String recordKey : kind == DeliveryKind.REMINDER ? java.util.Set.<String>of() : target.recordKeys())
|
||||
{
|
||||
String[] parts = recordKey.split(":", 2);
|
||||
RecordReference reference = new RecordReference(parts[0], parts[1]);
|
||||
@@ -374,7 +479,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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,6 +488,61 @@ public class AlertRouter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit members plus the team's optional host-managed source. Missing
|
||||
** or failed resolvers contribute nobody and leave an actionable log entry.
|
||||
*******************************************************************************/
|
||||
private Set<String> teamMembers(Integer teamId) throws QException
|
||||
{
|
||||
Set<String> userIds = new LinkedHashSet<>();
|
||||
QRecord team = AlertsDb.byId(AlertsTeam.TABLE_NAME, teamId);
|
||||
if(team == null)
|
||||
{
|
||||
return (userIds);
|
||||
}
|
||||
for(QRecord member : AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", teamId).withLimit(Math.addExact(runtime.getConfig().getMaxAudienceSize(), 1))))
|
||||
{
|
||||
if(userIds.size() >= runtime.getConfig().getMaxAudienceSize())
|
||||
{
|
||||
LOG.warn("Team audience exceeds maxAudienceSize; extra members are not notified", logPair("teamId", teamId));
|
||||
break;
|
||||
}
|
||||
userIds.add(member.getValueString("userId"));
|
||||
}
|
||||
String source = team.getValueString("memberSource");
|
||||
if(MemberSource.ROLE.name().equals(source) || MemberSource.IDP_GROUP.name().equals(source))
|
||||
{
|
||||
AlertAudienceResolver resolver = runtime.getConfig().getAudienceResolver();
|
||||
if(resolver == null)
|
||||
{
|
||||
LOG.warn("Team source resolves to nobody: no host audience resolver is configured", logPair("teamId", teamId), logPair("source", source));
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Set<String> resolved = resolver.resolve(source, team.getValueString("memberSourceValue"));
|
||||
if(resolved != null)
|
||||
{
|
||||
if(resolved.size() > runtime.getConfig().getMaxAudienceSize()
|
||||
|| resolved.stream().filter(userId -> !userIds.contains(userId)).count() > runtime.getConfig().getMaxAudienceSize() - userIds.size())
|
||||
{
|
||||
throw (new QException("Team audience exceeds maxAudienceSize"));
|
||||
}
|
||||
userIds.addAll(resolved);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Team source resolves to nobody: host audience resolver failed", logPair("teamId", teamId), logPair("source", source));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (userIds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's channels for a subscription: the ones it names, else in-app
|
||||
** (unless turned off) and email (when enabled in their settings).
|
||||
@@ -429,6 +589,7 @@ public class AlertRouter
|
||||
return switch(type)
|
||||
{
|
||||
case ALL -> true;
|
||||
case ALERT -> scopeValue != null && scopeValue.equals(target.alertKey());
|
||||
case ALERT_TYPE -> scopeValue != null && scopeValue.equals(target.typeName());
|
||||
case MONITOR -> scopeValue != null && target.monitorId() != null && scopeValue.equals(String.valueOf(target.monitorId()));
|
||||
case TABLE -> scopeValue != null && (scopeValue.equals(target.subjectTableName()) || target.recordKeys().stream().anyMatch(key -> key.startsWith(scopeValue + ":")));
|
||||
@@ -541,6 +702,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.
|
||||
@@ -594,7 +765,7 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** Whether the user paused notifications (vacation) until after now.
|
||||
*******************************************************************************/
|
||||
private static boolean isPaused(QRecord preference, Instant now)
|
||||
public static boolean isPaused(QRecord preference, Instant now)
|
||||
{
|
||||
return (preference != null && preference.getValueInstant("pausedUntil") != null && preference.getValueInstant("pausedUntil").isAfter(now));
|
||||
}
|
||||
@@ -622,13 +793,24 @@ 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, Integer eventId, Instant now) 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(kind == DeliveryKind.ESCALATED)
|
||||
{
|
||||
filter = AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("channelId", QCriteriaOperator.EQUALS, destination.channel().getValue("id"))
|
||||
.withCriteria("alertEventId", QCriteriaOperator.EQUALS, eventId)
|
||||
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, DeliveryStatus.SUPPRESSED.name());
|
||||
}
|
||||
if(destination.userId() != null)
|
||||
{
|
||||
filter.withCriteria("recipientUserId", QCriteriaOperator.EQUALS, destination.userId());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,14 @@ import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.QStatusMessage;
|
||||
@@ -56,7 +59,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (QueryAction.execute(tableName, filter));
|
||||
return (new QueryAction().execute(new QueryInput(tableName).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getRecords());
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +69,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static QRecord first(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
List<QRecord> records = QueryAction.execute(tableName, filter.withLimit(1));
|
||||
List<QRecord> records = query(tableName, filter.withLimit(1));
|
||||
return (CollectionUtils.nullSafeIsEmpty(records) ? null : records.get(0));
|
||||
}
|
||||
|
||||
@@ -91,7 +94,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static int count(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
Integer count = CountAction.execute(tableName, filter);
|
||||
Integer count = new CountAction().execute(new CountInput(tableName).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getCount();
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
@@ -102,7 +105,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String tableName, QRecord record) throws QException
|
||||
{
|
||||
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(tableName).withRecord(record));
|
||||
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction()));
|
||||
QRecord result = inserted.get(0);
|
||||
throwIfErrors(tableName, result);
|
||||
return (result);
|
||||
@@ -115,7 +118,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static QRecord update(String tableName, QRecord record) throws QException
|
||||
{
|
||||
List<QRecord> updated = UpdateAction.executeForRecords(new UpdateInput(tableName).withRecord(record));
|
||||
List<QRecord> updated = UpdateAction.executeForRecords(new UpdateInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction()));
|
||||
QRecord result = updated.get(0);
|
||||
throwIfErrors(tableName, result);
|
||||
return (result);
|
||||
|
||||
@@ -568,6 +568,8 @@ public class AlertsEngine
|
||||
.withValue("severity", severity.name())
|
||||
.withValue("lastRaisedAt", now)
|
||||
.withValue("occurrenceCount", (resolved.getValueInteger("occurrenceCount") == null ? 0 : resolved.getValueInteger("occurrenceCount")) + 1)
|
||||
.withValue("escalationLevel", 0)
|
||||
.withValue("timedNotificationCheckedAt", null)
|
||||
.withValue("reopenCount", (resolved.getValueInteger("reopenCount") == null ? 0 : resolved.getValueInteger("reopenCount")) + 1)
|
||||
.withValue("resolvedAt", null)
|
||||
.withValue("resolvedByUserId", null)
|
||||
@@ -824,7 +826,7 @@ public class AlertsEngine
|
||||
/*******************************************************************************
|
||||
** What a subscription or mute scope is matched against.
|
||||
*******************************************************************************/
|
||||
public record AlertTarget(Integer typeId, String typeName, Integer groupId, Integer monitorId, String subjectTableName, Set<String> recordKeys, Map<String, String> attributes)
|
||||
public record AlertTarget(Integer typeId, String typeName, Integer groupId, Integer monitorId, String subjectTableName, Set<String> recordKeys, Map<String, String> attributes, String alertKey)
|
||||
{
|
||||
/***************************************************************************
|
||||
** From a type row and a signal (before the alert exists).
|
||||
@@ -837,7 +839,7 @@ public class AlertsEngine
|
||||
keys.add(ref.getTableName() + ":" + ref.getPrimaryKey());
|
||||
}
|
||||
return (new AlertTarget(type.getValueInteger("id"), signal.getAlertTypeName(), type.getValueInteger("groupId"), monitorId,
|
||||
type.getValueString("subjectTableName"), keys, signal.getAttributes()));
|
||||
type.getValueString("subjectTableName"), keys, signal.getAttributes(), openKey(signal.getAlertTypeName(), signal.getEffectiveDedupKey())));
|
||||
}
|
||||
|
||||
|
||||
@@ -852,6 +854,10 @@ public class AlertsEngine
|
||||
{
|
||||
keys.add(ref.getValueString("tableName") + ":" + ref.getValueString("recordId"));
|
||||
}
|
||||
if(StringUtils.hasContent(alert.getValueString("subjectTableName")) && StringUtils.hasContent(alert.getValueString("subjectRecordId")))
|
||||
{
|
||||
keys.add(alert.getValueString("subjectTableName") + ":" + alert.getValueString("subjectRecordId"));
|
||||
}
|
||||
Map<String, String> attributes = new HashMap<>();
|
||||
String json = alert.getValueString("attributes");
|
||||
if(StringUtils.hasContent(json))
|
||||
@@ -867,7 +873,7 @@ public class AlertsEngine
|
||||
}
|
||||
}
|
||||
return (new AlertTarget(alert.getValueInteger("alertTypeId"), alert.getValueString("alertTypeName"), alert.getValueInteger("groupId"), alert.getValueInteger("monitorId"),
|
||||
alert.getValueString("subjectTableName"), keys, attributes));
|
||||
alert.getValueString("subjectTableName"), keys, attributes, openKey(alert.getValueString("alertTypeName"), alert.getValueString("dedupKey"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* 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.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed notifications for open, unacknowledged alerts. Progress and fan-out
|
||||
** commit together per alert. Run on one scheduler node, like the other jobs.
|
||||
*******************************************************************************/
|
||||
public class EscalationAndReminder
|
||||
{
|
||||
private static final int PAGE_SIZE = 100;
|
||||
|
||||
private final AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the installed runtime, including its controllable clock.
|
||||
*******************************************************************************/
|
||||
public EscalationAndReminder(AlertsRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read bounded pages. A persisted checkpoint makes repeated ticks harmless,
|
||||
** including attempts with no eligible recipients. Serialize this runtime's ticks.
|
||||
*******************************************************************************/
|
||||
public int runDue() throws QException
|
||||
{
|
||||
synchronized(runtime)
|
||||
{
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
int changed = 0;
|
||||
int lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> alerts = AlertsDb.query(AlertsAlert.TABLE_NAME, AlertsDb.eq("status", Status.OPEN.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(PAGE_SIZE));
|
||||
if(alerts.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord alert : alerts)
|
||||
{
|
||||
lastId = alert.getValueInteger("id");
|
||||
changed += process(lastId);
|
||||
}
|
||||
}
|
||||
return (changed);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One alert is an atomic unit, including in-app recipients and delivery rows.
|
||||
*******************************************************************************/
|
||||
private int process(Integer alertId) throws QException
|
||||
{
|
||||
QBackendTransaction previous = QContext.getQBackendTransaction();
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new QueryInput(AlertsAlert.TABLE_NAME)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
int changed = apply(AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId), runtime.now());
|
||||
transaction.commit();
|
||||
return (changed);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (new QException("Could not apply timed notifications for alert " + alertId, e));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setTransaction(previous);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Acknowledge, snooze, resolve, either mute effect, and disabled types stop work.
|
||||
*******************************************************************************/
|
||||
private int apply(QRecord alert, Instant now) throws QException
|
||||
{
|
||||
if(alert == null || !Status.OPEN.name().equals(alert.getValueString("status")) || alert.getValue("acknowledgedAt") != null)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
Instant checked = alert.getValueInstant("timedNotificationCheckedAt");
|
||||
if(checked != null && !checked.truncatedTo(ChronoUnit.MINUTES).isBefore(now.truncatedTo(ChronoUnit.MINUTES)))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
if(type == null || Boolean.FALSE.equals(type.getValueBoolean("enabled")) || Boolean.FALSE.equals(type.getValueBoolean("notify")))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
AlertsEngine.AlertTarget target = AlertsEngine.AlertTarget.of(alert);
|
||||
if(Boolean.TRUE.equals(alert.getValueBoolean("muted")) || Boolean.TRUE.equals(alert.getValueBoolean("flapping"))
|
||||
|| runtime.getRouter().isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS)
|
||||
|| runtime.getRouter().isSuppressedByMaintenance(target, now))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
|
||||
int changed = escalate(alert, type, now) ? 1 : 0;
|
||||
QRecord fresh = AlertsDb.byId(AlertsAlert.TABLE_NAME, alert.getValue("id"));
|
||||
Integer interval = type.getValueInteger("reminderIntervalMinutes");
|
||||
if(interval == null && "CRITICAL".equals(fresh.getValueString("severity")))
|
||||
{
|
||||
interval = 60;
|
||||
}
|
||||
Instant raised = latest(fresh.getValueInstant("lastRaisedAt"), fresh.getValueInstant("firstRaisedAt"));
|
||||
Instant since = latest(fresh.getValueInstant("lastNotifiedAt"), raised);
|
||||
if(interval != null && interval > 0 && since != null && !since.plus(interval, ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
if(runtime.getRouter().remind(fresh) > 0)
|
||||
{
|
||||
changed = 1;
|
||||
}
|
||||
}
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("timedNotificationCheckedAt", now));
|
||||
return (changed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Type policy wins; otherwise use the alert's group policy. Steps are ordered
|
||||
** by stepOrder and measured from the most recent reopen (else first raise).
|
||||
** At most one step is taken per minute; missed steps are not burst-delivered.
|
||||
*******************************************************************************/
|
||||
private boolean escalate(QRecord alert, QRecord type, Instant now) throws QException
|
||||
{
|
||||
Integer policyId = type.getValueInteger("escalationPolicyId");
|
||||
if(policyId == null)
|
||||
{
|
||||
QRecord group = AlertsDb.byId(AlertsGroup.TABLE_NAME, alert.getValue("groupId"));
|
||||
policyId = group == null ? null : group.getValueInteger("escalationPolicyId");
|
||||
}
|
||||
QRecord policy = AlertsDb.byId(AlertsEscalationPolicy.TABLE_NAME, policyId);
|
||||
if(policy == null || !Boolean.TRUE.equals(policy.getValueBoolean("enabled")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QRecord reopened = AlertsDb.first(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.REOPENED.name()).withOrderBy(new QFilterOrderBy("id", false)));
|
||||
Instant start = reopened == null ? alert.getValueInstant("firstRaisedAt") : reopened.getValueInstant("at");
|
||||
int level = alert.getValueInteger("escalationLevel") == null ? 0 : alert.getValueInteger("escalationLevel");
|
||||
QRecord step = AlertsDb.first(AlertsEscalationStep.TABLE_NAME, AlertsDb.eq("policyId", policyId)
|
||||
.withCriteria("stepOrder", QCriteriaOperator.GREATER_THAN, level).withOrderBy(new QFilterOrderBy("stepOrder", true)));
|
||||
if(step == null)
|
||||
{
|
||||
step = repeatStep(alert, policy, reopened, level, now);
|
||||
}
|
||||
if(step == null || start == null || step.getValueInteger("afterMinutes") == null || step.getValueInteger("afterMinutes") < 0
|
||||
|| !Status.OPEN.name().equals(step.getValueString("onlyIfStatus"))
|
||||
|| start.plus(step.getValueInteger("afterMinutes"), ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int nextLevel = step.getValueInteger("stepOrder");
|
||||
Integer eventId = runtime.getEngine().event(alert.getValueInteger("id"), EventType.ESCALATED, null,
|
||||
String.valueOf(level), String.valueOf(nextLevel), level == nextLevel ? "Repeated final escalation step" : "Escalation step " + nextLevel, now);
|
||||
runtime.getRouter().escalate(alert, step, eventId);
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("escalationLevel", nextLevel));
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeat only the final step, capped by the number of same-level events in
|
||||
** this open cycle. Repeat spacing uses escalation time, never reminder time.
|
||||
*******************************************************************************/
|
||||
private QRecord repeatStep(QRecord alert, QRecord policy, QRecord reopened, int level, Instant now) throws QException
|
||||
{
|
||||
Integer interval = policy.getValueInteger("repeatAfterMinutes");
|
||||
Integer maxRepeats = policy.getValueInteger("maxRepeats");
|
||||
if(level == 0 || interval == null || interval <= 0 || maxRepeats == null || maxRepeats <= 0)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QQueryFilter cycle = AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.ESCALATED.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, reopened == null ? 0 : reopened.getValueInteger("id"));
|
||||
QRecord last = AlertsDb.first(AlertsEvent.TABLE_NAME, cycle.withOrderBy(new QFilterOrderBy("id", false)));
|
||||
if(last == null || last.getValueInstant("at").plus(interval, ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
int repeats = AlertsDb.count(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.ESCALATED.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, reopened == null ? 0 : reopened.getValueInteger("id"))
|
||||
.withCriteria("fromValue", QCriteriaOperator.EQUALS, String.valueOf(level))
|
||||
.withCriteria("toValue", QCriteriaOperator.EQUALS, String.valueOf(level)));
|
||||
return (repeats >= maxRepeats ? null : AlertsDb.first(AlertsEscalationStep.TABLE_NAME, AlertsDb.eq("policyId", policy.getValue("id"))
|
||||
.withCriteria("stepOrder", QCriteriaOperator.EQUALS, level)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Later of two optional timestamps.
|
||||
*******************************************************************************/
|
||||
private static Instant latest(Instant left, Instant right)
|
||||
{
|
||||
return (left == null ? right : right == null || left.isAfter(right) ? left : right);
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ public class MonitorEvaluator
|
||||
long started = System.currentTimeMillis();
|
||||
String typeName = type.getValueString("name");
|
||||
|
||||
AlertTarget target = new AlertTarget(type.getValueInteger("id"), typeName, type.getValueInteger("groupId"), monitorId, type.getValueString("subjectTableName"), Set.of(), Map.of());
|
||||
AlertTarget target = new AlertTarget(type.getValueInteger("id"), typeName, type.getValueInteger("groupId"), monitorId, type.getValueString("subjectTableName"), Set.of(), Map.of(), null);
|
||||
if(runtime.getRouter().isSuppressedByMaintenance(target, now))
|
||||
{
|
||||
Plan plan = new Plan(MonitorStatus.SKIPPED_MUTED, "skipped: maintenance window", List.of(), List.of(), List.of(), List.of(), null);
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
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.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
@@ -55,7 +56,7 @@ public class AlertsAppMetaDataProducer extends QBitComponentMetaDataProducer<QAp
|
||||
{
|
||||
/** the main tables, in nav order. */
|
||||
public static final List<String> MAIN_TABLES = List.of(AlertsAlert.TABLE_NAME, AlertsNotification.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME,
|
||||
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsDelivery.TABLE_NAME);
|
||||
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsEscalationPolicy.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsDelivery.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
|
||||
+7
-2
@@ -46,13 +46,16 @@ 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.
|
||||
** - four hidden scheduled processes: alertsEvaluateMonitors (monitors, plus
|
||||
** snooze / quiet-period / flapping upkeep), alertsDeliverNotifications,
|
||||
** alertsEscalateAndRemind and alertsSendDigests.
|
||||
** They exist without a scheduler and run on one only when the config names
|
||||
** it.
|
||||
*******************************************************************************/
|
||||
public class AlertsProcessesMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, AlertsQBitConfig>
|
||||
{
|
||||
public static final String SEND_DIGESTS = "alertsSendDigests";
|
||||
public static final String ESCALATE_AND_REMIND = "alertsEscalateAndRemind";
|
||||
public static final String EVALUATE_MONITORS = "alertsEvaluateMonitors";
|
||||
public static final String DELIVER_NOTIFICATIONS = "alertsDeliverNotifications";
|
||||
|
||||
@@ -87,6 +90,8 @@ 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));
|
||||
processes.add(scheduled(config, ESCALATE_AND_REMIND, "Escalate and remind", AlertsScheduledSteps.EscalateAndRemind.class, 60));
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.AlertsPermissionChecker;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.customizers.AlertsNotificationCustomizer;
|
||||
@@ -29,6 +30,8 @@ import com.kingsrook.qbits.alerts.customizers.AlertsWriteGuardCustomizer;
|
||||
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.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
@@ -62,6 +65,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
@@ -94,6 +98,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
*******************************************************************************/
|
||||
public final class AlertsTableMetaDataHelper
|
||||
{
|
||||
public static final String JOIN_POLICY_STEP = "alerts_escalation_policy_join_alerts_escalation_step";
|
||||
public static final String JOIN_ALERT_EVENT = "alerts_alert_join_alerts_event";
|
||||
public static final String JOIN_ALERT_RECORD_REF = "alerts_alert_join_alerts_record_ref";
|
||||
public static final String JOIN_ALERT_DELIVERY = "alerts_alert_join_alerts_delivery";
|
||||
@@ -134,7 +139,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(widgetSection(JOIN_ALERT_RECORD_REF, "Records", "link"))
|
||||
.withSection(widgetSection(JOIN_ALERT_DELIVERY, "Notifications", "send"))
|
||||
.withSection(new QFieldSection("occurrence", "Occurrences", new QIcon("repeat"), Tier.T3, List.of("firstRaisedAt", "lastRaisedAt", "occurrenceCount", "reopenCount", "flapping",
|
||||
"lastNotifiedAt", "monitorId", "dedupKey", "alertTypeName", "visibilityPermission", "openDedupKey", "escalationLevel", "links", "attributes", "createDate", "modifyDate")));
|
||||
"lastNotifiedAt", "timedNotificationCheckedAt", "monitorId", "dedupKey", "alertTypeName", "visibilityPermission", "openDedupKey", "escalationLevel", "links", "attributes", "createDate", "modifyDate")));
|
||||
return (readOnly(table));
|
||||
}
|
||||
|
||||
@@ -153,7 +158,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "groupId", "sourceName", "openCount")))
|
||||
.withSection(new QFieldSection("about", "About", new QIcon("info"), Tier.T2, List.of("description", "howToFix", "subjectTableName", "visibilityPermission")))
|
||||
.withSection(new QFieldSection("tuning", "Tuning", new QIcon("tune"), Tier.T2, List.of("enabled", "notify", "defaultSeverity", "severityOverride", "resolveMode",
|
||||
"quietPeriodMinutes", "reminderIntervalMinutes", "externalDetailCeiling")))
|
||||
"quietPeriodMinutes", "reminderIntervalMinutes", "escalationPolicyId", "externalDetailCeiling")))
|
||||
.withSection(widgetSection(JOIN_TYPE_MONITOR, "Monitors", "monitor_heart"))
|
||||
.withSection(widgetSection(JOIN_TYPE_ALERT, "Alerts", "notifications_active"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("installed", "lastRaisedAt", "createDate", "modifyDate")));
|
||||
@@ -174,7 +179,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "parentGroupId")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "ownerTeamId", "icon", "sortOrder")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "ownerTeamId", "escalationPolicyId", "icon", "sortOrder")))
|
||||
.withSection(widgetSection(JOIN_GROUP_TYPE, "Alert Types", "category"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
return (table);
|
||||
@@ -304,6 +309,10 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description")))
|
||||
.withSection(widgetSection(JOIN_TEAM_MEMBER, "Members", "person"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
if(config.getAudienceResolver() == null)
|
||||
{
|
||||
table.getField("memberSource").withHelpContent(new QHelpContent("ROLE and IdP Group members need a host resolver; none is configured"));
|
||||
}
|
||||
table.getField("memberSource").setDefaultValue(AlertsPossibleValues.MemberSource.MANUAL.name());
|
||||
return (table);
|
||||
}
|
||||
@@ -322,6 +331,10 @@ public final class AlertsTableMetaDataHelper
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "teamId", "userId", "role")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
if(config.getAudienceResolver() == null)
|
||||
{
|
||||
table.getField("teamId").withHelpContent(new QHelpContent("ROLE and IdP Group members need a host resolver; none is configured"));
|
||||
}
|
||||
table.getField("role").setDefaultValue(AlertsPossibleValues.TeamRole.MEMBER.name());
|
||||
return (table);
|
||||
}
|
||||
@@ -364,7 +377,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withRecordLabelFields("name")
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "name", "enabled", "effect")))
|
||||
.withSection(new QFieldSection("scope", "What and When", new QIcon("filter_alt"), Tier.T2, List.of("scopeType", "scopeValue", "startsAt", "endsAt", "reason")))
|
||||
.withSection(new QFieldSection("scope", "What and When", new QIcon("filter_alt"), Tier.T2, List.of("scopeType", "scopeValue", "scopeRecordId", "startsAt", "endsAt", "reason")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createdByUserId", "createDate", "modifyDate")));
|
||||
table.getField("effect").setDefaultValue(AlertsPossibleValues.MuteEffect.SILENCE_NOTIFICATIONS.name());
|
||||
table.getField("enabled").setDefaultValue(true);
|
||||
@@ -380,11 +393,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));
|
||||
}
|
||||
|
||||
@@ -431,6 +445,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withRecordSecurityLock(AlertsSecurity.recipientLock("userId"))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notificationId", "userId", "deliveredAt", "readAt", "dismissedAt")))
|
||||
.withSection(new QFieldSection("email", "Email", new QIcon("mail"), Tier.T2, List.of("emailStatus", "emailedAt")))
|
||||
.withSection(new QFieldSection("slack", "Slack", new QIcon("chat"), Tier.T2, List.of("slackStatus", "slackMessageId")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_DELETE);
|
||||
return (notificationCustomizer(table, true));
|
||||
@@ -449,12 +464,14 @@ public final class AlertsTableMetaDataHelper
|
||||
.withUniqueKey(new UniqueKey("userId", "category"))
|
||||
.withRecordSecurityLock(AlertsSecurity.ownerLock("userId"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "emailMode", "emailMinSeverity")))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "emailMode", "emailMinSeverity", "slackMode", "slackMinSeverity")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("userId", "createDate", "modifyDate")));
|
||||
table.getField("category").setDefaultValue(NotificationCategory.SYSTEM.name());
|
||||
table.getField("inAppMuted").setDefaultValue(false);
|
||||
table.getField("emailMode").setDefaultValue(AlertsPossibleValues.EmailMode.OFF.name());
|
||||
table.getField("emailMinSeverity").setDefaultValue(NotificationSeverity.WARNING.name());
|
||||
table.getField("slackMode").setDefaultValue(AlertsPossibleValues.EmailMode.OFF.name());
|
||||
table.getField("slackMinSeverity").setDefaultValue(NotificationSeverity.WARNING.name());
|
||||
return (notificationCustomizer(table, false));
|
||||
}
|
||||
|
||||
@@ -497,12 +514,52 @@ public final class AlertsTableMetaDataHelper
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escalation policies, with ordered steps on the record screen.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildEscalationPolicyTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, AlertsEscalationPolicy.TABLE_NAME, "Escalation Policies", "trending_up", AlertsEscalationPolicy.class), "description")
|
||||
.withPermissionRules(AlertsPermissionChecker.rules(AlertsQBitConfig.PERMISSION_ADMIN))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "name", "enabled")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "repeatAfterMinutes", "maxRepeats")))
|
||||
.withSection(widgetSection(JOIN_POLICY_STEP, "Steps", "trending_up"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.getField("enabled").setDefaultValue(true);
|
||||
table.getField("maxRepeats").setDefaultValue(0);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escalation steps are standard admin tables, reached from their policy.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildEscalationStepTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, AlertsEscalationStep.TABLE_NAME, "Escalation Steps", "trending_up", AlertsEscalationStep.class)
|
||||
.withPermissionRules(AlertsPermissionChecker.rules(AlertsQBitConfig.PERMISSION_ADMIN))
|
||||
.withRecordLabelFormat("Step %s").withRecordLabelFields("stepOrder")
|
||||
.withUniqueKey(new UniqueKey("policyId", "stepOrder"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "policyId", "stepOrder", "afterMinutes")))
|
||||
.withSection(new QFieldSection("target", new QIcon("person"), Tier.T2, List.of("targetType", "userId", "teamId", "channelId", "onlyIfStatus")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.getField("onlyIfStatus").setDefaultValue(AlertsPossibleValues.Status.OPEN.name());
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All the tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> buildAllTables(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
return (List.of(buildAlertTable(config), buildMonitorTable(config), buildTypeTable(config), buildGroupTable(config), buildSubscriptionTable(config),
|
||||
return (List.of(buildEscalationPolicyTable(config), buildEscalationStepTable(config), buildAlertTable(config), buildMonitorTable(config), buildTypeTable(config), buildGroupTable(config), buildSubscriptionTable(config),
|
||||
buildUserPreferenceTable(config), buildTeamTable(config), buildTeamMemberTable(config), buildChannelTable(config), buildMuteTable(config),
|
||||
buildDeliveryTable(config), buildEventTable(config), buildRecordRefTable(config), buildHeartbeatTable(config), buildNotificationTable(config),
|
||||
buildNotificationRecipientTable(config), buildNotificationPreferenceTable(config)));
|
||||
@@ -516,7 +573,7 @@ public final class AlertsTableMetaDataHelper
|
||||
public static List<QPossibleValueSource> buildPossibleValueSources()
|
||||
{
|
||||
List<QPossibleValueSource> sources = new ArrayList<>(AlertsPossibleValues.buildEnumSources());
|
||||
for(String table : List.of(AlertsAlert.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME,
|
||||
for(String table : List.of(AlertsEscalationPolicy.TABLE_NAME, AlertsAlert.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME,
|
||||
AlertsChannel.TABLE_NAME, AlertsSubscription.TABLE_NAME, AlertsNotification.TABLE_NAME))
|
||||
{
|
||||
sources.add(QPossibleValueSource.newForTable(table));
|
||||
@@ -532,6 +589,7 @@ public final class AlertsTableMetaDataHelper
|
||||
public static List<QJoinMetaData> buildJoins()
|
||||
{
|
||||
List<QJoinMetaData> joins = new ArrayList<>();
|
||||
joins.add(oneToMany(JOIN_POLICY_STEP, AlertsEscalationPolicy.TABLE_NAME, AlertsEscalationStep.TABLE_NAME, "policyId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_EVENT, AlertsAlert.TABLE_NAME, AlertsEvent.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_RECORD_REF, AlertsAlert.TABLE_NAME, AlertsRecordRef.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_DELIVERY, AlertsAlert.TABLE_NAME, AlertsDelivery.TABLE_NAME, "alertId"));
|
||||
@@ -560,6 +618,9 @@ public final class AlertsTableMetaDataHelper
|
||||
}
|
||||
|
||||
List<QWidgetMetaDataInterface> widgets = new ArrayList<>();
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(JOIN_POLICY_STEP)).withLabel("Steps").withCanAddChildRecord(true).withMaxRows(100)
|
||||
.withOrderBys(List.of(new QFilterOrderBy("stepOrder", true)))
|
||||
.withOnlyIncludeFieldNames(List.of("stepOrder", "afterMinutes", "targetType", "onlyIfStatus")).getWidgetMetaData());
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_EVENT), "Timeline", 50, new QFilterOrderBy("at", false), List.of("at", "eventType", "userId", "toValue", "note")));
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_RECORD_REF), "Records", 25, new QFilterOrderBy("id", true), List.of("tableName", "recordId", "label", "active")));
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
|
||||
@@ -570,7 +631,7 @@ public final class AlertsTableMetaDataHelper
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(JOIN_TEAM_MEMBER)).withLabel("Members").withCanAddChildRecord(true).withMaxRows(100)
|
||||
.withOnlyIncludeFieldNames(List.of("userId", "role")).getWidgetMetaData());
|
||||
widgets.add(childList(joins.get(JOIN_CHANNEL_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
|
||||
widgets.add(childList(joins.get(JOIN_NOTIFICATION_RECIPIENT), "Recipients", 25, new QFilterOrderBy("id", true), List.of("userId", "deliveredAt", "readAt", "dismissedAt", "emailStatus")));
|
||||
widgets.add(childList(joins.get(JOIN_NOTIFICATION_RECIPIENT), "Recipients", 25, new QFilterOrderBy("id", true), List.of("userId", "deliveredAt", "readAt", "dismissedAt", "emailStatus", "slackStatus")));
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ public class AlertsAlert extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer escalationLevel;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant timedNotificationCheckedAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -1380,4 +1383,35 @@ public class AlertsAlert extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public Instant getTimedNotificationCheckedAt()
|
||||
{
|
||||
return (this.timedNotificationCheckedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public void setTimedNotificationCheckedAt(Instant timedNotificationCheckedAt)
|
||||
{
|
||||
this.timedNotificationCheckedAt = timedNotificationCheckedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public AlertsAlert withTimedNotificationCheckedAt(Instant timedNotificationCheckedAt)
|
||||
{
|
||||
this.timedNotificationCheckedAt = timedNotificationCheckedAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* 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.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed notification steps for an unacknowledged alert.
|
||||
*******************************************************************************/
|
||||
public class AlertsEscalationPolicy extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_escalation_policy";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100)
|
||||
private String name;
|
||||
|
||||
@QField()
|
||||
private String description;
|
||||
|
||||
@QField()
|
||||
private Integer repeatAfterMinutes;
|
||||
|
||||
@QField()
|
||||
private Integer maxRepeats;
|
||||
|
||||
@QField()
|
||||
private Boolean enabled;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for description
|
||||
*******************************************************************************/
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public Integer getRepeatAfterMinutes()
|
||||
{
|
||||
return (this.repeatAfterMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public void setRepeatAfterMinutes(Integer repeatAfterMinutes)
|
||||
{
|
||||
this.repeatAfterMinutes = repeatAfterMinutes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withRepeatAfterMinutes(Integer repeatAfterMinutes)
|
||||
{
|
||||
this.repeatAfterMinutes = repeatAfterMinutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public Integer getMaxRepeats()
|
||||
{
|
||||
return (this.maxRepeats);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public void setMaxRepeats(Integer maxRepeats)
|
||||
{
|
||||
this.maxRepeats = maxRepeats;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withMaxRepeats(Integer maxRepeats)
|
||||
{
|
||||
this.maxRepeats = maxRepeats;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enabled
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (this.enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for enabled
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enabled
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/*
|
||||
* 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.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One ordered destination in an escalation policy.
|
||||
*******************************************************************************/
|
||||
public class AlertsEscalationStep extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_escalation_step";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer policyId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer stepOrder;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer afterMinutes;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_subscriber_type")
|
||||
private String targetType;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String userId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_team")
|
||||
private Integer teamId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_channel")
|
||||
private Integer channelId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_status")
|
||||
private String onlyIfStatus;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyId
|
||||
*******************************************************************************/
|
||||
public Integer getPolicyId()
|
||||
{
|
||||
return (this.policyId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for policyId
|
||||
*******************************************************************************/
|
||||
public void setPolicyId(Integer policyId)
|
||||
{
|
||||
this.policyId = policyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withPolicyId(Integer policyId)
|
||||
{
|
||||
this.policyId = policyId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for stepOrder
|
||||
*******************************************************************************/
|
||||
public Integer getStepOrder()
|
||||
{
|
||||
return (this.stepOrder);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for stepOrder
|
||||
*******************************************************************************/
|
||||
public void setStepOrder(Integer stepOrder)
|
||||
{
|
||||
this.stepOrder = stepOrder;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for stepOrder
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withStepOrder(Integer stepOrder)
|
||||
{
|
||||
this.stepOrder = stepOrder;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for afterMinutes
|
||||
*******************************************************************************/
|
||||
public Integer getAfterMinutes()
|
||||
{
|
||||
return (this.afterMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for afterMinutes
|
||||
*******************************************************************************/
|
||||
public void setAfterMinutes(Integer afterMinutes)
|
||||
{
|
||||
this.afterMinutes = afterMinutes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for afterMinutes
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withAfterMinutes(Integer afterMinutes)
|
||||
{
|
||||
this.afterMinutes = afterMinutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetType
|
||||
*******************************************************************************/
|
||||
public String getTargetType()
|
||||
{
|
||||
return (this.targetType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for targetType
|
||||
*******************************************************************************/
|
||||
public void setTargetType(String targetType)
|
||||
{
|
||||
this.targetType = targetType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetType
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withTargetType(String targetType)
|
||||
{
|
||||
this.targetType = targetType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for teamId
|
||||
*******************************************************************************/
|
||||
public Integer getTeamId()
|
||||
{
|
||||
return (this.teamId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for teamId
|
||||
*******************************************************************************/
|
||||
public void setTeamId(Integer teamId)
|
||||
{
|
||||
this.teamId = teamId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for teamId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withTeamId(Integer teamId)
|
||||
{
|
||||
this.teamId = teamId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for channelId
|
||||
*******************************************************************************/
|
||||
public Integer getChannelId()
|
||||
{
|
||||
return (this.channelId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for channelId
|
||||
*******************************************************************************/
|
||||
public void setChannelId(Integer channelId)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for channelId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withChannelId(Integer channelId)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for onlyIfStatus
|
||||
*******************************************************************************/
|
||||
public String getOnlyIfStatus()
|
||||
{
|
||||
return (this.onlyIfStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for onlyIfStatus
|
||||
*******************************************************************************/
|
||||
public void setOnlyIfStatus(String onlyIfStatus)
|
||||
{
|
||||
this.onlyIfStatus = onlyIfStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for onlyIfStatus
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withOnlyIfStatus(String onlyIfStatus)
|
||||
{
|
||||
this.onlyIfStatus = onlyIfStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ public class AlertsGroup extends QRecordEntity
|
||||
@QField(possibleValueSourceName = "alerts_team", label = "Owner Team")
|
||||
private Integer ownerTeamId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer escalationPolicyId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -394,4 +397,35 @@ public class AlertsGroup extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public Integer getEscalationPolicyId()
|
||||
{
|
||||
return (this.escalationPolicyId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public void setEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public AlertsGroup withEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,12 @@ public class AlertsMute extends QRecordEntity
|
||||
@QField(isRequired = true, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_scope_type")
|
||||
private String scopeType;
|
||||
|
||||
@QField(maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
@QField(maxLength = 520, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String scopeValue;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Scope Record")
|
||||
private String scopeRecordId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Instant startsAt;
|
||||
|
||||
@@ -247,6 +250,37 @@ public class AlertsMute extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scopeRecordId
|
||||
*******************************************************************************/
|
||||
public String getScopeRecordId()
|
||||
{
|
||||
return (this.scopeRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scopeRecordId
|
||||
*******************************************************************************/
|
||||
public void setScopeRecordId(String scopeRecordId)
|
||||
{
|
||||
this.scopeRecordId = scopeRecordId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scopeRecordId
|
||||
*******************************************************************************/
|
||||
public AlertsMute withScopeRecordId(String scopeRecordId)
|
||||
{
|
||||
this.scopeRecordId = scopeRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startsAt
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -52,6 +52,12 @@ public class AlertsNotificationPreference extends QRecordEntity
|
||||
@QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "Email From Severity")
|
||||
private String emailMinSeverity;
|
||||
|
||||
@QField(maxLength = 16, possibleValueSourceName = "alerts_email_mode", label = "Slack")
|
||||
private String slackMode;
|
||||
|
||||
@QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "Slack From Severity")
|
||||
private String slackMinSeverity;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -299,4 +305,64 @@ public class AlertsNotificationPreference extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackMode
|
||||
*******************************************************************************/
|
||||
public String getSlackMode()
|
||||
{
|
||||
return (this.slackMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for slackMode
|
||||
*******************************************************************************/
|
||||
public void setSlackMode(String slackMode)
|
||||
{
|
||||
this.slackMode = slackMode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackMode
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withSlackMode(String slackMode)
|
||||
{
|
||||
this.slackMode = slackMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackMinSeverity
|
||||
*******************************************************************************/
|
||||
public String getSlackMinSeverity()
|
||||
{
|
||||
return (this.slackMinSeverity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for slackMinSeverity
|
||||
*******************************************************************************/
|
||||
public void setSlackMinSeverity(String slackMinSeverity)
|
||||
{
|
||||
this.slackMinSeverity = slackMinSeverity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackMinSeverity
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withSlackMinSeverity(String slackMinSeverity)
|
||||
{
|
||||
this.slackMinSeverity = slackMinSeverity;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,12 @@ public class AlertsNotificationRecipient extends QRecordEntity
|
||||
@QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_email_status")
|
||||
private String emailStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_slack_status")
|
||||
private String slackStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String slackMessageId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -361,4 +367,64 @@ public class AlertsNotificationRecipient extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackStatus
|
||||
*******************************************************************************/
|
||||
public String getSlackStatus()
|
||||
{
|
||||
return (this.slackStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for slackStatus
|
||||
*******************************************************************************/
|
||||
public void setSlackStatus(String slackStatus)
|
||||
{
|
||||
this.slackStatus = slackStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackStatus
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationRecipient withSlackStatus(String slackStatus)
|
||||
{
|
||||
this.slackStatus = slackStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackMessageId
|
||||
*******************************************************************************/
|
||||
public String getSlackMessageId()
|
||||
{
|
||||
return (this.slackMessageId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for slackMessageId
|
||||
*******************************************************************************/
|
||||
public void setSlackMessageId(String slackMessageId)
|
||||
{
|
||||
this.slackMessageId = slackMessageId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackMessageId
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationRecipient withSlackMessageId(String slackMessageId)
|
||||
{
|
||||
this.slackMessageId = slackMessageId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ public final class AlertsPossibleValues
|
||||
public static final String AUDIENCE_TYPE = "alerts_audience_type";
|
||||
public static final String EMAIL_STATUS = "alerts_email_status";
|
||||
public static final String EMAIL_MODE = "alerts_email_mode";
|
||||
public static final String SLACK_STATUS = "alerts_slack_status";
|
||||
|
||||
|
||||
|
||||
@@ -132,7 +133,7 @@ public final class AlertsPossibleValues
|
||||
*******************************************************************************/
|
||||
public enum ScopeType
|
||||
{
|
||||
ALL, GROUP, ALERT_TYPE, MONITOR, TABLE, RECORD, ATTRIBUTE
|
||||
ALL, GROUP, ALERT_TYPE, MONITOR, TABLE, RECORD, ATTRIBUTE, ALERT
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +224,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
|
||||
}
|
||||
|
||||
|
||||
@@ -251,7 +252,7 @@ public final class AlertsPossibleValues
|
||||
enumSource(MONITOR_KIND, AlertMonitorKind.values()),
|
||||
enumSource(MONITOR_STATUS, MonitorStatus.values()),
|
||||
enumSource(EVENT_TYPE, EventType.values()),
|
||||
enumSource(RESOLUTION, Resolution.values()),
|
||||
enumSource(RESOLUTION, new Resolution[] { Resolution.PRODUCER, Resolution.MONITOR, Resolution.QUIET_PERIOD, Resolution.MANUAL }),
|
||||
enumSource(SUBSCRIBER_TYPE, SubscriberType.values()),
|
||||
enumSource(SCOPE_TYPE, ScopeType.values()),
|
||||
enumSource(DELIVERY_MODE, DeliveryMode.values()),
|
||||
@@ -268,7 +269,8 @@ public final class AlertsPossibleValues
|
||||
enumSource(NOTIFICATION_STATUS, NotificationStatus.values()),
|
||||
enumSource(AUDIENCE_TYPE, NotificationAudience.Type.values()),
|
||||
enumSource(EMAIL_STATUS, EmailStatus.values()),
|
||||
enumSource(EMAIL_MODE, EmailMode.values())));
|
||||
enumSource(EMAIL_MODE, EmailMode.values()),
|
||||
enumSource(SLACK_STATUS, SlackStatus.values())));
|
||||
}
|
||||
|
||||
|
||||
@@ -294,7 +296,17 @@ public final class AlertsPossibleValues
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How a user wants a category by email.
|
||||
** Whether (and how) a recipient was sent a Slack direct message.
|
||||
*******************************************************************************/
|
||||
public enum SlackStatus
|
||||
{
|
||||
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How a user wants a category by email (also used for Slack: same modes).
|
||||
*******************************************************************************/
|
||||
public enum EmailMode
|
||||
{
|
||||
|
||||
@@ -96,6 +96,9 @@ public class AlertsType extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer openCount;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer escalationPolicyId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -802,4 +805,35 @@ public class AlertsType extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public Integer getEscalationPolicyId()
|
||||
{
|
||||
return (this.escalationPolicyId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public void setEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public AlertsType withEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -421,6 +421,16 @@ public class NotificationEmailer
|
||||
** A link made absolute with the public base URL (relative app paths).
|
||||
*******************************************************************************/
|
||||
private String absolute(String link)
|
||||
{
|
||||
return (absolute(runtime, link));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link made absolute with the public base URL (relative app paths).
|
||||
*******************************************************************************/
|
||||
static String absolute(AlertsRuntime runtime, String link)
|
||||
{
|
||||
String base = runtime.getConfig().getPublicBaseUrl();
|
||||
if(link == null || !link.startsWith("/") || !StringUtils.hasContent(base))
|
||||
@@ -445,7 +455,7 @@ public class NotificationEmailer
|
||||
/*******************************************************************************
|
||||
** The live (SENT, published, not expired) notifications for the rows.
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, QRecord> notifications(List<QRecord> recipients, Instant now) throws QException
|
||||
static Map<Integer, QRecord> notifications(List<QRecord> recipients, Instant now) throws QException
|
||||
{
|
||||
Map<Integer, QRecord> live = new HashMap<>();
|
||||
List<Integer> ids = recipients.stream().map(recipient -> recipient.getValueInteger("notificationId")).distinct().toList();
|
||||
@@ -470,7 +480,7 @@ public class NotificationEmailer
|
||||
/*******************************************************************************
|
||||
** My Alert Settings rows (address, time zone, digest hour) by user.
|
||||
*******************************************************************************/
|
||||
private static Map<String, QRecord> userSettings(java.util.Set<String> userIds) throws QException
|
||||
static Map<String, QRecord> userSettings(java.util.Set<String> userIds) throws QException
|
||||
{
|
||||
Map<String, QRecord> settings = new HashMap<>();
|
||||
for(QRecord setting : AlertsDb.query(AlertsUserPreference.TABLE_NAME, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(userIds))))
|
||||
|
||||
@@ -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).
|
||||
*******************************************************************************/
|
||||
@@ -555,7 +579,7 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
private static QRecord newRecipient(Integer notificationId, String userId, Instant now)
|
||||
{
|
||||
return (new QRecord().withValue("notificationId", notificationId).withValue("userId", userId).withValue("deliveredAt", now).withValue("emailStatus", "NONE"));
|
||||
return (new QRecord().withValue("notificationId", notificationId).withValue("userId", userId).withValue("deliveredAt", now).withValue("emailStatus", "NONE").withValue("slackStatus", "NONE"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.notifications;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertRouter;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertTiming;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SlackStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.ContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Slack direct messages for user notifications (docs/NOTIFICATIONS.md section
|
||||
** 7), opt-in per user and category in My Notification Settings, alongside
|
||||
** the bell and email:
|
||||
** - IMMEDIATE: one DM per notification at or above slackMinSeverity;
|
||||
** - DAILY_DIGEST: one DM per user at their digest hour listing their unread
|
||||
** notifications.
|
||||
** Sent only through core SendMessageAction (provider slackProviderName,
|
||||
** address "user:<id>", the recipient as owner, so the DM travels through that
|
||||
** user's own Slack connection); qbit-slack is never imported. Slack is a push
|
||||
** channel: pause and quiet hours hold it (CRITICAL bypasses quiet hours),
|
||||
** never the bell. ALERT notifications are not sent here. SECURITY DMs carry
|
||||
** the title and link only. A user without a Slack connection (the provider
|
||||
** throws a QUserFacingException) is SKIPPED with no retry; other failures are
|
||||
** FAILED. Never fails the notification itself. Runs as the system user from
|
||||
** the delivery job.
|
||||
*******************************************************************************/
|
||||
public class NotificationSlackSender
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(NotificationSlackSender.class);
|
||||
|
||||
private final AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What one run did.
|
||||
*******************************************************************************/
|
||||
public record Result(int sent, int digests, int held, int skipped, int failed)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public NotificationSlackSender(AlertsRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send what is due: immediate DMs, and digest DMs for users at their hour.
|
||||
*******************************************************************************/
|
||||
public Result run() throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
Map<String, Map<String, QRecord>> preferences = new HashMap<>();
|
||||
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("slackMode", QCriteriaOperator.IN, List.of(EmailMode.IMMEDIATE.name(), EmailMode.DAILY_DIGEST.name()))))
|
||||
{
|
||||
preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
|
||||
}
|
||||
if(preferences.isEmpty())
|
||||
{
|
||||
return (new Result(0, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
|
||||
.withCriteria("slackStatus", QCriteriaOperator.IN, List.of(SlackStatus.NONE.name(), SlackStatus.PENDING.name()))
|
||||
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS))
|
||||
.withLimit(NotificationEmailer.SCAN));
|
||||
Map<Integer, QRecord> notifications = NotificationEmailer.notifications(recipients, now);
|
||||
Map<String, QRecord> settings = NotificationEmailer.userSettings(preferences.keySet());
|
||||
boolean available = isAvailable();
|
||||
|
||||
int sent = 0;
|
||||
int held = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
Map<String, List<QRecord[]>> digests = new LinkedHashMap<>();
|
||||
for(QRecord recipient : recipients)
|
||||
{
|
||||
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
|
||||
String userId = recipient.getValueString("userId");
|
||||
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
|
||||
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|
||||
|| !NotificationEmailer.atLeast(notification.getValueString("severity"), preference.getValueString("slackMinSeverity")) || !available)
|
||||
{
|
||||
mark(recipient, SlackStatus.SKIPPED);
|
||||
skipped++;
|
||||
}
|
||||
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("slackMode")))
|
||||
{
|
||||
if(isHeld(settings.get(userId), notification, now))
|
||||
{
|
||||
held++;
|
||||
continue;
|
||||
}
|
||||
SlackStatus status = sendExternal(userId, notification.getValueString("title"), List.of(notification), notification.getValueInteger("id"));
|
||||
mark(recipient, status);
|
||||
sent += status == SlackStatus.SENT ? 1 : 0;
|
||||
skipped += status == SlackStatus.SKIPPED ? 1 : 0;
|
||||
failed += status == SlackStatus.FAILED ? 1 : 0;
|
||||
}
|
||||
else if(recipient.getValue("readAt") == null)
|
||||
{
|
||||
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
|
||||
}
|
||||
}
|
||||
|
||||
int digestCount = 0;
|
||||
for(Map.Entry<String, List<QRecord[]>> digest : digests.entrySet())
|
||||
{
|
||||
QRecord setting = settings.get(digest.getKey());
|
||||
if(!NotificationEmailer.isDigestHour(setting, now))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
List<QRecord> items = digest.getValue().stream().map(pair -> pair[1]).toList();
|
||||
if(items.stream().allMatch(item -> isHeld(setting, item, now)))
|
||||
{
|
||||
held++;
|
||||
continue;
|
||||
}
|
||||
SlackStatus status = sendExternal(digest.getKey(), items.size() == 1 ? "1 new notification" : items.size() + " new notifications", items, null);
|
||||
for(QRecord[] pair : digest.getValue())
|
||||
{
|
||||
mark(pair[0], status == SlackStatus.SENT ? SlackStatus.DIGESTED : status);
|
||||
}
|
||||
digestCount += status == SlackStatus.SENT ? 1 : 0;
|
||||
failed += status == SlackStatus.FAILED ? 1 : 0;
|
||||
}
|
||||
return (new Result(sent, digestCount, held, skipped, failed));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the Slack messaging provider is registered in this instance.
|
||||
** RC.6 core has no SendMessageAction.isAvailable, so ask the instance.
|
||||
*******************************************************************************/
|
||||
private boolean isAvailable()
|
||||
{
|
||||
String name = runtime.getConfig().getSlackProviderName();
|
||||
return (StringUtils.hasContent(name) && QContext.getQInstance().getMessagingProvider(name) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a push is held for now: the user paused notifications, or it is
|
||||
** their quiet hours (CRITICAL bypasses). The bell is never held.
|
||||
*******************************************************************************/
|
||||
private static boolean isHeld(QRecord setting, QRecord notification, Instant now)
|
||||
{
|
||||
return (AlertRouter.isPaused(setting, now)
|
||||
|| AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The external notification channel step: one Slack DM to the user (the
|
||||
** recipient is the owner of the connection that sends it) with the
|
||||
** notification titles, bodies (not for SECURITY) and links. SENT; SKIPPED
|
||||
** when the provider says the user can't be reached (no Slack connection),
|
||||
** with no retry; FAILED for anything else.
|
||||
**
|
||||
** STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6 SendMessageInput has no
|
||||
** idempotency key (notify:<notificationId>:<userId>:slack), so the
|
||||
** recipient row's slackStatus is what prevents a resend.
|
||||
*******************************************************************************/
|
||||
private SlackStatus sendExternal(String userId, String subject, List<QRecord> items, Integer notificationId)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
for(QRecord item : items)
|
||||
{
|
||||
String body = NotificationCategory.SECURITY.name().equals(item.getValueString("category")) ? null : item.getValueString("body");
|
||||
String link = NotificationEmailer.absolute(runtime, NotificationService.safeLink(item.getValueString("linkUrl")));
|
||||
text.append(items.size() > 1 ? "- " : "").append(item.getValueString("title")).append("\n");
|
||||
if(StringUtils.hasContent(body))
|
||||
{
|
||||
text.append(body).append("\n");
|
||||
}
|
||||
if(link != null)
|
||||
{
|
||||
text.append(link).append("\n");
|
||||
}
|
||||
}
|
||||
String key = notificationId == null ? null : "notify:" + notificationId + ":" + userId + ":slack";
|
||||
try
|
||||
{
|
||||
new SendMessageAction().execute(new SendMessageInput()
|
||||
.withMessagingProviderName(runtime.getConfig().getSlackProviderName())
|
||||
.withTo(new Party().withAddress("user:" + userId))
|
||||
.withSubject(subject)
|
||||
.withContentList(List.of(new Content().withContentRole(ContentRole.Default.DEFAULT).withBody(text.toString().stripTrailing()))));
|
||||
return (SlackStatus.SENT);
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
LOG.info("Slack could not reach a user; skipped", logPair("userId", userId), logPair("key", key), logPair("reason", e.getMessage()));
|
||||
return (SlackStatus.SKIPPED);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not send a Slack notification", e, logPair("userId", userId), logPair("key", key));
|
||||
return (SlackStatus.FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void mark(QRecord recipient, SlackStatus status) throws QException
|
||||
{
|
||||
AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, new QRecord().withValue("id", recipient.getValue("id")).withValue("slackStatus", status.name()));
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,9 @@ public enum AlertsAction
|
||||
ASSIGN("alertsAssign", "Assign", "assignment_ind", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, null),
|
||||
COMMENT("alertsAddComment", "Add comment", "comment", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1),
|
||||
MUTE_ALERT("alertsMuteAlert", "Mute", "notifications_off", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
MUTE_ALERT_TYPE("alertsMuteAlertType", "Mute this alert type", "notifications_off", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
SUBSCRIBE_TYPE("alertsSubscribeToType", "Subscribe me", "notifications", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1),
|
||||
MUTE_TYPE("alertsMuteType", "Mute", "notifications_off", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
MUTE_TYPE("alertsMuteType", "Mute this alert type", "notifications_off", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
CREATE_MONITOR("alertsCreateMonitorFromType", "Create monitor", "add_alert", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, 1),
|
||||
SUBSCRIBE_GROUP("alertsSubscribeToGroup", "Subscribe me", "notifications", AlertsGroup.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1),
|
||||
MUTE_GROUP("alertsMuteGroup", "Mute", "notifications_off", AlertsGroup.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
@@ -131,7 +132,7 @@ public enum AlertsAction
|
||||
.withPossibleValueSourceName(AlertsPossibleValues.SEVERITY),
|
||||
new QFieldMetaData(FIELD_DELIVERY_MODE, QFieldType.STRING).withLabel("Delivery").withIsRequired(true).withDefaultValue("IMMEDIATE")
|
||||
.withPossibleValueSourceName(AlertsPossibleValues.DELIVERY_MODE));
|
||||
case MUTE_ALERT, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> List.of(
|
||||
case MUTE_ALERT, MUTE_ALERT_TYPE, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> List.of(
|
||||
new QFieldMetaData(FIELD_MUTE_MINUTES, QFieldType.INTEGER).withLabel("Mute for (minutes)").withIsRequired(true).withDefaultValue(60),
|
||||
new QFieldMetaData(FIELD_NOTE, QFieldType.TEXT).withLabel("Reason").withIsRequired(true));
|
||||
default -> List.of();
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.Objects;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsEngine;
|
||||
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
@@ -302,7 +303,8 @@ public final class AlertsActionSteps
|
||||
case MUTE_TYPE -> mute(ScopeType.ALERT_TYPE, record.getValueString("name"), "type " + record.getValueString("label"), input, actor);
|
||||
case MUTE_GROUP -> mute(ScopeType.GROUP, record.getValueString("name"), "group " + record.getValueString("label"), input, actor);
|
||||
case MUTE_MONITOR -> mute(ScopeType.MONITOR, String.valueOf(id), "monitor " + record.getValueString("name"), input, actor);
|
||||
case MUTE_ALERT -> mute(ScopeType.ALERT_TYPE, record.getValueString("alertTypeName"), "alert type " + record.getValueString("alertTypeName"), input, actor);
|
||||
case MUTE_ALERT -> muteAlert(record, input, actor);
|
||||
case MUTE_ALERT_TYPE -> mute(ScopeType.ALERT_TYPE, record.getValueString("alertTypeName"), "alert type " + record.getValueString("alertTypeName"), input, actor);
|
||||
case RUN_CHECK ->
|
||||
{
|
||||
MonitorEvaluator.Plan plan = runtime.getEvaluator().runNow(id);
|
||||
@@ -421,9 +423,34 @@ public final class AlertsActionSteps
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a silence-notifications mute from now (through the table).
|
||||
** Mute the subject record when present, otherwise this type and dedup key.
|
||||
*******************************************************************************/
|
||||
private static boolean muteAlert(QRecord alert, RunBackendStepInput input, String actor) throws QException
|
||||
{
|
||||
if(StringUtils.hasContent(alert.getValueString("subjectTableName")) && StringUtils.hasContent(alert.getValueString("subjectRecordId")))
|
||||
{
|
||||
return (mute(ScopeType.RECORD, alert.getValueString("subjectTableName"), alert.getValueString("subjectRecordId"), "alert " + alert.getValue("id"), input, actor));
|
||||
}
|
||||
return (mute(ScopeType.ALERT, AlertsEngine.openKey(alert.getValueString("alertTypeName"), alert.getValueString("dedupKey")), null,
|
||||
"alert " + alert.getValue("id"), input, actor));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a mute without a subject record ID.
|
||||
*******************************************************************************/
|
||||
private static boolean mute(ScopeType scopeType, String scopeValue, String what, RunBackendStepInput input, String actor) throws QException
|
||||
{
|
||||
return (mute(scopeType, scopeValue, null, what, input, actor));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a silence-notifications mute from now (through the table).
|
||||
*******************************************************************************/
|
||||
private static boolean mute(ScopeType scopeType, String scopeValue, String scopeRecordId, String what, RunBackendStepInput input, String actor) throws QException
|
||||
{
|
||||
Integer minutes = input.getValueInteger(AlertsAction.FIELD_MUTE_MINUTES);
|
||||
String reason = input.getValueString(AlertsAction.FIELD_NOTE);
|
||||
@@ -437,6 +464,7 @@ public final class AlertsActionSteps
|
||||
.withValue("reason", reason)
|
||||
.withValue("scopeType", scopeType.name())
|
||||
.withValue("scopeValue", scopeValue)
|
||||
.withValue("scopeRecordId", scopeRecordId)
|
||||
.withValue("startsAt", now)
|
||||
.withValue("endsAt", now.plus(minutes == null || minutes < 1 ? 60 : minutes, ChronoUnit.MINUTES))
|
||||
.withValue("effect", MuteEffect.SILENCE_NOTIFICATIONS.name())
|
||||
@@ -586,7 +614,7 @@ public final class AlertsActionSteps
|
||||
case ASSIGN -> "assigned";
|
||||
case COMMENT -> "commented on";
|
||||
case SUBSCRIBE_TYPE, SUBSCRIBE_GROUP, SUBSCRIBE_MONITOR -> "subscribed to";
|
||||
case MUTE_ALERT, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> "muted";
|
||||
case MUTE_ALERT, MUTE_ALERT_TYPE, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> "muted";
|
||||
case RUN_CHECK -> "checked";
|
||||
case ENABLE_MONITOR -> "enabled";
|
||||
case DISABLE_MONITOR -> "disabled";
|
||||
|
||||
@@ -18,7 +18,10 @@ package com.kingsrook.qbits.alerts.processes;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.delivery.DigestWorker;
|
||||
import com.kingsrook.qbits.alerts.engine.EscalationAndReminder;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationEmailer;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationSlackSender;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
@@ -27,7 +30,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 notifications, escalate, remind and send digests.
|
||||
*******************************************************************************/
|
||||
public final class AlertsScheduledSteps
|
||||
{
|
||||
@@ -77,7 +80,42 @@ public final class AlertsScheduledSteps
|
||||
runtime.getNotifications().sendDue();
|
||||
runtime.getNotifications().purge();
|
||||
new NotificationEmailer(runtime).run();
|
||||
new NotificationSlackSender(runtime).run();
|
||||
output.addValue(OUTPUT_COUNT, runtime.getDeliveryWorker().deliverDue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** alertsEscalateAndRemind
|
||||
*******************************************************************************/
|
||||
public static class EscalateAndRemind implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Run timed notifications using the installed runtime's clock.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.addValue(OUTPUT_COUNT, new EscalationAndReminder(AlertsRuntime.get()).runDue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,3 +427,89 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference (
|
||||
CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category)
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:21
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME) = 'alerts_mute' AND LOWER(COLUMN_NAME) = 'scope_record_id'
|
||||
-- comment: Record mutes store the subject record ID separately from its table name.
|
||||
ALTER TABLE alerts_mute ADD COLUMN scope_record_id VARCHAR(100);
|
||||
|
||||
-- changeset qbit-alerts:22
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME) = 'alerts_mute' AND LOWER(COLUMN_NAME) = 'scope_value' AND CHARACTER_MAXIMUM_LENGTH < 520
|
||||
-- comment: ALERT mutes hold the complete type and dedup key, including long keys.
|
||||
ALTER TABLE alerts_mute ALTER COLUMN scope_value SET DATA TYPE VARCHAR(520);
|
||||
|
||||
-- changeset qbit-alerts:23
|
||||
-- comment: Admin escalation policies and their ordered destinations.
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_policy (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
repeat_after_minutes INTEGER,
|
||||
max_repeats INTEGER DEFAULT 0,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_policy PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_policy UNIQUE (name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_step (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
policy_id INTEGER NOT NULL,
|
||||
step_order INTEGER NOT NULL,
|
||||
after_minutes INTEGER NOT NULL,
|
||||
target_type VARCHAR(16) NOT NULL,
|
||||
user_id VARCHAR(100),
|
||||
team_id INTEGER,
|
||||
channel_id INTEGER,
|
||||
only_if_status VARCHAR(16) DEFAULT 'OPEN' NOT NULL,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_step PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_step UNIQUE (policy_id, step_order),
|
||||
CONSTRAINT ck_alerts_escalation_step_order CHECK (step_order > 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_minutes CHECK (after_minutes >= 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_target CHECK (
|
||||
(target_type = 'USER' AND user_id IS NOT NULL AND team_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'TEAM' AND team_id IS NOT NULL AND user_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'CHANNEL' AND channel_id IS NOT NULL AND user_id IS NULL AND team_id IS NULL))
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:24
|
||||
-- comment: Type policies override the group's policy.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_type' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_type ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:25
|
||||
-- comment: A group policy is used when the type has none.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_group' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_group ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:26
|
||||
-- comment: Persist the timed job checkpoint, including attempts without recipients.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_alert' AND LOWER(column_name) = 'timed_notification_checked_at'
|
||||
ALTER TABLE alerts_alert ADD COLUMN timed_notification_checked_at TIMESTAMP;
|
||||
|
||||
-- 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);
|
||||
|
||||
-- changeset qbit-alerts:28
|
||||
-- comment: Per-user Slack direct messages for notification categories: the Slack mode and minimum severity, and each recipient's Slack status and message id.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_notification_preference' AND LOWER(column_name) = 'slack_mode'
|
||||
ALTER TABLE alerts_notification_preference ADD COLUMN slack_mode VARCHAR(16);
|
||||
ALTER TABLE alerts_notification_preference ADD COLUMN slack_min_severity VARCHAR(16);
|
||||
ALTER TABLE alerts_notification_recipient ADD COLUMN slack_status VARCHAR(16);
|
||||
ALTER TABLE alerts_notification_recipient ADD COLUMN slack_message_id VARCHAR(100);
|
||||
CREATE INDEX ix_alerts_notification_recipient_slack_status ON alerts_notification_recipient (slack_status);
|
||||
@@ -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;
|
||||
@@ -26,6 +30,7 @@ import com.kingsrook.qbits.alerts.api.AlertsRouteProvider;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
@@ -69,6 +74,23 @@ class AlertsQBitProducerTest extends BaseAlertsTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Team and member screens explain missing host setup only when needed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTeamResolverHelp() throws Exception
|
||||
{
|
||||
assertThat(AlertsTableMetaDataHelper.buildTeamTable(config).getField("memberSource").getHelpContents())
|
||||
.anyMatch(help -> help.getContent().contains("ROLE and IdP Group members need a host resolver; none is configured"));
|
||||
assertThat(AlertsTableMetaDataHelper.buildTeamMemberTable(config).getField("teamId").getHelpContents())
|
||||
.anyMatch(help -> help.getContent().contains("none is configured"));
|
||||
config.withAudienceResolver((source, value) -> Set.of());
|
||||
assertThat(AlertsTableMetaDataHelper.buildTeamTable(config).getField("memberSource").getHelpContents()).isNullOrEmpty();
|
||||
assertThat(AlertsTableMetaDataHelper.buildTeamMemberTable(config).getField("teamId").getHelpContents()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without a config the producer is disabled; bad configs fail.
|
||||
*******************************************************************************/
|
||||
@@ -182,13 +204,111 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Upgrade the old mute table using the shipped SQL and guards twice;
|
||||
** existing rules survive and the new fields accept full alert keys.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMuteSchemaUpgradeIsRepeatable() 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);
|
||||
}
|
||||
int start = changelog.indexOf("CREATE TABLE IF NOT EXISTS alerts_mute (");
|
||||
try(java.sql.Connection connection = com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.getConnection(defineH2Backend());
|
||||
java.sql.Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("DROP TABLE alerts_mute");
|
||||
statement.execute(changelog.substring(start, changelog.indexOf(");", start) + 2));
|
||||
statement.execute("INSERT INTO alerts_mute (name, scope_type, scope_value, starts_at, ends_at) VALUES ('Existing', 'ALERT_TYPE', 'existing.type', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)");
|
||||
for(int pass = 0; pass < 2; pass++)
|
||||
{
|
||||
for(String number : List.of("21", "22"))
|
||||
{
|
||||
String block = changelog.substring(changelog.indexOf("-- changeset qbit-alerts:" + number));
|
||||
String guard = block.lines().filter(line -> line.startsWith("-- precondition-sql-check")).findFirst().orElseThrow();
|
||||
String sql = block.lines().filter(line -> line.startsWith("ALTER TABLE")).findFirst().orElseThrow();
|
||||
int expected = Integer.parseInt(guard.substring(guard.indexOf("expectedResult:") + 15, guard.indexOf(" SELECT")));
|
||||
try(java.sql.ResultSet result = statement.executeQuery(guard.substring(guard.indexOf("SELECT"))))
|
||||
{
|
||||
assertThat(result.next()).isTrue();
|
||||
if(result.getInt(1) != expected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
try(java.sql.ResultSet result = statement.executeQuery("SELECT scope_type, scope_value, scope_record_id FROM alerts_mute"))
|
||||
{
|
||||
assertThat(result.next()).isTrue();
|
||||
assertThat(result.getString(1)).isEqualTo("ALERT_TYPE");
|
||||
assertThat(result.getString(2)).isEqualTo("existing.type");
|
||||
assertThat(result.getString(3)).isNull();
|
||||
}
|
||||
String key = "k".repeat(520);
|
||||
statement.execute("UPDATE alerts_mute SET scope_value = '" + key + "', scope_record_id = '123'");
|
||||
try(java.sql.ResultSet result = statement.executeQuery("SELECT scope_value, scope_record_id FROM alerts_mute"))
|
||||
{
|
||||
assertThat(result.next()).isTrue();
|
||||
assertThat(result.getString(1)).isEqualTo(key);
|
||||
assertThat(result.getString(2)).isEqualTo("123");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@@ -216,4 +336,37 @@ class AlertsQBitProducerTest extends BaseAlertsTest
|
||||
assertThat(AlertsIntegrations.esbTableNames()).containsExactly("alerts_alert", "alerts_monitor", "alerts_type");
|
||||
assertThat(AlertsIntegrations.quickSearchTables()).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Policy records are admin-only, steps are children, and the job is hidden.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEscalationMetadata() throws Exception
|
||||
{
|
||||
assertThat(AlertsAppMetaDataProducer.MAIN_TABLES).contains("alerts_escalation_policy").doesNotContain("alerts_escalation_step");
|
||||
assertThat(qInstance.getTable("alerts_escalation_policy").getSections()).anySatisfy(section ->
|
||||
assertThat(section.getWidgetName()).isEqualTo("alerts_escalation_policy_join_alerts_escalation_step"));
|
||||
assertThat(qInstance.getTable("alerts_type").getField("escalationPolicyId").getPossibleValueSourceName()).isEqualTo("alerts_escalation_policy");
|
||||
assertThat(qInstance.getTable("alerts_group").getField("escalationPolicyId").getPossibleValueSourceName()).isEqualTo("alerts_escalation_policy");
|
||||
assertThat(qInstance.getTable("alerts_escalation_step").getField("onlyIfStatus").getDefaultValue()).isEqualTo("OPEN");
|
||||
assertThat(qInstance.getPossibleValueSource("alerts_resolution").getEnumValues()).extracting(value -> String.valueOf(value.getId())).doesNotContain("EXPIRED");
|
||||
QProcessMetaData process = qInstance.getProcess(AlertsProcessesMetaDataProducer.ESCALATE_AND_REMIND);
|
||||
assertThat(process.getIsHidden()).isTrue();
|
||||
assertThat(process.getSchedule()).isNull();
|
||||
QProcessMetaData scheduled = AlertsProcessesMetaDataProducer.buildProcesses(new AlertsQBitConfig().withSchedulerName("scheduler"))
|
||||
.stream().filter(candidate -> candidate.getName().equals(AlertsProcessesMetaDataProducer.ESCALATE_AND_REMIND)).findFirst().orElseThrow();
|
||||
assertThat(scheduled.getSchedule().getSchedulerName()).isEqualTo("scheduler");
|
||||
assertThat(scheduled.getSchedule().getRepeatSeconds()).isEqualTo(60);
|
||||
loginAs(VIEWER);
|
||||
for(String table : List.of("alerts_escalation_policy", "alerts_escalation_step"))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasTablePermission(new com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput(), table,
|
||||
com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType.READ)).isFalse();
|
||||
}
|
||||
loginAs(ADMIN);
|
||||
assertThat(PermissionsHelper.hasTablePermission(new com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput(), "alerts_escalation_policy",
|
||||
com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType.INSERT)).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -271,6 +271,100 @@ class SecurityAndActionsTest extends BaseAlertsTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record action mutes its subject, leaving other records of the same
|
||||
** alert type deliverable. The separate type action silences those too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecordAndTypeMuteActions() throws Exception
|
||||
{
|
||||
List<Integer> widgets = insertWidgets("BROKEN", "BROKEN");
|
||||
QRecord one = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("one").withRecordRef(AlertRecordRef.of(WIDGET, widgets.get(0))));
|
||||
QRecord two = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("two").withRecordRef(AlertRecordRef.of(WIDGET, widgets.get(1))));
|
||||
subscribeForMuteTest();
|
||||
loginAs(MANAGER);
|
||||
assertThat(run(AlertsAction.MUTE_ALERT, List.of(one.getValueInteger("id")), Map.of(AlertsAction.FIELD_NOTE, "Working on this record")))
|
||||
.anyMatch(line -> line.startsWith("OK: 1"));
|
||||
QRecord mute = all(AlertsMute.TABLE_NAME).get(0);
|
||||
assertThat(mute.getValueString("scopeType")).isEqualTo("RECORD");
|
||||
assertThat(mute.getValueString("scopeValue")).isEqualTo(WIDGET);
|
||||
assertThat(mute.getValueString("scopeRecordId")).isEqualTo(String.valueOf(widgets.get(0)));
|
||||
routeForMuteTest(one);
|
||||
routeForMuteTest(two);
|
||||
assertThat(deliveriesFor(one)).singleElement().matches(d -> "MUTED".equals(d.getValueString("suppressedReason")));
|
||||
assertThat(deliveriesFor(two)).singleElement().matches(d -> "SENT".equals(d.getValueString("status")));
|
||||
|
||||
run(AlertsAction.MUTE_ALERT_TYPE, List.of(one.getValueInteger("id")), Map.of(AlertsAction.FIELD_NOTE, "Working on the type"));
|
||||
routeForMuteTest(two);
|
||||
assertThat(deliveriesFor(two)).anyMatch(d -> "MUTED".equals(d.getValueString("suppressedReason")));
|
||||
assertThat(AlertsAction.MUTE_ALERT_TYPE.getLabel()).isEqualTo("Mute this alert type");
|
||||
assertThat(AlertsAction.MUTE_TYPE.getLabel()).isEqualTo("Mute this alert type");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Subjectless alerts mute by full type and dedup key, never by type alone.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSubjectlessMuteAction() throws Exception
|
||||
{
|
||||
String type = "test.system." + "t".repeat(170);
|
||||
String key = "k".repeat(300);
|
||||
QRecord one = runtime.getEngine().raise(new AlertSignal(type).withDedupKey(key));
|
||||
QRecord two = runtime.getEngine().raise(new AlertSignal(type).withDedupKey("other"));
|
||||
QRecord otherType = runtime.getEngine().raise(new AlertSignal("test.system.other").withDedupKey(key));
|
||||
subscribeForMuteTest();
|
||||
loginAs(MANAGER);
|
||||
run(AlertsAction.MUTE_ALERT, List.of(one.getValueInteger("id")), Map.of(AlertsAction.FIELD_NOTE, "Working on this alert"));
|
||||
QRecord mute = all(AlertsMute.TABLE_NAME).get(0);
|
||||
assertThat(mute.getValueString("scopeType")).isEqualTo("ALERT");
|
||||
assertThat(mute.getValueString("scopeValue")).isEqualTo(type + "|" + key);
|
||||
routeForMuteTest(one);
|
||||
routeForMuteTest(two);
|
||||
routeForMuteTest(otherType);
|
||||
assertThat(deliveriesFor(one)).singleElement().matches(d -> "MUTED".equals(d.getValueString("suppressedReason")));
|
||||
assertThat(deliveriesFor(two)).singleElement().matches(d -> "SENT".equals(d.getValueString("status")));
|
||||
assertThat(deliveriesFor(otherType)).singleElement().matches(d -> "SENT".equals(d.getValueString("status")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Subscribe after raising so initial delivery does not mask mute assertions.
|
||||
*******************************************************************************/
|
||||
private void subscribeForMuteTest() throws QException
|
||||
{
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER")
|
||||
.withValue("userId", VIEWER).withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("enabled", true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Route a fresh notification event against the current mute rules.
|
||||
*******************************************************************************/
|
||||
private void routeForMuteTest(QRecord alert) throws QException
|
||||
{
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
new com.kingsrook.qbits.alerts.engine.AlertRouter(runtime).route(alert, com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType.RAISED, null);
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read delivery outcomes for one alert.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> deliveriesFor(QRecord alert) throws QException
|
||||
{
|
||||
return (where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Type, group and monitor actions; channel and notification actions.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -160,6 +160,6 @@ class RenderingTest
|
||||
assertThat(AlertsPossibleValues.label("IDP_GROUP")).isEqualTo("IdP Group");
|
||||
assertThat(AlertsPossibleValues.label("IN_APP")).isEqualTo("In App");
|
||||
assertThat(AlertsPossibleValues.label(null)).isNull();
|
||||
assertThat(AlertsPossibleValues.buildEnumSources()).hasSize(25);
|
||||
assertThat(AlertsPossibleValues.buildEnumSources()).hasSize(26);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* 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.util.List;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
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.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
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.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsScheduledSteps;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed notifications against the real H2 tables and router, with a movable clock.
|
||||
*******************************************************************************/
|
||||
class EscalationAndReminderTest extends BaseAlertsTest
|
||||
{
|
||||
private static final String TYPE = "test.timed";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A normal declared alert type with a group.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeInstance(QInstance instance)
|
||||
{
|
||||
AlertsInstanceMetaData.ofOrWithNew(instance).withAlertType(new QAlertTypeMetaData()
|
||||
.withName(TYPE).withLabel("Timed alert").withGroupName("timed").withDefaultSeverity(AlertSeverity.ERROR));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise and tune the reminder interval without initial notifications.
|
||||
*******************************************************************************/
|
||||
private QRecord alert(Integer interval, AlertSeverity severity) throws QException
|
||||
{
|
||||
QRecord alert = runtime.getEngine().raise(new AlertSignal(TYPE).withSeverity(severity));
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("reminderIntervalMinutes", interval));
|
||||
return (alert);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store changes as the system, like the engine.
|
||||
*******************************************************************************/
|
||||
private void update(String table, QRecord record) throws QException
|
||||
{
|
||||
runtime.asSystem(() -> AlertsDb.update(table, record));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a test configuration row.
|
||||
*******************************************************************************/
|
||||
private QRecord insert(String table, QRecord record) throws QException
|
||||
{
|
||||
return (runtime.asSystem(() -> AlertsDb.insert(table, record)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One subscription with an explicit event filter.
|
||||
*******************************************************************************/
|
||||
private void subscribe(String user, String events) throws QException
|
||||
{
|
||||
insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", user)
|
||||
.withValue("scopeType", "ALL").withValue("events", events).withValue("enabled", true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A policy attached to this alert's type.
|
||||
*******************************************************************************/
|
||||
private QRecord policy(QRecord alert, int repeatMinutes, int maxRepeats) throws QException
|
||||
{
|
||||
QRecord policy = insert(AlertsEscalationPolicy.TABLE_NAME, new QRecord().withValue("name", "On call")
|
||||
.withValue("enabled", true).withValue("repeatAfterMinutes", repeatMinutes).withValue("maxRepeats", maxRepeats));
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("escalationPolicyId", policy.getValue("id")));
|
||||
return (policy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An explicit ordered target.
|
||||
*******************************************************************************/
|
||||
private QRecord step(QRecord policy, int order, int minutes, String targetType, String targetField, java.io.Serializable targetId) throws QException
|
||||
{
|
||||
return (insert(AlertsEscalationStep.TABLE_NAME, new QRecord().withValue("policyId", policy.getValue("id"))
|
||||
.withValue("stepOrder", order).withValue("afterMinutes", minutes).withValue("targetType", targetType)
|
||||
.withValue(targetField, targetId).withValue("onlyIfStatus", "OPEN")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tick through the real hidden backend step.
|
||||
*******************************************************************************/
|
||||
private void tick() throws QException
|
||||
{
|
||||
new AlertsScheduledSteps.EscalateAndRemind().run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a refreshed alert.
|
||||
*******************************************************************************/
|
||||
private QRecord fresh(QRecord alert) throws QException
|
||||
{
|
||||
return (runtime.asSystem(() -> AlertsDb.byId(AlertsAlert.TABLE_NAME, alert.getValue("id"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Due boundary, event filter, NOTIFIED timeline and same-minute idempotency.
|
||||
** A one-minute interval also proves reminders bypass the ten-minute duplicate window.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReminderDueAndNotDue() throws Exception
|
||||
{
|
||||
QRecord alert = alert(1, AlertSeverity.ERROR);
|
||||
subscribe("viewer-reminders", "REMINDER");
|
||||
subscribe("viewer-raised", "RAISED");
|
||||
clock.advance(Duration.ofSeconds(59));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
clock.advance(Duration.ofSeconds(1));
|
||||
tick();
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("kind")).isEqualTo("REMINDER");
|
||||
assertThat(row.getValueString("recipientUserId")).isEqualTo("viewer-reminders");
|
||||
assertThat(row.getValueString("status")).isEqualTo("SENT");
|
||||
});
|
||||
assertThat(fresh(alert).getValueInstant("lastNotifiedAt")).isEqualTo(clock.instant());
|
||||
assertThat(all(AlertsEvent.TABLE_NAME)).extracting(row -> row.getValueString("eventType")).containsExactly("RAISED", "NOTIFIED");
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The newer of last raise and last successful notification sets the due time.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReminderUsesLatestRaiseOrNotification() throws Exception
|
||||
{
|
||||
QRecord alert = alert(10, AlertSeverity.ERROR);
|
||||
subscribe("viewer-reminders", "REMINDER");
|
||||
clock.advance(Duration.ofMinutes(8));
|
||||
runtime.getEngine().raise(new AlertSignal(TYPE));
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
clock.advance(Duration.ofMinutes(8));
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(9));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2);
|
||||
assertThat(fresh(alert).getValueInstant("lastNotifiedAt")).isEqualTo(clock.instant());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CRITICAL has a 60-minute default; explicit zero still disables reminders.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCriticalDefaultAndExplicitOff() throws Exception
|
||||
{
|
||||
QRecord alert = alert(null, AlertSeverity.CRITICAL);
|
||||
subscribe("viewer", "REMINDER");
|
||||
clock.advance(Duration.ofMinutes(59));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("reminderIntervalMinutes", 0));
|
||||
clock.advance(Duration.ofHours(2));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null is off for non-critical alerts.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReminderDefaultOff() throws Exception
|
||||
{
|
||||
alert(null, AlertSeverity.ERROR);
|
||||
subscribe("viewer", "REMINDER");
|
||||
clock.advance(Duration.ofDays(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every stopping condition blocks both kinds of timed notification.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"ACKNOWLEDGED", "SNOOZED", "RESOLVED", "muted", "disabled", "silent", "flapping", "SILENCE_NOTIFICATIONS", "SUPPRESS_ALERTS"})
|
||||
void testStops(String condition) throws Exception
|
||||
{
|
||||
QRecord alert = alert(1, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 1, 2);
|
||||
step(policy, 1, 1, "USER", "userId", "viewer");
|
||||
subscribe("viewer", "REMINDER");
|
||||
switch(condition)
|
||||
{
|
||||
case "ACKNOWLEDGED" -> runtime.getEngine().acknowledge(alert.getValueInteger("id"), ADMIN);
|
||||
case "SNOOZED" -> runtime.getEngine().snooze(alert.getValueInteger("id"), clock.instant().plusSeconds(3600), ADMIN);
|
||||
case "RESOLVED" -> runtime.getEngine().resolveById(alert.getValueInteger("id"), Resolution.MANUAL, ADMIN, "Done");
|
||||
case "disabled", "silent" -> update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId"))
|
||||
.withValue("disabled".equals(condition) ? "enabled" : "notify", false));
|
||||
case "muted", "flapping" -> update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue(condition, true));
|
||||
default -> insert(AlertsMute.TABLE_NAME, new QRecord().withValue("name", "Maintenance").withValue("scopeType", "ALL")
|
||||
.withValue("enabled", true).withValue("effect", condition).withValue("startsAt", clock.instant()).withValue("endsAt", clock.instant().plusSeconds(3600)));
|
||||
}
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sequential steps and capped repeats, with the same target inside ten minutes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStepsRepeatsAndIdempotency() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 2, 2);
|
||||
step(policy, 1, 1, "USER", "userId", "viewer");
|
||||
step(policy, 2, 3, "USER", "userId", "viewer");
|
||||
tick();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isZero();
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
tick();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isEqualTo(1);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isEqualTo(1);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isEqualTo(2);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
tick();
|
||||
clock.advance(Duration.ofDays(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(4).allSatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueString("status")).isEqualTo("SENT");
|
||||
assertThat(row.getValueString("kind")).isEqualTo("ESCALATED");
|
||||
});
|
||||
List<QRecord> events = where(AlertsEvent.TABLE_NAME, AlertsDb.eq("eventType", "ESCALATED"));
|
||||
assertThat(events).extracting(row -> row.getValueString("fromValue")).containsExactly("0", "1", "2", "2");
|
||||
assertThat(events).extracting(row -> row.getValueString("toValue")).containsExactly("1", "2", "2", "2");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Acknowledging after step one stops the next step and reminders.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAcknowledgeAfterFirstStep() throws Exception
|
||||
{
|
||||
QRecord alert = alert(1, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 1, 5);
|
||||
step(policy, 1, 0, "USER", "userId", "viewer");
|
||||
step(policy, 2, 1, "USER", "userId", "viewer");
|
||||
subscribe("viewer", "REMINDER");
|
||||
tick();
|
||||
runtime.getEngine().acknowledge(alert.getValueInteger("id"), ADMIN);
|
||||
clock.advance(Duration.ofHours(2));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Team expansion uses members; channel targets enqueue an external delivery.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTeamAndChannelTargets() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 0, 0);
|
||||
QRecord team = insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", "oncall").withValue("label", "On call"));
|
||||
for(String user : List.of("viewer-one", "viewer-two"))
|
||||
{
|
||||
insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", team.getValue("id")).withValue("userId", user));
|
||||
}
|
||||
QRecord channel = insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "chat").withValue("label", "Chat")
|
||||
.withValue("channelType", "SLACK").withValue("enabled", true));
|
||||
step(policy, 1, 0, "TEAM", "teamId", team.getValueInteger("id"));
|
||||
step(policy, 2, 1, "CHANNEL", "channelId", channel.getValueInteger("id"));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).extracting(row -> row.getValueString("recipientUserId")).containsExactly("viewer-one", "viewer-two");
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id")))).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("kind")).isEqualTo("ESCALATED");
|
||||
assertThat(row.getValueString("status")).isEqualTo("PENDING");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A group policy applies only when a type has no override; disabled overrides stop it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testGroupFallbackAndDisabledPolicy() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 0, 0);
|
||||
step(policy, 1, 0, "USER", "userId", "viewer");
|
||||
update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", alert.getValue("groupId")).withValue("escalationPolicyId", policy.getValue("id")));
|
||||
QRecord disabled = insert(AlertsEscalationPolicy.TABLE_NAME, new QRecord().withValue("name", "Disabled").withValue("enabled", false));
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("escalationPolicyId", disabled.getValue("id")));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
update(AlertsType.TABLE_NAME, new QRecord().withValue("id", alert.getValue("alertTypeId")).withValue("escalationPolicyId", null));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeated raises do not postpone escalation; reopening starts a fresh cycle.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testReopenRestartsStepsAndRepeatBudget() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 1, 1);
|
||||
step(policy, 1, 5, "USER", "userId", "viewer");
|
||||
clock.advance(Duration.ofMinutes(4));
|
||||
runtime.getEngine().raise(new AlertSignal(TYPE));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
runtime.getEngine().resolveById(alert.getValueInteger("id"), Resolution.MANUAL, ADMIN, "Done");
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
runtime.getEngine().reopenById(alert.getValueInteger("id"), ADMIN, "Back");
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isZero();
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(4));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(4);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An incompatible status never escalates, even though the alert is open.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOnlyIfStatus() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 0, 0);
|
||||
QRecord step = step(policy, 1, 0, "USER", "userId", "viewer");
|
||||
update(AlertsEscalationStep.TABLE_NAME, new QRecord().withValue("id", step.getValue("id")).withValue("onlyIfStatus", "ACKNOWLEDGED"));
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Routing failures roll back the event, level, deliveries and minute checkpoint.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRollbackAndRetry() throws Exception
|
||||
{
|
||||
QRecord alert = alert(0, AlertSeverity.ERROR);
|
||||
QRecord policy = policy(alert, 0, 0);
|
||||
step(policy, 1, 0, "USER", "userId", "viewer");
|
||||
QCodeReference original = qInstance.getTable(AlertsDelivery.TABLE_NAME).getCustomizers().put(TableCustomizers.PRE_INSERT_RECORD.getRole(), new QCodeReference(RejectDelivery.class));
|
||||
assertThatThrownBy(this::tick).isInstanceOf(QException.class);
|
||||
assertThat(where(AlertsEvent.TABLE_NAME, AlertsDb.eq("eventType", "ESCALATED"))).isEmpty();
|
||||
assertThat(fresh(alert).getValueInteger("escalationLevel")).isZero();
|
||||
assertThat(fresh(alert).getValueInstant("timedNotificationCheckedAt")).isNull();
|
||||
qInstance.getTable(AlertsDelivery.TABLE_NAME).getCustomizers().put(TableCustomizers.PRE_INSERT_RECORD.getRole(), original);
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Suppressed attempts do not repeat within a minute or claim a successful send.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSuppressedReminderCheckpoint() throws Exception
|
||||
{
|
||||
QRecord alert = alert(1, AlertSeverity.ERROR);
|
||||
QRecord channel = insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "disabled-chat").withValue("label", "Chat")
|
||||
.withValue("channelType", "SLACK").withValue("enabled", false));
|
||||
insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "CHANNEL").withValue("channelId", channel.getValue("id"))
|
||||
.withValue("scopeType", "ALL").withValue("events", "REMINDER").withValue("enabled", true));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
tick();
|
||||
tick();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).singleElement().satisfies(row ->
|
||||
{
|
||||
assertThat(row.getValueString("status")).isEqualTo("SUPPRESSED");
|
||||
assertThat(row.getValueString("suppressedReason")).isEqualTo("CHANNEL_DISABLED");
|
||||
});
|
||||
assertThat(fresh(alert).getValueInstant("lastNotifiedAt")).isNull();
|
||||
assertThat(where(AlertsEvent.TABLE_NAME, AlertsDb.eq("eventType", "NOTIFIED"))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The scan crosses page boundaries without missing alerts or retaining all rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAlertPages() throws Exception
|
||||
{
|
||||
QRecord first = alert(0, AlertSeverity.ERROR);
|
||||
for(int i = 1; i <= 100; i++)
|
||||
{
|
||||
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("page-" + i));
|
||||
}
|
||||
tick();
|
||||
assertThat(all(AlertsAlert.TABLE_NAME)).hasSize(101).allSatisfy(row ->
|
||||
assertThat(row.getValueInstant("timedNotificationCheckedAt")).isEqualTo(clock.instant()));
|
||||
assertThat(fresh(first).getValueInteger("escalationLevel")).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A deliberate storage failure for transaction verification.
|
||||
*******************************************************************************/
|
||||
public static class RejectDelivery implements TableCustomizerInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
** Reject before a delivery can be stored.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
throw (new QException("Deliberate test failure"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
/*
|
||||
* 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 java.util.Set;
|
||||
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.CsvSource;
|
||||
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<QRecord> 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit and host-resolved team members share personal quiet windows.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"MANUAL", "ROLE", "IDP_GROUP"})
|
||||
void testTeamQuietHours(String source) 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")
|
||||
.withValue("memberSource", source).withValue("memberSourceValue", "operators")));
|
||||
config.withAudienceResolver((memberSource, value) -> Set.of(ADMIN));
|
||||
if("MANUAL".equals(source))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed deliveries bypass both digest cadences while push channels still
|
||||
** respect quiet hours and pause; the in-app bell remains immediate.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({"REMINDER,DIGEST_HOURLY", "REMINDER,DIGEST_DAILY", "ESCALATED,DIGEST_HOURLY", "ESCALATED,DIGEST_DAILY",
|
||||
"POLICY,DIGEST_HOURLY", "POLICY,DIGEST_DAILY"})
|
||||
void testTimedNotificationsBypassDigests(String entryPoint, String mode) throws Exception
|
||||
{
|
||||
QRecord subscription = subscribe(mode, null);
|
||||
runtime.asSystem(() -> AlertsDb.update(AlertsSubscription.TABLE_NAME, new QRecord().withValue("id", subscription.getValue("id"))
|
||||
.withValue("events", "REMINDER,ESCALATED")));
|
||||
settings(new QRecord().withValue("emailEnabled", true).withValue("emailAddress", "admin@example.com"));
|
||||
timedNotification(raise("Immediate timed alert", AlertSeverity.ERROR), entryPoint);
|
||||
assertThat(items()).hasSize(2).allSatisfy(row ->
|
||||
{
|
||||
assertThat(row.getValueString("kind")).isEqualTo("REMINDER".equals(entryPoint) ? "REMINDER" : "ESCALATED");
|
||||
assertThat(row.getValueString("digestKey")).isNull();
|
||||
});
|
||||
assertThat(items()).filteredOn(row -> "PENDING".equals(row.getValueString("status"))).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueInstant("nextAttemptAt")).isEqualTo(clock.instant()));
|
||||
assertThat(runtime.getDeliveryWorker().deliverDue()).isEqualTo(1);
|
||||
assertThat(TestSteps.SENT).hasSize(1);
|
||||
|
||||
settings(new QRecord().withValue("quietHoursStart", "11:00").withValue("quietHoursEnd", "13:00"));
|
||||
timedNotification(raise("Quiet timed alert", AlertSeverity.ERROR), entryPoint);
|
||||
settings(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(60)));
|
||||
timedNotification(raise("Paused timed alert", AlertSeverity.CRITICAL), entryPoint);
|
||||
assertThat(items()).filteredOn(row -> "SUPPRESSED".equals(row.getValueString("status")))
|
||||
.extracting(row -> row.getValueString("suppressedReason")).containsExactly("QUIET_HOURS", "USER_PAUSED");
|
||||
assertThat(items()).filteredOn(row -> "SENT".equals(row.getValueString("status"))).hasSize(4);
|
||||
assertThat(items()).noneMatch(row -> "DIGEST".equals(row.getValueString("kind")));
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).hasSize(3);
|
||||
assertThat(runtime.getDeliveryWorker().deliverDue()).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise each shared router entry point under the scheduler's system context.
|
||||
*******************************************************************************/
|
||||
private void timedNotification(QRecord alert, String entryPoint) throws QException
|
||||
{
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
Integer eventId = "REMINDER".equals(entryPoint) ? null : runtime.getEngine().event(alert.getValueInteger("id"),
|
||||
EventType.ESCALATED, null, "0", "1", "Timed notification", clock.instant());
|
||||
if("REMINDER".equals(entryPoint))
|
||||
{
|
||||
runtime.getRouter().remind(alert);
|
||||
}
|
||||
else if("POLICY".equals(entryPoint))
|
||||
{
|
||||
runtime.getRouter().escalate(alert, new QRecord().withValue("targetType", "USER").withValue("userId", ADMIN), eventId);
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime.getRouter().route(alert, EventType.ESCALATED, eventId);
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Personal settings win over channel severity, which is logged before disabled.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPersonalSuppressionPrecedesChannelChecks() throws Exception
|
||||
{
|
||||
subscribe("IMMEDIATE", null);
|
||||
settings(new QRecord().withValue("inAppEnabled", false).withValue("emailEnabled", true).withValue("emailAddress", "admin@example.com")
|
||||
.withValue("quietHoursStart", "11:00").withValue("quietHoursEnd", "13:00").withValue("pausedUntil", clock.instant().plusSeconds(60)));
|
||||
QRecord email = runtime.asSystem(() -> runtime.getChannels().defaultChannel(ChannelType.EMAIL));
|
||||
runtime.asSystem(() -> AlertsDb.update(AlertsChannel.TABLE_NAME, new QRecord().withValue("id", email.getValue("id"))
|
||||
.withValue("enabled", false).withValue("minSeverity", "CRITICAL")));
|
||||
raise("Pause first", AlertSeverity.WARNING);
|
||||
settings(new QRecord().withValue("pausedUntil", null));
|
||||
raise("Quiet next", AlertSeverity.WARNING);
|
||||
settings(new QRecord().withValue("quietHoursStart", null).withValue("quietHoursEnd", null));
|
||||
raise("Minimum severity next", AlertSeverity.WARNING);
|
||||
raise("Disabled last", AlertSeverity.CRITICAL);
|
||||
assertThat(items()).extracting(row -> row.getValueString("suppressedReason"))
|
||||
.containsExactly("USER_PAUSED", "QUIET_HOURS", "MIN_SEVERITY", "CHANNEL_DISABLED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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"))));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ package com.kingsrook.qbits.alerts.engine;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsPreferences;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
@@ -200,6 +201,124 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both host sources combine with explicit members and other subscriptions;
|
||||
** duplicate users receive once and invisible users remain suppressed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHostTeamSources() throws Exception
|
||||
{
|
||||
List<String> calls = new java.util.ArrayList<>();
|
||||
config.withMaxAudienceSize(3).withAudienceResolver((source, value) ->
|
||||
{
|
||||
calls.add(source + ":" + value);
|
||||
return (Set.of(VIEWER, MANAGER, OUTSIDER));
|
||||
});
|
||||
for(String source : List.of("ROLE", "IDP_GROUP"))
|
||||
{
|
||||
Integer teamId = sourcedTeam(source);
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", teamId).withValue("userId", VIEWER)));
|
||||
}
|
||||
subscribe("USER", VIEWER, null, null, "ALL", null, null, "ERROR");
|
||||
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("source-members"));
|
||||
|
||||
assertThat(calls).containsExactly("ROLE:source-ROLE", "IDP_GROUP:source-IDP_GROUP");
|
||||
assertThat(notifications()).hasSize(3);
|
||||
assertThat(notifications()).filteredOn(n -> "SENT".equals(n.getValueString("status")))
|
||||
.extracting(n -> n.getValueString("recipientUserId")).containsExactlyInAnyOrder(VIEWER, MANAGER);
|
||||
assertThat(notifications()).filteredOn(n -> OUTSIDER.equals(n.getValueString("recipientUserId")))
|
||||
.singleElement().matches(n -> "NOT_VISIBLE".equals(n.getValueString("suppressedReason")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unconfigured sources resolve to nobody; explicit members still work.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMissingTeamResolver() throws Exception
|
||||
{
|
||||
sourcedTeam("ROLE");
|
||||
sourcedTeam("IDP_GROUP");
|
||||
runtime.getEngine().raise(new AlertSignal(SYSTEM).withDedupKey("no-resolver"));
|
||||
assertThat(notifications()).isEmpty();
|
||||
|
||||
Integer manualTeam = sourcedTeam("MANUAL");
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", manualTeam).withValue("userId", VIEWER)));
|
||||
config.withAudienceResolver((source, value) ->
|
||||
{
|
||||
throw (new QException("Unavailable"));
|
||||
});
|
||||
runtime.getEngine().raise(new AlertSignal(SYSTEM).withDedupKey("failed-resolver"));
|
||||
assertThat(notifications()).singleElement().matches(n -> VIEWER.equals(n.getValueString("recipientUserId")) && "SENT".equals(n.getValueString("status")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Oversized resolver results fail closed without truncating the audience.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTeamAudienceLimit() throws Exception
|
||||
{
|
||||
config.withMaxAudienceSize(1).withAudienceResolver((source, value) -> Set.of(VIEWER, MANAGER));
|
||||
sourcedTeam("ROLE");
|
||||
runtime.getEngine().raise(new AlertSignal(SYSTEM).withDedupKey("too-many"));
|
||||
assertThat(notifications()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a team subscription using a host-managed or manual source.
|
||||
*******************************************************************************/
|
||||
private Integer sourcedTeam(String source) throws QException
|
||||
{
|
||||
Integer teamId = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", source).withValue("label", source)
|
||||
.withValue("memberSource", source).withValue("memberSourceValue", "source-" + source)).getValueInteger("id"));
|
||||
subscribe("TEAM", null, teamId, null, "ALL", null, null, "ERROR");
|
||||
return (teamId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RECORD mutes require both table and ID, including an alert whose subject
|
||||
** is stored directly without an active record-reference row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecordMuteMatching() throws Exception
|
||||
{
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsMute.TABLE_NAME, new QRecord().withValue("name", "Record mute")
|
||||
.withValue("scopeType", "RECORD").withValue("scopeValue", WIDGET).withValue("scopeRecordId", "123")
|
||||
.withValue("startsAt", clock.instant()).withValue("endsAt", clock.instant().plusSeconds(60))
|
||||
.withValue("effect", "SILENCE_NOTIFICATIONS").withValue("enabled", true)));
|
||||
QRecord alert = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("subject-only"));
|
||||
alert.setValue("subjectRecordId", "123");
|
||||
assertThat(recordMuted(alert)).isTrue();
|
||||
alert.setValue("subjectRecordId", "124");
|
||||
assertThat(recordMuted(alert)).isFalse();
|
||||
alert.setValue("subjectRecordId", "123");
|
||||
alert.setValue("subjectTableName", "another_table");
|
||||
assertThat(recordMuted(alert)).isFalse();
|
||||
alert.setValue("subjectTableName", WIDGET);
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
assertThat(recordMuted(alert)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Match a mute as the router does under the system session.
|
||||
*******************************************************************************/
|
||||
private boolean recordMuted(QRecord alert) throws QException
|
||||
{
|
||||
return (runtime.asSystem(() -> new AlertRouter(runtime).isMuted(AlertsEngine.AlertTarget.of(alert), clock.instant(),
|
||||
com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect.SILENCE_NOTIFICATIONS)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Suppressions are logged with their reason.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record-view child lists serialize to JSON (the payload the record page
|
||||
** fetches). The child table's meta-data carries the qbit config, whose
|
||||
** runtime points back at the config: unless that back-reference is ignored,
|
||||
** Jackson recurses until its nesting limit ("Error in JSON Serialization").
|
||||
*******************************************************************************/
|
||||
class ChildListWidgetsJsonTest extends BaseAlertsTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit config itself serializes, without its runtime.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConfigSerializesWithoutRuntime()
|
||||
{
|
||||
assertThat(config.getRuntime()).isNotNull();
|
||||
assertThat(JsonUtils.toJson(config)).doesNotContain("\"runtime\"").doesNotContain("\"audienceResolver\"");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification's Recipients list, with recipient rows (the kof22 case).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecipientsChildListSerializes() throws Exception
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Run backup now finished with errors").withSeverity(NotificationSeverity.ERROR)
|
||||
.withCategory(NotificationCategory.JOB).withAudience(NotificationAudience.users(VIEWER, ADMIN)));
|
||||
QRecord notification = runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME)).get(0);
|
||||
|
||||
loginAs(ADMIN);
|
||||
QWidgetMetaDataInterface widget = qInstance.getWidget(AlertsTableMetaDataHelper.JOIN_NOTIFICATION_RECIPIENT);
|
||||
assertThat(widget).isNotNull();
|
||||
|
||||
ChildRecordListData data = (ChildRecordListData) render(widget, notification.getValue("id"));
|
||||
assertThat(data.getQueryOutput().getRecords()).hasSize(2);
|
||||
|
||||
String json = JsonUtils.toJson(data);
|
||||
assertThat(json).contains("\"totalRows\":2").doesNotContain("\"runtime\"");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every child list in the qbit (rendered without a parent, as on an insert
|
||||
** screen, which still sends the child table's meta-data).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEveryChildListSerializes() throws Exception
|
||||
{
|
||||
loginAs(ADMIN);
|
||||
List<String> rendered = new ArrayList<>();
|
||||
for(QWidgetMetaDataInterface widget : qInstance.getWidgets().values())
|
||||
{
|
||||
if(widget.getCodeReference() != null && ChildRecordListRenderer.class.getName().equals(widget.getCodeReference().getName()))
|
||||
{
|
||||
String json = JsonUtils.toJson(render(widget, null));
|
||||
assertThat(json).as(widget.getName()).contains("\"childTableMetaData\"").doesNotContain("\"runtime\"");
|
||||
rendered.add(widget.getName());
|
||||
}
|
||||
}
|
||||
assertThat(rendered).contains(AlertsTableMetaDataHelper.JOIN_NOTIFICATION_RECIPIENT).hasSizeGreaterThan(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render a child-list widget as the record page does (its default values as
|
||||
** query params, plus the parent id when there is one).
|
||||
*******************************************************************************/
|
||||
private QWidgetData render(QWidgetMetaDataInterface widget, Serializable id) throws Exception
|
||||
{
|
||||
Map<String, String> params = new HashMap<>();
|
||||
widget.getDefaultValues().forEach((key, value) -> params.put(key, value == null ? null : String.valueOf(value)));
|
||||
if(id != null)
|
||||
{
|
||||
params.put("id", String.valueOf(id));
|
||||
}
|
||||
|
||||
RenderWidgetInput input = new RenderWidgetInput().withWidgetMetaData(widget);
|
||||
input.setQueryParams(params);
|
||||
return (new RenderWidgetAction().execute(input).getWidgetData());
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
*******************************************************************************/
|
||||
class EntitiesTest
|
||||
{
|
||||
static final List<Class<? extends QRecordEntity>> ENTITIES = List.of(AlertsAlert.class, AlertsChannel.class, AlertsEvent.class, AlertsGroup.class, AlertsHeartbeat.class,
|
||||
static final List<Class<? extends QRecordEntity>> ENTITIES = List.of(AlertsEscalationPolicy.class, AlertsEscalationStep.class, AlertsAlert.class, AlertsChannel.class, AlertsEvent.class, AlertsGroup.class, AlertsHeartbeat.class,
|
||||
AlertsMonitor.class, AlertsNotification.class, AlertsNotificationRecipient.class, AlertsNotificationPreference.class, AlertsMute.class, AlertsDelivery.class, AlertsRecordRef.class, AlertsSubscription.class, AlertsTeam.class,
|
||||
AlertsTeamMember.class, AlertsType.class, AlertsUserPreference.class);
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.notifications;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import com.kingsrook.qbits.alerts.AlertsPreferences;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-user Slack notifications (immediate and daily digest) through a fake
|
||||
** core messaging provider registered as "slack".
|
||||
*******************************************************************************/
|
||||
class NotificationSlackTest extends BaseAlertsTest
|
||||
{
|
||||
static final List<SendMessageInput> SENT = new CopyOnWriteArrayList<>();
|
||||
|
||||
static volatile String failureMode = "NONE";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Records inputs instead of sending them; can be told to fail.
|
||||
*******************************************************************************/
|
||||
public static class FakeSlackProvider implements MessagingProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("alerts-slack-capture");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SendMessageOutput sendMessage(SendMessageInput sendMessageInput) throws QException
|
||||
{
|
||||
if("NO_CONNECTION".equals(failureMode))
|
||||
{
|
||||
throw (new QUserFacingException("Connect Slack first."));
|
||||
}
|
||||
if("BOOM".equals(failureMode))
|
||||
{
|
||||
throw (new IllegalStateException("Slack is down"));
|
||||
}
|
||||
SENT.add(sendMessageInput);
|
||||
return (new SendMessageOutput());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(AlertsQBitConfig config)
|
||||
{
|
||||
QMessagingProviderDispatcher.registerMessagingProvider(new FakeSlackProvider());
|
||||
qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("slack").withType("alerts-slack-capture"));
|
||||
SENT.clear();
|
||||
failureMode = "NONE";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's settings (UTC, digest hour 12, and any extra settings) and one
|
||||
** category preference for Slack.
|
||||
*******************************************************************************/
|
||||
private void slackSettings(String userId, String category, String mode, String minSeverity, QRecord extra) throws QException
|
||||
{
|
||||
AlertsPreferences.ensure(runtime, loginAs(userId));
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
QRecord update = new QRecord().withValue("id", settings.getValue("id")).withValue("timeZone", "UTC").withValue("digestHour", 12);
|
||||
if(extra != null)
|
||||
{
|
||||
extra.getValues().forEach(update::setValue);
|
||||
}
|
||||
AlertsDb.update(AlertsUserPreference.TABLE_NAME, update);
|
||||
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", userId).withValue("category", category)
|
||||
.withValue("slackMode", mode).withValue("slackMinSeverity", minSeverity).withValue("inAppMuted", false));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String slackStatus(String userId) throws QException
|
||||
{
|
||||
return (runtime.asSystem(() -> AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId))).getValueString("slackStatus"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String text(SendMessageInput input)
|
||||
{
|
||||
return (input.getContentList().stream().map(Content::getBody).reduce("", String::concat));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void notifyJob(String title, NotificationSeverity severity, String... userIds)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal(title).withBody("Disk full").withSeverity(severity).withCategory(NotificationCategory.JOB)
|
||||
.withLink(AlertLink.toPath("/app/widget", "Widgets")).withAudience(NotificationAudience.users(userIds)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** IMMEDIATE sends one DM to user:<id> through the Slack provider; below
|
||||
** the minimum severity is skipped; OFF and users without a row are left
|
||||
** alone; running again does not send twice.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testImmediate() throws Exception
|
||||
{
|
||||
slackSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING", null);
|
||||
slackSettings("viewer-2", "JOB", "IMMEDIATE", "ERROR", null);
|
||||
slackSettings("viewer-3", "JOB", "OFF", "INFO", null);
|
||||
notifyJob("Export failed", NotificationSeverity.WARNING, "viewer-1", "viewer-2", "viewer-3", "manager-1");
|
||||
|
||||
NotificationSlackSender.Result result = new NotificationSlackSender(runtime).run();
|
||||
assertThat(result.sent()).isEqualTo(1);
|
||||
assertThat(result.skipped()).isEqualTo(1);
|
||||
assertThat(SENT).singleElement().satisfies(message ->
|
||||
{
|
||||
assertThat(message.getMessagingProviderName()).isEqualTo("slack");
|
||||
assertThat(message.getTo().getAddress()).isEqualTo("user:viewer-1");
|
||||
assertThat(message.getSubject()).isEqualTo("Export failed");
|
||||
assertThat(text(message)).contains("Export failed").contains("Disk full").contains("https://admin.example.com/app/widget");
|
||||
});
|
||||
assertThat(slackStatus("viewer-1")).isEqualTo("SENT");
|
||||
assertThat(slackStatus("viewer-2")).isEqualTo("SKIPPED");
|
||||
assertThat(slackStatus("viewer-3")).isEqualTo("NONE");
|
||||
assertThat(slackStatus("manager-1")).isEqualTo("NONE");
|
||||
|
||||
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(0);
|
||||
assertThat(SENT).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A category muted in-app gets no recipient row, so no Slack message.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMuted() throws Exception
|
||||
{
|
||||
slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", null);
|
||||
runtime.asSystem(() -> AlertsDb.update(AlertsNotificationPreference.TABLE_NAME, new QRecord()
|
||||
.withValue("id", AlertsDb.first(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-1")).getValue("id")).withValue("inAppMuted", true)));
|
||||
notifyJob("Export failed", NotificationSeverity.ERROR, "viewer-1");
|
||||
|
||||
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(0);
|
||||
assertThat(SENT).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Quiet hours hold the DM (the bell is untouched); CRITICAL bypasses; it
|
||||
** goes out once the quiet hours end.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testQuietHours() throws Exception
|
||||
{
|
||||
clock.set(Instant.parse("2026-10-12T23:00:00Z"));
|
||||
slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", new QRecord().withValue("quietHoursStart", "22:00").withValue("quietHoursEnd", "07:00"));
|
||||
notifyJob("Export failed", NotificationSeverity.ERROR, "viewer-1");
|
||||
notifyJob("Database down", NotificationSeverity.CRITICAL, "viewer-1");
|
||||
|
||||
NotificationSlackSender.Result result = new NotificationSlackSender(runtime).run();
|
||||
assertThat(result.sent()).isEqualTo(1);
|
||||
assertThat(result.held()).isEqualTo(1);
|
||||
assertThat(SENT).singleElement().satisfies(message -> assertThat(message.getSubject()).isEqualTo("Database down"));
|
||||
assertThat(runtime.asSystem(() -> AlertsDb.count(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "viewer-1")))).isEqualTo(2);
|
||||
|
||||
clock.set(Instant.parse("2026-10-13T07:05:00Z"));
|
||||
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1);
|
||||
assertThat(SENT).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A paused user gets nothing until the pause ends (even CRITICAL).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPaused() throws Exception
|
||||
{
|
||||
clock.set(Instant.parse("2026-10-12T10:00:00Z"));
|
||||
slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", new QRecord().withValue("pausedUntil", Instant.parse("2026-10-14T10:00:00Z")));
|
||||
notifyJob("Database down", NotificationSeverity.CRITICAL, "viewer-1");
|
||||
|
||||
assertThat(new NotificationSlackSender(runtime).run().held()).isEqualTo(1);
|
||||
assertThat(SENT).isEmpty();
|
||||
assertThat(slackStatus("viewer-1")).isEqualTo("NONE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No provider under the configured name: SKIPPED, nothing thrown.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNoProvider() throws Exception
|
||||
{
|
||||
runtime.getConfig().withSlackProviderName("not-registered");
|
||||
slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", null);
|
||||
notifyJob("Export failed", NotificationSeverity.ERROR, "viewer-1");
|
||||
|
||||
assertThat(new NotificationSlackSender(runtime).run().skipped()).isEqualTo(1);
|
||||
assertThat(SENT).isEmpty();
|
||||
assertThat(slackStatus("viewer-1")).isEqualTo("SKIPPED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user-facing error (no Slack connection) is SKIPPED and not retried;
|
||||
** any other error is FAILED.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testErrors() throws Exception
|
||||
{
|
||||
slackSettings("viewer-1", "JOB", "IMMEDIATE", "INFO", null);
|
||||
slackSettings("viewer-2", "JOB", "IMMEDIATE", "INFO", null);
|
||||
|
||||
failureMode = "NO_CONNECTION";
|
||||
notifyJob("Export failed", NotificationSeverity.ERROR, "viewer-1");
|
||||
NotificationSlackSender.Result first = new NotificationSlackSender(runtime).run();
|
||||
assertThat(first.skipped()).isEqualTo(1);
|
||||
assertThat(slackStatus("viewer-1")).isEqualTo("SKIPPED");
|
||||
|
||||
failureMode = "BOOM";
|
||||
notifyJob("Another failure", NotificationSeverity.ERROR, "viewer-2");
|
||||
NotificationSlackSender.Result second = new NotificationSlackSender(runtime).run();
|
||||
assertThat(second.failed()).isEqualTo(1);
|
||||
assertThat(slackStatus("viewer-2")).isEqualTo("FAILED");
|
||||
|
||||
failureMode = "NONE";
|
||||
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(0);
|
||||
assertThat(SENT).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SECURITY sends the title and link, never the body.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSecurityHasNoBody() throws Exception
|
||||
{
|
||||
slackSettings("viewer-1", "SECURITY", "IMMEDIATE", "INFO", null);
|
||||
QAlerts.notify(new NotificationSignal("New sign-in").withBody("Firefox on Windows, please change your password").withCategory(NotificationCategory.SECURITY)
|
||||
.withSeverity(NotificationSeverity.WARNING).withLink(AlertLink.toPath("/app/widget", "Review")).withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1);
|
||||
assertThat(SENT).singleElement().satisfies(message ->
|
||||
{
|
||||
assertThat(text(message)).contains("New sign-in").contains("https://admin.example.com/app/widget");
|
||||
assertThat(text(message)).doesNotContain("Firefox").doesNotContain("password");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The digest waits for the user's hour, then sends one DM listing their
|
||||
** unread notifications and marks them DIGESTED; email-only users get none.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDigest() throws Exception
|
||||
{
|
||||
clock.set(Instant.parse("2026-10-12T09:00:00Z"));
|
||||
slackSettings("viewer-1", "JOB", "DAILY_DIGEST", "INFO", null);
|
||||
notifyJob("Export failed", NotificationSeverity.WARNING, "viewer-1");
|
||||
notifyJob("Import failed", NotificationSeverity.ERROR, "viewer-1");
|
||||
|
||||
assertThat(new NotificationSlackSender(runtime).run().digests()).isEqualTo(0);
|
||||
assertThat(SENT).isEmpty();
|
||||
clock.set(Instant.parse("2026-10-12T12:05:00Z"));
|
||||
assertThat(new NotificationSlackSender(runtime).run().digests()).isEqualTo(1);
|
||||
assertThat(SENT).singleElement().satisfies(message ->
|
||||
{
|
||||
assertThat(message.getTo().getAddress()).isEqualTo("user:viewer-1");
|
||||
assertThat(message.getSubject()).isEqualTo("2 new notifications");
|
||||
assertThat(text(message)).contains("Export failed").contains("Import failed");
|
||||
});
|
||||
assertThat(runtime.asSystem(() -> AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "viewer-1"))))
|
||||
.allSatisfy(recipient -> assertThat(recipient.getValueString("slackStatus")).isEqualTo("DIGESTED"));
|
||||
assertThat(new NotificationSlackSender(runtime).run().digests()).isEqualTo(0);
|
||||
assertThat(SENT).hasSize(1);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
DROP TABLE IF EXISTS alerts_escalation_step;
|
||||
DROP TABLE IF EXISTS alerts_escalation_policy;
|
||||
-- H2 version of src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql, for unit tests (generated together).
|
||||
|
||||
DROP TABLE IF EXISTS alerts_heartbeat;
|
||||
@@ -265,7 +267,8 @@ CREATE TABLE IF NOT EXISTS alerts_mute (
|
||||
name VARCHAR(200) NOT NULL,
|
||||
reason VARCHAR(2000),
|
||||
scope_type VARCHAR(20) NOT NULL,
|
||||
scope_value VARCHAR(300),
|
||||
scope_value VARCHAR(520),
|
||||
scope_record_id VARCHAR(100),
|
||||
starts_at TIMESTAMP NOT NULL,
|
||||
ends_at TIMESTAMP NOT NULL,
|
||||
effect VARCHAR(30),
|
||||
@@ -296,8 +299,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);
|
||||
@@ -353,6 +361,8 @@ CREATE TABLE IF NOT EXISTS alerts_notification_recipient (
|
||||
dismissed_at TIMESTAMP,
|
||||
emailed_at TIMESTAMP,
|
||||
email_status VARCHAR(16),
|
||||
slack_status VARCHAR(16),
|
||||
slack_message_id VARCHAR(100),
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification_recipient PRIMARY KEY (id),
|
||||
@@ -360,6 +370,7 @@ CREATE TABLE IF NOT EXISTS alerts_notification_recipient (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_user_id_read_at ON alerts_notification_recipient (user_id, read_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_email_status ON alerts_notification_recipient (email_status);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_slack_status ON alerts_notification_recipient (slack_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts_notification_preference (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
@@ -368,8 +379,64 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference (
|
||||
in_app_muted BOOLEAN,
|
||||
email_mode VARCHAR(16),
|
||||
email_min_severity VARCHAR(16),
|
||||
slack_mode VARCHAR(16),
|
||||
slack_min_severity VARCHAR(16),
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category)
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:21
|
||||
-- comment: Admin escalation policies and their ordered destinations.
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_policy (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
repeat_after_minutes INTEGER,
|
||||
max_repeats INTEGER DEFAULT 0,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_policy PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_policy UNIQUE (name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_step (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
policy_id INTEGER NOT NULL,
|
||||
step_order INTEGER NOT NULL,
|
||||
after_minutes INTEGER NOT NULL,
|
||||
target_type VARCHAR(16) NOT NULL,
|
||||
user_id VARCHAR(100),
|
||||
team_id INTEGER,
|
||||
channel_id INTEGER,
|
||||
only_if_status VARCHAR(16) DEFAULT 'OPEN' NOT NULL,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_step PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_step UNIQUE (policy_id, step_order),
|
||||
CONSTRAINT ck_alerts_escalation_step_order CHECK (step_order > 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_minutes CHECK (after_minutes >= 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_target CHECK (
|
||||
(target_type = 'USER' AND user_id IS NOT NULL AND team_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'TEAM' AND team_id IS NOT NULL AND user_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'CHANNEL' AND channel_id IS NOT NULL AND user_id IS NULL AND team_id IS NULL))
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:22
|
||||
-- comment: Type policies override the group's policy.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_type' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_type ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:23
|
||||
-- comment: A group policy is used when the type has none.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_group' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_group ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:24
|
||||
-- comment: Persist the timed job checkpoint, including attempts without recipients.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_alert' AND LOWER(column_name) = 'timed_notification_checked_at'
|
||||
ALTER TABLE alerts_alert ADD COLUMN timed_notification_checked_at TIMESTAMP;
|
||||
Reference in new issue
Block a user