Compare commits

...
Author SHA1 Message Date
james.maesandCodex bb7028a446 ci: centralize QBit source scanning
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:19:50 -05:00
james.maesandCodex 5e76611393 ci: adopt orb 0.7.12 and stable verification gate (#55)
* ci: qualify the shared stable verification gate

Co-Authored-By: Codex <noreply@openai.com>

* ci: adopt verified stable orb 0.7.12

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 03:26:52 -05:00
james.maesandCodex d1939eefd5 ci: use corrected orb rc reference lookup (#54)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:18:17 -05:00
james.maesandCodex f4cbdae305 ci: adopt verified orb rc timeout recovery (#53)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:06:06 -05:00
james.maesandCodex c0acf6bbf8 ci: adopt shared orb 0.7.11 (#52)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:57:45 -05:00
james.maes 8846d0e514 Merge pull request #51 from QRun-IO/feature/orb-rc-worker
ci: move RC coordination into shared orb
2026-10-20 18:58:23 -05:00
james.maesandCodex 7f77969f8e ci: adopt orb-owned RC coordination
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:55:16 -05:00
james.maes 33f1e8dcf4 Merge pull request #50 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:46:04 -05:00
james.maesandCodex 318f176260 ci: adopt verified central QQQ orb 0.7.2
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:42:46 -05:00
james.maes 3adb6dc3d6 Merge pull request #46 from QRun-IO/feature/service-principal
fix(routing): per-recipient checks via the alerts service principal
2026-10-16 13:33:27 -05:00
james.maesandClaude Opus 5.5 b89c95f749 build: QQQ 4.1.0-RC.19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:19:08 -05:00
james.maesandClaude Opus 5.5 86b4ca5ff4 fix(routing): per-recipient checks run via the alerts service principal
Token-less automated sessions are rejected under OAuth2, so visibility,
follower and audience checks never worked there. With the host's alerts
principal declared they run on the recipient's behalf through it.
Recording/routing bookkeeping stays system so alerting never depends on
the IdP. Undeclared: unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:30:04 -05:00
james.maes a79ca3f696 Merge pull request #45 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:05:01 -05:00
james.maesandClaude Opus 5.5 c70210dee0 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:19:32 -05:00
james.maes 56e174444a Merge pull request #44 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:12:44 -05:00
james.maesandClaude Opus 5.5 90e389a55c chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:02 -05:00
james.maesandClaude Opus 5.5 489663a946 feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Regenerated against UI API 1.6.0: every operation has an operationId and
the standard error responses (BasicErrorResponseV1: error, code, message,
fieldErrors, traceId); the extension's own error schema is kept but
deprecated; hand-written error bodies go through QJavalinUtils so they carry
code/message/traceId. Extension MINOR bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:36 -05:00
james.maes fe4732e12f Merge pull request #43 from QRun-IO/feature/runtime-fields-not-audited
fix(audit): do not record-audit engine bookkeeping updates
2026-10-15 07:48:45 -05:00
james.maesandClaude Opus 5.5 b08d41da7f fix(audit): do not record-audit engine bookkeeping updates
Monitor lease claims and run results, and alert reminder/notification
timers, changed on every scheduler pass and each wrote a FIELD audit row
(~7k alerts_monitor and ~1.8k alerts_alert audits per 6h in production).
AlertsDb.update now omits the DML audit when an update touches only the
RuntimeFields of its table; any update with a configuration or state field
is audited in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:45:58 -05:00
james.maes 4a7dd2bb71 Merge pull request #42 from QRun-IO/fix/alerts-system-session-instance
fix(alerts): asSystem builds its system session with the runtime instance
2026-10-15 07:38:26 -05:00
james.maesandClaude Opus 5.5 b638741c61 fix(alerts): asSystem builds its system session with the runtime instance
The alerts-sink drain thread (and schedulers) run engine work through
AlertsRuntime.asSystem with no QContext, so new QSystemUserSession()
read a null instance and warned "QInstance was not supplied" on every
call. AlertsSecurity.systemSession(QInstance) takes the instance; the
no-arg form delegates with the context instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:25:44 -05:00
james.maes 7f79f86222 Merge pull request #41 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:57 -05:00
james.maesandClaude Opus 5.5 69b94a13f5 fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-alerts.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:23:05 -05:00
james.maes 884eab9432 Merge pull request #40 from QRun-IO/fix/help-bundle-version-test
test(help): bundle version test compares with the artifact version
2026-10-14 23:18:42 -05:00
james.maesandClaude Opus 5.5 dc04474689 test(help): compare the help bundle version with the artifact version, not a hardcoded SNAPSHOT
The bundle version is now Maven-filtered, so a literal SNAPSHOT in the test
fails every release build. Surefire passes the artifact version in
qbit.artifactVersion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:07:02 -05:00
james.maes 912b83c2a4 Merge pull request #39 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 e5d31991eb fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:02:04 -05:00
james.maes fc04d0a4fb Merge pull request #38 from QRun-IO/feature/help-bundle
feat(help): Alerts help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:29:38 -05:00
james.maesandClaude Opus 5.5 50e1b52a4d feat(help): ship the Alerts help bundle through QQQ help bundles (419 entries, coverage gate)
Host-neutral text (kof22-specific facts removed), registered with
HelpBundles in the producer, gated by HelpBundleAssertions. Replaces the
temporary AlertsHelp loader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:01:53 -05:00
james.maes 2dcd6cd3e4 Merge pull request #37 from QRun-IO/feature/notify-email-requested-v2
feat(notifications): email REQUESTED recipients immediately (per-job opt-in), v2
2026-10-14 18:51:03 -05:00
james.maesandClaude Opus 5.5 04104ad4aa feat(notifications): email REQUESTED recipients immediately (per-job opt-in), v2
Merges feature/notify-email-requested (#22) onto current develop and
reconciles it with the paginated emailer, user pause, quiet hours and the
notification preferences. REQUESTED email honours the category email
minimum severity, pause (SECURITY bypasses) and quiet hours. No schema
change, so no new changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:48:28 -05:00
james.maes c37d5d3f2e Merge pull request #35 from QRun-IO/feature/foreign-keys
feat(schema): foreign keys for every alerts relation (changesets 30-53, after #32)
2026-10-14 18:00:42 -05:00
james.maes 0143caf4c9 Merge pull request #32 from QRun-IO/feature/alerts-join-indexes
fix(schema): index the join child columns monitor_id, alert_type_id and group_id
2026-10-14 18:00:34 -05:00
james.maes 8a4791508c Merge pull request #33 from QRun-IO/feature/bootstrap-subscription-once
fix(preferences): create the bootstrap subscription once per user
2026-10-14 18:00:27 -05:00
james.maesandClaude Opus 5.5 adb7fd7cec Merge feature/alerts-join-indexes (now with develop) into feature/foreign-keys
develop owns qbit-alerts:29 and the join indexes are now :30, so the
foreign-key changesets move from 30-53 to 31-54. Constraint and index
names are unchanged. Changesets 32-54 use PostgreSQL-only SQL
(row_to_json), so they carry dbms:postgresql and the H2 tests skip them.
The H2 digest test and AlertsForeignKeysTest's pre-FK cut now stop
before :31.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:52:37 -05:00
james.maesandClaude Opus 5.5 28545fa126 Merge origin/develop into feature/alerts-join-indexes
develop now owns qbit-alerts:29 (in_app_min_severity), so the join-index
changeset becomes qbit-alerts:30. Index names are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:50:41 -05:00
james.maesandClaude Opus 5.5 1ff75ffcda Merge origin/develop into feature/bootstrap-subscription-once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:49:11 -05:00
james.maes ff177bd021 Merge pull request #36 from QRun-IO/feature/tables-populated-defaults
Every Alerts table used, seeded or explained; setup card and help
2026-10-14 17:31:48 -05:00
james.maesandClaude Opus 5.5 5c545dc8e1 fix(routing): review findings on owner team, sync and default channels
- coverage is decided by scope and severity, so a subscription with fewer events
  still counts; follow-ups go to the owner team only after it was told of the raise
- the first-visit type sync is best effort and never breaks the bell
- default channels keep the bell and replace only the built-in email choice
- KIND_SETUP in AlertsHome.kinds(); help marker reworded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:23:04 -05:00
james.maesandClaude Opus 5.5 35c3b00d6f fix(defaults): empty rollback, rollback test, include order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:13:22 -05:00
james.maesandClaude Opus 5.5 4077e8a725 feat: starter defaults, setup card and table help so no config table starts empty
Adds alerts-defaults.changelog.sql (in-app and email channels, alerts and other
groups; insert-only, guarded, tested through real Liquibase), a help bundle for
the app and every table, a Setup card on the home, guardrail tests, and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:52:48 -05:00
james.maesandClaude Opus 5.5 684a2157b8 fix(routing): use a group's owner team and a user's default channels
ownerTeamId on a group and defaultChannelIds on a user were stored but never
read. An alert that no subscription matches (WARNING and above) now goes to the
owner team of its group (or the nearest parent with one); default channels
decide where a user is reached when a subscription names none. Types, groups and
default monitors also sync on the first bell request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:52:47 -05:00
james.maes f82b8cfbd8 Merge pull request #18 from QRun-IO/feature/notification-preferences-section
feat(preferences): Notifications section in user Preferences
2026-10-14 16:43:56 -05:00
james.maesandClaude Opus 5.5 411a8ff604 Merge origin/develop into feature/notification-preferences-section; pin QQQ 4.1.0-RC.7
Keeps develop's per-user Slack delivery, escalation and mute work; the
in_app_min_severity changeset moves to qbit-alerts:29; the Notifications
preferences section gains Slack per category; pause also skips non-security
Slack; the summary schema carries preferencesSection (alerts extension 1.1.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:36:00 -05:00
james.maesandClaude Opus 5.5 6e70e02623 test(schema): PostgreSQL guardrail for foreign keys on every declared relation
AlertsForeignKeysTest applies the shipped changelog with Liquibase to
PostgreSQL 17 (Testcontainers) and asserts every one-to-many join and table
possible-value field has a foreign key with the decided ON DELETE rule (or
an exemption with a reason), every FK column leads an index, orphans are
quarantined then nulled or deleted, re-runs are no-ops, and a constraint a
host already added is skipped. The H2 digest-migration test stops before
the PostgreSQL-only FK changesets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:18:31 -05:00
james.maesandClaude Opus 5.5 2e1e22cdd4 feat(schema): foreign keys for every alerts relation, orphan-safe (changesets 30-53)
Changeset 30 indexes the FK columns no earlier index leads with. Changesets
31-53 add one constraint each, fk_<child_table>_<child_column>, guarded by a
precondition on the name so a host that already added it marks it ran. Each
first records orphans in qqq_fk_quarantine, then nulls a nullable column or
deletes a row whose column is NOT NULL (full row kept in row_data).

ON DELETE: CASCADE for owned detail rows (timeline, record refs, delivery
log of an alert, team members, notification recipients, escalation steps);
SET NULL for optional pointers (group, parent group, owner/assigned team,
monitor, subscription, alert event, escalation policy); RESTRICT for config
that must not vanish under live data (alert type, a channel or team that a
subscription, delivery or escalation step uses).

The H2 test schema carries the same constraints and indexes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:18:30 -05:00
james.maes 512dc7f790 Merge pull request #34 from QRun-IO/feature/recipients-widget-json
fix(config): never serialize the alerts runtime (child lists failed to render)
2026-10-14 16:10:01 -05:00
james.maesandClaude Opus 5.5 49ebea22a1 fix(config): never serialize the alerts runtime (child lists failed to render)
Record-view child lists, such as a notification's Recipients, failed with
"Error in JSON Serialization". ChildRecordListData carries the child
QTableMetaData, whose SourceQBitAware getSourceQBitConfig() hands Jackson
the AlertsQBitConfig; its getRuntime() returns an AlertsRuntime that points
back at the config, so serialization recursed to Jackson's 1000-level
nesting limit.

getRuntime() and getAudienceResolver() are now @JsonIgnore, as in the cms,
quick-search and sql-workbench configs. ChildListWidgetsJsonTest renders
the Recipients list with rows and every child-list widget, and serializes
each one; all three tests fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:54:23 -05:00
james.maesandClaude Opus 5.5 c9c1efca0a fix(preferences): create the bootstrap subscription once per user
AlertsPreferences.ensure was an unlocked check-then-insert, and a page
load fires the home widgets and the bell together. On kof22 this left
two identical "alerts (bootstrap rule)" subscriptions for the owner, so
every alert notified them twice. First calls are now serialized in the
JVM. Across replicas, each first call keeps the oldest bootstrap
subscription and deletes the rest. Subscriptions a person made are never
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:22:24 -05:00
james.maesandClaude Opus 5.5 b54d8d103c fix(schema): index the join child columns monitor_id, alert_type_id and group_id
Changeset 29 adds alerts_alert (monitor_id, status), alerts_monitor
(alert_type_id) and alerts_type (group_id). MonitorEvaluator looks up a
per-record monitor's open alerts by monitor_id + status on every
evaluation, and the type and group record pages list children by these
columns. A new test checks that every one-to-many join's child column
leads an index in the shipped changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:18:11 -05:00
james.maes 99a0547c4a Merge pull request #31 from QRun-IO/feature/slack-routing
feat(notifications): per-user Slack delivery (qbit-slack routing)
2026-10-14 11:30:56 -05:00
james.maesandClaude Opus 5.5 718e13c236 feat(notifications): deliver notifications to Slack per user preference
NotificationSlackSender runs after the emailer: core SendMessageAction to the
configured provider (default "slack") with user:<id>, no qbit-slack import.
Pause and quiet hours apply (CRITICAL bypasses quiet hours), SECURITY sends
title and link only, daily digest DMs, SKIPPED without a provider or on a
user-facing refusal. Supersedes the 2026-10-13 "Slack never per user"
decision (owner, 2026-10-14; QRun-IO/qbit-slack DESIGN section 10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:27:35 -05:00
james.maesandClaude Opus 5.5 a2e7f9d9e5 feat(notifications): Slack preference and recipient columns (changeset 28)
slackMode and slackMinSeverity on alerts_notification_preference;
slackStatus and slackMessageId on alerts_notification_recipient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:27:35 -05:00
james.maes aca61324aa Merge pull request #30 from QRun-IO/feature/quiet-hours-digests
feat(routing): apply quiet hours, user pause and digests (#27, part 1)
2026-10-14 11:17:28 -05:00
james.maes ebfb71c910 Merge remote-tracking branch 'origin/develop' into feature/quiet-hours-digests
# Conflicts:
#	CHANGELOG.md
#	README.md
#	src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java
#	src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsProcessesMetaDataProducer.java
#	src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java
#	src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql
#	src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java
2026-10-14 11:12:39 -05:00
james.maesandClaude Opus 5.5 8c6e4dadfa feat(routing): apply quiet hours, user pause and digests
Quiet hours, digest hour and pause were editable in My Alert Settings
but never applied, and DIGEST_HOURLY / DIGEST_DAILY subscriptions sent
immediately.

- The router suppresses push deliveries (email, lists, webhooks) to a
  user during their quiet hours (time zone, windows across midnight,
  quiet days; severities at or above quietBypassSeverity, default
  CRITICAL, break through) as QUIET_HOURS, and while paused as
  USER_PAUSED. The in-app bell is never suppressed by either. A channel
  below its minimum severity is now logged as MIN_SEVERITY.
- Digest subscriptions queue DIGEST deliveries per user/channel and
  window. The hidden hourly alertsSendDigests job sends one grouped
  message per window (hourly, or daily at the user's digest hour in
  their time zone) through the existing email path or one in-app
  notification, rechecks visibility, skips empty digests and never
  sends a window twice. Failed or oversized windows stay visible.
- Changeset 27 adds the durable digest dispatch columns.

Refs #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:02:08 -05:00
james.maes 9de98746a4 Merge pull request #29 from QRun-IO/feature/reminders-escalation
feat(engine): reminders and escalation policies (#27, part 2)
2026-10-14 10:58:28 -05:00
james.maes af4695ac60 Merge pull request #25 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:56:25 -05:00
james.maes e2c5181782 Merge remote-tracking branch 'origin/develop' into feature/reminders-escalation
# Conflicts:
#	src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql
#	src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java
2026-10-14 10:54:01 -05:00
james.maesandClaude Opus 5.5 28664d7d8b feat(engine): reminders and escalation policies
reminderIntervalMinutes was stored but never read, escalationLevel was
always 0 and nothing raised ESCALATED or REMINDER.

- Reminders: an open, unacknowledged, unsnoozed, unmuted alert sends a
  REMINDER through the normal router every reminderIntervalMinutes of
  its type (CRITICAL defaults to 60, explicit 0 turns it off).
- Escalation policies (design table 14): alerts_escalation_policy and
  alerts_escalation_step (step list on the policy record), linked from
  alert types and groups (type wins). A hidden scheduled job notifies
  each step's USER, TEAM or CHANNEL target as ESCALATED when the alert
  reaches the step's age, records an ESCALATED event and the level,
  then repeats the last step every repeatAfterMinutes up to maxRepeats.
  Acknowledge, snooze, resolve and mute stop both; a reopen resets.
  Idempotent within a minute.
- EXPIRED stays reserved and is no longer offered as a resolution.

Changesets 23-26 (21-22 are taken by the team-sources PR).

Refs #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:52:29 -05:00
james.maes 62f484712e Merge pull request #28 from QRun-IO/fix/team-sources-mute-scope
feat(routing): resolve ROLE and IdP Group teams; per-alert mutes (#27, part 3)
2026-10-14 10:50:35 -05:00
james.maesandClaude Opus 5.5 ea5f67933a feat(routing): resolve ROLE and IdP Group teams; per-alert mutes
Team member sources ROLE and IDP_GROUP were stored but never resolved,
so only manual members were notified. A host can now register an
AlertAudienceResolver (AlertsQBitConfig.withAudienceResolver); team
expansion merges its users with manual members, de-duplicated, bounded
by maxAudienceSize, and every user still passes the visibility check.
Without a resolver those sources resolve to nobody, the router logs it
and the team screen says so.

The per-alert Mute action muted the whole alert type, and RECORD-scope
mutes never matched because no record id reached the matcher. Mute now
mutes only that alert (new ALERT scope on type + dedup key), RECORD
mutes compare the stored scopeRecordId, and "Mute this alert type" is a
separate, clearly labelled action. Changesets 21-22 add
alerts_mute.scope_record_id and widen scope_value.

Refs #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:46:38 -05:00
james.maes c942d90a2f Merge pull request #26 from QRun-IO/feature/email-api
refactor(email): use qbit-email's public api package; no G1 allow-list
2026-10-14 10:25:00 -05:00
james.maesandClaude Opus 5.5 b08e729728 refactor(email): use qbit-email's public api package; no G1 allow-list
Moves the optional email integration from qbit-email internals to
com.kingsrook.qbits.email.api (qbit-email 0.1.0-RC.2) with identical
behaviour, and moves to qbit-build-parent 2.1.1, whose default G1
allow-lists include qbit-email's API (QRun-IO/qbit-bom#12), so the
per-repo allow-lists are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:20:27 -05:00
james.maesandClaude Opus 5.5 2202037c00 feat(notifications): email REQUESTED recipients immediately (per-job opt-in)
A recipient with emailStatus REQUESTED is emailed at once, without a
standing category preference or minimum severity, and only once even when
an IMMEDIATE preference also applies. No address: SKIPPED. ALERT
notifications are still never emailed here. This is the delivery half of
the durable-jobs "Email me when done" opt-in (QRun-IO/qqq durable jobs spec
2.11); marking recipients REQUESTED from core's NotificationSignal
attribute emailRequested lands with the RC.6 core notify() adoption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:14:23 -05:00
james.maesandClaude Opus 5.5 3e5ab03b41 fix(preferences): show an unset in-app threshold as Info (everything)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:38:20 -05:00
james.maesandClaude Opus 5.5 63bdc9313d feat(preferences): Notifications section in user Preferences
Registers a core Preferences section 'notifications' backed by
alerts_user_preference, alerts_notification_preference and the user's own
alerts_subscription rows: pause, digest time and time zone, alert email and
in-app switches, per-category in-app/email settings (security can't be
muted) and per-subscription overrides. Adds in_app_min_severity
(changeset qbit-alerts:21), honours it for in-app delivery, and skips
non-security emails while paused. The inbox summary returns
preferencesSection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:35:29 -05:00
93 changed files with 11523 additions and 485 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+34 -2
View File
@@ -4,9 +4,33 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Fixed
- Under OAuth2, per-recipient checks (alert visibility for external channels, follower record access, audience sessions) never worked: they used token-less automated sessions, which OAuth2 rejects, so external sends fell back to the conservative rule. When the host declares the QQQ `alerts` service principal (`AlertsQBitConfig.withServicePrincipal`, default `alerts`), these checks run on the recipient's behalf through it: the recipient's current access intersected with the principal's. Alert recording and routing bookkeeping stay on the system session, so alerting does not depend on the identity provider being up. With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256).
- Audit noise: every monitor run (lease claim, then run result) and every reminder check wrote a "Record was Edited" audit (about 7,000 `alerts_monitor` and 1,800 `alerts_alert` audits in 6 hours on one production host, most with no field details). Engine updates that touch only bookkeeping fields (`RuntimeFields`: monitor `claimedBy`, `claimedUntil`, `lastRunAt`, `nextRunAt`, `lastStatus`, `lastValue`, `lastError`, `lastElapsedMs`, `consecutiveFailures`, `conditionSince`; alert `timedNotificationCheckedAt`, `lastNotifiedAt`, `recordRefCount`) are written with `omitDmlAudit`. Configuration edits, raises and alert state changes are audited as before, as are UI and API edits of any field.
- Read the QBit metadata version from the Maven-filtered artifact version so release builds report their release version.
- 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.
### Added
- Ship the full host-neutral Alerts help bundle through QQQ 4.1.0-RC.7 core registration, replacing the temporary loader. Cover owned tables, fields, actions, jobs, widgets and possible-value sources with a bundle coverage gate; support HOST overrides and hosts without help tables.
- Starter defaults changelog `alerts-defaults.changelog.sql` (insert-only, guarded): the `in-app` and `email` channels and the `alerts` and `other` groups, equal to what the runtime creates on first use. Tested through real Liquibase on H2.
- Help for the Alerts app and every `alerts_*` table (`help/qbit-alerts.yaml`): what the table is for and how its rows get created, shown when it is empty.
- A Setup card first on the Alerts home (alerts admins): each configuration table, its state and the next step.
- `AlertsTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help.
### Fixed
- A group's `ownerTeamId` was stored but never read. An alert that no subscription matches (WARNING and above) now goes to the owner team of its group, or of the nearest parent group that has one.
- A user's `defaultChannelIds` was stored but never read. It now adds the chosen external channels to the bell (unless In-App is off) for subscriptions that name none.
- Alert types, groups and default monitors also sync on the first bell request, so those tables are not empty on a running install.
### Changed
- Email goes through qbit-email when it is produced in the instance (optional dependency, `qbit-email.version` 0.1.0-RC.1): 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).
- 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.7`; verify offline with `./mvnw -o -q verify` (no version override).
- Adopt `qbit-build-parent:2.1.0` with architecture guardrail enforcement and strict UI API routes enabled for tests. Dependency, extension contract and stand-in checks run in the normal build.
- Add record followers to alert routing through core `RecordFollowers.get(instance, ref, FollowEvent.ALERT)` (#16, QRun-IO/qqq#1164). Deduplicate by user/channel with subscriptions and other record refs; require source-record read access under the follower's session, including record locks. Followers use the existing alert visibility, preferences and suppression pipeline without a dependency on qbit-collaboration. Missing/disabled/failing providers preserve subscription routing.
- Register six JSON UI specs as extension `alerts` at the established `/alerts` prefix, preserving methods, paths and existing payload fields. Publish the OpenAPI fragment with a regeneration/contract test and strict server coverage (#13, F2).
@@ -20,6 +44,14 @@ 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
- Alerts UI API extension 1.1.0 adds nullable `preferencesSection` to the typed inbox summary alongside `inboxHref`, with a regenerated OpenAPI fragment. Slack category controls appear in Notifications preferences when the configured per-user Slack provider is registered; SECURITY Slack controls remain editable.
- Notifications user-preferences section over the existing settings and subscription tables, with user/client-channel audits, category in-app/email/Slack severity controls, ownership validation, and an inbox `preferencesSection` shortcut. Enabled by default when core preferences metadata is available; configurable with `withPreferencesSectionEnabled`.
- Nullable in-app notification severity threshold (`qbit-alerts:29`); SECURITY always arrives in-app. Notification email and per-user Slack pauses now defer non-security immediate and digest delivery without losing older pending items.
- 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 holds non-SECURITY messages; 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.
- Per-notification email opt-in via recipient email status `REQUESTED`: immediate delivery without a standing category preference. It still honours the category email minimum severity, the user pause (SECURITY bypasses) and quiet hours (held until they end, for up to a day), is skipped when no address is known, and is never emailed for ALERT notifications.
- Optional external channels:
- Slack (an Incoming Webhook, Block Kit)
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
+130 -9
View File
@@ -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)):
@@ -42,13 +76,14 @@ The qbit also carries ordinary notifications for people (design: [docs/NOTIFICAT
- **Email** is opt-in per user and category (IMMEDIATE, or DAILY_DIGEST at the digest hour in My Alert Settings), through the configured messaging provider; without one it is off. ALERT notifications are not emailed here (alert subscriptions already do), and SECURITY emails carry only the title and link.
- **Failed and partial jobs** notify the person who started them (`notifyFailedJobs`, `jobRunLinkPattern`).
- **Links** must be app paths with one leading slash, or https URLs; backslashes, whitespace and control characters are refused.
- **People** see them in the Next UI bell and notifications page, and choose per-category settings in My Notification Settings (security notices can't be muted).
- **Preferences:** the Notifications section combines pause, digest time and zone, alert switches, per-category in-app/email/Slack settings, and the user's own subscriptions. It uses existing tables and FIELD audits; SECURITY cannot be muted in-app. Slack controls appear when the configured per-user Slack provider is registered before Alerts is produced; SECURITY Slack settings stay editable. Uses core's released user-preferences contract in QQQ `4.1.0-RC.7`; disable with `withPreferencesSectionEnabled(false)`. See [Notification settings in Preferences](docs/NOTIFICATIONS.md#34-notification-settings-in-preferences).
- **People** see them in the Next UI bell and notifications page, and choose settings in Preferences > Notifications or the standard My Notification Settings table (security notices can't be muted).
Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions include their own recipient rows and settings.
**Upgrading from 0.1.0-RC.1:** changeset 16 renames the delivery log `alerts_notification` to `alerts_delivery`, and changeset 17 drops `alerts_inbox_item` (bell state only). Both are guarded, so a fresh schema and an RC.1 schema both end up the same.
The UI routes are published as the `alerts` extension (`1.0.0`, established prefix `/alerts`), with
The UI routes are published as the `alerts` extension (`1.2.0`, established prefix `/alerts`), with
[the OpenAPI fragment](src/main/resources/openapi/qqq-v1/alerts.yaml) and a contract test.
See [UI API and webhook migration](docs/UI-API.md) for operation IDs and the complete Next handoff.
Summary `inboxHref`, item/alert `actions`, and panel `follow.available` are additive. Existing
@@ -70,7 +105,7 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano
## Requirements
- Java 21.
- QQQ `4.1.0-RC.6` (released on Maven Central).
- QQQ `4.1.0-RC.7` (released on Maven Central).
- An RDBMS backend.
- A scheduler, for monitors and email delivery.
- For email, either [qbit-email](https://github.com/QRun-IO/qbit-email) produced in the same instance, or a messaging provider.
@@ -79,7 +114,7 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano
The build uses `qbit-build-parent:2.1.0` with architecture guardrails and strict UI API route
tests enabled. Run `./mvnw -B verify` (`.\mvnw.cmd -B verify` on Windows), without a snapshot
override. The default-active `qqq-snapshot` profile retains its historical name but selects
the released RC.6 BOM ahead of the parent's QQQ 4.0 BOM.
the released RC.7 BOM ahead of the parent's QQQ 4.0 BOM.
## Install
@@ -106,6 +141,20 @@ new AlertsQBitProducer()
4. **Optional: ESB and Quick Search.** `AlertsIntegrations` names the main tables and searchable fields for the host's ESB and Quick Search configuration.
## Starter defaults and setup
After the schema, include the starter defaults. Include order: the schema first, then the defaults. They are insert-only (a row is added only when its name is absent, so edits survive and nothing is ever updated), hold no secrets, and are guarded so a host without the tables skips them:
```yaml
- include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
```
They create the `in-app` and `email` channels and the `alerts` and `other` groups, exactly the rows the runtime would otherwise create on first use. Everything else starts empty for a reason the table's own help gives: alert types and default monitors are declared in code by the installed qbits and synced on the first visit; teams, escalation policies and subscriptions name your people; the rest is runtime data and user content.
Every table has help that says what it is for and **how its rows get created**, shown on the table screen and when it is empty (`help/qbit-alerts.yaml`). Alerts admins also get a **Setup** card first on the home that lists each configuration table, whether it is set up, and the next step.
Routing details worth knowing: an alert that no subscription matches goes to the owner team of its group (or the nearest parent group that has one), at WARNING and above; a person's **Default Channels** (My Alert Settings) replace the built-in email choice for subscriptions that name no channels (the bell stays unless turned off). Follow-ups go to the owner team only if it was told about the raise.
## Raising alerts
Use the producer SPI from `com.kingsrook.qqq.backend.core.alerts`, released in QQQ 4.1.0-RC.5 ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)). `AlertsQBitProducer` registers the qbit's sink through core's `AlertsInstanceMetaData`, so host producers need only core:
@@ -127,7 +176,7 @@ User notifications use core `QAlerts.notify(NotificationSignal)` (RC.6, QRun-IO/
| Method | Path | What |
|---|---|---|
| GET | `/qqq/v1/alerts/inbox/summary` | Unread count, highest unread severity, the latest 10 notifications and sticky banners (the bell) |
| GET | `/qqq/v1/alerts/inbox/summary` | Unread count, highest unread severity, the latest 10 notifications and sticky banners (the bell), plus `inboxHref` and `preferencesSection` (`"notifications"` when registered, otherwise null; see [API contract](docs/NOTIFICATIONS.md#81-api-qqqv1alerts-session-authenticated-alertsview)) |
| GET | `/qqq/v1/alerts/inbox` | The notifications page: `status` (all, unread), `category`, `severity`, `offset`, `limit` |
| POST | `/qqq/v1/alerts/inbox/read` | `{"id": n}`, `{"ids": [..]}`, `{"all": true}`, or `{"alertId": n}` |
| POST | `/qqq/v1/alerts/inbox/unread` | `{"id": n}` or `{"ids": [..]}` |
@@ -155,9 +204,21 @@ 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 `EmailService` instead of core `SendMessageAction`:
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:
- **Templates**, registered on first use and never overwritten (edit them in the Email app): `alerts_alert` (a channel email for one alert or a batch), `alerts_notification` (one notification) and `alerts_digest` (a list of items, `{{#each items}}`). All are Markdown, category NOTIFICATION, so unsubscribe and suppression apply.
- **One recipient per message.** An email list channel sends one message per address.
@@ -167,14 +228,74 @@ 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.
## In-app help
The producer registers [`help/qbit-alerts.yaml`](src/main/resources/help/qbit-alerts.yaml)
with QQQ's help-bundle registry. It covers the Alerts app, tables and fields,
actions, scheduled jobs, widgets and possible-value sources. QQQ syncs the bundle
when the host installs the help-content tables; registration is harmless without them.
Hosts can override individual entries by registering their own bundle with
`layer: HOST`, using the same entry `key` and `role` (default `ALL_SCREENS`).
Register it with `HelpBundles.register(qInstance, "help/my-host.yaml")`. Host
bundles take precedence over this `QBIT` bundle, and edits made by users remain protected.
`AlertsTablesGuardrailTest` runs `HelpBundleAssertions` against the produced instance
to check owned-object coverage, summary length and live references. It also requires
widget and possible-value-source help and preserves the table empty-state guidance.
When releasing, keep the bundle's literal version aligned with the POM's revision.
## Build
+16
View File
@@ -0,0 +1,16 @@
# Upgrading
## Unreleased: starter defaults, setup card, table help
1. **Add the defaults changelog** after `alerts.changelog.sql` in the host's master changelog:
```yaml
- include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
```
It is insert-only and guarded (row exists by name: no change; table missing: skipped), so it is safe on an installed host. It creates the `in-app` and `email` channels and the `alerts` and `other` groups only, the same rows the runtime creates on first use.
2. **Behavior changes to know about.**
- A group's **Owner Team** is now used: an alert at WARNING or above that no subscription covers (by scope and severity) is sent to that team's members; follow-ups (resolved, reminders) go only to members who were told about the raise. Hosts that never set an owner team see no change.
- **Default Channels** on My Alert Settings is now used for subscriptions that name no channels. The bell stays whenever In-App is not turned off; the list replaces the built-in email choice with the channels you chose (unknown ids are ignored). Hosts that never set it see no change.
- Alert types, groups and default monitors now also sync on the first request of the bell, not only on the home or the first signal.
3. Nothing else is required. The help bundle and the Setup card need no configuration.
+15 -1
View File
@@ -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.
+78 -7
View File
@@ -23,7 +23,9 @@ system as well"
## 1. Owner decisions (2026-10-13)
1. **Channels.** In-app (the bell and the notifications page) always. Email is opt-in per user, by
severity or as a daily digest. Slack stays a group or monitor channel only, never per user.
severity or as a daily digest. Slack is also a per-user channel, chosen per category (owner decision
2026-10-14, superseding the 2026-10-13 "Slack stays a group or monitor channel only, never per user";
alert-group and monitor Slack channels are unchanged).
2. **Audience.** Dynamic audiences: all users, groups or roles, holders of a permission, or named users.
The audience is resolved at send time and materialized as one recipient row per user, so read,
unread and dismissed are tracked per user.
@@ -88,8 +90,9 @@ everywhere. Deleting it deletes its recipient rows.
A child of the notification: one row per user, unique (`notificationId`, `userId`).
`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus`
(NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED).
`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus`,
`slackStatus`, `slackMessageId` (each status is NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED;
`slackMessageId` is filled when the Slack provider reports one).
Users see and change only their own rows (owner lock on `userId`). Only `readAt` and `dismissedAt` are
editable; inserts and deletes are the qbit's.
@@ -98,10 +101,62 @@ 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
`userId`, `category`, `inAppMuted` (boolean; refused for SECURITY), `inAppMinSeverity` (nullable; null means every severity, refused for SECURITY), `emailMode` (OFF / IMMEDIATE /
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`.
### 3.4 Notification settings in Preferences
With the core user-preferences contract installed, the qbit registers **Notifications** (`notifications`,
order 20, icon `notifications`): “What reaches you in the app, by email and on Slack.” It requires `alerts.view`
and has no app scope. Hosts can disable it with `AlertsQBitConfig.withPreferencesSectionEnabled(false)`.
Older core runtimes without preferences metadata omit the section.
The section reads and writes the existing owner-locked tables, through core table actions with FIELD
level audits retaining the signed-in user and client channel. It exposes:
- Pause notifications until (security notices still arrive), daily digest time (24 hourly choices),
time zone (a valid Java `ZoneId`), and the alert in-app and email switches from `alerts_user_preference`.
A missing user row gets the same defaults as the bell's first-visit setup.
- One category row per notification category: In-app, In-app from, Email (Off / Immediate / Daily digest),
Email from, Slack (Off / Immediate / Daily digest) and Slack from. Slack fields appear only when
the configured `slackProviderName` messaging provider is registered before Alerts is produced.
They use the same `alerts_email_mode` choices as the table. Missing category rows use table defaults;
edits upsert only that user's category. SECURITY Slack fields remain editable.
SECURITY's two in-app fields are read-only: “Security notices can't be muted.”
- Only the user's own USER subscriptions, labelled by scope, with Enabled, From severity and Delivery mode.
Other users' rules and team/channel rules cannot be edited here, including by administrators.
`GET /qqq/v1/preferences/notifications` loads the section and inline enum choices.
`PATCH /qqq/v1/preferences/notifications` accepts partial changes, for example:
```json
{
"values": { "digestHour": 8, "timeZone": "America/Chicago" },
"lists": {
"categories": [{ "key": "JOB", "values": { "inAppMinSeverity": "WARNING", "emailMode": "DAILY_DIGEST", "slack": "IMMEDIATE", "slackMinSeverity": "WARNING" } }],
"subscriptions": [{ "key": "42", "values": { "enabled": true, "minSeverity": "ERROR" } }]
}
}
```
Subscription keys are persisted row IDs. Invalid zones, foreign/team subscription keys and SECURITY
in-app changes return 400; a section hidden by permission or configuration returns 404.
The section has no user parameter and cannot change ownership. Administrators retain the standard
table screens for administering other users' settings.
In-app severity filters apply when recipients are materialized, just like category muting; existing
inbox items remain. SECURITY always reaches its in-app recipients. While `pausedUntil` is in the future,
non-SECURITY notification emails and per-user Slack DMs are deferred, including daily digests; recipients remain in the inbox
and eligible after the pause ends, even after more than a day. SECURITY keeps its category email mode
and Slack mode/severity settings but bypasses the pause. Existing alert routing continues to use the same pause
and alert channel switches.
Schema migration: `qbit-alerts:29` adds nullable `in_app_min_severity VARCHAR(16)`.
The migration uses `onFail:MARK_RAN` when the column already exists.
The POM targets released QQQ `4.1.0-RC.7`; build with `./mvnw -o -q verify`.
## 4. Audiences
`AlertsAudienceResolver` resolves an audience to user ids at send time:
@@ -191,7 +246,20 @@ 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
holds non-SECURITY items; quiet hours (`alerts_user_preference`) hold items with the same CRITICAL
bypass as other push channels. SECURITY bypasses pause, and mixed digests include only eligible items;
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
@@ -212,7 +280,7 @@ Gating is unchanged from #85: no bell and zero requests unless `metaData.apps.al
| Method | Path | Body or query | Returns |
|---|---|---|---|
| GET | `/inbox/summary` | | `{unreadCount, highestUnreadSeverity, items[≤10], banners[], pollSeconds, notificationsHref}` |
| GET | `/inbox/summary` | | `{unreadCount, highestUnreadSeverity, items[≤10], banners[], pollSeconds, notificationsHref, inboxHref, alertsHref, preferencesSection}` |
| GET | `/inbox` | `?status=all\|unread&category=&severity=&offset=0&limit=25` | `{items[], total, offset, limit}` |
| POST | `/inbox/read` | `{id}` or `{ids:[..]}` or `{all:true}` (`{alertId}` still works) | `{updated}` |
| POST | `/inbox/unread` | `{id}` | `{updated}` |
@@ -222,6 +290,9 @@ An item is `{id, notificationId, title, body, severity, category, source, href,
deliveredAt, age, unread, sticky}`. `id` is the recipient row id. `body` appears only in `/inbox` and in
banners. `highestUnreadSeverity` is one of the five severities. Every call works in the caller's
session, so the record locks apply.
The alerts extension is version `1.1.0`. `inboxHref` retains the canonical inbox link.
`preferencesSection` is `"notifications"` when that section is registered, otherwise JSON null.
Clients can use it for the bell's settings shortcut to the core Preferences section.
## 9. Security
+20 -7
View File
@@ -1,7 +1,7 @@
# Alerts UI API and webhook migration
The qbit targets QQQ **4.1.0-RC.6** (pending release). `AlertsQBitProducer` registers the
`alerts` `MiddlewareSpecExtension`, version `1.0.0`, with the established `/alerts` prefix.
The qbit targets QQQ **4.1.0-RC.15**, UI API **1.6.0**. `AlertsQBitProducer` registers the
`alerts` `MiddlewareSpecExtension`, version `1.2.0`, with the established `/alerts` prefix.
`withRegisterRoutes(false)` opts out of both the extension and webhook registration.
The six JSON specs extend `AbstractJsonEndpointSpec`; existing `AlertsApi` logic continues
to enforce caller permissions, recipient ownership and alert visibility.
@@ -20,13 +20,24 @@ discovers the `alerts` extension so clients need not infer installation from an
| GET | `/qqq/v1/alerts/record-panel` | `alertsRecordPanel` |
All existing query parameters, selector precedence, paging defaults and clamps, status
codes, JSON content-type requirements, and response keys remain. IDs refer to recipient
codes, JSON content-type requirements, and success response keys remain. IDs refer to recipient
rows for inbox operations; legacy `alertId` selectors remain accepted. `all:true` applies
only to read/dismiss. Items in a summary omit `body`; page items and banners include it.
Nullable fields remain serialized as JSON null.
## UI API extension changes
UI API extension alerts 1.2.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds,
standard error responses with BasicErrorResponseV1, and extension tags. The six existing
operationIds are preserved. Errors retain their HTTP statuses and now include `error`,
`code`, `message`, and `traceId` (with `fieldErrors` when applicable). `AlertsErrorV1`
remains in components with a deprecation description for compatibility. The core upgrade
also retires the expired Slack shim: capable providers now receive notification
idempotency keys; providers without that capability keep recipient-row deduplication.
## Next handoff: additive payload fields
- Summary adds nullable `preferencesSection`: the user Preferences section name (`notifications`) or null when not registered. Clients can use `/qqq/v1/preferences/{name}` for notification settings.
- Summary adds `inboxHref`, currently `/app/notifications`. Retain and honor the existing
`notificationsHref` and `alertsHref` fields for older clients.
- Every inbox item (including banners) adds `actions`: `name`, `label`, `operationId`.
@@ -126,13 +137,15 @@ The supplied Next `main` checkout still has no alerts client; the
## Verification
Use the Maven wrapper. The committed BOM version is RC.6; while it is unreleased, verify with
an already installed local core build:
Use the Maven wrapper offline against the locally installed migration build:
```sh
./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
./mvnw -o verify -Dqqq.snapshot.version=4.1.99-SNAPSHOT -Denforcer.skipRules=requireReleaseDeps
```
Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test`
(and the same local version override). Normal tests compare the fragment without rewriting it.
(with `-o` for an offline build). Normal tests compare the fragment without rewriting it.
Checkstyle uses `checkstyle/config.xml` and the repository's existing short Apache headers.
The Enforcer switch skips only the release-dependency rule for the local SNAPSHOT;
omit it and the version override when building against the released RC.12 BOM.
+67 -9
View File
@@ -23,20 +23,17 @@
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
<liquibase.version>4.33.0</liquibase.version>
<testcontainers.version>2.0.5</testcontainers.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
<!-- Optional email integration: only AlertsEmailSender references its API, -->
<!-- after classpath and EmailRuntime.config(instance) checks. EmailRuntime -->
<!-- lives in the root package; the guardrail accepts package prefixes. -->
<qbit.guardrails.allowedQbitArtifacts>com.kingsrook.qbits:qbit-object-storage,com.kingsrook.qbits:*-api,com.kingsrook.qbits:qbit-email</qbit.guardrails.allowedQbitArtifacts>
<qbit.guardrails.allowedApiPackages>com.kingsrook.qbits.objectstorage.api,com.kingsrook.qbits.email</qbit.guardrails.allowedApiPackages>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -109,7 +106,39 @@
<version>2.2.220</version>
<scope>test</scope>
</dependency>
<!-- AlertsForeignKeysTest applies the shipped changelog with Liquibase to a real PostgreSQL (Testcontainers) -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>${liquibase.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.14</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<!-- AlertsDefaultsChangelogTest runs the shipped seed changelog through real Liquibase on H2. -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.33.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -124,7 +153,36 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-alerts.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-alerts.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<!-- the artifact version, for the help bundle version test -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -202,14 +260,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -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;
}
@@ -17,6 +17,8 @@
package com.kingsrook.qbits.alerts;
import java.io.Serializable;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
@@ -26,7 +28,12 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
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.delete.DeleteInput;
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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -42,6 +49,9 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
*******************************************************************************/
public final class AlertsPreferences
{
/** marks the bootstrap rule's subscription (createdByUserId). */
static final String BOOTSTRAP_CREATOR = "alerts (bootstrap rule)";
private static final Set<String> ENSURED = ConcurrentHashMap.newKeySet();
@@ -69,36 +79,72 @@ public final class AlertsPreferences
boolean admin = session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN);
String fullName = session.getUser() == null ? null : session.getUser().getFullName();
runtime.asSystem(() ->
{
if(AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
{
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord()
.withValue("userId", userId)
.withValue("userLabel", fullName == null ? userId : fullName)
.withValue("inAppEnabled", true)
.withValue("emailEnabled", true)
.withValue("quietBypassSeverity", "CRITICAL")
.withValue("digestHour", 8));
}
if(admin && runtime.getConfig().getBootstrapMinSeverity() != null && AlertsDb.first(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
////////////////////////////////////////////////////////////////////////////
// A page load fires the home widgets and the bell together, so first //
// calls for one user race. Serialize them in this JVM; across replicas, //
// the clean-up below keeps one bootstrap subscription (the lowest id). //
////////////////////////////////////////////////////////////////////////////
synchronized(AlertsPreferences.class)
{
if(ENSURED.contains(userId))
{
QRecord inApp = runtime.getChannels().defaultChannel(ChannelType.IN_APP);
AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
.withValue("subscriberType", SubscriberType.USER.name())
.withValue("userId", userId)
.withValue("scopeType", ScopeType.ALL.name())
.withValue("minSeverity", runtime.getConfig().getBootstrapMinSeverity().name())
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
.withValue("deliveryMode", DeliveryMode.IMMEDIATE.name())
.withValue("channelOverrideIds", String.valueOf(inApp.getValue("id")))
.withValue("enabled", true)
.withValue("createdByUserId", "alerts (bootstrap rule)"));
return;
}
return (null);
});
ENSURED.add(userId);
runtime.asSystem(() ->
{
if(AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
{
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord()
.withValue("userId", userId)
.withValue("userLabel", fullName == null ? userId : fullName)
.withValue("inAppEnabled", true)
.withValue("emailEnabled", true)
.withValue("quietBypassSeverity", "CRITICAL")
.withValue("digestHour", 8));
}
if(admin && runtime.getConfig().getBootstrapMinSeverity() != null && AlertsDb.first(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
{
QRecord inApp = runtime.getChannels().defaultChannel(ChannelType.IN_APP);
AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
.withValue("subscriberType", SubscriberType.USER.name())
.withValue("userId", userId)
.withValue("scopeType", ScopeType.ALL.name())
.withValue("minSeverity", runtime.getConfig().getBootstrapMinSeverity().name())
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
.withValue("deliveryMode", DeliveryMode.IMMEDIATE.name())
.withValue("channelOverrideIds", String.valueOf(inApp.getValue("id")))
.withValue("enabled", true)
.withValue("createdByUserId", BOOTSTRAP_CREATOR));
}
removeDuplicateBootstrapSubscriptions(userId);
return (null);
});
ENSURED.add(userId);
}
}
/*******************************************************************************
** Keep only the first bootstrap subscription of a user. Two replicas can
** each see no subscription and insert one; every later first call (and the
** losers' own calls) delete the extra copies, so the user is not notified
** twice per alert. Subscriptions a person made are never touched.
*******************************************************************************/
static void removeDuplicateBootstrapSubscriptions(String userId) throws QException
{
List<QRecord> bootstrap = AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("createdByUserId", QCriteriaOperator.EQUALS, BOOTSTRAP_CREATOR)
.withOrderBy(new QFilterOrderBy("id")));
if(bootstrap.size() < 2)
{
return;
}
List<Serializable> extra = bootstrap.subList(1, bootstrap.size()).stream().map(r -> (Serializable) r.getValue("id")).toList();
new DeleteAction().execute(new DeleteInput(AlertsSubscription.TABLE_NAME).withPrimaryKeys(extra).withTransaction(QContext.getQBackendTransaction()));
}
@@ -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;
@@ -77,9 +80,11 @@ public class AlertsQBitConfig implements QBitConfig
private Boolean legacyAlertmanagerPath = false;
private String backendName;
private String servicePrincipal = "alerts";
private String schedulerName;
private String messagingProviderName;
private String fromAddress;
private String slackProviderName = "slack";
private String publicBaseUrl;
private Integer reopenWindowMinutes = 30;
@@ -97,6 +102,7 @@ public class AlertsQBitConfig implements QBitConfig
private String alertmanagerIngestTokenSecretRef;
private Boolean preferencesSectionEnabled = true;
private Boolean registerRoutes = true;
private Boolean synchronousSink = false;
@@ -106,10 +112,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 +183,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 +346,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 +677,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);
@@ -798,6 +854,27 @@ public class AlertsQBitConfig implements QBitConfig
/*******************************************************************************
** Whether to register Notifications in user preferences (default true).
*******************************************************************************/
public Boolean getPreferencesSectionEnabled()
{
return (preferencesSectionEnabled);
}
/*******************************************************************************
** Enable or disable the Notifications preferences section.
*******************************************************************************/
public AlertsQBitConfig withPreferencesSectionEnabled(Boolean enabled)
{
preferencesSectionEnabled = enabled;
return (this);
}
/*******************************************************************************
** Whether to expose the deprecated UI-path webhook alias (default false).
** Requires non-strict UI route mode; remove after one minor release.
@@ -817,4 +894,30 @@ public class AlertsQBitConfig implements QBitConfig
this.legacyAlertmanagerPath = legacyAlertmanagerPath;
return (this);
}
/*******************************************************************************
** Getter for servicePrincipal: when the host declares this QQQ service
** principal, per-recipient checks (alert visibility, follower record access,
** audience) run on the recipient's behalf through it - their current access
** intersected with the principal's - instead of a token-less automated
** session (rejected under OAuth2). Alert recording and routing bookkeeping
** stay on the system session so alerting never depends on the identity
** provider being up.
*******************************************************************************/
public String getServicePrincipal()
{
return (this.servicePrincipal);
}
/*******************************************************************************
** Fluent setter for servicePrincipal (default alerts).
*******************************************************************************/
public AlertsQBitConfig withServicePrincipal(String servicePrincipal)
{
this.servicePrincipal = servicePrincipal;
return (this);
}
}
@@ -17,8 +17,11 @@
package com.kingsrook.qbits.alerts;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
import com.kingsrook.qbits.alerts.engine.AlertsProcessListener;
import com.kingsrook.qbits.alerts.engine.AlertsSinkAdapter;
@@ -37,6 +40,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -65,7 +69,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-alerts";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private static final String JAVALIN_CLASS = "com.kingsrook.qqq.middleware.javalin.QJavalinMetaData";
@@ -74,6 +78,34 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
/*******************************************************************************
** Read the artifact version from the Maven-filtered resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = AlertsQBitProducer.class.getClassLoader().getResourceAsStream("qbit-alerts.properties"))
{
if(inputStream == null)
{
throw (new IllegalStateException("Missing qbit-alerts.properties; build with Maven to generate the artifact version"));
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw (new IllegalStateException("Missing or invalid version in qbit-alerts.properties; build with Maven resource filtering enabled"));
}
return (version);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not read artifact version from qbit-alerts.properties", e));
}
}
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
@@ -173,6 +205,8 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
output.addSelfToInstance(qInstance);
// Registration is metadata-only; core guards startup sync when helpContent is absent.
HelpBundles.register(qInstance, "help/qbit-alerts.yaml");
return (new MetaDataProducerMultiOutput());
}
@@ -209,6 +243,11 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
qInstance.withProcessLifecycleListener(new QCodeReference(AlertsProcessListener.class));
}
if(Boolean.TRUE.equals(qBitConfig.getPreferencesSectionEnabled()) && isPreferencesAvailable())
{
com.kingsrook.qbits.alerts.preferences.AlertsNotificationPreferencesProvider.register(qInstance, qBitConfig);
}
boolean routes = false;
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
{
@@ -290,4 +329,32 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
return (false);
}
}
/*******************************************************************************
** Older core versions can run without the optional preferences integration.
*******************************************************************************/
private static boolean isPreferencesAvailable()
{
try
{
Class.forName("com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData", false, AlertsQBitProducer.class.getClassLoader());
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
}
/*******************************************************************************
** The bell shortcut is present only when the section is registered.
*******************************************************************************/
public static String preferencesSectionName(QInstance instance)
{
return (isPreferencesAvailable() && com.kingsrook.qbits.alerts.preferences.AlertsNotificationPreferencesProvider.isRegistered(instance) ? "notifications" : null);
}
}
@@ -144,7 +144,7 @@ public class AlertsRuntime
public <R> R asSystem(SystemWork<R> work) throws QException
{
Object[] result = new Object[1];
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession()), () ->
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession(qInstance)), () ->
{
result[0] = work.run();
});
@@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock;
@@ -34,6 +35,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -278,7 +282,19 @@ public final class AlertsSecurity
*******************************************************************************/
public static QSystemUserSession systemSession()
{
QSystemUserSession session = new QSystemUserSession();
return (systemSession(QContext.getQInstance()));
}
/*******************************************************************************
** A system-user session for this instance, holding the alerts all-access
** keys. Background threads (the alerts sink, schedulers) have no QContext
** yet, so they must pass the instance in.
*******************************************************************************/
public static QSystemUserSession systemSession(QInstance qInstance)
{
QSystemUserSession session = new QSystemUserSession(qInstance);
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
session.withSecurityKeyValue(KEY_PERMISSION_ALL, true);
session.withSecurityKeyValue(KEY_READABLE_TABLE_ALL, true);
@@ -295,4 +311,25 @@ public final class AlertsSecurity
{
return (QContext.getQSession() instanceof QSystemUserSession);
}
/*******************************************************************************
** A session for a recipient: on their behalf through the alerts service
** principal when the host declares it (QQQ service identities), else the
** authentication module's automated session (previous behavior). Null when
** neither can be made - callers fail closed.
*******************************************************************************/
public static QSession recipientSession(QInstance qInstance, String principalName, String userId) throws QException
{
if(qInstance == null || qInstance.getAuthentication() == null)
{
return (null);
}
if(StringUtils.hasContent(principalName) && ServiceIdentities.isDeclared(qInstance, principalName))
{
return (ServiceIdentities.sessionOnBehalfOf(qInstance, principalName, userId));
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
}
}
@@ -30,6 +30,7 @@ import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsPreferences;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsQBitProducer;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.delivery.AlertMessageRenderer;
@@ -55,6 +56,7 @@ import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -75,6 +77,8 @@ import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExten
*******************************************************************************/
public class AlertsApi
{
private static final QLogger LOG = QLogger.getLogger(AlertsApi.class);
static final int INBOX_ITEMS = 10;
static final int MAX_BANNERS = 3;
static final int SCAN_LIMIT = 1000;
@@ -102,7 +106,9 @@ public class AlertsApi
/*******************************************************************************
** Check alerts.view and set up the session (keys, settings).
** Check alerts.view and set up the session (keys, settings, and the first-visit
** sync of alert types, groups and default monitors, so those tables are never
** empty on a running install).
*******************************************************************************/
public String requireViewer() throws QException
{
@@ -113,6 +119,17 @@ public class AlertsApi
}
AlertsSecurity.ensureSessionKeys(runtime.getQInstance(), session);
AlertsPreferences.ensure(runtime, session);
try
{
runtime.getTypeSync().syncIfNeeded();
}
catch(Exception e)
{
////////////////////////////////////////////////////////////////////////
// best effort: the bell must work even if the sync fails; next call retries //
////////////////////////////////////////////////////////////////////////
LOG.warn("Alert type sync failed on the first visit; will retry on the next request", e);
}
return (AlertsSecurity.userId(session));
}
@@ -156,6 +173,7 @@ public class AlertsApi
result.put("items", latest);
result.put("banners", banners);
result.put("notificationsHref", NOTIFICATIONS_HREF);
result.put("preferencesSection", AlertsQBitProducer.preferencesSectionName(QContext.getQInstance()));
result.put("inboxHref", NOTIFICATIONS_HREF);
result.put("alertsHref", "/app/" + AlertsQBitConfig.APP_NAME);
result.put("pollSeconds", POLL_SECONDS);
@@ -23,6 +23,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.Method;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
@@ -79,10 +80,10 @@ abstract class AlertsJsonSpec extends AbstractJsonEndpointSpec
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AlertsErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "AlertsErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "AlertsErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", "AlertsErrorV1")));
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", BasicErrorResponseV1.class.getSimpleName())));
}
@@ -49,7 +49,7 @@ public final class AlertsSchemas
Map.entry("AlertsInboxItemV1", new AlertsInboxItemV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsPanelAlertV1", new AlertsPanelAlertV1(null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsFollowV1", new AlertsFollowV1(null).toSchema()),
Map.entry("AlertsInboxSummaryV1", new AlertsInboxSummaryV1(null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsInboxSummaryV1", new AlertsInboxSummaryV1(null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsInboxPageV1", new AlertsInboxPageV1(null, null, null, null).toSchema()),
Map.entry("AlertsRecordPanelV1", new AlertsRecordPanelV1(null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsMutationResultV1", new AlertsMutationResultV1(null).toSchema()),
@@ -161,7 +161,7 @@ public final class AlertsSchemas
/*******************************************************************************
** Bell summary, latest ten items and up to three sticky banners.
*******************************************************************************/
public record AlertsInboxSummaryV1(Integer unreadCount, String highestUnreadSeverity, List<AlertsInboxItemV1> items, List<AlertsInboxItemV1> banners, String notificationsHref, String inboxHref, String alertsHref, Integer pollSeconds) implements ToSchema
public record AlertsInboxSummaryV1(Integer unreadCount, String highestUnreadSeverity, List<AlertsInboxItemV1> items, List<AlertsInboxItemV1> banners, String notificationsHref, String inboxHref, String preferencesSection, String alertsHref, Integer pollSeconds) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
@@ -175,6 +175,7 @@ public final class AlertsSchemas
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("banners", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("notificationsHref", new Schema().withType(Type.STRING).withDescription("Existing notifications page link; retained for compatibility.")),
Map.entry("preferencesSection", new Schema().withType(Type.STRING).withNullable(true).withDescription("Name of the user Preferences section for notification settings (/qqq/v1/preferences/{name}), or null when not registered.")),
Map.entry("inboxHref", new Schema().withType(Type.STRING).withDescription("Canonical inbox navigation link.")),
Map.entry("alertsHref", new Schema().withType(Type.STRING)),
Map.entry("pollSeconds", new Schema().withType(Type.INTEGER))))));
@@ -299,7 +300,7 @@ public final class AlertsSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Framework error response.").withProperties(new TreeMap<>(Map.ofEntries(
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.2.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
}
}
@@ -52,7 +52,7 @@ public final class AlertsUiApi
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
.withSemanticVersion("1.0.0").withDescription("Alerts inbox and record panel")
.withSemanticVersion("1.2.0").withDescription("Alerts inbox and record panel")
.withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class)
.withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class)
.withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class));
@@ -236,7 +236,7 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
*******************************************************************************/
static void checkPreference(QRecord record)
{
if(NotificationCategory.SECURITY.name().equals(record.getValueString("category")) && Boolean.TRUE.equals(record.getValueBoolean("inAppMuted")))
if(NotificationCategory.SECURITY.name().equals(record.getValueString("category")) && (Boolean.TRUE.equals(record.getValueBoolean("inAppMuted")) || record.getValue("inAppMinSeverity") != null))
{
record.addError(new PermissionDeniedMessage("Security notices can't be muted in-app; you can still choose how they are emailed."));
}
@@ -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.
*******************************************************************************/
@@ -30,7 +30,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
*******************************************************************************/
public final class AlertsEmail
{
private static final String EMAIL_SERVICE_CLASS = "com.kingsrook.qbits.email.outbox.EmailService";
private static final String EMAIL_API_CLASS = "com.kingsrook.qbits.email.api.EmailApi";
@@ -64,7 +64,7 @@ public final class AlertsEmail
}
try
{
Class.forName(EMAIL_SERVICE_CLASS);
Class.forName(EMAIL_API_CLASS);
}
catch(ClassNotFoundException | LinkageError e)
{
@@ -27,13 +27,11 @@ import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
import com.kingsrook.qbits.email.template.EmailTemplates;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -42,7 +40,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
** The one place that names qbit-email classes. Callers reach it only after
** {@link AlertsEmail#isActive()}. It registers the alerts templates on first
** use (never overwriting people's edits) and sends one message per recipient
** through {@code EmailService}, all in the NOTIFICATION category:
** through {@code EmailApi}, all in the NOTIFICATION category:
** - alerts_alert: an alert channel email (one alert or a batch);
** - alerts_notification: one user notification;
** - alerts_digest: a user's daily digest of notifications.
@@ -96,7 +94,7 @@ public final class AlertsEmailSender
*******************************************************************************/
static boolean isInstalled(QInstance qInstance)
{
return (EmailRuntime.config(qInstance) != null);
return (EmailApi.isInstalled(qInstance));
}
@@ -126,7 +124,7 @@ public final class AlertsEmailSender
String idPart = ids.isEmpty() ? "test" : String.join("-", ids.stream().map(String::valueOf).toList());
String key = "alerts:alert:" + idPart + ":" + hash(to) + ":" + messageKind + ":" + (now.getEpochSecond() / 60);
EmailRequest request = base(to, process, key)
EmailApiRequest request = base(to, process, key)
.withTemplateName(TEMPLATE_ALERT)
.withVariable("subject", subject)
.withVariable("severity", severity)
@@ -138,7 +136,7 @@ public final class AlertsEmailSender
}
withUrl(request, "link", link);
withUrl(request, "settings_url", settingsUrl);
return (result(EmailService.send(request)));
return (result(EmailApi.send(request)));
}
@@ -155,7 +153,7 @@ public final class AlertsEmailSender
public static Result sendNotification(String to, String process, Integer notificationId, Map<String, Serializable> item, String settingsUrl) throws QException
{
ensure(TEMPLATE_NOTIFICATION);
EmailRequest request = base(to, process, "alerts:notification:" + notificationId + ":" + hash(to))
EmailApiRequest request = base(to, process, "alerts:notification:" + notificationId + ":" + hash(to))
.withTemplateName(TEMPLATE_NOTIFICATION)
.withLinkedRecord(AlertsNotification.TABLE_NAME, notificationId);
for(Map.Entry<String, Serializable> entry : item.entrySet())
@@ -170,7 +168,7 @@ public final class AlertsEmailSender
}
}
withUrl(request, "settings_url", settingsUrl);
return (result(EmailService.send(request)));
return (result(EmailApi.send(request)));
}
@@ -190,13 +188,29 @@ public final class AlertsEmailSender
String settingsUrl) throws QException
{
ensure(TEMPLATE_DIGEST);
EmailRequest request = base(to, process, "alerts:digest:" + userId + ":" + windowStart)
EmailApiRequest request = base(to, process, "alerts:digest:" + userId + ":" + windowStart)
.withTemplateName(TEMPLATE_DIGEST)
.withVariable("count", items.size())
.withVariable("items", (Serializable) items)
.withLinkedRecord(AlertsNotification.TABLE_NAME, firstNotificationId);
withUrl(request, "settings_url", settingsUrl);
return (result(EmailService.send(request)));
return (result(EmailApi.send(request)));
}
/*******************************************************************************
** 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)));
}
@@ -204,9 +218,9 @@ public final class AlertsEmailSender
/*******************************************************************************
** The parts every message has: one recipient, the sender and the key.
*******************************************************************************/
private static EmailRequest base(String to, String process, String idempotencyKey)
private static EmailApiRequest base(String to, String process, String idempotencyKey)
{
return (new EmailRequest().withTo(to).withCategory(EmailCategory.NOTIFICATION).withSender(SENDER_QBIT, process).withIdempotencyKey(idempotencyKey));
return (new EmailApiRequest().withTo(to).withCategory(EmailApiCategory.NOTIFICATION).withSender(SENDER_QBIT, process).withIdempotencyKey(idempotencyKey));
}
@@ -215,7 +229,7 @@ public final class AlertsEmailSender
** Pass a URL only when it is absolute http(s); the template schema refuses
** relative links, and they are useless in a mail anyway.
*******************************************************************************/
private static void withUrl(EmailRequest request, String name, String url)
private static void withUrl(EmailApiRequest request, String name, String url)
{
if(url != null && (url.startsWith("https://") || url.startsWith("http://")))
{
@@ -228,7 +242,7 @@ public final class AlertsEmailSender
/*******************************************************************************
** Translate qbit-email's result.
*******************************************************************************/
private static Result result(EmailSendResult sent)
private static Result result(EmailApiResult sent)
{
String detail = sent.reasons() == null || sent.reasons().isEmpty() ? null : String.join("; ", sent.reasons());
return (new Result(Status.valueOf(sent.status().name()), sent.messageId(), detail));
@@ -266,10 +280,10 @@ public final class AlertsEmailSender
*******************************************************************************/
private static void ensure(String name) throws QException
{
EmailTemplates.ensure(switch(name)
EmailApi.ensureTemplate(switch(name)
{
case TEMPLATE_ALERT -> new EmailTemplateDefinition()
.withName(TEMPLATE_ALERT).withLabel("Alerts: alert").withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT)
case TEMPLATE_ALERT -> new EmailApiTemplate()
.withName(TEMPLATE_ALERT).withLabel("Alerts: alert").withCategory(EmailApiCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT)
.withSubject("{{subject}}")
.withBody("""
{{#each items}}**{{this.title}}**
@@ -293,8 +307,8 @@ public final class AlertsEmailSender
.withVariable("settings_url", "Settings link", "URL", false, null)
.withSampleVariables("{\"subject\":\"[ERROR] Export failed\",\"severity\":\"ERROR\",\"kind\":\"RAISED\",\"items\":[{\"title\":\"Export failed\",\"severity\":\"ERROR\","
+ "\"typeLabel\":\"Jobs\",\"summary\":\"3 records\"}],\"link\":\"https://example.com/app/alerts_alert/1\",\"settings_url\":\"https://example.com/app/alerts_subscription\"}");
case TEMPLATE_NOTIFICATION -> new EmailTemplateDefinition()
.withName(TEMPLATE_NOTIFICATION).withLabel("Alerts: notification").withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT)
case TEMPLATE_NOTIFICATION -> new EmailApiTemplate()
.withName(TEMPLATE_NOTIFICATION).withLabel("Alerts: notification").withCategory(EmailApiCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT)
.withSubject("{{title}}")
.withBody("""
{{#if body}}{{body}}
@@ -309,8 +323,8 @@ public final class AlertsEmailSender
.withVariable("settings_url", "Settings link", "URL", false, null)
.withSampleVariables("{\"title\":\"Export failed\",\"body\":\"The nightly export did not finish.\",\"link\":\"https://example.com/app/widget\",\"linkLabel\":\"Open\","
+ "\"settings_url\":\"https://example.com/app/alerts_notification_preference\"}");
case TEMPLATE_DIGEST -> new EmailTemplateDefinition()
.withName(TEMPLATE_DIGEST).withLabel("Alerts: daily digest").withCategory(EmailCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT)
case TEMPLATE_DIGEST -> new EmailApiTemplate()
.withName(TEMPLATE_DIGEST).withLabel("Alerts: daily digest").withCategory(EmailApiCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT)
.withSubject("{{count}} new notifications")
.withBody("""
{{#each items}}**{{this.title}}**
@@ -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;
@@ -61,8 +66,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -75,7 +78,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 +99,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 +145,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, alert.getValue("id")));
}
catch(Exception e)
{
@@ -274,6 +156,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, alert.getValue("id"))));
}
/*******************************************************************************
** 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 +399,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);
}
@@ -311,20 +413,32 @@ public class AlertRouter
** Every destination for an alert: subscribed users (directly or through a
** team) with their in-app and email channels (or the subscription's
** chosen channels), subscribed channels, and readable record followers.
** An alert that no subscription matches (at WARNING or above) goes to the
** members of its group's owner team, so an unowned problem is not silent.
*******************************************************************************/
List<Destination> destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) throws QException
List<Destination> destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind, Object alertId) throws QException
{
Map<Integer, List<String>> groupNames = new HashMap<>();
Map<String, Destination> destinations = new LinkedHashMap<>();
boolean subscribed = false;
for(QRecord subscription : AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("enabled", true)))
{
AlertSeverity min = AlertSeverity.fromLabel(subscription.getValueString("minSeverity"), AlertSeverity.WARNING);
if(!severity.isAtLeast(min) || !includesKind(subscription.getValueString("events"), kind)
if(!severity.isAtLeast(min)
|| !scopeMatches(subscription.getValueString("scopeType"), subscription.getValueString("scopeValue"), subscription.getValueString("scopeRecordId"), target, groupNames))
{
continue;
}
//////////////////////////////////////////////////////////////////////////////
// covered by scope and severity alone: a subscription that leaves out an //
// event kind (events=RAISED) still means someone owns this alert //
//////////////////////////////////////////////////////////////////////////////
subscribed = true;
if(!includesKind(subscription.getValueString("events"), kind))
{
continue;
}
Integer subscriptionId = subscription.getValueInteger("id");
String minName = subscription.getValueString("minSeverity");
String subscriberType = subscription.getValueString("subscriberType");
@@ -333,18 +447,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 +464,22 @@ 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())
if(!subscribed && severity.isAtLeast(AlertSeverity.WARNING))
{
addOwnerTeam(destinations, target, kind, alertId);
}
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 +493,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,9 +502,139 @@ public class AlertRouter
/*******************************************************************************
** The fallback for an alert nobody subscribed to: the members of the owner
** team of its group (or of the nearest parent group that has one), through
** their normal channels and the usual suppression checks.
*******************************************************************************/
private void addOwnerTeam(Map<String, Destination> destinations, AlertTarget target, DeliveryKind kind, Object alertId) throws QException
{
Integer teamId = ownerTeamId(target.groupId());
if(teamId == null)
{
return;
}
for(String userId : teamMembers(teamId))
{
if(!StringUtils.hasContent(userId))
{
continue;
}
if((kind == DeliveryKind.RESOLVED || kind == DeliveryKind.REMINDER) && !wasToldOfRaise(alertId, userId))
{
///////////////////////////////////////////////////////////////////////////
// a follow-up only goes to owners who were told about the alert itself //
///////////////////////////////////////////////////////////////////////////
continue;
}
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, "IMMEDIATE"));
}
}
}
/*******************************************************************************
** Whether the user was sent (not suppressed) the RAISED notice for this alert.
*******************************************************************************/
private boolean wasToldOfRaise(Object alertId, String userId) throws QException
{
return (alertId != null && AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("alertId", (java.io.Serializable) alertId)
.withCriteria("recipientUserId", QCriteriaOperator.EQUALS, userId).withCriteria("kind", QCriteriaOperator.EQUALS, DeliveryKind.RAISED.name())
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, DeliveryStatus.SUPPRESSED.name())) > 0);
}
/*******************************************************************************
** The owner team of a group, or of the nearest parent that has one (null:
** none).
*******************************************************************************/
Integer ownerTeamId(Integer groupId) throws QException
{
Integer current = groupId;
int depth = 0;
while(current != null && depth++ < 10)
{
QRecord group = AlertsDb.byId(AlertsGroup.TABLE_NAME, current);
if(group == null)
{
return (null);
}
if(group.getValueInteger("ownerTeamId") != null)
{
return (group.getValueInteger("ownerTeamId"));
}
current = group.getValueInteger("parentGroupId");
}
return (null);
}
/*******************************************************************************
** 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).
** (unless turned off) plus the user's own default channels (My Alert
** Settings) in place of the built-in email, else in-app and email (when
** enabled in their settings).
*******************************************************************************/
private List<QRecord> userChannels(QRecord subscription, QRecord preference) throws QException
{
@@ -393,18 +642,32 @@ public class AlertRouter
String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds");
if(StringUtils.hasContent(overrides))
{
for(String id : overrides.split("[,\\s]+"))
return (channelsById(overrides));
}
boolean inApp = preference == null || !Boolean.FALSE.equals(preference.getValueBoolean("inAppEnabled"));
String defaults = preference == null ? null : preference.getValueString("defaultChannelIds");
if(StringUtils.hasContent(defaults))
{
List<QRecord> chosen = channelsById(defaults);
if(!chosen.isEmpty())
{
if(!id.isBlank())
/////////////////////////////////////////////////////////////////////////
// the bell stays (unless turned off); the list replaces external ones //
/////////////////////////////////////////////////////////////////////////
if(inApp)
{
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, Integer.valueOf(id.trim()));
if(channel != null)
channels.add(runtime.getChannels().defaultChannel(ChannelType.IN_APP));
}
for(QRecord channel : chosen)
{
if(!ChannelType.IN_APP.name().equals(channel.getValueString("channelType")))
{
channels.add(channel);
}
}
return (channels);
}
return (channels);
}
if(preference == null || !Boolean.FALSE.equals(preference.getValueBoolean("inAppEnabled")))
@@ -420,6 +683,37 @@ public class AlertRouter
/*******************************************************************************
** The channel rows for a list of ids ("3, 5"); ids that are not numbers or
** no longer exist are skipped.
*******************************************************************************/
private List<QRecord> channelsById(String ids) throws QException
{
List<QRecord> channels = new ArrayList<>();
for(String id : ids.split("[,\\s]+"))
{
if(id.isBlank())
{
continue;
}
try
{
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, Integer.valueOf(id.trim()));
if(channel != null)
{
channels.add(channel);
}
}
catch(NumberFormatException e)
{
LOG.debug("Ignoring a channel id that is not a number", logPair("id", id));
}
}
return (channels);
}
/*******************************************************************************
** Whether a subscription or mute scope covers the target.
*******************************************************************************/
@@ -429,6 +723,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 +836,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.
@@ -575,18 +880,12 @@ public class AlertRouter
/*******************************************************************************
** A session for a user, from the instance's authentication module (null
** when it can't make one).
** A session for a user: on their behalf via the alerts service principal
** when declared, else the authentication module's (null when neither).
*******************************************************************************/
private QSession automatedSession(String userId) throws QException
{
QInstance qInstance = runtime.getQInstance();
if(qInstance.getAuthentication() == null)
{
return (null);
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
}
@@ -594,7 +893,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 +921,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;
@@ -37,7 +40,8 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** Small helpers over QQQ's table actions, for the engine. They run in
** whatever context the caller set up (the engine uses the system user), so
** customizers, audits and ESB listeners apply as for any other write.
** customizers, audits and ESB listeners apply as for any other write, except
** that bookkeeping-only updates (RuntimeFields) are not record-audited.
*******************************************************************************/
public final class AlertsDb
{
@@ -56,7 +60,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 +70,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 +95,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 +106,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);
@@ -111,11 +115,14 @@ public final class AlertsDb
/*******************************************************************************
** Update one row (it must carry its id); throws on an error.
** Update one row (it must carry its id); throws on an error. An update of
** engine bookkeeping fields only (RuntimeFields) is not record-audited.
*******************************************************************************/
public static QRecord update(String tableName, QRecord record) throws QException
{
List<QRecord> updated = UpdateAction.executeForRecords(new UpdateInput(tableName).withRecord(record));
UpdateInput input = new UpdateInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction())
.withOmitDmlAudit(RuntimeFields.onlyRuntime(tableName, record));
List<QRecord> updated = UpdateAction.executeForRecords(input);
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);
@@ -0,0 +1,76 @@
/*
* 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.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Engine bookkeeping columns: evaluation leases, run results and notification
** timers that change on every scheduler pass. An update that touches only
** these (plus the primary key and modifyDate) is not record-audited; the core
** FIELD audit would otherwise write an audit row per monitor run and per
** reminder check. Any update that also changes a configuration or alert-state
** field (status, severity, acknowledgement, assignment, a monitor's settings)
** is audited in full, as before. Run history lives in the run fields
** themselves; alert raises, transitions and notifications have their own
** event and delivery tables.
*******************************************************************************/
public final class RuntimeFields
{
private static final Set<String> ALWAYS = Set.of("id", "modifyDate");
private static final Map<String, Set<String>> BY_TABLE = Map.of(
AlertsMonitor.TABLE_NAME, Set.of("claimedBy", "claimedUntil", "lastRunAt", "nextRunAt", "lastStatus", "lastValue", "lastError",
"lastElapsedMs", "consecutiveFailures", "conditionSince"),
AlertsAlert.TABLE_NAME, Set.of("timedNotificationCheckedAt", "lastNotifiedAt", "recordRefCount"));
/*******************************************************************************
** Constants only.
*******************************************************************************/
private RuntimeFields()
{
}
/*******************************************************************************
** The bookkeeping fields of a table (empty when it has none).
*******************************************************************************/
public static Set<String> of(String tableName)
{
return (BY_TABLE.getOrDefault(tableName, Set.of()));
}
/*******************************************************************************
** Whether an update record carries nothing but bookkeeping fields.
*******************************************************************************/
public static boolean onlyRuntime(String tableName, QRecord update)
{
Set<String> runtime = of(tableName);
return (!runtime.isEmpty() && update.getValues().keySet().stream().allMatch(field -> runtime.contains(field) || ALWAYS.contains(field)));
}
}
@@ -80,6 +80,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
*******************************************************************************/
public final class AlertsHome
{
public static final String KIND_SETUP = "setup";
public static final String KIND_SEVERITY = "severity";
public static final String KIND_MINE = "mine";
public static final String KIND_ACTION = "needsAction";
@@ -121,6 +122,7 @@ public final class AlertsHome
Instant now = runtime.now();
return switch(kind == null ? "" : kind)
{
case KIND_SETUP -> setup(runtime, now);
case KIND_SEVERITY -> severity(runtime, now);
case KIND_MINE -> mine(now);
case KIND_ACTION -> needsAction(now);
@@ -136,6 +138,34 @@ public final class AlertsHome
/*******************************************************************************
** Setup (alerts admins): each configuration table, whether it is set up, and
** the next step, linked to its table.
*******************************************************************************/
static QWidgetData setup(AlertsRuntime runtime, Instant now) throws QException
{
List<AlertsSetupStatus.Item> items = AlertsSetupStatus.load(runtime, now);
long todo = items.stream().filter(item -> item.state() == AlertsSetupStatus.State.TODO).count();
StringBuilder html = new StringBuilder(HomeHtml.note(todo == 0 ? "Everything Alerts needs is set up." : HomeHtml.plural(todo, "item") + (todo == 1 ? " needs" : " need") + " attention."));
for(AlertsSetupStatus.Item item : items)
{
String badge = switch(item.state())
{
case OK -> HomeHtml.badge("Set up", HomeHtml.OK_COLOR);
case TODO -> HomeHtml.badge("Needs setup", HomeHtml.BAD_COLOR);
case DEFAULT -> HomeHtml.badge("Using defaults", HomeHtml.WARN_COLOR);
case OPTIONAL -> HomeHtml.badge("Optional", HomeHtml.OFF_COLOR);
};
String label = item.tableName() == null ? HomeHtml.escape(item.label()) : HomeHtml.link("/app/" + item.tableName(), item.label());
html.append("<div style=\"padding:8px 0;border-top:1px solid rgba(127,127,127,.2)\">")
.append(HomeHtml.card("<strong>" + label + "</strong> " + badge, HomeHtml.escape(item.detail()), item.nextStep() == null ? null : "Next: " + HomeHtml.escape(item.nextStep())))
.append("</div>");
}
return (new RawHTML(null, html.toString()));
}
/*******************************************************************************
** Open by severity: four tiles, each with the unacknowledged count and the
** oldest age, linking to the filtered list.
@@ -749,6 +779,6 @@ public final class AlertsHome
*******************************************************************************/
static Set<String> kinds()
{
return (Set.of(KIND_SEVERITY, KIND_MINE, KIND_ACTION, KIND_BY_GROUP, KIND_TREND, KIND_NOISY, KIND_DELIVERY, KIND_RESOLVED));
return (Set.of(KIND_SETUP, KIND_SEVERITY, KIND_MINE, KIND_ACTION, KIND_BY_GROUP, KIND_TREND, KIND_NOISY, KIND_DELIVERY, KIND_RESOLVED));
}
}
@@ -0,0 +1,179 @@
/*
* 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.home;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy;
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsType;
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.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The Setup card on the Alerts home: each configuration table, whether it is
** set up, and the next step. Reads only the qbit's own small configuration
** tables, in the signed-in administrator's session.
*******************************************************************************/
public final class AlertsSetupStatus
{
/*******************************************************************************
** OK: working. TODO: needs attention. DEFAULT: working on built-in defaults.
** OPTIONAL: a feature that is off until set up.
*******************************************************************************/
public enum State
{
OK, TODO, DEFAULT, OPTIONAL
}
/*******************************************************************************
** One line of the card. tableName is null for an item with no table.
*******************************************************************************/
public record Item(String label, String tableName, State state, String detail, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsSetupStatus()
{
}
/*******************************************************************************
** Build the items.
*******************************************************************************/
public static List<Item> load(AlertsRuntime runtime, Instant now) throws QException
{
List<Item> items = new ArrayList<>();
/////////////
// channels //
/////////////
List<QRecord> channels = AlertsDb.query(AlertsChannel.TABLE_NAME, new QQueryFilter());
long external = channels.stream().filter(c -> !List.of(ChannelType.IN_APP.name(), ChannelType.EMAIL.name()).contains(c.getValueString("channelType"))).count();
long unhealthy = channels.stream().filter(c -> HealthStatus.UNHEALTHY.name().equals(c.getValueString("healthStatus"))).count();
if(channels.isEmpty())
{
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.DEFAULT, "No channel rows yet: the bell and email channels are created when the first alert is routed.",
"Include alerts-defaults.changelog.sql to create them now, then add Slack, Teams or a webhook if you want them."));
}
else if(unhealthy > 0)
{
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.TODO, unhealthy + (unhealthy == 1 ? " channel is" : " channels are") + " failing.",
"Open it, check its settings and secret reference, then Send test notification."));
}
else
{
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.OK, plural(channels.size(), "channel") + (external == 0 ? ": the bell and email only." : ", " + external + " of them outside the bell and email."),
external == 0 ? "Optional: add Slack, Teams, a webhook or PagerDuty." : null));
}
///////////////////////
// types and monitors //
///////////////////////
int types = AlertsDb.count(AlertsType.TABLE_NAME, AlertsDb.eq("installed", true));
items.add(types == 0
? new Item("Alert types", AlertsType.TABLE_NAME, State.TODO, "No installed alert type.", "Types are declared in code by the installed qbits (QAlertTypeMetaData); open the Alerts home once so they sync.")
: new Item("Alert types", AlertsType.TABLE_NAME, State.OK, plural(types, "type") + " declared by the installed qbits.", null));
int monitors = AlertsDb.count(AlertsMonitor.TABLE_NAME, AlertsDb.eq("enabled", true));
items.add(monitors == 0
? new Item("Monitors", AlertsMonitor.TABLE_NAME, State.OPTIONAL, "No monitor is turned on: alerts come only from code that raises them.", "On an alert type, choose Create monitor, or add one with New.")
: new Item("Monitors", AlertsMonitor.TABLE_NAME, State.OK, plural(monitors, "monitor") + " turned on.", null));
/////////////////////////////////
// groups, teams and owner teams //
/////////////////////////////////
List<QRecord> groups = AlertsDb.query(AlertsGroup.TABLE_NAME, new QQueryFilter());
long ownedGroups = groups.stream().filter(g -> g.getValueInteger("ownerTeamId") != null).count();
items.add(ownedGroups == 0
? new Item("Groups", AlertsGroup.TABLE_NAME, State.OPTIONAL, plural(groups.size(), "group") + ", none with an owner team.", "Give a group an Owner Team so alerts that nobody subscribed to still reach someone.")
: new Item("Groups", AlertsGroup.TABLE_NAME, State.OK, plural(groups.size(), "group") + ", " + ownedGroups + " with an owner team.", null));
int teams = AlertsDb.count(AlertsTeam.TABLE_NAME, new QQueryFilter());
items.add(teams == 0
? new Item("Teams", AlertsTeam.TABLE_NAME, State.OPTIONAL, "No team.", "Add a team and its members to subscribe people together or to own a group.")
: new Item("Teams", AlertsTeam.TABLE_NAME, State.OK, plural(teams, "team") + ".", null));
/////////////////////////////////////////////////////
// subscriptions: nobody hears anything without one //
/////////////////////////////////////////////////////
int subscriptions = AlertsDb.count(AlertsSubscription.TABLE_NAME, AlertsDb.eq("enabled", true));
if(subscriptions > 0)
{
items.add(new Item("Subscriptions", AlertsSubscription.TABLE_NAME, State.OK, plural(subscriptions, "subscription") + " turned on.", null));
}
else
{
items.add(new Item("Subscriptions", AlertsSubscription.TABLE_NAME, ownedGroups > 0 ? State.DEFAULT : State.TODO, "No subscription: " + (ownedGroups > 0 ? "alerts reach only the owner teams of their groups." : "alerts reach nobody."),
"Use Subscribe me on a type, group or monitor, or add one with New. An alerts admin gets an in-app subscription to errors on first visit."));
}
///////////////////////////////////////
// escalation, maintenance, scheduler //
///////////////////////////////////////
int policies = AlertsDb.count(AlertsEscalationPolicy.TABLE_NAME, AlertsDb.eq("enabled", true));
int steps = AlertsDb.count(AlertsEscalationStep.TABLE_NAME, new QQueryFilter());
items.add(policies == 0
? new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.OPTIONAL, "None: alerts are reminded, not escalated.", "Add a policy and its steps, then pick it on a type or group.")
: steps == 0
? new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.TODO, plural(policies, "policy") + ", but no steps, so nothing escalates.", "Add steps to the policy.")
: new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.OK, plural(policies, "policy") + ", " + plural(steps, "step") + ".", null));
int mutes = AlertsDb.count(AlertsMute.TABLE_NAME, AlertsDb.eq("enabled", true).withCriteria("endsAt", QCriteriaOperator.GREATER_THAN, now));
items.add(new Item("Mutes and maintenance", AlertsMute.TABLE_NAME, State.OPTIONAL, mutes == 0 ? "Nothing is muted." : plural(mutes, "mute") + " active or scheduled.",
mutes == 0 ? "Add a window before planned maintenance." : null));
items.add(runtime.getConfig().getSchedulerName() == null
? new Item("Scheduler", null, State.TODO, "No scheduler is configured, so monitors, delivery, digests and escalation only run when started by hand.", "Set schedulerName in the Alerts config.")
: new Item("Scheduler", null, State.OK, "Monitors, delivery, digests and escalation run on the " + runtime.getConfig().getSchedulerName() + " scheduler.", null));
return (items);
}
/*******************************************************************************
** "1 channel", "2 channels".
*******************************************************************************/
private static String plural(long count, String noun)
{
return (count + " " + noun + (count == 1 ? "" : "s"));
}
}
@@ -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);
@@ -33,11 +33,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaData
/*******************************************************************************
** The Alerts home dashboard (design section 6.2), in page order, all full
** width: open by severity, my alerts, needs action, by group, trend, noisy
** width: setup (admins), open by severity, my alerts, needs action, by group, trend, noisy
** types, delivery health (admins) and recently resolved.
*******************************************************************************/
public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, AlertsQBitConfig>
{
public static final String SETUP = "alertsHomeSetup";
public static final String SEVERITY = "alertsHomeOpenBySeverity";
public static final String MINE = "alertsHomeMyAlerts";
public static final String ACTION = "alertsHomeNeedsAction";
@@ -55,7 +56,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
*******************************************************************************/
public static List<String> homeWidgetNames()
{
return (List.of(SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED));
return (List.of(SETUP, SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED));
}
@@ -70,6 +71,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
String table = WidgetType.TABLE.getType();
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
output.add(widget(SETUP, "Setup", html, AlertsHome.KIND_SETUP, AlertsQBitConfig.PERMISSION_ADMIN));
output.add(widget(SEVERITY, "Open Alerts", html, AlertsHome.KIND_SEVERITY, AlertsQBitConfig.PERMISSION_VIEW));
output.add(widget(MINE, "My Alerts", table, AlertsHome.KIND_MINE, AlertsQBitConfig.PERMISSION_VIEW));
output.add(widget(ACTION, "Needs Action", table, AlertsHome.KIND_ACTION, AlertsQBitConfig.PERMISSION_VIEW));
@@ -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", "inAppMinSeverity", "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
*******************************************************************************/
@@ -25,7 +25,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
/*******************************************************************************
** A user's settings for one notification category: muted in-app (never for security), and email off, immediate or in a daily digest, from a minimum severity.
** A user's settings for one notification category: in-app minimum severity (never for security), and email/Slack off, immediate or in a daily digest, from a minimum severity.
**
** Generated from the qbit-alerts schema (one definition for the entity,
** the Liquibase changelog and the test DDL).
@@ -46,12 +46,21 @@ public class AlertsNotificationPreference extends QRecordEntity
@QField(label = "Muted In-App")
private Boolean inAppMuted;
@QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "In-App From Severity")
private String inAppMinSeverity;
@QField(maxLength = 16, possibleValueSourceName = "alerts_email_mode", label = "Email")
private String emailMode;
@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 +308,93 @@ public class AlertsNotificationPreference extends QRecordEntity
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Getter for inAppMinSeverity
*******************************************************************************/
public String getInAppMinSeverity()
{
return (this.inAppMinSeverity);
}
/*******************************************************************************
** Setter for inAppMinSeverity
*******************************************************************************/
public void setInAppMinSeverity(String inAppMinSeverity)
{
this.inAppMinSeverity = inAppMinSeverity;
}
/*******************************************************************************
** Fluent setter for inAppMinSeverity
*******************************************************************************/
public AlertsNotificationPreference withInAppMinSeverity(String inAppMinSeverity)
{
this.inAppMinSeverity = inAppMinSeverity;
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())));
}
@@ -287,6 +289,16 @@ public final class AlertsPossibleValues
** Whether (and how) a recipient was emailed.
*******************************************************************************/
public enum EmailStatus
{
NONE, PENDING, REQUESTED, SENT, DIGESTED, SKIPPED, FAILED
}
/*******************************************************************************
** Whether (and how) a recipient was sent a Slack direct message.
*******************************************************************************/
public enum SlackStatus
{
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
}
@@ -294,7 +306,7 @@ public final class AlertsPossibleValues
/*******************************************************************************
** How a user wants a category by email.
** How a user wants a category by email (also used for Slack: same modes).
*******************************************************************************/
public enum EmailMode
{
@@ -312,7 +324,7 @@ public final class AlertsPossibleValues
List<QPossibleValue<?>> possibleValues = new ArrayList<>();
for(Enum<?> value : values)
{
possibleValues.add(new QPossibleValue<>(value.name(), label(value.name())));
possibleValues.add(new QPossibleValue<>(value.name(), value == EmailMode.DAILY_DIGEST ? "Daily digest" : label(value.name())));
}
return (new QPossibleValueSource()
.withName(name)
@@ -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);
}
}
@@ -26,6 +26,7 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -33,8 +34,6 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -218,20 +217,14 @@ public class AlertsAudienceResolver
/*******************************************************************************
** A session for a user from the instance's authentication module (null
** when it can't make one).
** A session for a user: on their behalf via the alerts service principal
** when declared, else the authentication module's (null when neither).
*******************************************************************************/
private QSession automatedSession(String userId)
{
try
{
QInstance qInstance = runtime.getQInstance();
if(qInstance.getAuthentication() == null)
{
return (null);
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
}
catch(Exception e)
{
@@ -29,11 +29,14 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
import com.kingsrook.qbits.alerts.email.AlertsEmail;
import com.kingsrook.qbits.alerts.email.AlertsEmailSender;
import com.kingsrook.qbits.alerts.engine.AlertTiming;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
@@ -43,6 +46,7 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailStatus;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
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.alerts.NotificationSeverity;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -54,6 +58,7 @@ import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
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;
@@ -66,6 +71,11 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** - IMMEDIATE: one email per notification at or above the minimum severity;
** - DAILY_DIGEST: one email at the user's digest hour (in their time zone,
** from My Alert Settings) listing the day's unread notifications.
** A recipient with email status REQUESTED (a per-notification opt-in, e.g. by
** a job) is emailed immediately without a standing category preference. It
** still honours the category's email minimum severity when one is set, the
** user's pause (SECURITY bypasses) and quiet hours (held, not dropped), is
** skipped without a known address, and is only eligible for a day.
** Sent through qbit-email when it is produced in the instance (one message
** per recipient; templates alerts_notification and alerts_digest,
** NOTIFICATION), else through core SendMessageAction. Inert without either
@@ -136,45 +146,84 @@ public class NotificationEmailer
{
preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
}
if(preferences.isEmpty())
QQueryFilter eligible = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withSubFilter(new QQueryFilter()
.withCriteria("emailStatus", QCriteriaOperator.EQUALS, EmailStatus.REQUESTED.name())
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)));
if(!preferences.isEmpty())
{
return (new Result(0, 0, 0, 0));
eligible.withSubFilter(new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name())));
}
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name()))
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS))
.withLimit(SCAN));
Map<Integer, QRecord> notifications = notifications(recipients, now);
Map<String, QRecord> settings = userSettings(preferences.keySet());
int sent = 0;
int skipped = 0;
int failed = 0;
Map<String, QRecord> settings = new HashMap<>();
int sent = 0;
int skipped = 0;
int failed = 0;
Map<String, List<QRecord[]>> digests = new LinkedHashMap<>();
for(QRecord recipient : recipients)
int afterId = 0;
while(true)
{
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"))
|| !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
.withSubFilter(eligible)
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId)
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(SCAN));
if(recipients.isEmpty())
{
mark(recipient, EmailStatus.SKIPPED, null);
skipped++;
break;
}
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
Map<Integer, QRecord> notifications = notifications(recipients, now);
Set<String> pageUsers = recipients.stream().map(recipient -> recipient.getValueString("userId")).collect(Collectors.toSet());
settings.putAll(userSettings(pageUsers));
Map<String, Map<String, QRecord>> requestedPreferences = new HashMap<>();
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(pageUsers))))
{
EmailStatus status = sendImmediate(address(settings.get(userId)), notification);
mark(recipient, status, status == EmailStatus.SENT ? now : null);
sent += status == EmailStatus.SENT ? 1 : 0;
skipped += status == EmailStatus.SKIPPED ? 1 : 0;
failed += status == EmailStatus.FAILED ? 1 : 0;
requestedPreferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
}
else if(recipient.getValue("readAt") == null)
for(QRecord recipient : recipients)
{
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
String userId = recipient.getValueString("userId");
boolean requested = EmailStatus.REQUESTED.name().equals(recipient.getValueString("emailStatus"));
String category = notification == null ? null : notification.getValueString("category");
QRecord preference = category == null ? null : (requested ? requestedPreferences : preferences).getOrDefault(userId, Map.of()).get(category);
QRecord setting = settings.get(userId);
if(notification == null || NotificationCategory.ALERT.name().equals(category)
|| (requested ? (!StringUtils.hasContent(address(setting)) || (preference != null && StringUtils.hasContent(preference.getValueString("emailMinSeverity"))
&& !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity"))))
: (preference == null || !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))))
{
mark(recipient, EmailStatus.SKIPPED, null);
skipped++;
}
else if(!NotificationCategory.SECURITY.name().equals(category)
&& setting != null && setting.getValueInstant("pausedUntil") != null
&& setting.getValueInstant("pausedUntil").isAfter(now))
{
// Leave the recipient pending so a later run can deliver it.
skipped++;
}
else if(requested && AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now))
{
// Quiet hours hold a requested email; it stays REQUESTED until the window ends or it ages out.
skipped++;
}
else if(requested || EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
{
EmailStatus status = sendImmediate(address(settings.get(userId)), notification);
mark(recipient, status, status == EmailStatus.SENT ? now : null);
sent += status == EmailStatus.SENT ? 1 : 0;
skipped += status == EmailStatus.SKIPPED ? 1 : 0;
failed += status == EmailStatus.FAILED ? 1 : 0;
}
else if(recipient.getValue("readAt") == null)
{
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
}
}
}
@@ -421,6 +470,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 +504,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 +529,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;
@@ -172,7 +173,7 @@ public class NotificationService
AlertsAudienceResolver.Resolved resolved = resolver.resolve(audienceOf(notification));
Set<String> existing = recipientUserIds(notificationId);
Set<String> muted = mutedUsers(notification.getValueString("category"));
Set<String> muted = mutedUsers(notification.getValueString("category"), notification.getValueString("severity"));
List<QRecord> inserts = new ArrayList<>();
for(String userId : resolved.userIds())
@@ -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"));
}
@@ -578,16 +602,20 @@ public class NotificationService
/*******************************************************************************
** Users who muted a category in-app (never for SECURITY).
*******************************************************************************/
private Set<String> mutedUsers(String category) throws QException
private Set<String> mutedUsers(String category, String severity) throws QException
{
Set<String> userIds = new HashSet<>();
if(category == null || NotificationCategory.SECURITY.name().equals(category))
{
return (userIds);
}
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("category", category).withCriteria("inAppMuted", QCriteriaOperator.EQUALS, true)))
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("category", category)))
{
userIds.add(preference.getValueString("userId"));
String minimum = preference.getValueString("inAppMinSeverity");
if(Boolean.TRUE.equals(preference.getValueBoolean("inAppMuted")) || (minimum != null && !NotificationEmailer.atLeast(severity, minimum)))
{
userIds.add(preference.getValueString("userId"));
}
}
return (userIds);
}
@@ -0,0 +1,288 @@
/*
* 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.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.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.modules.messaging.MessagingProviderCapability;
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));
}
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<>();
int afterId = 0;
while(true)
{
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("id", QCriteriaOperator.GREATER_THAN, afterId)
.withOrderBy(new QFilterOrderBy("id", true))
.withLimit(NotificationEmailer.SCAN));
if(recipients.isEmpty())
{
break;
}
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
Map<Integer, QRecord> notifications = NotificationEmailer.notifications(recipients, now);
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(!NotificationCategory.SECURITY.name().equals(notification.getValueString("category")) && AlertRouter.isPaused(settings.get(userId), now))
{
held++;
}
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). SECURITY bypasses the pause.
** The bell is never held.
*******************************************************************************/
private static boolean isHeld(QRecord setting, QRecord notification, Instant now)
{
return ((!NotificationCategory.SECURITY.name().equals(notification.getValueString("category")) && 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.
**
** Pass the notification idempotency key when the provider supports it.
** The recipient row's slackStatus also 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())
.withIdempotencyKey(SendMessageAction.capabilities(runtime.getConfig().getSlackProviderName()).contains(MessagingProviderCapability.IDEMPOTENCY) ? key : null)
.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()));
}
}
@@ -0,0 +1,349 @@
/*
* 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.preferences;
import java.io.Serializable;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRowChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
/*******************************************************************************
** The session user's notification settings, stored in the existing Alerts tables.
*******************************************************************************/
public class AlertsNotificationPreferencesProvider implements UserPreferenceProviderInterface
{
public static final String SECTION_NAME = "notifications";
private static final String SECURITY_REASON = "Security notices can't be muted.";
private static final Map<String, String> SCALARS = Map.of("pausedUntil", "pausedUntil", "digestHour", "digestHour", "timeZone", "timeZone",
"alertInAppEnabled", "inAppEnabled", "alertEmailEnabled", "emailEnabled");
/*******************************************************************************
** Register once; no app scope, so bell users need only alerts.view.
*******************************************************************************/
public static void register(QInstance instance, AlertsQBitConfig config)
{
if(isRegistered(instance))
{
return;
}
List<QPossibleValue<?>> hours = new ArrayList<>();
for(int hour = 0; hour < 24; hour++)
{
hours.add(new QPossibleValue<>(hour, (hour % 12 == 0 ? 12 : hour % 12) + ":00 " + (hour < 12 ? "AM" : "PM")));
}
instance.addPossibleValueSource(new QPossibleValueSource().withName("alerts_digest_hour").withType(QPossibleValueSourceType.ENUM).withEnumValues(hours));
UserPreferenceListMetaData categories = new UserPreferenceListMetaData().withName("categories").withLabel("Categories").withRowLabel("Category")
.withField(new QFieldMetaData("inApp", QFieldType.BOOLEAN).withLabel("In-app"))
.withField(enumField("inAppMinSeverity", "In-app from", "alerts_notification_severity"))
.withField(enumField("emailMode", "Email", "alerts_email_mode"))
.withField(enumField("emailMinSeverity", "Email from", "alerts_notification_severity"));
if(slackConfigured(instance, config))
{
categories.withField(enumField("slack", "Slack", "alerts_email_mode"))
.withField(enumField("slackMinSeverity", "Slack from", "alerts_notification_severity"));
}
UserPreferencesMetaData.ofOrWithNew(instance).withSection(new UserPreferenceSectionMetaData()
.withName(SECTION_NAME).withLabel("Notifications").withIcon(new QIcon("notifications")).withOrder(20)
.withDescription("What reaches you in the app, by email and on Slack.").withPermissionName(AlertsQBitConfig.PERMISSION_VIEW)
.withProvider(new QCodeReference(AlertsNotificationPreferencesProvider.class))
.withField(new QFieldMetaData("pausedUntil", QFieldType.DATE_TIME).withLabel("Pause notifications until")
.withHelpContent(new QHelpContent("Security notices still arrive.")))
.withField(new QFieldMetaData("digestHour", QFieldType.INTEGER).withLabel("Daily digest time").withPossibleValueSourceName("alerts_digest_hour"))
.withField(new QFieldMetaData("timeZone", QFieldType.STRING).withLabel("Time zone").withMaxLength(64))
.withField(new QFieldMetaData("alertInAppEnabled", QFieldType.BOOLEAN).withLabel("Show alerts in the app"))
.withField(new QFieldMetaData("alertEmailEnabled", QFieldType.BOOLEAN).withLabel("Email me alerts"))
.withList(categories)
.withList(new UserPreferenceListMetaData().withName("subscriptions").withLabel("Subscriptions").withRowLabel("Alert rule")
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Enabled"))
.withField(enumField("minSeverity", "From severity", "alerts_severity"))
.withField(enumField("deliveryMode", "Delivery mode", "alerts_delivery_mode"))));
}
/*******************************************************************************
** Only expose Slack controls when the configured messaging provider exists.
*******************************************************************************/
private static boolean slackConfigured(QInstance instance, AlertsQBitConfig config)
{
String name = config.getSlackProviderName();
return (name != null && !name.isBlank() && instance.getMessagingProvider(name) != null);
}
/*******************************************************************************
** Whether the section is actually installed in this instance.
*******************************************************************************/
public static boolean isRegistered(QInstance instance)
{
UserPreferencesMetaData metadata = UserPreferencesMetaData.of(instance);
return (metadata != null && metadata.getSections().stream().anyMatch(section -> SECTION_NAME.equals(section.getName())));
}
/*******************************************************************************
** String enums use ordinary fields with an ENUM possible-value source.
*******************************************************************************/
private static QFieldMetaData enumField(String name, String label, String source)
{
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withPossibleValueSourceName(source));
}
/*******************************************************************************
** Load defaults and fixed category rows, plus only this user's subscriptions.
*******************************************************************************/
@Override
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
{
QRecord settings = userSettings(context);
UserPreferenceValues result = new UserPreferenceValues();
SCALARS.forEach((name, column) -> result.getValues().put(name, settings.getValue(column)));
Map<String, QRecord> preferences = new LinkedHashMap<>();
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId())))
{
preferences.put(preference.getValueString("category"), preference);
}
List<UserPreferenceRow> categories = new ArrayList<>();
for(NotificationCategory category : NotificationCategory.values())
{
QRecord preference = preferences.get(category.name());
Map<String, Serializable> values = new LinkedHashMap<>();
values.put("inApp", preference == null || !Boolean.TRUE.equals(preference.getValueBoolean("inAppMuted")));
for(String field : List.of("inAppMinSeverity", "emailMode", "emailMinSeverity"))
{
values.put(field, preference == null ? QContext.getQInstance().getTable(AlertsNotificationPreference.TABLE_NAME).getField(field).getDefaultValue() : preference.getValue(field));
}
///////////////////////////////////////////////////////////////////////
// no in-app threshold means everything; show it as the lowest level //
///////////////////////////////////////////////////////////////////////
if(values.get("inAppMinSeverity") == null)
{
values.put("inAppMinSeverity", NotificationSeverity.INFO.name());
}
if(slackConfigured(QContext.getQInstance(), AlertsRuntime.get(QContext.getQInstance()).getConfig()))
{
for(String field : List.of("slackMode", "slackMinSeverity"))
{
values.put("slackMode".equals(field) ? "slack" : field, preference == null
? QContext.getQInstance().getTable(AlertsNotificationPreference.TABLE_NAME).getField(field).getDefaultValue() : preference.getValue(field));
}
}
String label = QContext.getQInstance().getPossibleValueSource("alerts_notification_category").getEnumValues().stream()
.filter(value -> category.name().equals(value.getId())).findFirst().orElseThrow().getLabel();
UserPreferenceRow row = new UserPreferenceRow().withKey(category.name()).withLabel(label).withValues(values);
if(category == NotificationCategory.SECURITY)
{
values.put("inApp", true);
values.put("inAppMinSeverity", NotificationSeverity.INFO.name());
row.withReadOnlyFieldNames(List.of("inApp", "inAppMinSeverity")).withReadOnlyReason(SECURITY_REASON);
}
categories.add(row);
}
result.getLists().put("categories", categories);
List<UserPreferenceRow> subscriptions = new ArrayList<>();
for(QRecord subscription : ownSubscriptions(context))
{
Map<String, Serializable> values = new LinkedHashMap<>();
for(String field : List.of("enabled", "minSeverity", "deliveryMode"))
{
values.put(field, subscription.getValue(field));
}
subscriptions.add(new UserPreferenceRow().withKey(subscription.getValueString("id")).withLabel(scopeLabel(subscription)).withValues(values));
}
result.getLists().put("subscriptions", subscriptions);
return (result);
}
/*******************************************************************************
** Validate domain rules; core invokes this outside its trusted store scope.
*******************************************************************************/
@Override
public List<String> validate(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
List<String> errors = new ArrayList<>();
Serializable zone = change.getValues().get("timeZone");
if(zone != null)
{
try
{
ZoneId.of(zone.toString());
}
catch(DateTimeException e)
{
errors.add("Time zone: must be a valid time zone, such as America/Chicago.");
}
}
for(UserPreferenceRowChange row : change.getLists().getOrDefault("categories", List.of()))
{
if("SECURITY".equals(row.getKey()) && (row.getValues().containsKey("inApp") || row.getValues().containsKey("inAppMinSeverity")))
{
errors.add(SECURITY_REASON);
}
}
if(!change.getLists().getOrDefault("subscriptions", List.of()).isEmpty())
{
Set<String> ownIds = CorePermissionEnforcer.runTrusted("validate session user's alert subscriptions", () -> ownSubscriptions(context).stream()
.map(row -> row.getValueString("id")).collect(Collectors.toSet()));
for(UserPreferenceRowChange row : change.getLists().get("subscriptions"))
{
if(!ownIds.contains(row.getKey()))
{
errors.add("Alert rule: isn't one of your subscriptions.");
}
}
}
return (errors);
}
/*******************************************************************************
** Partial updates through normal actions preserve auditing and customizers.
*******************************************************************************/
@Override
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
{
List<String> errors = validate(context, change);
if(!errors.isEmpty())
{
throw (new QBadRequestException(String.join("; ", errors)));
}
if(!change.getValues().isEmpty())
{
QRecord update = new QRecord().withValue("id", userSettings(context).getValue("id"));
change.getValues().forEach((field, value) -> update.setValue(SCALARS.get(field), value));
AlertsDb.update(AlertsUserPreference.TABLE_NAME, update);
}
for(UserPreferenceRowChange row : change.getLists().getOrDefault("categories", List.of()))
{
QRecord existing = AlertsDb.first(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId())
.withCriteria("category", QCriteriaOperator.EQUALS, row.getKey()));
QRecord update = new QRecord();
row.getValues().forEach((field, value) -> update.setValue("inApp".equals(field) ? "inAppMuted" : "slack".equals(field) ? "slackMode" : field,
"inApp".equals(field) ? Boolean.FALSE.equals(value) : value));
if(existing == null)
{
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, update.withValue("userId", context.getUserId()).withValue("category", row.getKey()));
}
else
{
AlertsDb.update(AlertsNotificationPreference.TABLE_NAME, update.withValue("id", existing.getValue("id")));
}
}
for(UserPreferenceRowChange row : change.getLists().getOrDefault("subscriptions", List.of()))
{
QRecord update = new QRecord().withValue("id", Integer.valueOf(row.getKey()));
row.getValues().forEach(update::setValue);
AlertsDb.update(AlertsSubscription.TABLE_NAME, update);
}
}
/*******************************************************************************
** Create the same defaults as AlertsPreferences.ensure, retaining the user.
*******************************************************************************/
private QRecord userSettings(UserPreferenceRequestContext context) throws QException
{
QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId()));
if(settings == null)
{
String label = context.getSession().getUser().getFullName();
settings = AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", context.getUserId())
.withValue("userLabel", label == null ? context.getUserId() : label).withValue("inAppEnabled", true).withValue("emailEnabled", true)
.withValue("quietBypassSeverity", "CRITICAL").withValue("digestHour", 8));
}
return (settings);
}
/*******************************************************************************
** Never expose team or channel rules, even to an administrator.
*******************************************************************************/
private List<QRecord> ownSubscriptions(UserPreferenceRequestContext context) throws QException
{
return (AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", context.getUserId()).withCriteria("subscriberType", QCriteriaOperator.EQUALS, "USER")));
}
/*******************************************************************************
** Readable scope without relying on access to the subscription's target table.
*******************************************************************************/
private String scopeLabel(QRecord subscription)
{
String value = subscription.getValueString("scopeValue");
return (switch(subscription.getValueString("scopeType"))
{
case "ALL" -> "All alerts";
case "TYPE" -> "Type: " + value;
case "GROUP" -> "Group: " + value;
case "RECORD" -> "Record: " + value + " #" + subscription.getValueString("scopeRecordId");
case "MONITOR" -> "Monitor: " + value;
default -> "Table: " + value;
});
}
}
@@ -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());
}
}
}
@@ -0,0 +1,59 @@
-- liquibase formatted sql
-- qbit-alerts starter defaults: the two channels every install needs, and the
-- two groups the qbit itself files alerts under, so Delivery health and the
-- group lists are not empty before the first alert.
-- Include it from the host's master changelog AFTER alerts.changelog.sql:
--
-- - include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
--
-- Rollback is empty on purpose: a name-based delete would also remove rows the host already had.
-- Rules for this file:
-- * insert-only: every row is inserted only when its natural key (the name) is
-- absent, so an administrator's edits always survive and nothing is updated;
-- * no secrets: the channels hold settings only, never a secret;
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
-- * every changeset has a precondition on its table, so a host that has not
-- created a table yet simply skips it (MARK_RAN).
-- The runtime creates the same rows when they are missing (AlertChannels.defaultChannel,
-- AlertTypeSync.ensureGroup); this file only makes them exist from the first deploy.
-- Alert types, monitors, escalation policies, teams and subscriptions are NOT seeded
-- here: types and default monitors are declared by the installed qbits and synced at
-- startup of the Alerts home, and the rest is the host's own people and policy.
-- AlertsDefaultsChangelogTest runs this file twice against a fresh schema.
-- changeset qbit-alerts:defaults-1
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_channel WHERE name = 'in-app'
-- comment: The in-app channel (the bell): every user has it, full detail, no rate limit.
INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date)
SELECT 'in-app', 'In-App (Bell)', 'IN_APP', TRUE, 'FULL', 'INFO', 0, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'in-app');
-- rollback empty
-- changeset qbit-alerts:defaults-2
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_channel WHERE name = 'email'
-- comment: The personal email channel: a user's own address (My Alert Settings), minimal detail, 60 an hour.
INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date)
SELECT 'email', 'Email (Personal Address)', 'EMAIL', TRUE, 'MINIMAL', 'INFO', 60, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'email');
-- rollback empty
-- changeset qbit-alerts:defaults-3
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_group WHERE name = 'alerts'
-- comment: The group the qbit files its own alerts under (a monitor that cannot run, a failing channel, dropped signals).
INSERT INTO alerts_group (name, label, create_date, modify_date)
SELECT 'alerts', 'Alerts', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'alerts');
-- rollback empty
-- changeset qbit-alerts:defaults-4
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_group WHERE name = 'other'
-- comment: The catch-all group for alert types that name no group (and the unregistered type).
INSERT INTO alerts_group (name, label, create_date, modify_date)
SELECT 'other', 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'other');
-- rollback empty
@@ -8,6 +8,10 @@
-- Rules for this file: the host runs it, the qbit never runs DDL; additive only
-- (nothing is ever dropped or rewritten, except the one pre-GA rename in changesets 16-17);
-- every statement is idempotent (IF NOT EXISTS, or a precondition). Column names are the snake_case form of the field names.
-- Foreign keys (changesets 30-53): one changeset per constraint, named fk_<child_table>_<child_column> and guarded by a
-- precondition on that name, so a host that already added it (e.g. from its own changelog) marks it ran. Before adding the
-- constraint each one records orphan rows in qqq_fk_quarantine (the same DDL in every qbit), then nulls a nullable column
-- or deletes a row whose column is NOT NULL (row_data keeps the full row), so the migration never fails on old data.
-- changeset qbit-alerts:1
-- comment: A category of alerts for people ("Backups", "CDN"), optionally nested. The Alerts home groups by these, subscriptions target them, and every alert type belongs to one.
@@ -427,3 +431,347 @@ 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);
-- changeset qbit-alerts:29
-- 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) = 'in_app_min_severity'
-- comment: Optional minimum severity for in-app notifications (security always arrives).
ALTER TABLE alerts_notification_preference ADD COLUMN in_app_min_severity VARCHAR(16);
-- changeset qbit-alerts:30
-- comment: Indexes for the join child columns: a per-record monitor finds its open alerts by (monitor_id, status) on every evaluation (MonitorEvaluator), and the record pages list a type's monitors (alert_type_id) and a group's types (group_id).
CREATE INDEX IF NOT EXISTS ix_alerts_alert_monitor_id_status ON alerts_alert (monitor_id, status);
CREATE INDEX IF NOT EXISTS ix_alerts_monitor_alert_type_id ON alerts_monitor (alert_type_id);
CREATE INDEX IF NOT EXISTS ix_alerts_type_group_id ON alerts_type (group_id);
-- changeset qbit-alerts:31
-- comment: Indexes for the foreign-key columns no earlier index leads with, so FK checks and ON DELETE actions do not scan the child table.
CREATE INDEX IF NOT EXISTS ix_alerts_alert_group_id ON alerts_alert (group_id);
CREATE INDEX IF NOT EXISTS ix_alerts_alert_assigned_team_id ON alerts_alert (assigned_team_id);
CREATE INDEX IF NOT EXISTS ix_alerts_type_escalation_policy_id ON alerts_type (escalation_policy_id);
CREATE INDEX IF NOT EXISTS ix_alerts_group_parent_group_id ON alerts_group (parent_group_id);
CREATE INDEX IF NOT EXISTS ix_alerts_group_owner_team_id ON alerts_group (owner_team_id);
CREATE INDEX IF NOT EXISTS ix_alerts_group_escalation_policy_id ON alerts_group (escalation_policy_id);
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_alert_event_id ON alerts_delivery (alert_event_id);
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_subscription_id ON alerts_delivery (subscription_id);
CREATE INDEX IF NOT EXISTS ix_alerts_subscription_team_id ON alerts_subscription (team_id);
CREATE INDEX IF NOT EXISTS ix_alerts_subscription_channel_id ON alerts_subscription (channel_id);
CREATE INDEX IF NOT EXISTS ix_alerts_escalation_step_team_id ON alerts_escalation_step (team_id);
CREATE INDEX IF NOT EXISTS ix_alerts_escalation_step_channel_id ON alerts_escalation_step (channel_id);
-- rollback DROP INDEX IF EXISTS ix_alerts_alert_group_id; DROP INDEX IF EXISTS ix_alerts_alert_assigned_team_id; DROP INDEX IF EXISTS ix_alerts_type_escalation_policy_id; DROP INDEX IF EXISTS ix_alerts_group_parent_group_id; DROP INDEX IF EXISTS ix_alerts_group_owner_team_id; DROP INDEX IF EXISTS ix_alerts_group_escalation_policy_id; DROP INDEX IF EXISTS ix_alerts_delivery_alert_event_id; DROP INDEX IF EXISTS ix_alerts_delivery_subscription_id; DROP INDEX IF EXISTS ix_alerts_subscription_team_id; DROP INDEX IF EXISTS ix_alerts_subscription_channel_id; DROP INDEX IF EXISTS ix_alerts_escalation_step_team_id; DROP INDEX IF EXISTS ix_alerts_escalation_step_channel_id;
-- changeset qbit-alerts:32 dbms:postgresql
-- comment: alerts_type.group_id -> alerts_group ON DELETE SET NULL. A deleted group leaves its types ungrouped; AlertTypeSync puts them back in their declared (or the default) group at the next boot. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_type_group_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_type', 'group_id', CAST(c.id AS VARCHAR(100)), CAST(c.group_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:32' FROM alerts_type c WHERE c.group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.group_id);
UPDATE alerts_type c SET group_id = NULL WHERE c.group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.group_id);
ALTER TABLE alerts_type ADD CONSTRAINT fk_alerts_type_group_id FOREIGN KEY (group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_type DROP CONSTRAINT IF EXISTS fk_alerts_type_group_id;
-- changeset qbit-alerts:33 dbms:postgresql
-- comment: alerts_type.escalation_policy_id -> alerts_escalation_policy ON DELETE SET NULL. A deleted escalation policy leaves the type on its group's policy (or none). Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_type_escalation_policy_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_type', 'escalation_policy_id', CAST(c.id AS VARCHAR(100)), CAST(c.escalation_policy_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:33' FROM alerts_type c WHERE c.escalation_policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.escalation_policy_id);
UPDATE alerts_type c SET escalation_policy_id = NULL WHERE c.escalation_policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.escalation_policy_id);
ALTER TABLE alerts_type ADD CONSTRAINT fk_alerts_type_escalation_policy_id FOREIGN KEY (escalation_policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_type DROP CONSTRAINT IF EXISTS fk_alerts_type_escalation_policy_id;
-- changeset qbit-alerts:34 dbms:postgresql
-- comment: alerts_group.parent_group_id -> alerts_group ON DELETE SET NULL. A deleted parent group makes its child groups top-level. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_group_parent_group_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_group', 'parent_group_id', CAST(c.id AS VARCHAR(100)), CAST(c.parent_group_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:34' FROM alerts_group c WHERE c.parent_group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.parent_group_id);
UPDATE alerts_group c SET parent_group_id = NULL WHERE c.parent_group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.parent_group_id);
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_parent_group_id FOREIGN KEY (parent_group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_group DROP CONSTRAINT IF EXISTS fk_alerts_group_parent_group_id;
-- changeset qbit-alerts:35 dbms:postgresql
-- comment: alerts_group.owner_team_id -> alerts_team ON DELETE SET NULL. A deleted team leaves the group without an owner. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_group_owner_team_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_group', 'owner_team_id', CAST(c.id AS VARCHAR(100)), CAST(c.owner_team_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:35' FROM alerts_group c WHERE c.owner_team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.owner_team_id);
UPDATE alerts_group c SET owner_team_id = NULL WHERE c.owner_team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.owner_team_id);
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_owner_team_id FOREIGN KEY (owner_team_id) REFERENCES alerts_team (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_group DROP CONSTRAINT IF EXISTS fk_alerts_group_owner_team_id;
-- changeset qbit-alerts:36 dbms:postgresql
-- comment: alerts_group.escalation_policy_id -> alerts_escalation_policy ON DELETE SET NULL. A deleted escalation policy leaves the group without one. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_group_escalation_policy_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_group', 'escalation_policy_id', CAST(c.id AS VARCHAR(100)), CAST(c.escalation_policy_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:36' FROM alerts_group c WHERE c.escalation_policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.escalation_policy_id);
UPDATE alerts_group c SET escalation_policy_id = NULL WHERE c.escalation_policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.escalation_policy_id);
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_escalation_policy_id FOREIGN KEY (escalation_policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_group DROP CONSTRAINT IF EXISTS fk_alerts_group_escalation_policy_id;
-- changeset qbit-alerts:37 dbms:postgresql
-- comment: alerts_monitor.alert_type_id -> alerts_type ON DELETE RESTRICT. A monitor needs its type; types are never deleted (TABLE_DELETE is off, AlertTypeSync only adds and updates). Orphans are recorded in qqq_fk_quarantine and deleted first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_monitor_alert_type_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_monitor', 'alert_type_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_type_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:37' FROM alerts_monitor c WHERE c.alert_type_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_type p WHERE p.id = c.alert_type_id);
DELETE FROM alerts_monitor c WHERE c.alert_type_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_type p WHERE p.id = c.alert_type_id);
ALTER TABLE alerts_monitor ADD CONSTRAINT fk_alerts_monitor_alert_type_id FOREIGN KEY (alert_type_id) REFERENCES alerts_type (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE alerts_monitor DROP CONSTRAINT IF EXISTS fk_alerts_monitor_alert_type_id;
-- changeset qbit-alerts:38 dbms:postgresql
-- comment: alerts_alert.alert_type_id -> alerts_type ON DELETE RESTRICT. An alert keeps its type; types are never deleted. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_alert_alert_type_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_alert', 'alert_type_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_type_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:38' FROM alerts_alert c WHERE c.alert_type_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_type p WHERE p.id = c.alert_type_id);
UPDATE alerts_alert c SET alert_type_id = NULL WHERE c.alert_type_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_type p WHERE p.id = c.alert_type_id);
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_alert_type_id FOREIGN KEY (alert_type_id) REFERENCES alerts_type (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE alerts_alert DROP CONSTRAINT IF EXISTS fk_alerts_alert_alert_type_id;
-- changeset qbit-alerts:39 dbms:postgresql
-- comment: alerts_alert.monitor_id -> alerts_monitor ON DELETE SET NULL. A deleted monitor leaves its alerts (history) in place, without the monitor. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_alert_monitor_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_alert', 'monitor_id', CAST(c.id AS VARCHAR(100)), CAST(c.monitor_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:39' FROM alerts_alert c WHERE c.monitor_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_monitor p WHERE p.id = c.monitor_id);
UPDATE alerts_alert c SET monitor_id = NULL WHERE c.monitor_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_monitor p WHERE p.id = c.monitor_id);
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_monitor_id FOREIGN KEY (monitor_id) REFERENCES alerts_monitor (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_alert DROP CONSTRAINT IF EXISTS fk_alerts_alert_monitor_id;
-- changeset qbit-alerts:40 dbms:postgresql
-- comment: alerts_alert.group_id -> alerts_group ON DELETE SET NULL. A deleted group leaves its alerts in place, ungrouped. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_alert_group_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_alert', 'group_id', CAST(c.id AS VARCHAR(100)), CAST(c.group_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:40' FROM alerts_alert c WHERE c.group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.group_id);
UPDATE alerts_alert c SET group_id = NULL WHERE c.group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.group_id);
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_group_id FOREIGN KEY (group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_alert DROP CONSTRAINT IF EXISTS fk_alerts_alert_group_id;
-- changeset qbit-alerts:41 dbms:postgresql
-- comment: alerts_alert.assigned_team_id -> alerts_team ON DELETE SET NULL. A deleted team unassigns its alerts. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_alert_assigned_team_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_alert', 'assigned_team_id', CAST(c.id AS VARCHAR(100)), CAST(c.assigned_team_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:41' FROM alerts_alert c WHERE c.assigned_team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.assigned_team_id);
UPDATE alerts_alert c SET assigned_team_id = NULL WHERE c.assigned_team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.assigned_team_id);
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_assigned_team_id FOREIGN KEY (assigned_team_id) REFERENCES alerts_team (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_alert DROP CONSTRAINT IF EXISTS fk_alerts_alert_assigned_team_id;
-- changeset qbit-alerts:42 dbms:postgresql
-- comment: alerts_record_ref.alert_id -> alerts_alert ON DELETE CASCADE. Record references belong to their alert. Orphans are recorded in qqq_fk_quarantine and deleted first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_record_ref_alert_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_record_ref', 'alert_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:42' FROM alerts_record_ref c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
DELETE FROM alerts_record_ref c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
ALTER TABLE alerts_record_ref ADD CONSTRAINT fk_alerts_record_ref_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
-- rollback ALTER TABLE alerts_record_ref DROP CONSTRAINT IF EXISTS fk_alerts_record_ref_alert_id;
-- changeset qbit-alerts:43 dbms:postgresql
-- comment: alerts_event.alert_id -> alerts_alert ON DELETE CASCADE. The timeline belongs to its alert. Orphans are recorded in qqq_fk_quarantine and deleted first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_event_alert_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_event', 'alert_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:43' FROM alerts_event c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
DELETE FROM alerts_event c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
ALTER TABLE alerts_event ADD CONSTRAINT fk_alerts_event_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
-- rollback ALTER TABLE alerts_event DROP CONSTRAINT IF EXISTS fk_alerts_event_alert_id;
-- changeset qbit-alerts:44 dbms:postgresql
-- comment: alerts_delivery.alert_id -> alerts_alert ON DELETE CASCADE. Delivery log rows of an alert go with the alert. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_delivery_alert_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_delivery', 'alert_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:44' FROM alerts_delivery c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
UPDATE alerts_delivery c SET alert_id = NULL WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
-- rollback ALTER TABLE alerts_delivery DROP CONSTRAINT IF EXISTS fk_alerts_delivery_alert_id;
-- changeset qbit-alerts:45 dbms:postgresql
-- comment: alerts_delivery.alert_event_id -> alerts_event ON DELETE SET NULL. The timeline event that caused a delivery (AlertRouter); the log row outlives it. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_delivery_alert_event_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_delivery', 'alert_event_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_event_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:45' FROM alerts_delivery c WHERE c.alert_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_event p WHERE p.id = c.alert_event_id);
UPDATE alerts_delivery c SET alert_event_id = NULL WHERE c.alert_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_event p WHERE p.id = c.alert_event_id);
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_alert_event_id FOREIGN KEY (alert_event_id) REFERENCES alerts_event (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_delivery DROP CONSTRAINT IF EXISTS fk_alerts_delivery_alert_event_id;
-- changeset qbit-alerts:46 dbms:postgresql
-- comment: alerts_delivery.subscription_id -> alerts_subscription ON DELETE SET NULL. A deleted subscription keeps its delivery history. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_delivery_subscription_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_delivery', 'subscription_id', CAST(c.id AS VARCHAR(100)), CAST(c.subscription_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:46' FROM alerts_delivery c WHERE c.subscription_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_subscription p WHERE p.id = c.subscription_id);
UPDATE alerts_delivery c SET subscription_id = NULL WHERE c.subscription_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_subscription p WHERE p.id = c.subscription_id);
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_subscription_id FOREIGN KEY (subscription_id) REFERENCES alerts_subscription (id) ON DELETE SET NULL;
-- rollback ALTER TABLE alerts_delivery DROP CONSTRAINT IF EXISTS fk_alerts_delivery_subscription_id;
-- changeset qbit-alerts:47 dbms:postgresql
-- comment: alerts_delivery.channel_id -> alerts_channel ON DELETE RESTRICT. A channel with delivery history is disabled, not deleted. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_delivery_channel_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_delivery', 'channel_id', CAST(c.id AS VARCHAR(100)), CAST(c.channel_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:47' FROM alerts_delivery c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
UPDATE alerts_delivery c SET channel_id = NULL WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE alerts_delivery DROP CONSTRAINT IF EXISTS fk_alerts_delivery_channel_id;
-- changeset qbit-alerts:48 dbms:postgresql
-- comment: alerts_subscription.team_id -> alerts_team ON DELETE RESTRICT. A team subscription needs its team (AlertsSubscriptionCustomizer); remove the subscriptions before the team. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_subscription_team_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_subscription', 'team_id', CAST(c.id AS VARCHAR(100)), CAST(c.team_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:48' FROM alerts_subscription c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
UPDATE alerts_subscription c SET team_id = NULL WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
ALTER TABLE alerts_subscription ADD CONSTRAINT fk_alerts_subscription_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE alerts_subscription DROP CONSTRAINT IF EXISTS fk_alerts_subscription_team_id;
-- changeset qbit-alerts:49 dbms:postgresql
-- comment: alerts_subscription.channel_id -> alerts_channel ON DELETE RESTRICT. A channel subscription needs its channel (AlertsSubscriptionCustomizer); remove the subscriptions before the channel. Orphans are recorded in qqq_fk_quarantine and nulled first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_subscription_channel_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_subscription', 'channel_id', CAST(c.id AS VARCHAR(100)), CAST(c.channel_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:49' FROM alerts_subscription c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
UPDATE alerts_subscription c SET channel_id = NULL WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
ALTER TABLE alerts_subscription ADD CONSTRAINT fk_alerts_subscription_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE alerts_subscription DROP CONSTRAINT IF EXISTS fk_alerts_subscription_channel_id;
-- changeset qbit-alerts:50 dbms:postgresql
-- comment: alerts_team_member.team_id -> alerts_team ON DELETE CASCADE. Members belong to their team. Orphans are recorded in qqq_fk_quarantine and deleted first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_team_member_team_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_team_member', 'team_id', CAST(c.id AS VARCHAR(100)), CAST(c.team_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:50' FROM alerts_team_member c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
DELETE FROM alerts_team_member c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
ALTER TABLE alerts_team_member ADD CONSTRAINT fk_alerts_team_member_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE CASCADE;
-- rollback ALTER TABLE alerts_team_member DROP CONSTRAINT IF EXISTS fk_alerts_team_member_team_id;
-- changeset qbit-alerts:51 dbms:postgresql
-- comment: alerts_notification_recipient.notification_id -> alerts_notification ON DELETE CASCADE. Recipient rows belong to their notification (NotificationService.purge deletes them first; this covers any other delete). Orphans are recorded in qqq_fk_quarantine and deleted first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_notification_recipient_notification_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_notification_recipient', 'notification_id', CAST(c.id AS VARCHAR(100)), CAST(c.notification_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:51' FROM alerts_notification_recipient c WHERE c.notification_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_notification p WHERE p.id = c.notification_id);
DELETE FROM alerts_notification_recipient c WHERE c.notification_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_notification p WHERE p.id = c.notification_id);
ALTER TABLE alerts_notification_recipient ADD CONSTRAINT fk_alerts_notification_recipient_notification_id FOREIGN KEY (notification_id) REFERENCES alerts_notification (id) ON DELETE CASCADE;
-- rollback ALTER TABLE alerts_notification_recipient DROP CONSTRAINT IF EXISTS fk_alerts_notification_recipient_notification_id;
-- changeset qbit-alerts:52 dbms:postgresql
-- comment: alerts_escalation_step.policy_id -> alerts_escalation_policy ON DELETE CASCADE. Steps belong to their policy. Orphans are recorded in qqq_fk_quarantine and deleted first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_escalation_step_policy_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_escalation_step', 'policy_id', CAST(c.id AS VARCHAR(100)), CAST(c.policy_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:52' FROM alerts_escalation_step c WHERE c.policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.policy_id);
DELETE FROM alerts_escalation_step c WHERE c.policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.policy_id);
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_policy_id FOREIGN KEY (policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE CASCADE;
-- rollback ALTER TABLE alerts_escalation_step DROP CONSTRAINT IF EXISTS fk_alerts_escalation_step_policy_id;
-- changeset qbit-alerts:53 dbms:postgresql
-- comment: alerts_escalation_step.team_id -> alerts_team ON DELETE RESTRICT. A team step needs its team (ck_alerts_escalation_step_target forbids a NULL), so an orphan step is removed, not nulled. Orphans are recorded in qqq_fk_quarantine and deleted first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_escalation_step_team_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_escalation_step', 'team_id', CAST(c.id AS VARCHAR(100)), CAST(c.team_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:53' FROM alerts_escalation_step c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
DELETE FROM alerts_escalation_step c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE alerts_escalation_step DROP CONSTRAINT IF EXISTS fk_alerts_escalation_step_team_id;
-- changeset qbit-alerts:54 dbms:postgresql
-- comment: alerts_escalation_step.channel_id -> alerts_channel ON DELETE RESTRICT. A channel step needs its channel (ck_alerts_escalation_step_target forbids a NULL), so an orphan step is removed, not nulled. Orphans are recorded in qqq_fk_quarantine and deleted first.
-- preconditions onFail:MARK_RAN onError:HALT
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_escalation_step_channel_id'
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_escalation_step', 'channel_id', CAST(c.id AS VARCHAR(100)), CAST(c.channel_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:54' FROM alerts_escalation_step c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
DELETE FROM alerts_escalation_step c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
-- rollback ALTER TABLE alerts_escalation_step DROP CONSTRAINT IF EXISTS fk_alerts_escalation_step_channel_id;
File diff suppressed because it is too large. Load diff
+235 -26
View File
@@ -16,7 +16,7 @@ components:
type: "string"
type: "object"
AlertsErrorV1:
description: "Framework error response."
description: "Deprecated since 1.2.0: errors use BasicErrorResponseV1."
properties:
error:
nullable: true
@@ -115,6 +115,11 @@ components:
type: "string"
pollSeconds:
type: "integer"
preferencesSection:
description: "Name of the user Preferences section for notification settings\
\ (/qqq/v1/preferences/{name}), or null when not registered."
nullable: true
type: "string"
unreadCount:
type: "integer"
type: "object"
@@ -207,17 +212,89 @@ components:
type: "integer"
type: "array"
type: "object"
BasicErrorResponseV1:
description: "The error body of every UI API operation (core and extensions)."
properties:
error:
description: "The error message (the same as message; kept for clients of\
\ UI API 1.0 to 1.5)."
type: "string"
code:
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
\ Extensions may add codes; treat an unknown code by the HTTP status.\
\ Since 1.6.0."
type: "string"
message:
description: "A message to show the user. Since 1.6.0."
type: "string"
fieldErrors:
description: "Errors about particular request fields, when known. Since\
\ 1.6.0."
items:
properties:
field:
description: "The field (or parameter) name."
type: "string"
message:
description: "The error about that field."
type: "string"
code:
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
\ PATTERN."
type: "string"
type: "object"
type: "array"
traceId:
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
\ response header); quote it when reporting a problem. Since 1.6.0."
type: "string"
type: "object"
securitySchemes:
sessionUuidCookie:
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
in: "cookie"
name: "sessionUUID"
type: "apiKey"
bearerAuth:
bearerFormat: "JWT or session UUID"
description: "`Authorization: Bearer <token>`, where the token is either\n-\
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
\ to the app's client,\n or to its native client (`values.nativeClientId`\
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
\ as signing in on the web. When it expires (401), refresh it at the provider\
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
\ which the server keeps refreshed."
scheme: "bearer"
type: "http"
oauth2:
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
\ token\nas `Authorization: Bearer`."
flows:
authorizationCode:
authorizationUrl: "/authorize"
scopes:
email: "Email address"
offline_access: "A refresh token, to renew the access token without signing\
\ in again"
openid: "OpenID Connect sign-in"
profile: "Name, username and groups (permissions come from the groups)"
tokenUrl: "/token"
type: "oauth2"
info:
contact:
email: "contact@kingsrook.com"
description: "Alerts inbox and record panel"
title: "QQQ Middleware API extension: alerts"
version: "1.0.0"
version: "1.2.0"
openapi: "3.0.3"
paths:
/qqq/v1/alerts/inbox/summary:
@@ -229,25 +306,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -255,10 +332,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsInboxSummaryV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Read the bell summary"
tags:
- "alerts"
/qqq/v1/alerts/inbox:
get:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -299,25 +397,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -325,10 +423,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsInboxPageV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Page the notification inbox"
tags:
- "alerts"
/qqq/v1/alerts/inbox/read:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -344,25 +463,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -370,10 +489,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Mark own notifications read"
tags:
- "alerts"
/qqq/v1/alerts/inbox/unread:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -389,25 +529,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -415,10 +555,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Mark own notifications unread"
tags:
- "alerts"
/qqq/v1/alerts/inbox/dismiss:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -434,25 +595,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -460,10 +621,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Dismiss own notifications"
tags:
- "alerts"
/qqq/v1/alerts/record-panel:
get:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -486,25 +668,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -512,7 +694,34 @@ paths:
schema:
$ref: "#/components/schemas/AlertsRecordPanelV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Read visible alerts for a record"
tags:
- "alerts"
servers:
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
url: "/"
tags:
- description: "Operations of the alerts extension."
name: "alerts"
@@ -0,0 +1 @@
version=${project.version}
@@ -51,12 +51,13 @@ class AlertsApiContractTest extends BaseAlertsTest
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts"));
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts") || name.equals("BasicErrorResponseV1"));
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder(
"/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read",
"/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel");
assertThat(extension.getSpecs()).hasSize(6);
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "actions", "follow");
assertThat(extension.getSemanticVersion()).isEqualTo("1.2.0");
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "preferencesSection", "actions", "follow");
assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/");
}
@@ -200,7 +200,7 @@ class AlertsApiHttpTest extends BaseAlertsTest
start();
String base = "/qqq/v1/alerts";
Map<String, Object> summary = json(request("GET", base + "/inbox/summary", null, ADMIN, null));
assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications");
assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications").containsEntry("preferencesSection", "notifications");
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
assertThat(items).hasSize(1);
Map<String, Object> item = items.get(0);
@@ -224,7 +224,12 @@ class AlertsApiHttpTest extends BaseAlertsTest
assertThat(request("POST", base + "/record-panel/watch", "{}", ADMIN, null).statusCode()).isEqualTo(404);
HttpResponse<String> invalidJson = request("POST", base + "/inbox/read", "not json", ADMIN, null);
assertThat(invalidJson.statusCode()).isEqualTo(400);
assertThat(invalidJson.body()).contains("The request body is not valid JSON.");
Map<String, Object> error = JsonUtils.toObject(invalidJson.body(), Map.class);
assertThat(error).containsKeys("error", "code", "message", "traceId")
.containsEntry("message", "The request body is not valid JSON.");
assertThat(error.get("error")).isEqualTo(error.get("message"));
assertThat(error.get("code")).isNotNull();
assertThat((String) error.get("traceId")).isNotBlank();
assertThat(request("POST", base + "/inbox/read", null, ADMIN, null).statusCode()).isEqualTo(400);
assertThat(request("GET", base + "/inbox/summary", null, "nobody", null).statusCode()).isEqualTo(403);
assertThat(request("GET", base + "/record-panel?tableName=widget&recordId=1", null, OUTSIDER, null).statusCode()).isEqualTo(403);
@@ -0,0 +1,352 @@
/*
* 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.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.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The shipped starter defaults (db/liquibase/qbit-alerts/alerts-defaults.
** changelog.sql), run through real Liquibase on H2 (PostgreSQL mode) against
** the qbit's test schema: the rows appear, a second update runs nothing, an
** edited row survives, a host without the tables marks the changesets ran,
** and the seeded rows are the ones the runtime would create itself.
*******************************************************************************/
class AlertsDefaultsChangelogTest extends BaseAlertsTest
{
static final String CHANGELOG = "db/liquibase/qbit-alerts/alerts-defaults.changelog.sql";
/** the tables the changelog seeds. */
static final Set<String> SEEDED_TABLES = Set.of("alerts_channel", "alerts_group");
/*******************************************************************************
** Fresh schema, update twice: same rows; an edit survives.
*******************************************************************************/
@Test
void seedsAFreshSchemaInsertOnly() throws Exception
{
try(Connection connection = freshDatabase(true))
{
update(connection);
assertThat(names(connection, "alerts_channel")).containsExactlyInAnyOrder("in-app", "email");
assertThat(names(connection, "alerts_group")).containsExactlyInAnyOrder("alerts", "other");
try(Statement statement = connection.createStatement())
{
statement.execute("UPDATE alerts_channel SET label = 'Bell', enabled = FALSE WHERE name = 'in-app'");
statement.execute("DELETE FROM alerts_group WHERE name = 'other'");
}
connection.commit();
update(connection);
assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'in-app'")).isEqualTo("Bell");
assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel")).isEqualTo("2");
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'EXECUTED'")).isEqualTo("4");
}
}
/*******************************************************************************
** A host that already has a row by that name (its own settings) keeps it
** untouched, and no second one is added.
*******************************************************************************/
@Test
void keepsAHostsExistingRows() throws Exception
{
try(Connection connection = freshDatabase(true); Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO alerts_channel (name, label, channel_type, enabled, rate_limit_per_hour) VALUES ('email', 'Ops mail', 'EMAIL', FALSE, 5)");
update(connection);
assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'email'")).isEqualTo("Ops mail");
assertThat(scalar(connection, "SELECT rate_limit_per_hour FROM alerts_channel WHERE name = 'email'")).isEqualTo("5");
assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel WHERE name = 'email'")).isEqualTo("1");
}
}
/*******************************************************************************
** Rolling the seeds back (Liquibase also rolls back changesets it only marked
** as ran) never deletes rows the host already had.
*******************************************************************************/
@Test
void rollbackKeepsHostOwnedRows() throws Exception
{
try(Connection connection = freshDatabase(true))
{
try(Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO alerts_channel (name, label, channel_type) VALUES ('in-app', 'Mine', 'IN_APP')");
statement.execute("INSERT INTO alerts_group (name, label) VALUES ('other', 'Mine')");
}
connection.commit();
update(connection);
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).rollback(4, "");
connection.commit();
assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'in-app'")).isEqualTo("Mine");
assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'other'")).isEqualTo("Mine");
assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel")).isEqualTo("2");
}
}
/*******************************************************************************
** A host that has not created the tables yet skips every changeset (marked
** ran), rather than failing its deploy.
*******************************************************************************/
@Test
void aMissingTableMarksTheChangesetsRan() throws Exception
{
try(Connection connection = freshDatabase(false))
{
update(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("4");
}
}
/*******************************************************************************
** Every changeset guards itself and uses only precondition forms that parse
** in Liquibase 4 and 5.
*******************************************************************************/
@Test
void everyChangesetIsGuarded() throws Exception
{
String text = changelogText();
assertThat(text).doesNotContain("precondition-table-exists");
String[] changesets = text.split("(?m)^(?=-- changeset )");
List<String> parts = new ArrayList<>();
for(String part : changesets)
{
if(part.startsWith("-- changeset "))
{
parts.add(part);
}
}
assertThat(parts).hasSize(4);
for(String part : parts)
{
assertThat(part).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("precondition-sql-check expectedResult:0").contains("-- rollback ").contains("NOT EXISTS");
}
for(String table : SEEDED_TABLES)
{
assertThat(text).contains("INSERT INTO " + table + " ");
}
}
/*******************************************************************************
** The seeded rows equal what the runtime creates by itself when they are
** missing, so a deploy never changes behavior.
*******************************************************************************/
@Test
void seededRowsEqualTheRuntimeDefaults() throws Exception
{
QRecordPair in = runtimeChannel(ChannelType.IN_APP);
QRecordPair email = runtimeChannel(ChannelType.EMAIL);
Integer alertsGroup = runtime.asSystem(() -> runtime.getTypeSync().ensureGroup("alerts"));
Integer otherGroup = runtime.asSystem(() -> runtime.getTypeSync().ensureGroup(com.kingsrook.qbits.alerts.engine.AlertTypeSync.DEFAULT_GROUP));
try(Connection connection = freshDatabase(true))
{
update(connection);
for(QRecordPair channel : List.of(in, email))
{
for(Map.Entry<String, String> column : channel.columns.entrySet())
{
assertThat(scalar(connection, "SELECT " + column.getKey() + " FROM alerts_channel WHERE name = '" + channel.name + "'")).as(channel.name + " " + column.getKey()).isEqualTo(column.getValue());
}
}
assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'alerts'")).isEqualTo(groupLabel(alertsGroup));
assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'other'")).isEqualTo(groupLabel(otherGroup));
}
}
/*******************************************************************************
** The channel the runtime makes when missing, as comparable column values.
*******************************************************************************/
private QRecordPair runtimeChannel(ChannelType type) throws Exception
{
return (runtime.asSystem(() ->
{
var row = runtime.getChannels().defaultChannel(type);
QRecordPair pair = new QRecordPair(row.getValueString("name"));
pair.columns.put("label", row.getValueString("label"));
pair.columns.put("channel_type", row.getValueString("channelType"));
pair.columns.put("detail_level", row.getValueString("detailLevel"));
pair.columns.put("min_severity", row.getValueString("minSeverity"));
pair.columns.put("rate_limit_per_hour", String.valueOf(row.getValueInteger("rateLimitPerHour")));
pair.columns.put("health_status", row.getValueString("healthStatus"));
return (pair);
}));
}
/*******************************************************************************
** The label of a group the runtime created.
*******************************************************************************/
private String groupLabel(Integer id) throws Exception
{
return (runtime.asSystem(() -> com.kingsrook.qbits.alerts.engine.AlertsDb.byId("alerts_group", id).getValueString("label")));
}
/*******************************************************************************
** A name and its expected column values.
*******************************************************************************/
private static final class QRecordPair
{
private final String name;
private final Map<String, String> columns = new java.util.LinkedHashMap<>();
/*******************************************************************************
**
*******************************************************************************/
private QRecordPair(String name)
{
this.name = name;
}
}
/*******************************************************************************
** A fresh in-memory H2 (PostgreSQL mode), with or without the qbit's tables.
*******************************************************************************/
static Connection freshDatabase(boolean withTables) throws Exception
{
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
if(withTables)
{
String script;
try(InputStream in = AlertsDefaultsChangelogTest.class.getClassLoader().getResourceAsStream("alerts-h2.sql"))
{
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
try(Statement statement = connection.createStatement())
{
StringBuilder current = new StringBuilder();
for(String line : script.split("\n"))
{
if(line.trim().startsWith("--"))
{
continue;
}
current.append(line).append('\n');
if(line.trim().endsWith(";"))
{
statement.execute(current.toString().trim().replaceAll(";$", ""));
current.setLength(0);
}
}
}
}
return (connection);
}
/*******************************************************************************
** Run the changelog with real Liquibase.
*******************************************************************************/
static void update(Connection connection) throws Exception
{
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
//////////////////////////////////////////////////////////////////
// not closed: closing Liquibase would close the test connection //
//////////////////////////////////////////////////////////////////
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).update("");
connection.commit();
}
/*******************************************************************************
**
*******************************************************************************/
static String changelogText() throws Exception
{
try(InputStream stream = AlertsDefaultsChangelogTest.class.getClassLoader().getResourceAsStream(CHANGELOG))
{
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> names(Connection connection, String table) throws Exception
{
List<String> names = new ArrayList<>();
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT name FROM " + table))
{
while(rs.next())
{
names.add(rs.getString(1));
}
}
return (names);
}
/*******************************************************************************
**
*******************************************************************************/
private static String scalar(Connection connection, String sql) throws Exception
{
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
{
rs.next();
return (rs.getString(1));
}
}
}
@@ -0,0 +1,419 @@
/*
* 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.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import liquibase.resource.DirectoryResourceAccessor;
import liquibase.resource.ResourceAccessor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Referential integrity guardrail: the shipped changelog, applied by Liquibase
** to PostgreSQL, gives every relation the metadata declares (one-to-many joins
** and table possible-value fields) a foreign key with the decided ON DELETE
** rule, led by an index; old orphan rows are quarantined, not lost; and a host
** that already added a constraint under the same name skips it.
*******************************************************************************/
@ExtendWith(RequiresDockerCondition.class)
@Testcontainers
class AlertsForeignKeysTest
{
private static final String CHANGELOG = "db/liquibase/qbit-alerts/alerts.changelog.sql";
/////////////////////////////////////////////////////////////////////////////////////
// child_table.child_column -> parent_table and ON DELETE rule (pg_constraint //
// confdeltype: c CASCADE, n SET NULL, r RESTRICT). Add a row here with the new //
// changeset when a table gains a relation; reasons are in the changeset comments. //
/////////////////////////////////////////////////////////////////////////////////////
private static final Map<String, String> EXPECTED = new TreeMap<>(Map.ofEntries(
Map.entry("alerts_type.group_id", "alerts_group n"),
Map.entry("alerts_type.escalation_policy_id", "alerts_escalation_policy n"),
Map.entry("alerts_group.parent_group_id", "alerts_group n"),
Map.entry("alerts_group.owner_team_id", "alerts_team n"),
Map.entry("alerts_group.escalation_policy_id", "alerts_escalation_policy n"),
Map.entry("alerts_monitor.alert_type_id", "alerts_type r"),
Map.entry("alerts_alert.alert_type_id", "alerts_type r"),
Map.entry("alerts_alert.monitor_id", "alerts_monitor n"),
Map.entry("alerts_alert.group_id", "alerts_group n"),
Map.entry("alerts_alert.assigned_team_id", "alerts_team n"),
Map.entry("alerts_record_ref.alert_id", "alerts_alert c"),
Map.entry("alerts_event.alert_id", "alerts_alert c"),
Map.entry("alerts_delivery.alert_id", "alerts_alert c"),
Map.entry("alerts_delivery.alert_event_id", "alerts_event n"),
Map.entry("alerts_delivery.subscription_id", "alerts_subscription n"),
Map.entry("alerts_delivery.channel_id", "alerts_channel r"),
Map.entry("alerts_subscription.team_id", "alerts_team r"),
Map.entry("alerts_subscription.channel_id", "alerts_channel r"),
Map.entry("alerts_team_member.team_id", "alerts_team c"),
Map.entry("alerts_notification_recipient.notification_id", "alerts_notification c"),
Map.entry("alerts_escalation_step.policy_id", "alerts_escalation_policy c"),
Map.entry("alerts_escalation_step.team_id", "alerts_team r"),
Map.entry("alerts_escalation_step.channel_id", "alerts_channel r")
));
//////////////////////////////////////////////////////////////////////////////////////
// declared relations that deliberately have no foreign key, with the reason. None //
// today: the polymorphic references (alerts_alert.subject_table_name/record_id, //
// alerts_record_ref.table_name/record_id, alerts_notification.source_table_name/ //
// source_record_id, subscription and mute scope_value/scope_record_id) and the //
// id lists (channel_override_ids, default_channel_ids) are not declared as joins //
// or table possible values, so they never reach this check. //
//////////////////////////////////////////////////////////////////////////////////////
private static final Map<String, String> EXEMPT = Map.of();
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
@TempDir
Path tempDir;
/*******************************************************************************
** Start every test from an empty schema.
*******************************************************************************/
@BeforeEach
void emptySchema() throws Exception
{
try(Connection connection = connect(); Statement statement = connection.createStatement())
{
statement.execute("DROP SCHEMA public CASCADE");
statement.execute("CREATE SCHEMA public");
}
}
/*******************************************************************************
** Every declared relation has its foreign key with the decided rule, there
** are no undecided ones, and every foreign-key column leads an index.
*******************************************************************************/
@Test
void testEveryRelationHasForeignKey() throws Exception
{
update(new ClassLoaderResourceAccessor());
Map<String, String> actual = foreignKeys();
for(String relation : declaredRelations().keySet())
{
if(EXEMPT.containsKey(relation))
{
continue;
}
assertThat(actual).as("a foreign key (or an EXEMPT entry with its reason) for " + relation + " -> " + declaredRelations().get(relation)).containsKey(relation);
assertThat(actual.get(relation)).as("foreign key " + relation + " references " + declaredRelations().get(relation))
.startsWith(declaredRelations().get(relation) + " ");
}
assertThat(actual).as("foreign keys and their ON DELETE rules").isEqualTo(EXPECTED);
assertThat(unindexedForeignKeys()).as("foreign-key columns no index leads with").isEmpty();
}
/*******************************************************************************
** Orphans from before the constraints: recorded, then nulled (nullable
** column) or deleted (NOT NULL column, full row kept); valid rows untouched.
** Running the changelog again changes nothing.
*******************************************************************************/
@Test
void testOrphansAreQuarantined() throws Exception
{
update(preForeignKeyChangelog());
try(Connection connection = connect(); Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO alerts_team (id, name, label) VALUES (1, 'ops', 'Ops')");
statement.execute("INSERT INTO alerts_team_member (id, team_id, user_id) VALUES (1, 1, 'kept'), (2, 999, 'orphan')");
statement.execute("INSERT INTO alerts_type (id, name, group_id) VALUES (1, 'fk.test', 999)");
statement.execute("INSERT INTO alerts_monitor (id, name, alert_type_id, kind) VALUES (1, 'orphan monitor', 999, 'TABLE')");
statement.execute("INSERT INTO alerts_alert (id, alert_type_id, monitor_id, group_id, assigned_team_id) VALUES (1, 1, 1, 999, 1)");
statement.execute("INSERT INTO alerts_event (id, alert_id, event_type) VALUES (1, 1, 'RAISED'), (2, 999, 'RAISED')");
statement.execute("INSERT INTO alerts_delivery (id, alert_id, alert_event_id, channel_id) VALUES (1, 1, 2, 999)");
statement.execute("INSERT INTO alerts_notification_recipient (id, notification_id, user_id) VALUES (1, 999, 'u')");
statement.execute("INSERT INTO alerts_escalation_policy (id, name) VALUES (1, 'p')");
statement.execute("INSERT INTO alerts_escalation_step (id, policy_id, step_order, after_minutes, target_type, team_id) VALUES (1, 1, 1, 0, 'TEAM', 999), (2, 1, 2, 5, 'TEAM', 1)");
}
update(new ClassLoaderResourceAccessor());
Set<String> expected = Set.of(
"alerts_type.group_id 1 999 NULLED",
"alerts_monitor.alert_type_id 1 999 DELETED",
"alerts_alert.monitor_id 1 1 NULLED",
"alerts_alert.group_id 1 999 NULLED",
"alerts_event.alert_id 2 999 DELETED",
"alerts_delivery.alert_event_id 1 2 NULLED",
"alerts_delivery.channel_id 1 999 NULLED",
"alerts_team_member.team_id 2 999 DELETED",
"alerts_notification_recipient.notification_id 1 999 DELETED",
"alerts_escalation_step.team_id 1 999 DELETED");
assertThat(quarantined()).isEqualTo(expected);
try(Connection connection = connect(); Statement statement = connection.createStatement())
{
assertThat(queryRow(statement, "SELECT alert_type_id, monitor_id, group_id, assigned_team_id FROM alerts_alert WHERE id = 1")).containsExactly("1", null, null, "1");
assertThat(queryRow(statement, "SELECT alert_id, alert_event_id, channel_id FROM alerts_delivery WHERE id = 1")).containsExactly("1", null, null);
assertThat(queryRow(statement, "SELECT COUNT(*) FROM alerts_team_member WHERE user_id = 'kept'")).containsExactly("1");
assertThat(queryRow(statement, "SELECT COUNT(*) FROM alerts_escalation_step WHERE id = 2")).containsExactly("1");
assertThat(queryRow(statement, "SELECT row_data FROM qqq_fk_quarantine WHERE table_name = 'alerts_monitor'").get(0)).contains("\"orphan monitor\"");
}
update(new ClassLoaderResourceAccessor());
assertThat(quarantined()).as("a second run quarantines nothing more").isEqualTo(expected);
assertThat(foreignKeys()).isEqualTo(EXPECTED);
}
/*******************************************************************************
** A host that added a constraint itself (same name) marks that changeset
** ran instead of failing on a duplicate constraint.
*******************************************************************************/
@Test
void testExistingConstraintIsSkipped() throws Exception
{
update(preForeignKeyChangelog());
try(Connection connection = connect(); Statement statement = connection.createStatement())
{
statement.execute("ALTER TABLE alerts_event ADD CONSTRAINT fk_alerts_event_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE");
}
update(new ClassLoaderResourceAccessor());
assertThat(foreignKeys()).isEqualTo(EXPECTED);
}
/*******************************************************************************
** The rules at work: an alert takes its timeline, records and delivery log
** with it; a used channel and the type of a monitor cannot be deleted; a
** deleted team unassigns its alerts and takes its members.
*******************************************************************************/
@Test
void testOnDeleteRules() throws Exception
{
update(new ClassLoaderResourceAccessor());
try(Connection connection = connect(); Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO alerts_type (id, name) VALUES (1, 't')");
statement.execute("INSERT INTO alerts_monitor (id, name, alert_type_id, kind) VALUES (1, 'm', 1, 'TABLE')");
statement.execute("INSERT INTO alerts_team (id, name, label) VALUES (1, 'ops', 'Ops')");
statement.execute("INSERT INTO alerts_team_member (team_id, user_id) VALUES (1, 'u')");
statement.execute("INSERT INTO alerts_channel (id, name, label, channel_type) VALUES (1, 'c', 'C', 'EMAIL')");
statement.execute("INSERT INTO alerts_alert (id, alert_type_id, monitor_id, assigned_team_id) VALUES (1, 1, 1, 1)");
statement.execute("INSERT INTO alerts_event (id, alert_id) VALUES (1, 1)");
statement.execute("INSERT INTO alerts_record_ref (alert_id) VALUES (1)");
statement.execute("INSERT INTO alerts_delivery (alert_id, alert_event_id, channel_id) VALUES (1, 1, 1)");
assertThatThrownBy(() -> statement.execute("DELETE FROM alerts_channel WHERE id = 1")).isInstanceOf(SQLException.class);
assertThatThrownBy(() -> statement.execute("DELETE FROM alerts_type WHERE id = 1")).isInstanceOf(SQLException.class);
statement.execute("DELETE FROM alerts_team WHERE id = 1");
assertThat(queryRow(statement, "SELECT assigned_team_id FROM alerts_alert WHERE id = 1")).containsExactly((String) null);
assertThat(queryRow(statement, "SELECT COUNT(*) FROM alerts_team_member")).containsExactly("0");
statement.execute("DELETE FROM alerts_alert WHERE id = 1");
assertThat(queryRow(statement, "SELECT (SELECT COUNT(*) FROM alerts_event) + (SELECT COUNT(*) FROM alerts_record_ref) + (SELECT COUNT(*) FROM alerts_delivery)")).containsExactly("0");
statement.execute("DELETE FROM alerts_channel WHERE id = 1");
}
}
/*******************************************************************************
** child_table.child_column -> parent_table for every one-to-many join and
** every field whose possible values are an alerts table.
*******************************************************************************/
private static Map<String, String> declaredRelations() throws Exception
{
Map<String, String> relations = new TreeMap<>();
Set<String> tables = new TreeSet<>(AlertsQBitConfig.allTableNames());
for(QTableMetaData table : AlertsTableMetaDataHelper.buildAllTables(new AlertsQBitConfig()))
{
for(QFieldMetaData field : table.getFields().values())
{
if(field.getPossibleValueSourceName() != null && tables.contains(field.getPossibleValueSourceName()))
{
relations.put(table.getName() + "." + QInstanceEnricher.inferBackendName(field.getName()), field.getPossibleValueSourceName());
}
}
}
for(QJoinMetaData join : AlertsTableMetaDataHelper.buildJoins())
{
relations.put(join.getRightTable() + "." + QInstanceEnricher.inferBackendName(join.getJoinOns().get(0).getRightField()), join.getLeftTable());
}
return (relations);
}
/*******************************************************************************
** The changelog up to (not including) the foreign-key changesets, at the
** same path, so the full changelog later continues where it stopped.
*******************************************************************************/
private ResourceAccessor preForeignKeyChangelog() throws Exception
{
String changelog;
try(InputStream in = getClass().getClassLoader().getResourceAsStream(CHANGELOG))
{
changelog = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
Path file = tempDir.resolve(CHANGELOG);
Files.createDirectories(file.getParent());
Files.writeString(file, changelog.substring(0, changelog.indexOf("-- changeset qbit-alerts:31")));
return (new DirectoryResourceAccessor(tempDir));
}
/*******************************************************************************
** Run Liquibase update with the changelog from the given accessor.
*******************************************************************************/
private static void update(ResourceAccessor accessor) throws Exception
{
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connect()));
try(Liquibase liquibase = new Liquibase(CHANGELOG, accessor, database))
{
liquibase.update(new Contexts(), new LabelExpression());
}
}
/*******************************************************************************
** child_table.child_column -> "parent_table rule" for every foreign key.
*******************************************************************************/
private static Map<String, String> foreignKeys() throws Exception
{
Map<String, String> keys = new TreeMap<>();
try(Connection connection = connect(); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("""
SELECT c.conrelid::regclass::text, a.attname, c.confrelid::regclass::text, c.confdeltype
FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
WHERE c.contype = 'f' AND c.conrelid::regclass::text LIKE 'alerts%'"""))
{
while(result.next())
{
keys.put(result.getString(1) + "." + result.getString(2), result.getString(3) + " " + result.getString(4));
}
}
return (keys);
}
/*******************************************************************************
** Foreign keys whose column does not lead any index on the child table.
*******************************************************************************/
private static List<String> unindexedForeignKeys() throws Exception
{
List<String> missing = new ArrayList<>();
try(Connection connection = connect(); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("""
SELECT c.conname FROM pg_constraint c
WHERE c.contype = 'f' AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1])"""))
{
while(result.next())
{
missing.add(result.getString(1));
}
}
return (missing);
}
/*******************************************************************************
** The quarantine log as "table.column rowId missingParentId action".
*******************************************************************************/
private static Set<String> quarantined() throws Exception
{
Set<String> rows = new TreeSet<>();
try(Connection connection = connect(); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT table_name, column_name, row_id, missing_parent_id, action, changeset FROM qqq_fk_quarantine"))
{
while(result.next())
{
assertThat(result.getString(6)).startsWith("qbit-alerts:");
rows.add(result.getString(1) + "." + result.getString(2) + " " + result.getString(3) + " " + result.getString(4) + " " + result.getString(5));
}
}
return (rows);
}
/*******************************************************************************
** The first row of a query, as strings.
*******************************************************************************/
private static List<String> queryRow(Statement statement, String sql) throws SQLException
{
try(ResultSet result = statement.executeQuery(sql))
{
assertThat(result.next()).as(sql).isTrue();
List<String> values = new ArrayList<>();
for(int i = 1; i <= result.getMetaData().getColumnCount(); i++)
{
values.add(result.getString(i));
}
return (values);
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Connection connect() throws SQLException
{
return (DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()));
}
}
@@ -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;
@@ -59,6 +64,8 @@ class AlertsQBitProducerTest extends BaseAlertsTest
void testProduce()
{
assertThat(qInstance.getQBits()).hasSize(1);
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank().isEqualTo(AlertsQBitProducer.VERSION);
assertThat(qInstance.getQBits().values().iterator().next().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion"));
assertThat(qInstance.getQBits().values().iterator().next().getArtifactId()).isEqualTo(AlertsQBitProducer.ARTIFACT_ID);
assertThat(AlertsRuntime.find(qInstance)).isSameAs(runtime);
assertThat(AlertsRuntime.find(new QInstance())).isNull();
@@ -69,6 +76,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,7 +206,154 @@ 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);
}
////////////////////////////////////////////////////////////////////////////////////
// the foreign-key changesets (31+) use PostgreSQL functions; AlertsForeignKeysTest //
// runs the whole changelog on PostgreSQL //
////////////////////////////////////////////////////////////////////////////////////
String preForeignKeys = changelog.substring(0, changelog.indexOf("-- changeset qbit-alerts:31"));
String sql = preForeignKeys.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')");
}
}
/*******************************************************************************
** Changeset 29 upgrades existing Slack preferences once and preserves their
** values; its MARK_RAN guard skips a database that already has the column.
*******************************************************************************/
@Test
void testInAppSeverityMigration() 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 block = changelog.substring(changelog.indexOf("-- changeset qbit-alerts:29"));
assertThat(block).contains("-- preconditions onFail:MARK_RAN onError:HALT");
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();
try(Connection connection = DriverManager.getConnection("jdbc:h2:mem:alerts_preferences_migration"); Statement statement = connection.createStatement())
{
statement.execute("CREATE TABLE alerts_notification_preference (user_id VARCHAR(100), slack_mode VARCHAR(16), slack_min_severity VARCHAR(16))");
statement.execute("INSERT INTO alerts_notification_preference VALUES ('viewer-1', 'DAILY_DIGEST', 'ERROR')");
for(int pass = 0; pass < 2; pass++)
{
try(ResultSet result = statement.executeQuery(guard.substring(guard.indexOf("SELECT"))))
{
assertThat(result.next()).isTrue();
assertThat(result.getInt(1)).isEqualTo(pass);
}
if(pass == 0)
{
statement.execute(sql);
}
}
try(ResultSet result = statement.executeQuery("SELECT slack_mode, slack_min_severity, in_app_min_severity FROM alerts_notification_preference"))
{
assertThat(result.next()).isTrue();
assertThat(result.getString(1)).isEqualTo("DAILY_DIGEST");
assertThat(result.getString(2)).isEqualTo("ERROR");
assertThat(result.getString(3)).isNull();
}
}
}
@@ -216,4 +387,68 @@ 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();
}
/*******************************************************************************
** Every one-to-many join's child column leads an index in the shipped
** changelog, so record pages, child lists and per-parent lookups do not scan
** the child table.
*******************************************************************************/
@Test
void testJoinChildColumnsAreIndexed() 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);
}
for(com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData join : AlertsTableMetaDataHelper.buildJoins())
{
String table = join.getRightTable();
String column = join.getJoinOns().get(0).getRightField().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase();
java.util.regex.Pattern index = java.util.regex.Pattern.compile("(?i)CREATE (UNIQUE )?INDEX (IF NOT EXISTS )?\\w+ ON " + table + " \\(" + column + "[,)]");
java.util.regex.Pattern unique = java.util.regex.Pattern.compile("(?is)CREATE TABLE IF NOT EXISTS " + table + " \\((?:(?!\\);).)*UNIQUE \\(" + column + "[,)]");
//////////////////////////////////////////////////////////////////////
// alerts_delivery was renamed from the RC.1 alerts_notification; //
// its indexes came along by ALTER INDEX ... RENAME TO ix_<t>_<col> //
//////////////////////////////////////////////////////////////////////
boolean renamed = changelog.contains("RENAME TO ix_" + table + "_" + column + ";") || changelog.contains("RENAME TO ix_" + table + "_" + column + "_");
assertThat(index.matcher(changelog).find() || unique.matcher(changelog).find() || renamed)
.as("index leading with " + table + "." + column + " for join " + join.getName())
.isTrue();
}
}
}
@@ -0,0 +1,52 @@
/*
* 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 com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.serviceidentity.QDelegatedSession;
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Per-recipient checks run on the recipient's behalf through the alerts
** service principal when the host declares it; otherwise unchanged.
*******************************************************************************/
class AlertsServicePrincipalTest extends BaseAlertsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void recipientSessionsGoThroughTheDeclaredPrincipal() throws Exception
{
QSession before = AlertsSecurity.recipientSession(qInstance, config.getServicePrincipal(), "alice");
assertThat(before).isNotInstanceOf(QDelegatedSession.class);
assertThat(before.getUser().getIdReference()).isEqualTo("alice");
ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("alerts").withMayActOnBehalfOfUsers(true));
QSession after = AlertsSecurity.recipientSession(qInstance, config.getServicePrincipal(), "alice");
assertThat(after).isInstanceOf(QDelegatedSession.class);
assertThat(after.getUser().getIdReference()).isEqualTo("alice");
assertThat(ServiceIdentities.principalOf(after)).isEqualTo("alerts");
assertThat(ServiceIdentities.attributionName(after)).endsWith("via service:alerts");
}
}
@@ -0,0 +1,56 @@
/*
* 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 com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Background threads (the alerts sink, schedulers) run engine work through
** asSystem with no QContext of their own; the system session must be built
** from the runtime instance, not from the (empty) context.
*******************************************************************************/
class AlertsSystemSessionTest extends BaseAlertsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void asSystemWithoutContextUsesTheRuntimeInstance() throws Exception
{
QContext.clear();
QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(QSystemUserSession.class);
try
{
Boolean systemUser = runtime.asSystem(() -> QContext.getQInstance() != null && AlertsSecurity.isSystemUser());
assertThat(systemUser).isTrue();
assertThat(collectingLogger.getCollectedMessages())
.noneMatch(m -> m.getMessage().contains("QInstance was not supplied"));
}
finally
{
QLogger.deactivateCollectingLoggerForClass(QSystemUserSession.class);
}
}
}
@@ -0,0 +1,218 @@
/*
* 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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated
** and the qbit should be fully using them"). For every alerts_* table:
** - runtime code (not just its model, metadata, config or home screen) reads
** or writes it, so no table exists only on paper;
** - it is either seeded by the shipped defaults changelog, or listed here
** with why it starts empty;
** - it has bundle help that says what it is for and how its rows get created.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class AlertsTablesGuardrailTest extends BaseAlertsTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "alerts");
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry("alerts_type", "synced from the alert types the installed qbits declare in code, on the first visit (AlertTypeSync)"),
Map.entry("alerts_monitor", "default monitors are shipped by the declaring qbits and seeded by AlertTypeSync; admins add the rest"),
Map.entry("alerts_alert", "runtime: a producer or monitor raises an alert"),
Map.entry("alerts_record_ref", "runtime: written with an alert that names records"),
Map.entry("alerts_event", "runtime: the alert timeline"),
Map.entry("alerts_subscription", "people's own choices; the first alerts admin gets one by the bootstrap rule (AlertsPreferences)"),
Map.entry("alerts_user_preference", "user content: created per person on their first visit (AlertsPreferences)"),
Map.entry("alerts_team", "the host's own people"),
Map.entry("alerts_team_member", "the host's own people"),
Map.entry("alerts_mute", "user content: maintenance windows and mutes people create"),
Map.entry("alerts_delivery", "runtime log: every delivery attempt"),
Map.entry("alerts_notification", "user content and producer messages"),
Map.entry("alerts_notification_recipient", "runtime: one per person a notification reaches"),
Map.entry("alerts_notification_preference", "user content: a person's per-category choice; defaults apply without a row"),
Map.entry("alerts_heartbeat", "runtime: written by QAlerts.heartbeat and completed processes"),
Map.entry("alerts_escalation_policy", "host-specific: names real people, teams and channels"),
Map.entry("alerts_escalation_step", "host-specific: names real people, teams and channels"));
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
Map<String, String> entityByTable = entityByTable();
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("home")) && !p.endsWith("AlertsQBitConfig.java"))
.map(AlertsTablesGuardrailTest::read).toList();
}
for(String table : alertsTables())
{
String entity = entityByTable.get(table);
assertThat(entity).as("entity class for " + table).isNotNull();
Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
String changelog = AlertsDefaultsChangelogTest.changelogText();
for(String table : alertsTables())
{
boolean seeded = AlertsDefaultsChangelogTest.SEEDED_TABLES.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
}
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance.getTable(table) != null, "STARTS_EMPTY names only real tables");
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
// Core adds bulk CRUD processes during validation; the qbit owns its declared actions and jobs.
var processes = AlertsProcessesMetaDataProducer.buildProcesses(config).stream().map(process -> process.getName()).toList();
HelpBundleAssertions.forInstance(qInstance)
.bundles("help/qbit-alerts.yaml")
.owning(target -> "process".equals(target.kind()) ? processes.contains(target.name()) : target.name().startsWith("alerts"))
.assertComplete();
var entries = HelpBundles.of(qInstance).getBundles().get(0).getEntries();
// Core's gate does not yet require widget and possible-value-source help.
var keys = entries.stream().map(entry -> entry.getKey()).toList();
qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("alerts"))
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:help"));
qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("alerts"))
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
for(String table : alertsTables())
{
assertThat(entries).anySatisfy(entry ->
{
assertThat(entry.getKey()).isEqualTo("table:" + table + ";slot:help");
assertThat(entry.getContent()).contains("How rows get created");
});
}
}
/*******************************************************************************
** Bundle registration needs no help table; core safely skips startup sync.
*******************************************************************************/
@Test
void hostWithoutHelpTableIsSupported()
{
assertThat(qInstance.getTable("helpContent")).isNull();
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-alerts");
assertThat(bundle.getVersion()).as("the help bundle carries the artifact and producer version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(AlertsQBitProducer.VERSION);
});
HelpBundles.syncOnStartup(qInstance);
}
/*******************************************************************************
**
*******************************************************************************/
private List<String> alertsTables()
{
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("alerts_")).sorted().toList();
assertThat(tables).hasSizeGreaterThan(15);
return (tables);
}
/*******************************************************************************
** model/X.java declares TABLE_NAME = "x".
*******************************************************************************/
private static Map<String, String> entityByTable() throws Exception
{
Map<String, String> rs = new HashMap<>();
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
try(Stream<Path> files = Files.list(MAIN.resolve("model")))
{
for(Path file : files.toList())
{
Matcher m = p.matcher(read(file));
if(m.find())
{
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -0,0 +1,58 @@
/*
* 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 org.junit.jupiter.api.extension.ConditionEvaluationResult;
import org.junit.jupiter.api.extension.ExecutionCondition;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.DockerClientFactory;
/*******************************************************************************
** Runs a PostgreSQL (Testcontainers) test when Docker is available; skips it
** on a workstation without Docker, and fails when CI is set but Docker is not.
*******************************************************************************/
public class RequiresDockerCondition implements ExecutionCondition
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context)
{
boolean available;
try
{
available = DockerClientFactory.instance().isDockerAvailable();
}
catch(Exception | LinkageError e)
{
available = false;
}
if(available)
{
return (ConditionEvaluationResult.enabled("Docker is available"));
}
if(System.getenv("CI") != null)
{
throw new IllegalStateException("Docker is required when CI is set");
}
return (ConditionEvaluationResult.disabled("Docker is unavailable; the PostgreSQL schema tests need it"));
}
}
@@ -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);
}
}
@@ -279,6 +279,10 @@ public class AlertsDemoServer extends AbstractQQQApplication
{
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", ADMIN));
AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", preference.getValue("id")).withValue("emailAddress", "dana@example.com"));
AlertsDb.insert("alerts_notification_preference", new QRecord().withValue("userId", ADMIN).withValue("category", "JOB")
.withValue("inAppMinSeverity", "WARNING").withValue("emailMode", "IMMEDIATE").withValue("emailMinSeverity", "ERROR"));
AlertsDb.insert("alerts_notification_preference", new QRecord().withValue("userId", ADMIN).withValue("category", "RELEASE")
.withValue("emailMode", "DAILY_DIGEST").withValue("emailMinSeverity", "INFO"));
QRecord list = AlertsDb.insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "ops-list").withValue("label", "Ops email list").withValue("channelType", "EMAIL_LIST")
.withValue("enabled", true).withValue("settings", JsonUtils.toJson(Map.of("addresses", List.of("ops@example.com")))).withValue("detailLevel", "MINIMAL")
.withValue("minSeverity", "ERROR").withValue("rateLimitPerHour", 60).withValue("healthStatus", "HEALTHY").withValue("consecutiveFailures", 0));
@@ -22,23 +22,28 @@ import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.alerts.AlertsPreferences;
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;
import com.kingsrook.qbits.email.EmailQBitProducer;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.metadata.EmailTables;
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;
@@ -64,7 +69,8 @@ class EmailQBitSendingTest extends BaseAlertsTest
{
/*******************************************************************************
** The email qbit in the same instance, and the alerts config without a
** messaging provider (qbit-email makes it unnecessary).
** messaging provider (qbit-email makes it unnecessary). G1 permits the
** producer and configuration imports in test setup.
*******************************************************************************/
@Override
protected void customizeInstance(QInstance qInstance)
@@ -116,7 +122,7 @@ class EmailQBitSendingTest extends BaseAlertsTest
*******************************************************************************/
private void addProvider() throws Exception
{
runtime.asSystem(() -> AlertsDb.insert(EmailTables.PROVIDER, new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP")
runtime.asSystem(() -> AlertsDb.insert("email_provider", new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP")
.withValue("host", "127.0.0.1").withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com")
.withValue("enabled", true).withValue("isDefault", true)));
}
@@ -124,12 +130,18 @@ class EmailQBitSendingTest extends BaseAlertsTest
/*******************************************************************************
** The queued messages, oldest first.
** Find messages through the API, oldest first. Read the fixture rows with
** core QQQ for content assertions: the API deliberately omits bodies,
** recipients and sender details from its metadata projection.
*******************************************************************************/
private List<QRecord> messages() throws QException
{
return (runtime.asSystem(() -> AlertsDb.query(EmailTables.MESSAGE, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter().withOrderBy(
new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id")))));
List<QRecord> messages = new ArrayList<>();
for(EmailApiMessage message : EmailApi.findMessagesByIdempotencyKeyPrefix("alerts:", 100))
{
messages.add(runtime.asSystem(() -> AlertsDb.byId("email_message", message.id())));
}
return (messages);
}
@@ -139,7 +151,7 @@ class EmailQBitSendingTest extends BaseAlertsTest
*******************************************************************************/
private String templateName(QRecord message) throws QException
{
return (runtime.asSystem(() -> AlertsDb.byId(EmailTables.TEMPLATE, message.getValue("templateId"))).getValueString("name"));
return (EmailApi.getMessage(message.getValueInteger("id")).orElseThrow().templateName());
}
@@ -184,7 +196,7 @@ class EmailQBitSendingTest extends BaseAlertsTest
{
assertThat(AlertsEmail.isActive()).isTrue();
assertThat(AlertsEmail.isActive(null)).isFalse();
assertThat(EmailRuntime.config(qInstance)).isNotNull();
assertThat(EmailApi.isInstalled(qInstance)).isTrue();
assertThat(new EmailChannel(new AlertsQBitConfig(), ChannelType.EMAIL).validate(null, null)).isEmpty();
assertThat(new NotificationEmailer(runtime).isConfigured()).isTrue();
}
@@ -201,7 +213,8 @@ class EmailQBitSendingTest extends BaseAlertsTest
{
addProvider();
EmailChannel channel = new EmailChannel(config, ChannelType.EMAIL);
assertThat(channel.deliver(alertMessage(), null, "ops@example.com", null).outcome()).isEqualTo(DeliveryResult.Outcome.SENT);
DeliveryResult delivered = channel.deliver(alertMessage(), null, "ops@example.com", null);
assertThat(delivered.outcome()).as("Delivery outcome: %s", delivered.error()).isEqualTo(DeliveryResult.Outcome.SENT);
List<QRecord> messages = messages();
assertThat(messages).hasSize(1);
@@ -267,7 +280,7 @@ class EmailQBitSendingTest extends BaseAlertsTest
void testSuppressedAddress() throws Exception
{
addProvider();
runtime.asSystem(() -> AlertsDb.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "ops@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE")));
runtime.asSystem(() -> AlertsDb.insert("email_suppression", new QRecord().withValue("address", "ops@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE")));
DeliveryResult result = new EmailChannel(config, ChannelType.EMAIL).deliver(alertMessage(), null, "ops@example.com", null);
assertThat(result.outcome()).isEqualTo(DeliveryResult.Outcome.FAILED);
assertThat(result.error()).contains("suppressed");
@@ -344,7 +357,7 @@ class EmailQBitSendingTest extends BaseAlertsTest
loginAs(ADMIN);
QAlerts.notify(new NotificationSignal("Export failed").withSeverity(NotificationSeverity.WARNING).withCategory(NotificationCategory.JOB)
.withAudience(NotificationAudience.users("viewer-1")));
runtime.asSystem(() -> AlertsDb.insert(EmailTables.SUPPRESSION, new QRecord().withValue("address", "viewer-1@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE")));
runtime.asSystem(() -> AlertsDb.insert("email_suppression", new QRecord().withValue("address", "viewer-1@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE")));
NotificationEmailer.Result suppressed = new NotificationEmailer(runtime).run();
assertThat(suppressed.skipped()).isEqualTo(1);
assertThat(suppressed.sent()).isEqualTo(0);
@@ -383,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,306 @@
/*
* 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.util.List;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.TestSteps;
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
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.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.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.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Two settings that used to be stored but never read, now enforced by the
** router: a group's owner team (the fallback audience for an alert nobody
** subscribed to) and a user's default channels.
*******************************************************************************/
class AlertsWiringTest extends BaseAlertsTest
{
static final String TYPE = "test.system.down";
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeConfig(AlertsQBitConfig config)
{
config.withChannelType(new QCodeReference(TestSteps.RecordingChannel.class));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeInstance(QInstance qInstance)
{
AlertsInstanceMetaData.ofOrWithNew(qInstance)
.withAlertType(new QAlertTypeMetaData().withName(TYPE).withLabel("System down").withGroupName("infrastructure").withDefaultSeverity(AlertSeverity.CRITICAL));
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void reset()
{
TestSteps.SENT.clear();
TestSteps.nextOutcome = DeliveryResult.Outcome.SENT;
}
/*******************************************************************************
** A team with these members; returns its id.
*******************************************************************************/
private Integer team(String name, String... members) throws QException
{
Integer teamId = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", name).withValue("label", name)).getValueInteger("id"));
for(String member : members)
{
runtime.asSystem(() -> AlertsDb.insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", teamId).withValue("userId", member)));
}
return (teamId);
}
/*******************************************************************************
** The group row for the test type (synced on first use).
*******************************************************************************/
private QRecord group(String name) throws QException
{
runtime.getTypeSync().syncIfNeeded();
return (runtime.asSystem(() -> AlertsDb.first(AlertsGroup.TABLE_NAME, AlertsDb.eq("name", name))));
}
/*******************************************************************************
** The users who were sent an alert, sorted.
*******************************************************************************/
private List<String> recipients() throws QException
{
return (all(AlertsDelivery.TABLE_NAME).stream().map(n -> n.getValueString("recipientUserId")).distinct().sorted().toList());
}
/*******************************************************************************
** A user subscription to everything at WARNING and worse.
*******************************************************************************/
private void subscribe(String userId) throws QException
{
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", userId)
.withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("events", "RAISED").withValue("deliveryMode", "IMMEDIATE").withValue("enabled", true)));
}
/*******************************************************************************
** An alert nobody subscribed to goes to its group's owner team; once a
** subscription matches, the owner team is not added.
*******************************************************************************/
@Test
void ownerTeamIsTheFallbackAudience() throws Exception
{
Integer teamId = team("infra", "owner-1", "owner-2");
QRecord group = group("infrastructure");
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId)));
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("a").withTitle("Down"));
assertThat(recipients()).containsExactly("owner-1", "owner-2");
assertThat(all(AlertsDelivery.TABLE_NAME)).allMatch(n -> "SENT".equals(n.getValueString("status")));
List<java.io.Serializable> before = all(AlertsDelivery.TABLE_NAME).stream().map(n -> n.getValue("id")).toList();
subscribe("subscriber");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("b").withTitle("Down again"));
List<QRecord> second = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !before.contains(n.getValue("id"))).toList();
assertThat(second).extracting(n -> n.getValueString("recipientUserId")).containsExactly("subscriber");
}
/*******************************************************************************
** The nearest parent group with an owner team is used; a group with no
** owner anywhere leaves the alert unrouted, as before.
*******************************************************************************/
@Test
void ownerTeamComesFromTheNearestParentGroup() throws Exception
{
QRecord group = group("infrastructure");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("none").withTitle("Down"));
assertThat(recipients()).as("no owner team anywhere: nobody is notified").isEmpty();
Integer teamId = team("platform", "parent-owner");
Integer parent = runtime.asSystem(() -> AlertsDb.insert(AlertsGroup.TABLE_NAME, new QRecord().withValue("name", "platform").withValue("label", "Platform")
.withValue("ownerTeamId", teamId)).getValueInteger("id"));
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("parentGroupId", parent)));
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("parent").withTitle("Down"));
assertThat(recipients()).containsExactly("parent-owner");
}
/*******************************************************************************
** The owner fallback is for alerts that matter: an INFO alert is not sent
** to the owner team.
*******************************************************************************/
@Test
void ownerTeamIsNotToldAboutInfo() throws Exception
{
Integer teamId = team("infra", "owner-1");
QRecord group = group("infrastructure");
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId)));
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("info").withSeverity(AlertSeverity.INFO).withTitle("FYI"));
assertThat(recipients()).isEmpty();
}
/*******************************************************************************
** A user's default channels replace the built-in email choice, but the bell
** stays unless the user turned it off.
*******************************************************************************/
@Test
void defaultChannelsDecideWhereAUserIsReached() throws Exception
{
QRecord chat = runtime.asSystem(() -> AlertsDb.insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "chat").withValue("label", "Chat")
.withValue("channelType", "SLACK").withValue("enabled", true).withValue("detailLevel", "STANDARD").withValue("rateLimitPerHour", 60)
.withValue("healthStatus", "HEALTHY").withValue("consecutiveFailures", 0)));
String chatId = String.valueOf(chat.getValue("id"));
subscribe("u1");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("plain").withTitle("Down"));
List<QRecord> plain = all(AlertsDelivery.TABLE_NAME);
assertThat(plain).singleElement().matches(n -> !chatId.equals(String.valueOf(n.getValue("channelId"))), "went to the built-in in-app channel");
QRecord preference = runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "u1").withValue("userLabel", "One")
.withValue("inAppEnabled", true).withValue("emailEnabled", false).withValue("defaultChannelIds", "not-a-number, " + chatId + ", 99999")));
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("chosen").withTitle("Down again"));
List<QRecord> chosen = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !n.getValue("id").equals(plain.get(0).getValue("id"))).toList();
assertThat(chosen).hasSize(2);
assertThat(chosen).anyMatch(n -> chatId.equals(String.valueOf(n.getValue("channelId"))));
assertThat(chosen).anyMatch(n -> !chatId.equals(String.valueOf(n.getValue("channelId"))));
runtime.asSystem(() -> AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", preference.getValue("id")).withValue("inAppEnabled", false)));
List<Object> before = all(AlertsDelivery.TABLE_NAME).stream().map(n -> (Object) n.getValue("id")).toList();
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("no-bell").withTitle("Down a third time"));
List<QRecord> third = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !before.contains(n.getValue("id"))).toList();
assertThat(third).singleElement().matches(n -> chatId.equals(String.valueOf(n.getValue("channelId"))), "only the chosen channel when the bell is off");
}
/*******************************************************************************
** A subscription that covers the alert by scope and severity, but only for
** some events, still counts as ownership: RESOLVED does not fall through to
** the owner team.
*******************************************************************************/
@Test
void aSubscriptionWithFewerEventsStillCoversTheAlert() throws Exception
{
Integer teamId = team("infra", "owner-1");
QRecord group = group("infrastructure");
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId)));
subscribe("subscriber");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("a").withTitle("Down"));
runtime.getEngine().resolve(TYPE, "a", Resolution.PRODUCER, null, "fixed");
assertThat(recipients()).containsExactly("subscriber");
}
/*******************************************************************************
** Follow-ups (resolved, reminders) go to the owner team only when it was
** told about the raise.
*******************************************************************************/
@Test
void ownerTeamGetsFollowUpsOnlyAfterTheRaise() throws Exception
{
Integer teamId = team("infra", "owner-1");
QRecord group = group("infrastructure");
/////////////////////////////////////////////////////////////////
// raised with no owner team: nobody is told, so nobody is told //
// about the resolution even though a team is named afterwards //
/////////////////////////////////////////////////////////////////
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("early").withTitle("Down"));
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId)));
runtime.getEngine().resolve(TYPE, "early", Resolution.PRODUCER, null, "fixed");
assertThat(recipients()).isEmpty();
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("late").withTitle("Down"));
assertThat(recipients()).containsExactly("owner-1");
runtime.getEngine().resolve(TYPE, "late", Resolution.PRODUCER, null, "fixed");
assertThat(all(AlertsDelivery.TABLE_NAME).stream().filter(n -> "RESOLVED".equals(n.getValueString("kind")))).hasSize(1);
}
/*******************************************************************************
** A failing type sync (a unique-key race between replicas, say) never breaks
** the bell; the next request retries.
*******************************************************************************/
@Test
void aFailingSyncDoesNotBreakTheBell() throws Exception
{
try(java.sql.Connection connection = com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.getConnection((com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME));
java.sql.Statement statement = connection.createStatement())
{
statement.execute("ALTER TABLE alerts_type RENAME TO alerts_type_gone");
try
{
assertThat(new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).requireViewer()).isEqualTo(ADMIN);
}
finally
{
statement.execute("ALTER TABLE alerts_type_gone RENAME TO alerts_type");
}
}
assertThat(new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).requireViewer()).isEqualTo(ADMIN);
assertThat(all("alerts_type")).isNotEmpty();
}
}
@@ -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;
@@ -134,6 +135,76 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
/*******************************************************************************
** First calls for one user race (the home widgets and the bell load
** together): only one bootstrap subscription is created.
*******************************************************************************/
@Test
void testConcurrentFirstCallsBootstrapOnce() throws Exception
{
com.kingsrook.qqq.backend.core.context.CapturedContext context = QContext.capture();
java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(4);
try
{
java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1);
List<java.util.concurrent.Future<Object>> futures = new java.util.ArrayList<>();
for(int i = 0; i < 4; i++)
{
futures.add(pool.submit(() ->
{
QContext.init(context);
start.await();
AlertsPreferences.ensure(runtime, QContext.getQSession());
return (null);
}));
}
start.countDown();
for(java.util.concurrent.Future<Object> future : futures)
{
future.get();
}
}
finally
{
pool.shutdownNow();
}
assertThat(all(AlertsSubscription.TABLE_NAME)).hasSize(1);
assertThat(all(AlertsUserPreference.TABLE_NAME)).hasSize(1);
}
/*******************************************************************************
** Another replica raced and left a second bootstrap subscription: the next
** first call keeps the oldest and leaves a person's own subscriptions alone.
*******************************************************************************/
@Test
void testDuplicateBootstrapSubscriptionsAreRemoved() throws Exception
{
for(String creator : List.of("alerts (bootstrap rule)", "alerts (bootstrap rule)", ADMIN))
{
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
.withValue("subscriberType", "USER")
.withValue("userId", ADMIN)
.withValue("scopeType", "ALL")
.withValue("minSeverity", "ERROR")
.withValue("events", "RAISED")
.withValue("deliveryMode", "IMMEDIATE")
.withValue("enabled", true)
.withValue("createdByUserId", creator)));
}
List<QRecord> before = all(AlertsSubscription.TABLE_NAME);
assertThat(before).hasSize(3);
AlertsPreferences.ensure(runtime, QContext.getQSession());
List<QRecord> after = all(AlertsSubscription.TABLE_NAME);
assertThat(after).hasSize(2);
assertThat(after).extracting(r -> r.getValueInteger("id")).containsExactlyInAnyOrder(before.get(0).getValueInteger("id"), before.get(2).getValueInteger("id"));
}
/*******************************************************************************
** No subscriptions: nobody is notified. The bootstrap rule gives admins an
** in-app subscription to ERROR and worse on first visit.
@@ -200,6 +271,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,166 @@
/*
* 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.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Engine bookkeeping writes (monitor leases and run results, alert reminder
** and notification timers) create no audit rows; configuration edits and
** alert state changes still do.
*******************************************************************************/
class RuntimeFieldsAuditTest extends BaseAlertsTest
{
static final String TYPE = "test.widget.broken";
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeInstance(QInstance qInstance)
{
AlertsInstanceMetaData.ofOrWithNew(qInstance)
.withAlertType(new QAlertTypeMetaData().withName(TYPE).withLabel("Widget broken").withGroupName("widgets").withSubjectTableName(WIDGET).withDefaultSeverity(AlertSeverity.WARNING));
}
/*******************************************************************************
** Core audit tables on the test backend.
*******************************************************************************/
@BeforeEach
void auditTables() throws Exception
{
new AuditsMetaDataProvider().defineAll(qInstance, BACKEND_NAME, null);
new QInstanceEnricher(qInstance).enrich();
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
{
for(String table : List.of("auditDetail", "audit", "auditUser", "auditTable"))
{
statement.execute("DROP TABLE IF EXISTS " + table);
}
statement.execute("CREATE TABLE auditUser (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE auditTable (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), label VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE audit (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditTableId INTEGER, auditUserId INTEGER, recordId INTEGER, message VARCHAR(250), timestamp TIMESTAMP)");
statement.execute("CREATE TABLE auditDetail (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditId BIGINT, message VARCHAR(250), fieldName VARCHAR(100), oldValue VARCHAR(250), newValue VARCHAR(250))");
}
}
/*******************************************************************************
** Edited-record audits for one table.
*******************************************************************************/
private List<QRecord> edits(String tableName) throws Exception
{
List<QRecord> tables = where("auditTable", AlertsDb.eq("name", tableName));
if(tables.isEmpty())
{
return (List.of());
}
return (where("audit", AlertsDb.eq("auditTableId", tables.get(0).getValue("id")).withCriteria("message", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, "Record was Edited")));
}
/*******************************************************************************
** Changed field names of one table's edit audits.
*******************************************************************************/
private List<String> editedFields(String tableName) throws Exception
{
List<Object> ids = edits(tableName).stream().map(r -> r.getValue("id")).map(Object.class::cast).toList();
return (all("auditDetail").stream().filter(d -> ids.contains(d.getValue("auditId"))).map(d -> d.getValueString("fieldName")).toList());
}
/*******************************************************************************
** The classifier: bookkeeping only, or not.
*******************************************************************************/
@Test
void classifiesBookkeepingOnlyUpdates()
{
assertThat(RuntimeFields.onlyRuntime(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", 1).withValue("claimedBy", "x").withValue("claimedUntil", Instant.now()))).isTrue();
assertThat(RuntimeFields.onlyRuntime(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", 1).withValue("lastRunAt", Instant.now()).withValue("enabled", false))).isFalse();
assertThat(RuntimeFields.onlyRuntime(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", 1).withValue("timedNotificationCheckedAt", Instant.now()))).isTrue();
assertThat(RuntimeFields.onlyRuntime(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", 1).withValue("status", "ACKNOWLEDGED"))).isFalse();
assertThat(RuntimeFields.onlyRuntime(WIDGET, new QRecord().withValue("id", 1).withValue("checkedAt", Instant.now()))).isFalse();
}
/*******************************************************************************
** Evaluations and reminder checks write no monitor or alert edit audits;
** an admin edit and an acknowledgement do.
*******************************************************************************/
@Test
void evaluationsAreNotAuditedButEditsAndTransitionsAre() throws Exception
{
insertWidgets("BROKEN", "OK");
runtime.getTypeSync().syncIfNeeded();
QRecord type = runtime.asSystem(() -> AlertsDb.first(AlertsType.TABLE_NAME, AlertsDb.eq("name", TYPE)));
Integer monitorId = runtime.asSystem(() -> AlertsDb.insert(AlertsMonitor.TABLE_NAME, new QRecord()
.withValue("name", "Broken widgets").withValue("alertTypeId", type.getValue("id")).withValue("kind", "TABLE_QUERY").withValue("enabled", true)
.withValue("definition", JsonUtils.toJson(Map.of("tableName", WIDGET, "filter", Map.of("criteria", List.of(Map.of("fieldName", "status", "operator", "EQUALS", "values", List.of("BROKEN")))))))
.withValue("perRecord", false).withValue("scheduleSeconds", 60).withValue("forMinutes", 0).withValue("consecutiveFailures", 0)).getValueInteger("id"));
for(int run = 0; run < 3; run++)
{
runtime.getEvaluator().evaluateDue();
new EscalationAndReminder(runtime).runDue();
clock.advance(java.time.Duration.ofMinutes(2));
}
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsMonitor.TABLE_NAME, monitorId)).getValueInstant("lastRunAt")).isNotNull();
assertThat(edits(AlertsMonitor.TABLE_NAME)).as("monitor runs are bookkeeping").isEmpty();
assertThat(editedFields(AlertsAlert.TABLE_NAME)).as("reminder checks and ref counts are bookkeeping")
.doesNotContain("timedNotificationCheckedAt", "recordRefCount", "lastNotifiedAt");
runtime.asSystem(() -> AlertsDb.update(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", monitorId).withValue("name", "Broken widgets (renamed)")));
runtime.asSystem(() -> AlertsDb.update(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", monitorId).withValue("enabled", false).withValue("nextRunAt", null)));
assertThat(edits(AlertsMonitor.TABLE_NAME)).hasSize(2);
assertThat(editedFields(AlertsMonitor.TABLE_NAME)).contains("name", "enabled");
QRecord alert = where(AlertsAlert.TABLE_NAME, AlertsDb.eq("monitorId", monitorId)).get(0);
runtime.getEngine().acknowledge(alert.getValueInteger("id"), ADMIN);
assertThat(editedFields(AlertsAlert.TABLE_NAME)).contains("status");
}
}
@@ -0,0 +1,105 @@
/*
* 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.home;
import java.util.List;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.home.AlertsSetupStatus.Item;
import com.kingsrook.qbits.alerts.home.AlertsSetupStatus.State;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The Setup card: states follow the real configuration, and the home widget
** renders for admins only.
*******************************************************************************/
class AlertsSetupTest extends BaseAlertsTest
{
/*******************************************************************************
**
*******************************************************************************/
private Item item(String label) throws Exception
{
return (AlertsSetupStatus.load(runtime, clock.instant()).stream().filter(i -> i.label().equals(label)).findFirst().orElseThrow());
}
/*******************************************************************************
** A fresh install: defaults channels, nobody subscribed, no scheduler.
*******************************************************************************/
@Test
void freshInstallNeedsSubscriptionsAndAScheduler() throws Exception
{
runtime.getTypeSync().syncIfNeeded();
assertThat(item("Channels").state()).isEqualTo(State.DEFAULT);
assertThat(item("Alert types").state()).isEqualTo(State.OK);
assertThat(item("Subscriptions").state()).isEqualTo(State.TODO);
assertThat(item("Scheduler").state()).isEqualTo(State.TODO);
assertThat(item("Teams").state()).isEqualTo(State.OPTIONAL);
assertThat(item("Groups").state()).isEqualTo(State.OPTIONAL);
assertThat(AlertsSetupStatus.load(runtime, clock.instant())).allMatch(i -> i.tableName() == null || i.tableName().startsWith("alerts_"));
}
/*******************************************************************************
** Configuring a part moves its line.
*******************************************************************************/
@Test
void configuringMovesTheState() throws Exception
{
runtime.getTypeSync().syncIfNeeded();
runtime.getChannels().ensureDefaults();
assertThat(item("Channels").state()).isEqualTo(State.OK);
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", "u")
.withValue("scopeType", "ALL").withValue("minSeverity", "ERROR").withValue("enabled", true)));
assertThat(item("Subscriptions").state()).isEqualTo(State.OK);
Integer team = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", "t").withValue("label", "T")).getValueInteger("id"));
QRecord group = runtime.asSystem(() -> AlertsDb.first(AlertsGroup.TABLE_NAME, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()));
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", team)));
assertThat(item("Groups").state()).isEqualTo(State.OK);
assertThat(item("Teams").state()).isEqualTo(State.OK);
runtime.asSystem(() -> AlertsDb.update(AlertsChannel.TABLE_NAME, new QRecord().withValue("id", 1).withValue("healthStatus", "UNHEALTHY")));
assertThat(item("Channels").state()).isEqualTo(State.TODO);
}
/*******************************************************************************
** The widget renders the card for an admin, with links to the tables.
*******************************************************************************/
@Test
void widgetRendersForAdmins() throws Exception
{
runtime.getTypeSync().syncIfNeeded();
String html = ((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) AlertsHome.load(AlertsHome.KIND_SETUP, runtime)).getHtml();
assertThat(html).contains("Needs setup").contains("href=\"/app/alerts_subscription\"").contains("Next: ").contains("attention");
}
}
@@ -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);
@@ -18,17 +18,21 @@
package com.kingsrook.qbits.alerts.notifications;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
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.api.AlertsApi;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
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.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
@@ -39,6 +43,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
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.actions.tables.insert.InsertInput;
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;
@@ -167,6 +172,232 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
/*******************************************************************************
** A requested email does not require a standing category preference.
*******************************************************************************/
@Test
void testRequestedWithoutPreference() throws Exception
{
addressOnly();
requested("JOB");
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** An explicit request and an immediate preference still send only once.
*******************************************************************************/
@Test
void testRequestedWithImmediatePreference() throws Exception
{
emailSettings("viewer-1", "JOB", "IMMEDIATE", "INFO");
requested("JOB");
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** A request overrides digest timing (not the category's minimum severity).
*******************************************************************************/
@Test
void testRequestedWithDigestPreference() throws Exception
{
emailSettings("viewer-1", "JOB", "DAILY_DIGEST", "INFO");
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.sent()).isEqualTo(1);
assertThat(result.digests()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** Knowing an address alone does not opt a notification into email.
*******************************************************************************/
@Test
void testNotRequestedWithoutPreference() throws Exception
{
addressOnly();
runtime.getNotifications().notify(new NotificationSignal("Done").withCategory(NotificationCategory.JOB)
.withAudience(NotificationAudience.user("viewer-1")));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("NONE");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Missing addresses are skipped rather than counted as delivery failures.
*******************************************************************************/
@Test
void testRequestedWithoutAddress() throws Exception
{
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.skipped()).isEqualTo(1);
assertThat(result.failed()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Alert subscriptions own alert emails, even with an explicit request.
*******************************************************************************/
@Test
void testRequestedAlertNeverEmailed() throws Exception
{
emailSettings("viewer-1", "ALERT", "IMMEDIATE", "INFO");
requested("ALERT");
assertThat(new NotificationEmailer(runtime).run().skipped()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Requests older than the delivery window are not emailed.
*******************************************************************************/
@Test
void testRequestedOutsideDeliveryWindow() throws Exception
{
addressOnly();
requested("JOB");
clock.set(clock.instant().plusSeconds(86401));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Provider failures remain terminal and do not cause repeated sends.
*******************************************************************************/
@Test
void testRequestedDeliveryFailure() throws Exception
{
addressOnly();
requested("JOB");
runtime.getConfig().withMessagingProviderName("missing-provider");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.failed()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("FAILED");
assertThat(new NotificationEmailer(runtime).run().failed()).isZero();
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** A request still honours the category's email minimum severity.
*******************************************************************************/
@Test
void testRequestedBelowMinSeverity() throws Exception
{
emailSettings("viewer-1", "JOB", "OFF", "ERROR");
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.skipped()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** A user's pause or quiet hours hold a requested email until they end.
*******************************************************************************/
@Test
void testRequestedHeldByPauseAndQuietHours() throws Exception
{
clock.set(Instant.parse("2026-10-12T12:00:00Z"));
addressOnly();
requested("JOB");
setUserPreference(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(3600)));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
clock.set(Instant.parse("2026-10-12T13:30:00Z"));
setUserPreference(new QRecord().withValue("timeZone", "UTC").withValue("quietHoursStart", "13:00").withValue("quietHoursEnd", "14:00"));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
clock.set(Instant.parse("2026-10-12T14:00:00Z"));
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** A pause does not hold a requested SECURITY email.
*******************************************************************************/
@Test
void testRequestedSecurityBypassesPause() throws Exception
{
addressOnly();
requested("SECURITY");
setUserPreference(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(3600)));
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
}
/*******************************************************************************
** Update the user's alert settings row.
*******************************************************************************/
private void setUserPreference(QRecord values) throws QException
{
runtime.asSystem(() ->
{
QRecord setting = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-1"));
return (AlertsDb.update(AlertsUserPreference.TABLE_NAME, values.withValue("id", setting.getValue("id"))));
});
}
/*******************************************************************************
** Store an address without opting into any category.
*******************************************************************************/
private void addressOnly() throws QException
{
runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME,
new QRecord().withValue("userId", "viewer-1").withValue("emailAddress", "viewer-1@example.com")));
}
/*******************************************************************************
** Set up a recipient explicitly requesting email for worker tests.
*******************************************************************************/
private void requested(String category) throws QException
{
Integer id = runtime.getNotifications().notify(new NotificationSignal("Done").withCategory(NotificationCategory.valueOf(category))
.withAudience(NotificationAudience.user("viewer-1")));
runtime.asSystem(() ->
{
QRecord recipient = AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", id));
return (AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME,
new QRecord().withValue("id", recipient.getValue("id")).withValue("emailStatus", "REQUESTED")));
});
}
/*******************************************************************************
** The daily digest waits for the user's hour, then lists the unread ones.
*******************************************************************************/
@@ -218,4 +449,80 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
assertThat(SENT).hasSize(4);
assertThat(SENT).noneMatch(message -> text(message).contains("change your password"));
}
/*******************************************************************************
** Pauses defer immediate and digest mail past a day without hiding the inbox;
** SECURITY still sends in both email modes.
*******************************************************************************/
@Test
void testPausedEmail() throws Exception
{
emailSettings("viewer-1", "JOB", "IMMEDIATE", "INFO");
emailSettings("viewer-1", "SECURITY", "IMMEDIATE", "INFO");
emailSettings("viewer-2", "JOB", "DAILY_DIGEST", "INFO");
emailSettings("viewer-2", "SECURITY", "DAILY_DIGEST", "INFO");
runtime.asSystem(() ->
{
for(QRecord setting : all(AlertsUserPreference.TABLE_NAME))
{
AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", setting.getValue("id"))
.withValue("pausedUntil", clock.instant().plus(Duration.ofDays(2))));
}
return (null);
});
for(NotificationCategory category : List.of(NotificationCategory.JOB, NotificationCategory.SECURITY))
{
runtime.getNotifications().notify(new NotificationSignal(category.name()).withCategory(category).withAudience(NotificationAudience.users("viewer-1", "viewer-2")));
}
NotificationEmailer.Result paused = new NotificationEmailer(runtime).run();
assertThat(paused.sent()).isEqualTo(1);
assertThat(paused.digests()).isEqualTo(1);
assertThat(paused.skipped()).isEqualTo(2);
assertThat(SENT).hasSize(2).allSatisfy(message -> assertThat(text(message)).contains("SECURITY").doesNotContain("JOB"));
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).filteredOn(row -> row.getValueInstant("emailedAt") == null).hasSize(2)
.allSatisfy(row -> assertThat(row.getValueString("emailStatus")).isEqualTo("NONE"));
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("unreadCount", 2);
clock.advance(Duration.ofDays(2));
NotificationEmailer.Result resumed = new NotificationEmailer(runtime).run();
assertThat(resumed.sent()).isEqualTo(1);
assertThat(resumed.digests()).isEqualTo(1);
assertThat(SENT).hasSize(4);
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).allSatisfy(row -> assertThat(row.getValueInstant("emailedAt")).isNotNull());
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
}
/*******************************************************************************
** A full scan page of paused recipients cannot starve a later security notice.
*******************************************************************************/
@Test
void testPausedBacklogDoesNotBlockSecurity() throws Exception
{
emailSettings(VIEWER, "JOB", "IMMEDIATE", "INFO");
emailSettings(VIEWER, "SECURITY", "IMMEDIATE", "INFO");
runtime.asSystem(() ->
{
QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER));
AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", settings.getValue("id"))
.withValue("pausedUntil", clock.instant().plusSeconds(3600)));
List<QRecord> notifications = new ArrayList<>();
for(int i = 0; i < NotificationEmailer.SCAN; i++)
{
notifications.add(new QRecord().withValue("title", "Paused job " + i).withValue("category", "JOB").withValue("status", "SENT"));
}
notifications = InsertAction.executeForRecords(
new InsertInput(AlertsNotification.TABLE_NAME).withRecords(notifications));
List<QRecord> recipients = notifications.stream().map(notification -> new QRecord().withValue("notificationId", notification.getValue("id"))
.withValue("userId", VIEWER).withValue("deliveredAt", clock.instant())).toList();
InsertAction.executeForRecords(
new InsertInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(recipients));
return (null);
});
runtime.getNotifications().notify(new NotificationSignal("Security after backlog").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user(VIEWER)));
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(SENT).singleElement().satisfies(message -> assertThat(message.getSubject()).isEqualTo("Security after backlog"));
}
}
@@ -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.notifications;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
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.MessagingProviderCapability;
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";
static boolean idempotency;
/*******************************************************************************
** 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");
}
/*******************************************************************************
** Advertise optional provider deduplication.
*******************************************************************************/
@Override
public Set<MessagingProviderCapability> getCapabilities()
{
return (idempotency ? Set.of(MessagingProviderCapability.IDEMPOTENCY) : Set.of());
}
/*******************************************************************************
**
*******************************************************************************/
@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";
idempotency = false;
}
/*******************************************************************************
** 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(message.getIdempotencyKey()).isNull();
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);
}
/*******************************************************************************
** Capable providers receive a stable key for immediate notifications.
*******************************************************************************/
@Test
void testIdempotencyKey() throws Exception
{
idempotency = true;
slackSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING", null);
notifyJob("Export failed", NotificationSeverity.WARNING, "viewer-1");
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1);
assertThat(SENT).singleElement().satisfies(message ->
assertThat(message.getIdempotencyKey()).matches("notify:[0-9]+:viewer-1:slack"));
}
/*******************************************************************************
** 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");
clock.advance(Duration.ofDays(2));
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1);
assertThat(slackStatus("viewer-1")).isEqualTo("SENT");
}
/*******************************************************************************
** Pauses hold JOB items in both Slack modes, even in a digest containing
** SECURITY. After two days every held item is delivered exactly once.
*******************************************************************************/
@Test
void testPausedSecurityAndMixedDigest() throws Exception
{
clock.set(Instant.parse("2026-10-12T12:05:00Z"));
for(String userId : List.of("viewer-1", "viewer-2"))
{
String mode = "viewer-1".equals(userId) ? "IMMEDIATE" : "DAILY_DIGEST";
QRecord paused = new QRecord().withValue("pausedUntil", clock.instant().plus(Duration.ofDays(2)));
slackSettings(userId, "JOB", mode, "INFO", paused);
slackSettings(userId, "SECURITY", mode, "INFO", paused);
}
for(NotificationCategory category : List.of(NotificationCategory.JOB, NotificationCategory.SECURITY))
{
QAlerts.notify(new NotificationSignal(category.name()).withCategory(category).withSeverity(NotificationSeverity.CRITICAL)
.withAudience(NotificationAudience.users("viewer-1", "viewer-2")));
}
NotificationSlackSender.Result paused = new NotificationSlackSender(runtime).run();
assertThat(paused.sent()).isEqualTo(1);
assertThat(paused.digests()).isEqualTo(1);
assertThat(paused.held()).isEqualTo(2);
assertThat(SENT).hasSize(2).allSatisfy(message -> assertThat(text(message)).contains("SECURITY").doesNotContain("JOB"));
clock.advance(Duration.ofDays(2));
NotificationSlackSender.Result resumed = new NotificationSlackSender(runtime).run();
assertThat(resumed.sent()).isEqualTo(1);
assertThat(resumed.digests()).isEqualTo(1);
assertThat(SENT).hasSize(4);
assertThat(new NotificationSlackSender(runtime).run()).isEqualTo(new NotificationSlackSender.Result(0, 0, 0, 0, 0));
}
/*******************************************************************************
** 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);
}
}
@@ -534,4 +534,37 @@ class NotificationsTest extends BaseAlertsTest
new DeleteAction().execute(new DeleteInput(AlertsNotification.TABLE_NAME).withPrimaryKeys(List.of(other)));
assertThat(runtime.asSystem(() -> all(AlertsNotificationRecipient.TABLE_NAME))).isEmpty();
}
/*******************************************************************************
** In-app severity is inclusive; null means all, and SECURITY always arrives.
*******************************************************************************/
@Test
void testInAppMinimumSeverity() throws Exception
{
runtime.asSystem(() ->
{
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", VIEWER).withValue("category", "JOB").withValue("inAppMinSeverity", "ERROR"));
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", VIEWER).withValue("category", "SECURITY")
.withValue("inAppMuted", true).withValue("inAppMinSeverity", "CRITICAL"));
return (null);
});
for(NotificationSeverity severity : List.of(NotificationSeverity.WARNING, NotificationSeverity.ERROR, NotificationSeverity.CRITICAL))
{
runtime.getNotifications().notify(new NotificationSignal("Job " + severity).withSeverity(severity).withCategory(NotificationCategory.JOB)
.withAudience(NotificationAudience.users(VIEWER, "viewer-2")));
}
runtime.getNotifications().notify(new NotificationSignal("Security info").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user(VIEWER)));
assertThat(where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", VIEWER))).hasSize(3);
assertThat(where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "viewer-2"))).hasSize(3);
loginAs(VIEWER);
QRecord preference = where(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER)).stream()
.filter(row -> "JOB".equals(row.getValueString("category"))).findFirst().orElseThrow();
assertThat(new UpdateAction().execute(new UpdateInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord().withValue("id", preference.getValue("id"))
.withValue("category", "SECURITY"))).getRecords().get(0).getErrors()).isNotEmpty();
AlertsDb.update(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("id", preference.getValue("id")).withValue("inAppMinSeverity", null));
runtime.getNotifications().notify(new NotificationSignal("Job info").withCategory(NotificationCategory.JOB).withAudience(NotificationAudience.user(VIEWER)));
assertThat(where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", VIEWER))).hasSize(4);
}
}
@@ -0,0 +1,262 @@
/*
* 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.preferences;
import java.io.Serializable;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsQBitProducer;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.api.AlertsApi;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRowChange;
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Real core action round trips, ownership, validation and field auditing.
*******************************************************************************/
class NotificationPreferencesTest extends BaseAlertsTest
{
private final UserPreferencesAction action = new UserPreferencesAction();
/*******************************************************************************
** Defaults, all settings, partial changes and isolation persist in real tables.
*******************************************************************************/
@Test
void roundTrip() throws Exception
{
QRecord mine = subscription(VIEWER, "USER", "ALL");
QRecord other = subscription("viewer-2", "USER", "GROUP");
subscription(VIEWER, "TEAM", "ALL");
loginAs("viewer-2");
action.patchSection("notifications", Map.of("values", Map.of("digestHour", 7), "lists", Map.of("categories", List.of(row("JOB", Map.of("emailMode", "IMMEDIATE"))))));
Map<String, Serializable> otherSettings = new HashMap<>(all(AlertsUserPreference.TABLE_NAME).get(0).getValues());
Map<String, Serializable> otherCategory = new HashMap<>(all(AlertsNotificationPreference.TABLE_NAME).get(0).getValues());
loginAs(VIEWER).setPermissions(Set.of(AlertsQBitConfig.PERMISSION_VIEW));
qInstance.setEnforcePermissionsInCoreActions(true);
var defaults = action.getSection("notifications");
assertThat(defaults.getSection().getAppName()).isNull();
assertThat(defaults.getValues().getValues()).containsEntry("digestHour", 8).containsEntry("alertInAppEnabled", true).containsEntry("alertEmailEnabled", true);
assertThat(defaults.getValues().getLists().get("categories")).hasSize(7).allSatisfy(category ->
{
assertThat(category.getValues()).containsEntry("inApp", true).containsEntry("inAppMinSeverity", "INFO").containsEntry("emailMode", "OFF").containsEntry("emailMinSeverity", "WARNING");
});
assertThat(defaults.getValues().getLists().get("subscriptions")).singleElement().satisfies(rule -> assertThat(rule.getLabel()).isEqualTo("All alerts"));
action.patchSection("notifications", Map.of("values", Map.of("pausedUntil", "2026-11-01T12:00:00Z", "digestHour", 22, "timeZone", "America/Chicago",
"alertInAppEnabled", false, "alertEmailEnabled", false), "lists", Map.of(
"categories", List.of(row("JOB", Map.of("inApp", false, "inAppMinSeverity", "ERROR", "emailMode", "DAILY_DIGEST", "emailMinSeverity", "CRITICAL"))),
"subscriptions", List.of(row(mine.getValueString("id"), Map.of("enabled", false, "minSeverity", "ERROR", "deliveryMode", "DIGEST_DAILY"))))));
var loaded = action.getSection("notifications").getValues();
assertThat(loaded.getValues()).containsEntry("digestHour", 22).containsEntry("timeZone", "America/Chicago")
.containsEntry("pausedUntil", Instant.parse("2026-11-01T12:00:00Z")).containsEntry("alertInAppEnabled", false).containsEntry("alertEmailEnabled", false);
assertThat(where(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER))).singleElement().satisfies(record ->
assertThat(record.getValues()).containsEntry("inAppEnabled", false).containsEntry("emailEnabled", false).containsEntry("quietBypassSeverity", "CRITICAL"));
assertThat(where(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER))).singleElement().satisfies(record ->
assertThat(record.getValues()).containsEntry("category", "JOB").containsEntry("inAppMuted", true).containsEntry("inAppMinSeverity", "ERROR")
.containsEntry("emailMode", "DAILY_DIGEST").containsEntry("emailMinSeverity", "CRITICAL"));
assertThat(where(AlertsSubscription.TABLE_NAME, AlertsDb.eq("id", mine.getValue("id")))).singleElement().satisfies(record ->
assertThat(record.getValues()).containsEntry("enabled", false).containsEntry("minSeverity", "ERROR").containsEntry("deliveryMode", "DIGEST_DAILY"));
Map<String, Object> clearMinimum = new HashMap<>();
clearMinimum.put("inAppMinSeverity", null);
action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(row("JOB", clearMinimum)))));
assertThat(action.getSection("notifications").getValues().getLists().get("categories")).filteredOn(row -> row.getKey().equals("JOB"))
.singleElement().satisfies(row -> assertThat(row.getValues()).containsEntry("inAppMinSeverity", "INFO").containsEntry("inApp", false));
assertThat(where(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-2")).get(0).getValues()).isEqualTo(otherSettings);
assertThat(where(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-2")).get(0).getValues()).isEqualTo(otherCategory);
loginAs("viewer-2");
assertThat(action.getSection("notifications").getValues().getLists().get("subscriptions")).singleElement()
.satisfies(row -> assertThat(row.getKey()).isEqualTo(other.getValueString("id")));
assertThat(where(AlertsSubscription.TABLE_NAME, AlertsDb.eq("id", other.getValue("id"))).get(0).getValueBoolean("enabled")).isTrue();
}
/*******************************************************************************
** Slack settings are available only with the configured provider, persist per
** user and category, and remain editable for SECURITY.
*******************************************************************************/
@Test
void slackPreferences() throws Exception
{
var absent = action.getSection("notifications");
assertThat(absent.getValues().getLists().get("categories")).allSatisfy(row ->
assertThat(row.getValues()).doesNotContainKeys("slack", "slackMinSeverity"));
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("lists", Map.of("categories",
List.of(row("JOB", Map.of("slack", "IMMEDIATE"))))))).isInstanceOf(QBadRequestException.class);
runtime.getConfig().withSlackProviderName("personal-slack");
qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("personal-slack").withType("test"));
UserPreferencesMetaData.of(qInstance).getSections().clear();
qInstance.getPossibleValueSources().remove("alerts_digest_hour");
AlertsNotificationPreferencesProvider.register(qInstance, runtime.getConfig());
loginAs(VIEWER).setPermissions(Set.of(AlertsQBitConfig.PERMISSION_VIEW));
qInstance.setEnforcePermissionsInCoreActions(true);
var defaults = action.getSection("notifications");
assertThat(defaults.getValues().getLists().get("categories")).allSatisfy(row ->
assertThat(row.getValues()).containsEntry("slack", "OFF").containsEntry("slackMinSeverity", "WARNING"));
action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(
row("JOB", Map.of("slack", "DAILY_DIGEST", "slackMinSeverity", "ERROR")),
row("SECURITY", Map.of("slack", "IMMEDIATE", "slackMinSeverity", "INFO"))))));
assertThat(action.getSection("notifications").getValues().getLists().get("categories"))
.filteredOn(row -> row.getKey().equals("JOB")).singleElement().satisfies(row ->
assertThat(row.getValues()).containsEntry("slack", "DAILY_DIGEST").containsEntry("slackMinSeverity", "ERROR"));
assertThat(where(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", VIEWER)))
.filteredOn(row -> "SECURITY".equals(row.getValueString("category"))).singleElement().satisfies(row ->
assertThat(row.getValues()).containsEntry("slackMode", "IMMEDIATE").containsEntry("slackMinSeverity", "INFO"));
action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(row("SECURITY", Map.of("slack", "OFF"))))));
loginAs("viewer-2");
assertThat(action.getSection("notifications").getValues().getLists().get("categories")).allSatisfy(row ->
assertThat(row.getValues()).containsEntry("slack", "OFF").containsEntry("slackMinSeverity", "WARNING"));
}
/*******************************************************************************
** Invalid and foreign row keys fail even for admins with all-access keys.
*******************************************************************************/
@Test
void rejectsUnsafeChanges() throws Exception
{
QRecord other = subscription("viewer-2", "USER", "ALL");
QRecord team = subscription(ADMIN, "TEAM", "ALL");
for(String key : List.of(other.getValueString("id"), team.getValueString("id"), "99999", "bogus"))
{
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("lists", Map.of("subscriptions", List.of(row(key, Map.of("enabled", false)))))))
.isInstanceOf(QBadRequestException.class);
}
for(String field : List.of("inApp", "inAppMinSeverity"))
{
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(row("SECURITY", Map.of(field, field.equals("inApp") ? false : "ERROR")))))))
.isInstanceOf(QBadRequestException.class).hasMessageContaining("Security notices can't be muted.");
}
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("values", Map.of("timeZone", "Not/AZone")))).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("values", Map.of("digestHour", 24)))).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> action.patchSection("notifications", Map.of("userId", "viewer-2"))).isInstanceOf(QBadRequestException.class);
var context = new UserPreferenceRequestContext(action.getSection("notifications").getSection(), QContext.getQSession());
var provider = new AlertsNotificationPreferencesProvider();
assertThat(provider.validate(context, new UserPreferenceChange().withLists(Map.of("subscriptions", List.of(new UserPreferenceRowChange().withKey(other.getValueString("id"))))))).isNotEmpty();
assertThat(provider.validate(context, new UserPreferenceChange().withLists(Map.of("categories", List.of(new UserPreferenceRowChange().withKey("SECURITY").withValues(Map.of("inApp", false))))))).isNotEmpty();
loginAs("nobody");
assertThat(action.listSections()).isEmpty();
assertThatThrownBy(() -> action.getSection("notifications")).isInstanceOf(QNotFoundException.class);
assertThatThrownBy(() -> action.patchSection("notifications", Map.of())).isInstanceOf(QNotFoundException.class);
}
/*******************************************************************************
** Registration is idempotent, configurable, and drives the inbox shortcut.
*******************************************************************************/
@Test
void registrationAndSummary() throws Exception
{
AlertsNotificationPreferencesProvider.register(qInstance, runtime.getConfig());
assertThat(UserPreferencesMetaData.of(qInstance).getSections()).hasSize(1);
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("preferencesSection", "notifications");
assertThat(qInstance.getPossibleValueSource("alerts_digest_hour").getEnumValues()).hasSize(24);
assertThat(qInstance.getPossibleValueSource("alerts_digest_hour").getEnumValues().get(0).getLabel()).isEqualTo("12:00 AM");
assertThat(qInstance.getPossibleValueSource("alerts_digest_hour").getEnumValues().get(23).getLabel()).isEqualTo("11:00 PM");
UserPreferencesMetaData.of(qInstance).getSections().clear();
new AlertsQBitProducer().withQBitConfig(config.withPreferencesSectionEnabled(false)).postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("preferencesSection", null);
assertThat(action.listSections()).isEmpty();
}
/*******************************************************************************
** Standard FIELD audits retain both user identity and client channel.
*******************************************************************************/
@Test
void audits() throws Exception
{
new AuditsMetaDataProvider().withClientChannelField(true).defineAll(qInstance, BACKEND_NAME, null);
new QInstanceEnricher(qInstance).enrich();
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
{
for(String table : List.of("auditDetail", "audit", "auditUser", "auditTable"))
{
statement.execute("DROP TABLE IF EXISTS " + table);
}
statement.execute("CREATE TABLE auditUser (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE auditTable (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), label VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE audit (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditTableId INTEGER, auditUserId INTEGER, recordId INTEGER, message VARCHAR(250), timestamp TIMESTAMP, clientChannel VARCHAR(100))");
statement.execute("CREATE TABLE auditDetail (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditId BIGINT, message VARCHAR(250), fieldName VARCHAR(100), oldValue VARCHAR(250), newValue VARCHAR(250))");
}
QRecord rule = subscription(VIEWER, "USER", "ALL");
loginAs(VIEWER).withClientChannel("cli/test").setPermissions(Set.of(AlertsQBitConfig.PERMISSION_VIEW));
qInstance.setEnforcePermissionsInCoreActions(true);
action.patchSection("notifications", Map.of("values", Map.of("digestHour", 17), "lists", Map.of("categories", List.of(row("JOB", Map.of("emailMode", "IMMEDIATE"))),
"subscriptions", List.of(row(rule.getValueString("id"), Map.of("enabled", false))))));
action.patchSection("notifications", Map.of("lists", Map.of("categories", List.of(row("JOB", Map.of("emailMode", "DAILY_DIGEST"))))));
List<QRecord> audits = all("audit").stream().filter(record -> "cli/test".equals(record.getValueString("clientChannel"))).toList();
assertThat(audits).hasSize(5);
QRecord user = where("auditUser", AlertsDb.eq("name", "Name of " + VIEWER)).get(0);
assertThat(audits).allSatisfy(audit -> assertThat(audit.getValue("auditUserId")).isEqualTo(user.getValue("id")));
assertThat(all("auditDetail")).anyMatch(detail -> "digestHour".equals(detail.getValueString("fieldName")))
.anyMatch(detail -> "emailMode".equals(detail.getValueString("fieldName"))).anyMatch(detail -> "enabled".equals(detail.getValueString("fieldName")));
}
/*******************************************************************************
** A raw API row patch.
*******************************************************************************/
private Map<String, Object> row(String key, Map<String, ?> values)
{
return (Map.of("key", key, "values", values));
}
/*******************************************************************************
** Seed a subscription as the engine would.
*******************************************************************************/
private QRecord subscription(String userId, String type, String scope) throws Exception
{
return (runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("userId", userId)
.withValue("subscriberType", type).withValue("scopeType", scope).withValue("scopeValue", "widgets"))));
}
}
+124 -19
View File
@@ -1,23 +1,25 @@
DROP TABLE IF EXISTS alerts_escalation_step CASCADE;
DROP TABLE IF EXISTS alerts_escalation_policy CASCADE;
-- H2 version of src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql, for unit tests (generated together).
DROP TABLE IF EXISTS alerts_heartbeat;
DROP TABLE IF EXISTS alerts_inbox_item;
DROP TABLE IF EXISTS alerts_notification_preference;
DROP TABLE IF EXISTS alerts_notification_recipient;
DROP TABLE IF EXISTS alerts_notification;
DROP TABLE IF EXISTS alerts_delivery;
DROP TABLE IF EXISTS alerts_mute;
DROP TABLE IF EXISTS alerts_channel;
DROP TABLE IF EXISTS alerts_team_member;
DROP TABLE IF EXISTS alerts_team;
DROP TABLE IF EXISTS alerts_user_preference;
DROP TABLE IF EXISTS alerts_subscription;
DROP TABLE IF EXISTS alerts_event;
DROP TABLE IF EXISTS alerts_record_ref;
DROP TABLE IF EXISTS alerts_alert;
DROP TABLE IF EXISTS alerts_monitor;
DROP TABLE IF EXISTS alerts_type;
DROP TABLE IF EXISTS alerts_group;
DROP TABLE IF EXISTS alerts_heartbeat CASCADE;
DROP TABLE IF EXISTS alerts_inbox_item CASCADE;
DROP TABLE IF EXISTS alerts_notification_preference CASCADE;
DROP TABLE IF EXISTS alerts_notification_recipient CASCADE;
DROP TABLE IF EXISTS alerts_notification CASCADE;
DROP TABLE IF EXISTS alerts_delivery CASCADE;
DROP TABLE IF EXISTS alerts_mute CASCADE;
DROP TABLE IF EXISTS alerts_channel CASCADE;
DROP TABLE IF EXISTS alerts_team_member CASCADE;
DROP TABLE IF EXISTS alerts_team CASCADE;
DROP TABLE IF EXISTS alerts_user_preference CASCADE;
DROP TABLE IF EXISTS alerts_subscription CASCADE;
DROP TABLE IF EXISTS alerts_event CASCADE;
DROP TABLE IF EXISTS alerts_record_ref CASCADE;
DROP TABLE IF EXISTS alerts_alert CASCADE;
DROP TABLE IF EXISTS alerts_monitor CASCADE;
DROP TABLE IF EXISTS alerts_type CASCADE;
DROP TABLE IF EXISTS alerts_group CASCADE;
CREATE TABLE IF NOT EXISTS alerts_group (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
@@ -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,16 +370,111 @@ 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,
user_id VARCHAR(100) NOT NULL,
category VARCHAR(16) NOT NULL,
in_app_muted BOOLEAN,
in_app_min_severity VARCHAR(16),
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;
-- Foreign keys and their indexes (changesets 30-53).
CREATE INDEX IF NOT EXISTS ix_alerts_alert_group_id ON alerts_alert (group_id);
CREATE INDEX IF NOT EXISTS ix_alerts_alert_assigned_team_id ON alerts_alert (assigned_team_id);
CREATE INDEX IF NOT EXISTS ix_alerts_type_escalation_policy_id ON alerts_type (escalation_policy_id);
CREATE INDEX IF NOT EXISTS ix_alerts_group_parent_group_id ON alerts_group (parent_group_id);
CREATE INDEX IF NOT EXISTS ix_alerts_group_owner_team_id ON alerts_group (owner_team_id);
CREATE INDEX IF NOT EXISTS ix_alerts_group_escalation_policy_id ON alerts_group (escalation_policy_id);
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_alert_event_id ON alerts_delivery (alert_event_id);
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_subscription_id ON alerts_delivery (subscription_id);
CREATE INDEX IF NOT EXISTS ix_alerts_subscription_team_id ON alerts_subscription (team_id);
CREATE INDEX IF NOT EXISTS ix_alerts_subscription_channel_id ON alerts_subscription (channel_id);
CREATE INDEX IF NOT EXISTS ix_alerts_escalation_step_team_id ON alerts_escalation_step (team_id);
CREATE INDEX IF NOT EXISTS ix_alerts_escalation_step_channel_id ON alerts_escalation_step (channel_id);
ALTER TABLE alerts_type ADD CONSTRAINT fk_alerts_type_group_id FOREIGN KEY (group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
ALTER TABLE alerts_type ADD CONSTRAINT fk_alerts_type_escalation_policy_id FOREIGN KEY (escalation_policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE SET NULL;
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_parent_group_id FOREIGN KEY (parent_group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_owner_team_id FOREIGN KEY (owner_team_id) REFERENCES alerts_team (id) ON DELETE SET NULL;
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_escalation_policy_id FOREIGN KEY (escalation_policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE SET NULL;
ALTER TABLE alerts_monitor ADD CONSTRAINT fk_alerts_monitor_alert_type_id FOREIGN KEY (alert_type_id) REFERENCES alerts_type (id) ON DELETE RESTRICT;
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_alert_type_id FOREIGN KEY (alert_type_id) REFERENCES alerts_type (id) ON DELETE RESTRICT;
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_monitor_id FOREIGN KEY (monitor_id) REFERENCES alerts_monitor (id) ON DELETE SET NULL;
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_group_id FOREIGN KEY (group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_assigned_team_id FOREIGN KEY (assigned_team_id) REFERENCES alerts_team (id) ON DELETE SET NULL;
ALTER TABLE alerts_record_ref ADD CONSTRAINT fk_alerts_record_ref_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
ALTER TABLE alerts_event ADD CONSTRAINT fk_alerts_event_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_alert_event_id FOREIGN KEY (alert_event_id) REFERENCES alerts_event (id) ON DELETE SET NULL;
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_subscription_id FOREIGN KEY (subscription_id) REFERENCES alerts_subscription (id) ON DELETE SET NULL;
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
ALTER TABLE alerts_subscription ADD CONSTRAINT fk_alerts_subscription_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE RESTRICT;
ALTER TABLE alerts_subscription ADD CONSTRAINT fk_alerts_subscription_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
ALTER TABLE alerts_team_member ADD CONSTRAINT fk_alerts_team_member_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE CASCADE;
ALTER TABLE alerts_notification_recipient ADD CONSTRAINT fk_alerts_notification_recipient_notification_id FOREIGN KEY (notification_id) REFERENCES alerts_notification (id) ON DELETE CASCADE;
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_policy_id FOREIGN KEY (policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE CASCADE;
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE RESTRICT;
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
+3 -1
View File
@@ -75,6 +75,7 @@ CREATE TABLE email_message (
phi_approved BOOLEAN,
unsubscribe_url TEXT,
idempotency_key VARCHAR(500),
rfc_message_id VARCHAR(500),
provider_id INTEGER,
provider_message_id VARCHAR(500),
attempt_count INTEGER,
@@ -93,7 +94,8 @@ CREATE TABLE email_message (
sender_process VARCHAR(500),
suppressed_addresses TEXT,
send_after TIMESTAMP,
UNIQUE (idempotency_key)
UNIQUE (idempotency_key),
UNIQUE (rfc_message_id)
);
CREATE TABLE email_message_attempt (
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<!-- Core can emit diagnostic events at OFF; exclude those as well. -->
<LevelRangeFilter minLevel="FATAL" maxLevel="WARN" onMatch="NEUTRAL" onMismatch="DENY"/>
<PatternLayout pattern="%d{HH:mm:ss} %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="org.testcontainers" level="WARN"/>
<Logger name="com.github.dockerjava" level="WARN"/>
<Logger name="org.apache.hc" level="WARN"/>
<Logger name="tc" level="WARN"/>
<Root level="WARN"><AppenderRef ref="Console"/></Root>
</Loggers>
</Configuration>