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 96b7a20a7d ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:05:48 -05:00
james.maes ee002b2cb3 Merge pull request #23 from QRun-IO/feature/email-qbit
feat(email): send alert, notification and digest email through qbit-email when installed
2026-10-14 08:27:24 -05:00
james.maesandClaude Opus 5.5 5515c4c2bc Merge develop into feature/email-qbit
Keeps develop's changes and re-applies the optional qbit-email 0.1.0-RC.1
delivery on top (adapted to develop's refactors; guardrail allow-lists
for the optional integration where the build parent enforces them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:19:02 -05:00
james.maesandClaude Opus 5.5 ae1f279633 build: qbit-email 0.1.0-RC.1 from Maven Central
Moves from 0.1.0-SNAPSHOT to the first release candidate. Test schemas
gain the RC's email_provider_sender_address table and the message
from_address_id column where they copy the qbit's DDL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:59:02 -05:00
james.maes 8431476609 Merge pull request #24 from QRun-IO/feature/arch-follower-fanout
feat(routing): follower fan-out via core RecordFollowers + guardrails (#16)
2026-10-14 07:47:53 -05:00
james.maesandClaude Opus 5.5 3d56ccc403 feat(routing): fan record alerts out to followers via core RecordFollowers
An alert about a record now also reaches the record's followers, from
core RecordFollowers.get(instance, ref, FollowEvent.ALERT) (QRun-IO/qqq#1164,
QQQ 4.1.0-RC.6). Followers are deduplicated with existing recipients by
user and channel. They must pass the source-record permission and lock
checks, and they get the same preferences and suppression as any other
recipient. With no provider registered, routing is unchanged. There is no
dependency on qbit-collaboration, and the #1164 stand-in marker is gone.

Also adopt qbit-build-parent 2.1.0 with the guardrails enforced and strict
raw-route mode. Comments that only used the word "stand-in" are reworded.

Refs #16 (architecture review QRun-IO/qqq#1150, finding F12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:43:46 -05:00
james.maes 9e139fe039 Merge pull request #21 from QRun-IO/feature/arch-core-notify
refactor(notifications): core QAlerts.notify, delete the spi stand-in (#15 part 2)
2026-10-14 07:37:06 -05:00
james.maesandClaude Opus 5.5 5348462c7f Merge origin/develop into feature/arch-core-notify
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:33:41 -05:00
james.maes 6460aadb0a Merge pull request #19 from QRun-IO/feature/arch-ui-api-extension
feat(api): alerts UI API extension, /qqq/hooks webhook, follow via collaboration (#13 #14 #16)
2026-10-14 07:20:05 -05:00
james.maesandClaude Opus 5.5 a65b019ffa ci: rebuild on the released QQQ 4.1.0-RC.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:15:35 -05:00
james.maesandClaude Opus 5.5 8004089b47 feat(email): send alert, notification and digest email through qbit-email when installed
Phase (d) of QRun-IO/qbit-email docs/DESIGN.md section 11. qbit-email is an
optional dependency (0.1.0-SNAPSHOT until its first RC) used through one
adapter class behind a classpath and instance guard; without it the
existing behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:12:34 -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 60b68cf722 refactor(notifications): deliver core QAlerts.notify; delete the spi stand-in
Core 4.1.0-RC.6 ships user notifications (QAlerts.notify,
NotificationSignal and friends; QRun-IO/qqq#1125). The qbit's sink now
implements core's notify path, so a QAlerts.notify from any qbit reaches
the notification queue, inbox, channels, quiet hours and preferences.
The rest of the stand-in package com.kingsrook.qbits.alerts.spi goes
(NotificationSignal, NotificationAudience, NotificationCategory,
NotificationSeverity, AlertLink, package-info), and so does
AlertsRuntime.sink().

Breaking for anything that used com.kingsrook.qbits.alerts.spi; its only
consumer, qbit-easypost-tracking, moved to core in #16 there.

Closes #15 (architecture review QRun-IO/qqq#1150, F3 and F12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:34:22 -05:00
james.maesandClaude Opus 5.5 2692f8b1dc feat(api): alerts UI routes as spec'd extension endpoints; webhook to /qqq/hooks; follow via collaboration
#13: the six inbox and record-panel routes move from the raw
AlertsRouteProvider onto a MiddlewareSpecExtension "alerts" (prefix
/alerts). Paths are unchanged. The committed fragment
openapi/qqq-v1/alerts.yaml is checked by a contract test, and the server
tests run in strict mode. Additive fields let Next stop hard-coding
alerts names: the summary has inboxHref, and inbox items and panel
alerts have actions with process names and hrefs.

#14: the Alertmanager webhook moves to POST /qqq/hooks/alerts/alertmanager.
The old /qqq/v1 path is an opt-in deprecated alias (legacyAlertmanagerPath,
default off; it needs non-strict mode). The alias sends Deprecation and
Sunset headers and logs a warning per call.

#16: POST /alerts/record-panel/watch is removed; collaboration owns
following a record. The record panel reports follow.available when the
collaboration extension is registered. Existing record-scoped watch
subscriptions stay readable and are deprecated. Follower fan-out waits for
core RecordFollowersProviderInterface (STAND-IN(core=QRun-IO/qqq#1164,
removeBy=4.1.0-RC.7)).

The checkstyle header template now matches the short Apache header that
the sources already use. The core baseline becomes 4.1.0-RC.6.

Closes #13, closes #14, closes #16 (architecture review QRun-IO/qqq#1150,
F2, F7, F12, F20).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:18:36 -05:00
james.maes 3044f15fbc Merge pull request #17 from QRun-IO/feature/arch-rc5-standins
refactor(alerts): core QAlerts and WebhookSigner on QQQ RC.5 (#15 part 1)
2026-10-13 19:36:43 -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
james.maesandClaude Opus 5.5 cb3d206e0f refactor(alerts): use core QAlerts and WebhookSigner from QQQ 4.1.0-RC.5
Bump the core baseline to 4.1.0-RC.5 and delete the alert-side copies in
the spi package. The qbit now registers its sink in core's
AlertsInstanceMetaData, so core QAlerts.raise/resolve/heartbeat from any
host qbit reaches it. The alertsQBitStandIn name is gone.

The notification types (NotificationSignal and friends) and
AlertsRuntime.sink().notify stay, binary compatible, for the released
qbit-easypost-tracking. They are tagged
STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6).

StandardWebhookSigner delegates to core WebhookSigner. The wire format is
unchanged. Legacy secrets shorter than core's 16-byte minimum are
zero-padded, which gives an identical HMAC-SHA256.

Refs #15 (architecture review QRun-IO/qqq#1150, F3 and F11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:31:16 -05:00
james.maes 110f15d866 Merge pull request #12 from QRun-IO/feature/release-rc-automation
ci: automate RC releases; hotfix/* pushes no longer publish
2026-10-13 14:43:10 -05:00
james.maesandClaude Opus 5.5 f50b3c1e18 ci: automate RC releases; hotfix/* pushes no longer publish
Removes publish_hotfix_release: a hotfix/* branch push published a GA
release to Maven Central with no tag or review (QRun-IO/qbit-cdn#12,
QRun-IO/qqq#1121). hotfix/* branches now run test_only; a hotfix
release publishes from a reviewed vX.Y.Z tag through publish_release.
Adds .github/workflows/release-rc.yml (reusable QRun-IO/.github
qbit-release-rc): a manual dispatch, or merging a develop PR labelled
release:rc, merges develop into the release line and the release/* push
runs the existing CircleCI RC publish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:37:13 -05:00
james.maes 758b68a6a9 Merge pull request #11 from QRun-IO/feature/notifications-de
feat(notifications): security, share and release notices, email digests; harden links (phases d, e)
2026-10-13 10:21:19 -05:00
james.maesandClaude Opus 5.5 020d38e810 fix(api): notificationsHref is the Next UI route /app/notifications
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:15:52 -05:00
james.maes 784bf0026c Merge pull request #10 from QRun-IO/feature/notifications-c-jobs
feat(notifications): notify people of their failed and partial jobs (phase c)
2026-10-13 10:15:26 -05:00
james.maesandClaude Opus 5.5 65fa558ed5 feat(notifications): security, share and release notices, email digests, home line; harden links (phases d, e)
- AlertsNotices: newSignIn, apiKeyCreated, apiKeyExpiring (deduped per
  key), permissionChanged, shared, release.
- NotificationEmailer: opt-in email per user and category, IMMEDIATE (at
  or above a minimum severity) or DAILY_DIGEST at the user's digest hour
  and time zone; inert without a messaging provider; ALERT not emailed
  here; SECURITY emails carry no body. Runs in the delivery job.
- Alerts home: a link-only Notifications line for authors.
- safeLink refuses backslashes, whitespace, control characters, a second
  leading slash, and anything but https with a host (open-redirect fix
  from security review); the table customizer uses it on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:15:05 -05:00
james.maes e68bdb9db2 Merge pull request #9 from QRun-IO/feature/notifications-a-tables
feat(notifications): user notifications, recipients, audiences and the bell API (phase a)
2026-10-13 10:09:01 -05:00
james.maesandClaude Opus 5.5 1e8f9aac25 test(demo): seed user notifications for the bell, page and admin screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:08:51 -05:00
james.maesandClaude Opus 5.5 62dfa5f350 feat(notifications): notify people of their failed and partial jobs (phase c)
The process lifecycle listener now also sends a JOB notification to the
person who started a run that fails, or that completes with ERROR summary
lines (a partial run). System runs and the qbit's own processes are
skipped; a repeat of the same run updates one notification (sourceKey
process:<uuid>). It links to jobRunLinkPattern or the process's table.
Monitor heartbeats stay limited to watched processes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:06:30 -05:00
james.maesandClaude Opus 5.5 81407f88c0 chore: license headers on the new notification classes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:06:28 -05:00
james.maesandClaude Opus 5.5 4a94a23314 feat(notifications): user notifications, recipients, audiences and the bell API (phase a)
- alerts_notification (user notifications), alerts_notification_recipient
  (one row per user, read/dismissed) and alerts_notification_preference
  (per-category mute and email settings), with entities, changelog and
  test DDL.
- The delivery log becomes alerts_delivery and alerts_inbox_item is
  retired (guarded changesets 16-17); in-app alert delivery writes ALERT
  notifications and recipient rows.
- Audiences resolved at send time (all users, roles, permission, users)
  through a pluggable user directory; record actions Preview audience,
  Send now, Schedule and Retract; scheduled sends and retention purge in
  the delivery job.
- Bell API: summary with banners, a paged page endpoint with filters,
  read, unread and dismiss; alerts_recipient security key and
  alerts.notification.admin.
- NotificationSignal / NotificationAudience in the SPI stand-in, and the
  sink's notify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:03:47 -05:00
james.maes cd06fff114 Merge pull request #8 from QRun-IO/feature/notifications-spec
docs: user notifications design (owner decisions 2026-10-13)
2026-10-13 10:03:46 -05:00
james.maesandClaude Opus 5.5 1a6bb6096e docs: user notifications design (owner decisions 2026-10-13)
Extends the alerts model with admin-authored and producer-emitted user
notifications, per-user recipient rows, dynamic audiences, the Next UI
notification center, and QAlerts.notify in the core SPI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:45:34 -05:00
james.maes b673032d6b Merge pull request #7 from QRun-IO/feature/sink-stop-drains-in-flight
fix(sink): stop() waits for in-flight signals before returning
2026-10-12 17:50:51 -05:00
james.maesandClaude Opus 5.5 23054f54f1 fix(sink): stop() waits for in-flight signals before returning
The drain thread could take a signal off the queue and still be writing
it when stop() flushed (an empty queue) and returned; the interrupt could
also land mid-write. A resolve submitted just before shutdown could be
lost. That is what failed SinkAndScheduleTest on the release/0.1 build.

stop() now ends the drain loop with a flag, joins the thread (up to 10 s,
interrupting only if it does not finish), then flushes what is left. The
drain poll is 200 ms so stop() returns promptly. New regression test fails
on the old code (3 of 4 runs) and passes on the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:48:46 -05:00
james.maes 5039c4b837 Merge pull request #5 from QRun-IO/feature/alerts-external-channels-p4
feat: external channels and Alertmanager ingest (P4)
2026-10-12 17:27:12 -05:00
james.maesandClaude Opus 5.5 050c141c6b feat: external channels (Slack, Teams, signed webhook, PagerDuty, Alertmanager) and Alertmanager ingest (P4)
- SLACK (Incoming Webhook, Block Kit), TEAMS (Workflows webhook, Adaptive
  Card), WEBHOOK (Standard Webhooks signature via a stand-in for core's
  WebhookSigner, QRun-IO/qqq#1109), PAGERDUTY (Events API v2 trigger /
  acknowledge / resolve, dedup_key = type:key), ALERTMANAGER sink
  (/api/v2/alerts, qqq_* labels, qqq_origin guard, endsAt on resolve)
- shared HTTP rules: https only unless allowInsecureHttp, 10 s timeout,
  2xx sent, 408/425/429/5xx retry, other 4xx permanent; URLs (secrets for
  Slack and Teams) never appear in errors
- AlertMessage carries one structured, redacted entry per alert
- POST /qqq/v1/alerts/ingest/alertmanager: bearer token by secret
  reference (off without one), firing/resolved to raise/resolve, EXTERNAL
  prometheus.* types with notifications off, loop guard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:23:03 -05:00
james.maes d821a0ad30 Merge pull request #4 from QRun-IO/feature/alerts-core-p1-p3
feat: alerts tables, engine, monitors, in-app and email, Alerts home and bell API (P1-P3)
2026-10-12 17:16:47 -05:00
james.maesandClaude Opus 5.5 fed79bd753 feat: spec table labels, record-label titles, readable heartbeat titles, local demo server
- table labels follow the design (Alerts, Monitors, Alert Types, ...)
- per-record monitor alerts are titled with the record's label field
- heartbeat titles read "for 46 h" instead of minutes; short group names
  are upper-cased (CDN)
- AlertsDemoServer (test scope, not shipped): H2 + mock admin + 30 days of
  history produced through the engine, for screenshots and manual checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:14:41 -05:00
james.maesandClaude Opus 5.5 45c7b4fb8f feat: alerts tables, engine, monitors, in-app and email delivery, Alerts home and bell API (P1-P3)
Implements phases P1 to P3 of the Alerts design (QRun-IO/qqq#1090):
- 15 alerts_* tables with the Liquibase changelog, record security
  (alerts_user / alerts_permission / alerts_readable_table keys) and a
  write guard so alerts change only through the engine and record actions
- the state machine: dedup, refresh, severity changes, snooze, resolve,
  reopen window, flapping, quiet-period auto-resolve, maintenance windows
- monitors: TABLE_QUERY, AGGREGATE, HEARTBEAT, PROCESS_FAILED, CODE, with
  the full-state diff, forMinutes, timeouts, CHECK_FAILED meta-alert and
  seeded monitors
- routing with scopes, teams, the bootstrap rule, recipient visibility and
  logged suppression reasons; in-app, email and email-list channels; a
  channel SPI; retries to DEAD and channel health; redaction
- record actions as streamed ETL processes; hidden scheduled processes
- the Alerts app home, AlertSummaryLoader, and /qqq/v1/alerts routes for
  the Next UI bell and record panel

The core producer SPI (QRun-IO/qqq#1108) is not released yet, so its types
are a stand-in in the spi package, marked for replacement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:57:39 -05:00
james.maes a41f8b8ecb Merge pull request #3 from QRun-IO/feature/ga-only-release-tags
ci: publish only GA tags (vX.Y.Z), never RC tags
2026-10-12 16:01:48 -05:00
james.maesandClaude Opus 5.5 9962e4ba57 ci: publish only GA tags (vX.Y.Z), never RC tags
An RC tag matched /v.*/ and the orb republished it as the next RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:59:03 -05:00
james.maesandClaude Opus 5.5 c67e39c247 fix(build): make the Maven wrapper executable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:42:14 -05:00
james.maesandClaude Opus 5.5 0e10190158 chore: bootstrap qbit-alerts with the QRun qbit repository standard
Maven scaffold (qbit-build-parent 2.0.0, com.kingsrook.qbits, <revision>
versioning, qqq 4.1.0-RC.4 BOM, JaCoCo gate), QBit config and producer,
CircleCI qqq-orb pipeline, and the standard repository files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:41:27 -05:00
189 changed files with 46215 additions and 2 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+20
View File
@@ -0,0 +1,20 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
+1
View File
@@ -0,0 +1 @@
* @KofTwentyTwo
+25
View File
@@ -0,0 +1,25 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
+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
+74
View File
@@ -0,0 +1,74 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
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
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
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 || 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
+21
View File
@@ -0,0 +1,21 @@
target/
.env
.idea
*.iml
*.class
*.log
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
.DS_Store
*.sw*
.flattened-pom.xml
dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+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
+55
View File
@@ -0,0 +1,55 @@
# AGENTS.md
This file provides guidance to Codex when working with code in this repository.
## Project Overview
**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the core notification integration in `notifications/`.
## Build Commands
```bash
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
./mvnw test # Unit tests
./mvnw verify # Unit tests + JaCoCo gate (70% instructions, 90% classes)
```
## Architecture
**Entry point:** `AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig>` in `com.kingsrook.qbits.alerts`. Component producers are found by package scanning, in the `metadata` package only (the `api` package needs the optional Javalin dependency).
Packages:
- `model`: entities, generated from one schema together with the changelog and the test DDL. If you change a table, change all three.
- `engine`: the state machine (`AlertsEngine`), routing (`AlertRouter`), monitors (`MonitorEvaluator`), type sync, the sink and the process listener.
- `delivery`: channels, rendering, redaction and the delivery worker.
- `processes`: the record actions (`AlertsAction`, `AlertsActionSteps`) and the scheduled steps.
- `home`: the Alerts home and `AlertSummaryLoader`.
- `api`: the bell and record-panel routes.
- `notifications`: core RC.6 notification delivery, audience resolution and email; all producer types come from `com.kingsrook.qqq.backend.core.alerts`.
## Naming (owner rule)
- Tables are lowercase snake_case prefixed with the full qbit name: `alerts_<name>`. The QQQ table name and the backend (SQL) name are identical; possible-value sources take the table name.
- Permissions use the full qbit name: `alerts.<action>` (for example `alerts.view`, `alerts.admin`).
- Qbit data lives in standard, visible QQQ tables, secured by permissions and record security locks, never hidden.
- Processes run as table or record actions, never in the nav.
## Code Style
- 3-space indentation, opening braces on the next line, `return (x);`.
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
- Fluent setters (`withX()`).
- Wrapper types (`Integer`, `Boolean`) in config and entities.
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.1.0` (qqq 4.0.0), with architecture guardrails enforced and strict UI API route tests enabled. The default-active `qqq-snapshot` profile imports released `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Build normally without a version override. Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
- CircleCI qqq-orb 0.6.8.
- `develop` publishes SNAPSHOTs.
- A `release/*` branch publishes RCs (the orb bumps the version).
- A `v*` tag publishes the release.
+94
View File
@@ -0,0 +1,94 @@
# Changelog
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
## [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
- 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).
- Add summary `inboxHref`, notification operation actions, alert process actions with complete hrefs, and record-panel `follow.available` (#13, F20). Follow discovery reads core extension metadata without a collaboration dependency or URL.
- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration (#16, F12).
- Move Alertmanager ingest to `/qqq/hooks/alerts/alertmanager`. The old path is an opt-in alias (`legacyAlertmanagerPath`, default false) for one minor release, incompatible with strict raw-route mode. Each alias call carries deprecation/sunset headers and logs a warning; authentication is shared (#14, F7).
- Run Checkstyle 14.3.0 in Maven validate (including tests); align the license template with this repository's existing headers and correct pre-existing switch/import formatting so the style check passes.
- Alert types and sink registration use core's alerts SPI (introduced in QQQ 4.1.0-RC.5); host `QAlerts.raise/resolve/heartbeat` calls reach the installed qbit through core instance metadata (qbit-alerts#15, F3).
- **Breaking:** remove the entire `com.kingsrook.qbits.alerts.spi` package (`NotificationSignal`, `NotificationAudience`, `NotificationCategory`, `NotificationSeverity`, `AlertLink`, package metadata) and `AlertsRuntime.sink().notify(...)`. Producers must use core RC.6 types and `QAlerts.notify`; rebuild existing callers. qbit-easypost-tracking#16 moved to core.
- Implement core `AlertSinkInterface.notify` in the registered adapter and runtime queue, retaining notification inbox, audience resolution, category preferences and email delivery behavior (qbit-alerts#15 part 2, F3/F12; QRun-IO/qqq#1125). Notification calls now receive core commit/rollback guarantees. No table or HTTP wire changes in this migration.
- 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)
- a generic webhook, signed with Standard Webhooks headers through core's `WebhookSigner` (QRun-IO/qqq#1109)
- PagerDuty (Events API v2: trigger, acknowledge and resolve)
- an Alertmanager sink (`/api/v2/alerts`, with the qqq labels and the origin guard)
- Prometheus Alertmanager ingest at `POST /qqq/hooks/alerts/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
- Tables (`alerts_*`), with the Liquibase changelog `db/liquibase/qbit-alerts/alerts.changelog.sql`:
- alerts, their timeline and records
- types, groups and monitors
- subscriptions, user settings, teams and members
- channels, mutes and maintenance windows
- the notification log, inbox items and heartbeats
- An engine with dedup, refresh, severity changes, snooze, resolve, a reopen window, flapping, quiet-period auto-resolve and maintenance windows.
- Monitors: `TABLE_QUERY`, `AGGREGATE`, `HEARTBEAT`, `PROCESS_FAILED` and `CODE`. They have:
- a full-state diff, so a clean check resolves the monitor's open alerts
- `forMinutes`
- timeouts
- `CHECK_FAILED`, which raises the meta-alert `alerts.monitor.failing`
- seeded default monitors
- Routing:
- subscriptions by scope, team expansion, the bootstrap rule and a recipient visibility check
- suppression reasons logged on the notification
- in-app (inbox), email and email-list channels
- a pluggable channel SPI
- retries with backoff to DEAD, and channel health
- a redactor and detail levels for every external message
- Record actions, each a streamed ETL process:
- on alerts: acknowledge, snooze, resolve, reopen, assign, comment and mute
- on types, groups and monitors: subscribe and mute
- on monitors: run check now, enable and disable
- on types: create monitor
- on channels: send test notification
- on notifications: retry delivery
- Two hidden scheduled processes, for evaluation and delivery.
- The top-level Alerts app and its home dashboard, plus `AlertSummaryLoader` for link-only summaries.
- `/qqq/v1/alerts` routes for the Next UI bell and record panel.
- Record security, through three key types (`alerts_user`, `alerts_permission`, `alerts_readable_table`).
- Core alert producer SPI integration (QRun-IO/qqq#1108), plus temporary notification SPI compatibility until RC.6 (QRun-IO/qqq#1125).
- Repository scaffold: `AlertsQBitConfig` and `AlertsQBitProducer` (table prefix `alerts_`, permissions `alerts.*`), build, CI and repository standard files.
+55
View File
@@ -0,0 +1,55 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
## Project Overview
**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the core SPI stand-in in `spi/`.
## Build Commands
```bash
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
./mvnw test # Unit tests
./mvnw verify # Unit tests + JaCoCo gate (70% instructions, 90% classes)
```
## Architecture
**Entry point:** `AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig>` in `com.kingsrook.qbits.alerts`. Component producers are found by package scanning, in the `metadata` package only (the `api` package needs the optional Javalin dependency).
Packages:
- `model`: entities, generated from one schema together with the changelog and the test DDL. If you change a table, change all three.
- `engine`: the state machine (`AlertsEngine`), routing (`AlertRouter`), monitors (`MonitorEvaluator`), type sync, the sink and the process listener.
- `delivery`: channels, rendering, redaction and the delivery worker.
- `processes`: the record actions (`AlertsAction`, `AlertsActionSteps`) and the scheduled steps.
- `home`: the Alerts home and `AlertSummaryLoader`.
- `api`: the bell and record-panel routes.
- `spi`: the stand-in for the core producer SPI.
## Naming (owner rule)
- Tables are lowercase snake_case prefixed with the full qbit name: `alerts_<name>`. The QQQ table name and the backend (SQL) name are identical; possible-value sources take the table name.
- Permissions use the full qbit name: `alerts.<action>` (for example `alerts.view`, `alerts.admin`).
- Qbit data lives in standard, visible QQQ tables, secured by permissions and record security locks, never hidden.
- Processes run as table or record actions, never in the nav.
## Code Style
- 3-space indentation, opening braces on the next line, `return (x);`.
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
- Fluent setters (`withX()`).
- Wrapper types (`Integer`, `Boolean`) in config and entities.
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
- CircleCI qqq-orb 0.6.8.
- `develop` publishes SNAPSHOTs.
- A `release/*` branch publishes RCs (the orb bumps the version).
- A `v*` tag publishes the release.
+3
View File
@@ -0,0 +1,3 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
+3
View File
@@ -0,0 +1,3 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
+6
View File
@@ -0,0 +1,6 @@
# Contributing to QBit Alerts
* See Project [Contributing](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md)
* Branches follow GitFlow: `feature/*` from `develop`, pull requests to `develop`, `release/*` for release candidates, `v*` tags for releases.
* Commits are signed and follow Conventional Commits.
* Build and test locally with `./mvnw verify` (Java 21).
+191
View File
@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on
behalf of the copyright owner. For the purposes of this definition,
"submitted" means any form of electronic, verbal, or written
communication sent to the Licensor or its representatives, including
but not limited to communication on electronic mailing lists, source
code control systems, and issue tracking systems that are managed by,
or on behalf of, the Licensor for the purpose of discussing and
improving the Work, but excluding communication that is conspicuously
marked or otherwise designated in writing by the copyright owner as
"Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 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.
+6
View File
@@ -0,0 +1,6 @@
QBit Alerts
Copyright 2026 Kingsrook, LLC
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
Licensed under the Apache License, Version 2.0.
+309 -2
View File
@@ -1,3 +1,310 @@
# Alerts qbit screenshots (P1-P3)
# QBit Alerts
Taken 2026-10-12 from the local demo (AlertsDemoServer + Next UI branch feature/alerts-bell-panel), light and dark, 390 and 1440 px.
[![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![Java](https://img.shields.io/badge/java-21+-blue.svg)](https://adoptium.net/)
> QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables).
**Status:** design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 of the design are implemented: tables, engine, monitors, in-app and email channels, the Alerts app home, and the bell and record-panel API. Not yet published.
## What it does
Any qbit or app reports exceptional conditions ("a person should do something"). The qbit:
- **Records them** as alerts, keyed by `(type, dedupKey)`. Repeats update the open alert; they never duplicate it.
- **Runs the alert lifecycle:** acknowledge, snooze, assign, comment and resolve, with a reopen window, flapping detection and auto-resolve (by the producer, the monitor, or a quiet period).
- **Evaluates monitors**, which are plain data an admin can edit:
- `TABLE_QUERY` and `AGGREGATE`
- `HEARTBEAT`, on a key, a process or a table's newest row
- `PROCESS_FAILED`
- `CODE`, through `AlertCheckInterface`
- **Notifies subscribers** (users, teams or channels) through in-app (the bell), email and email lists. These optional channels are also built in:
- Slack (an Incoming Webhook)
- Microsoft Teams (a Workflows webhook)
- a generic signed webhook
- PagerDuty (Events API v2)
- Prometheus Alertmanager, as a sink
Any channel another qbit registers works too. Every suppressed notification is logged with its reason.
- **Ingests Prometheus Alertmanager** at `POST /qqq/hooks/alerts/alertmanager`, with notifications off for those types, so infrastructure alerts are triaged in one place.
- **Owns the Alerts home:** open alerts by severity, my alerts, needs action, by group, a 30-day trend, the noisiest types, delivery health and recently resolved. Other pages get link-only summaries from `AlertSummaryLoader`.
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)):
- **Admins** with `alerts.notification.admin` write notifications in the Notifications table (title, markdown body, severity, category, link, audience, publish and expire times, a sticky banner flag), then use the record actions Preview audience, Send now, Schedule and Retract. Edits after sending show at once; retracting hides it.
- **Audiences** are resolved when sent: all users, roles or groups, holders of a permission, or named users. Each user gets one `alerts_notification_recipient` row, so read, unread and dismissed are per user. Roles need a user directory (`AlertsQBitConfig.withUserDirectory`, an `AlertsUserDirectoryInterface`); without one, "all users" means everyone who has used Alerts.
- **Producers** send with `QAlerts.notify(new NotificationSignal(...))` using `com.kingsrook.qqq.backend.core.alerts` types (RC.6). Alerts a user subscribes to arrive in the bell as ALERT notifications.
- **Ready-made notices** in `AlertsNotices`: `newSignIn`, `apiKeyCreated`, `apiKeyExpiring`, `permissionChanged` (SECURITY), `shared` (SHARE) and `release` (RELEASE, all users). Core has no sign-in or API key events yet, so hosts call these from their authentication customizer and API key screens.
- **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.
- **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.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
hrefs, `notificationsHref`, `alertsHref`, `canManage` and deprecated `watching` remain.
Record following belongs to collaboration: `POST /alerts/record-panel/watch` is removed. Old
RECORD subscriptions remain readable without migration. The panel creates no subscriptions;
Next uses its collaboration module when `follow.available` is true. Alerts with active record
refs also reach followers supplied by core's instance-level followers provider (`FollowEvent.ALERT`),
when FOLLOW is enabled and the follower can read the source record. Followers and subscribers
share recipient deduplication, channel preferences and suppression. No collaboration qbit dependency
is required; without a provider, subscription behavior is unchanged. See [follower routing](docs/UI-API.md#record-follower-routing).
The old Alertmanager path is available for one minor release only via
`withLegacyAlertmanagerPath(true)` (default false). It requires non-strict UI route mode,
returns `Deprecation: true` and `Sunset: Tue, 13 Apr 2027 00:00:00 GMT` on every call, and logs
a warning. Both paths use the same bearer-token check. Strict hosts use the canonical hook.
## Requirements
- Java 21.
- 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.
- `qqq-middleware-javalin`, for the `/qqq/v1/alerts` routes (optional).
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.7 BOM ahead of the parent's QQQ 4.0 BOM.
## Install
```java
new AlertsQBitProducer()
.withQBitConfig(new AlertsQBitConfig()
.withBackendName("rdbms")
.withSchedulerName("quartz")
.withMessagingProviderName("smtp")
.withFromAddress("alerts@example.com")
.withPublicBaseUrl("https://admin.example.com"))
.produce(qInstance);
```
1. **Run the schema.** Include the bundled Liquibase changelog from the host's master changelog. The qbit never runs DDL.
```yaml
- include: { file: db/liquibase/qbit-alerts/alerts.changelog.sql }
```
2. **Add the security keys to sessions.** Call `AlertsSecurity.applyToSession(qInstance, session)` from your authentication customizer, after the user and permissions are set. The qbit's own home, bell and panel endpoints also add the keys when they are missing.
3. **Map roles to permissions.** `AlertsQBitConfig.getViewerPermissionNames()`, `getManagerPermissionNames()` and `getAdminPermissionNames()` list what to grant: `alerts.view`, `alerts.manage` and `alerts.admin`, plus the standard per-table permissions. Nothing is granted by default.
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:
```java
QAlerts.raise(new AlertSignal("cdn.purge.dead")
.withDedupKey("provider:" + providerId)
.withTitle("3 CDN purges failed after retries") // external-safe: no personal data
.withRecordRefs(AlertRecordRef.of("cdn_purge_request", ids)));
```
`QAlerts.resolve(...)` and `QAlerts.heartbeat(...)` reach the same sink. Declare types with core's `QAlertTypeMetaData`. Calls through `QAlerts.raise/resolve` defer to transaction commit when a transaction is present; without this qbit installed, core uses its logging sink.
User notifications use core `QAlerts.notify(NotificationSignal)` (RC.6, QRun-IO/qqq#1125). Core dispatches through `AlertsSinkAdapter` into the same bounded queue, notification service, audience resolution and email preferences. Calls wait for an explicit or ambient transaction to commit and are dropped on rollback.
**Breaking Java API migration:** the entire `com.kingsrook.qbits.alerts.spi` package and `AlertsRuntime.sink()` are removed. Replace notification and `AlertLink` imports with `com.kingsrook.qqq.backend.core.alerts`, and replace `AlertsRuntime.get(qInstance).sink().notify(signal)` with `QAlerts.notify(signal)` in the producer's `QContext`. Rebuild callers; qbit-easypost-tracking#16 already moved to core. `getSink()` is for adapter delegation and queue lifecycle management, not a producer API. Notification tables and HTTP contracts are unchanged by this migration.
## Endpoints (for the Next UI)
| Method | Path | What |
|---|---|---|
| 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": [..]}` |
| POST | `/qqq/v1/alerts/inbox/dismiss` | `{"id": n}`, `{"ids": [..]}` or `{"all": true}` |
| GET | `/qqq/v1/alerts/record-panel?tableName=t&recordId=id` | The "Open alerts" panel for a record (`visible: false` when there is nothing to show) |
| POST | `/qqq/hooks/alerts/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. |
## External channels
Each external channel is an `alerts_channel` row that an admin creates. Its secret is a reference (`secretRef`, an environment variable name or a `${...}` expression), never the value. The minimum detail level is MINIMAL by default.
| Type | Secret | Settings |
|---|---|---|
| `SLACK` | The Incoming Webhook URL | none |
| `TEAMS` | The Workflows "when a webhook request is received" URL | none |
| `WEBHOOK` | The signing key (`whsec_...`) | `{"url": "https://receiver"}` |
| `PAGERDUTY` | The Events API v2 routing key | optional `url`, `source` |
| `ALERTMANAGER` | An optional bearer token | `{"url": "https://alertmanager:9093"}` |
How each one sends:
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed by core's `WebhookSigner` with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). Both raw UTF-8 and `whsec_` base64 secrets keep the same wire format. Core's `WebhookSecret` requires 16 key bytes; a thin adapter zero-pads legacy shorter keys to 16 bytes, which is HMAC-SHA256-equivalent and preserves their signatures. New secrets should use `WebhookSecret.generate()`.
- **PagerDuty** gets one event per alert: `trigger`, `acknowledge` or `resolve`, with `dedup_key` set to the type and key.
- **Alertmanager** alerts carry the labels `qqq_type`, `qqq_group`, `qqq_key` and `qqq_origin="qbit-alerts"`. Ingest ignores alerts with that origin, so nothing loops.
- **Responses:** 2xx is sent. 408, 425, 429, 5xx and network errors are retried. Any other 4xx fails permanently.
- **URLs** must be https, unless the channel's settings say `"allowInsecureHttp": true`, which is meant for local receivers.
## Quiet hours, pause and alert digests
My Alert Settings applies to user subscriptions, explicit and host-resolved team members, and record followers. Pause suppresses personal push channels (email, email lists and other non-IN_APP channels) until `pausedUntil`. Quiet hours also apply only to push channels; neither setting suppresses the in-app bell. Quiet hours use `timeZone` (UTC when missing or invalid), start inclusively and end exclusively. For a window such as 22:00-07:00, `quietDays` names the day the window starts: Monday includes Tuesday morning. Empty days mean every day; use comma-separated ISO day numbers (1=Monday), full English names or MON/TUE/etc. Missing, invalid or equal start/end times disable quiet hours. Severities at or above `quietBypassSeverity` (CRITICAL by default) bypass quiet hours, but never pause. Channel subscriptions remain independent of these personal settings; their digest cadence still applies, using UTC and 08:00 for daily delivery. Suppressions are recorded in the Delivery Log; channel severity filtering now records `MIN_SEVERITY` too.
Subscriptions with `DIGEST_HOURLY` or `DIGEST_DAILY` queue `DIGEST` delivery rows with status `PENDING`, a window key and a due time. Immediate delivery skips them. REMINDER and ESCALATED deliveries are always immediate, even for digest subscriptions, and still respect pause and quiet hours on push channels. The hidden `alertsSendDigests` process runs hourly on `schedulerName`; without a scheduler it can be invoked explicitly. Hourly windows close on the user's local hour. Daily windows close at their `digestHour` (08:00 by default), using local calendar days across daylight saving changes. A late scheduler catches up completed windows. Settings changes affect newly queued windows; the original boundary stays with existing items.
Each user/channel/window produces one message with the window's open alerts ordered by severity, new alerts and resolved alerts, each linked to its record. Multiple events for an alert produce one entry per applicable section. In-app gets one notification; email uses the existing `alerts_digest` template through qbit-email's public API, or the configured messaging provider. Pause and quiet hours are checked again for push channels at dispatch; visibility is checked for every channel. No empty message is sent. There is no `DIGESTS` feature switch in this configuration.
Changeset 27 adds the digest fields and a unique dispatch claim to `alerts_delivery`. The base key is `alerts:digest:<user>:<window start>`; alert digests append `:<delivery mode>:channel:<channel id>` to distinguish overlapping hourly/daily windows, channels and the existing notification digests. Channel-only digests use `alerts:channel-digest:<channel>:<window start>:<delivery mode>:channel:<channel id>`. Email keys also append a recipient hash so email-list addresses each receive their copy. A persisted claim prevents concurrent or repeated runs from sending twice. Its item cutoff freezes the contents; late items for a claimed window are logged as `DUPLICATE`, not reported as sent. Included items become `SENT`, or `FAILED` with the error. Digest transport failures are not automatically retried; an interrupted claim fails after ten minutes rather than risking another send through a provider without idempotency support. The Delivery Log retains that uncertainty for review.
Digest queries and rendering are bounded: up to 100 candidate items are scanned per run, and a message accepts at most 500 queued events. Larger windows fail visibly with guidance to narrow the subscription or use hourly digests; they are never sent partially. Digest messages use MINIMAL detail and contain a snapshot of content visible at dispatch. Protected alerts require verifiable user visibility even for an in-app digest.
## Email through qbit-email
When qbit-email is produced in the same instance (and on the classpath; it is an optional dependency), alert channel emails, notification emails and daily digests go through the published `com.kingsrook.qbits.email.api.EmailApi` in qbit-email `0.1.0-RC.2` instead of core `SendMessageAction`. Template registration, sends and installation detection use only the public API; templates, categories, idempotency keys, linked records and outcome mapping are unchanged:
- **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.
- **Idempotency keys** never hold an address: `alerts:alert:<alert ids>:<recipient hash>:<message kind>:<minute>` (the kind and minute keep a reminder or a reopened alert from looking like a repeat), `alerts:notification:<notification id>:<recipient hash>` and `alerts:digest:<user>:<digest window start>`.
- **Linked record** is the alert or notification row; the sender is `qbit-alerts` and the process `alertsDeliverNotifications`.
- **Outcomes.** Queued or duplicate is sent. Suppressed (the person unsubscribed) is `SKIPPED` for notifications and a permanent failure that says so for channels. Refused (for example no enabled email provider) is `FAILED` and not retried.
- **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 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. 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:** 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
```bash
./mvnw verify
```
Always use the Maven wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
## License
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
+4
View File
@@ -0,0 +1,4 @@
# Security Policy
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
* Report a vulnerability privately through this repository's **Security** tab ("Report a vulnerability").
+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.
+246
View File
@@ -0,0 +1,246 @@
<?xml version="1.0"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2022. Kingsrook, LLC
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
~ contact@kingsrook.com
~ https://github.com/Kingsrook/
~
~ 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
~
~ https://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.
-->
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
<!--
See reference at https://checkstyle.sourceforge.io/checks.html
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<!--
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
-->
<module name="TreeWalker">
<module name="SuppressWarningsHolder"/>
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="option" value="nl"/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
</module>
<!--
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
-->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="3"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="3"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="3"/>
<property name="arrayInitIndent" value="6"/>
</module>
<!--
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
-->
<module name="CustomImportOrder">
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
<property name="specialImportsRegExp" value="^javax\."/>
<property name="standardPackageRegExp" value="^java\."/>
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="false"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad"/>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="private"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="private"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
<module name="MissingOverride"/>
</module>
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="2"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
+15
View File
@@ -0,0 +1,15 @@
/*
* 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.
*/
+28
View File
@@ -0,0 +1,28 @@
# 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); 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.
- **Heartbeats** are stored in an extra table, `alerts_heartbeat`.
- **Claims** are leases, not atomic conditional updates (see README.md, Known limits).
- **Email threading headers** wait for core messaging support.
- **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.
+340
View File
@@ -0,0 +1,340 @@
# QBit Alerts: user notifications (design)
**Status:** approved design, built in phases (section 11). It extends the alerts model in
[QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090) (`docs/specs/2026-10-12-qbit-alerts-design.md`); it does not add a parallel system.
## 0. Summary
The Alerts qbit also carries ordinary user notifications: announcements an admin writes, a user's own
failed jobs, alerts they subscribe to, things shared or assigned to them, security notices, and release or
marketing messages. Every notification is a row in a standard QQQ table, and every person it reaches gets
one recipient row. The Next UI bell, which today shows only alerts, becomes a notification center: an
unread badge, a popover, mark read, dismiss, a "View all" page with filters, and a banner for sticky
critical items. Admins create, edit, schedule, send, retract and delete notifications in the admin
through standard record screens and record actions.
**Owner requirement (verbatim, 2026-10-13):** "the alerts qbit should work for normal notifications etc
too. and if installed and working, the next UI should support it via the 'bell' in the top nav. it should
show you notifications, allow you to mark them as read etc. in the admin system, the system admins should
be able to create notifications, edit, remove, etc. we should be able to send notifications to all users in
a smart way, or just some etc. and a users own alerts or errors or warnings should work through that
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 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.
3. **Automatic sources into a user's bell:**
- their failed or partial jobs (processes, ETL, imports and exports they started), linked to the run;
- alerts they subscribe to;
- things assigned or shared to them (a notebook share, a record lock, a pending approval);
- security notices (new sign-in, API key created or expiring, permission change);
- application releases and marketing notifications (authored by admins or emitted by apps, such as
"new version released" or a marketing-qbit campaign), sent to an audience.
**Owner decisions (2026-10-12): all recommendations accepted.** Every open question below (section 12)
is decided as recommended.
## 2. How it fits the alerts model
| Before (0.1.0-RC.1) | After |
|---|---|
| `alerts_notification` = the delivery log (one row per send attempt per channel) | Renamed `alerts_delivery` ("Delivery Log"). Same columns, same role. |
| `alerts_inbox_item` = one alert in one user's bell | Retired. In-app alert delivery writes an `alerts_notification` (category ALERT, linked to the alert) and a recipient row. |
| The bell shows alerts only | The bell shows every notification category, alerts included. |
| `alerts_user_preference` (quiet hours, digest hour, time zone) | Kept. Notification email settings per category live in `alerts_notification_preference`, and reuse its time zone and digest hour. |
| Producer SPI: `QAlerts.raise/resolve/heartbeat` | Plus `QAlerts.notify(NotificationSignal)` (section 6). |
The rename and the retirement break the changelog's additive-only rule once, before GA. Nobody runs
0.1.0-RC.1 in production. Changeset 16 renames the table, its key and its indexes, and changeset 17 drops
`alerts_inbox_item` (it holds only bell state). Both are guarded by preconditions, so they are safe on a
fresh schema and on an RC.1 schema.
## 3. Tables
All are standard QQQ tables with record screens, the API, audit and ESB events, secured by permissions
and record locks, never hidden.
### 3.1 `alerts_notification` (Notifications)
| Field | Type | Notes |
|---|---|---|
| `title` | string(200), required | Plain text |
| `body` | text | Markdown, rendered safely (no raw HTML) |
| `severity` | INFO / SUCCESS / WARNING / ERROR / CRITICAL | Default INFO |
| `category` | SYSTEM / JOB / SECURITY / RELEASE / MARKETING / SHARE / ALERT | Default SYSTEM |
| `sourceName` | string | Who emitted it: `admin`, `qbit-alerts`, a qbit or app name |
| `sourceKey` | string | Dedup key from producers (for example `process:<uuid>`). A second notify with the same key and audience updates the first. |
| `sourceTableName`, `sourceRecordId` | string | The record it is about (an alert, a run, a share) |
| `linkUrl`, `linkLabel` | string | A deep link (a record, a table filter, a run, or an https URL) |
| `audienceType` | ALL_USERS / ROLES / PERMISSION / USERS | |
| `audienceValue` | text | Comma-separated role names, one permission, or user ids |
| `publishAt`, `expireAt` | timestamp | Not shown before publishAt; hidden and purged after expireAt |
| `sticky` | boolean | A top banner until the user dismisses it (meant for CRITICAL) |
| `status` | DRAFT / SCHEDULED / SENT / RETRACTED | Default DRAFT |
| `authorUserId` | string | Set on insert |
| `sentAt`, `retractedAt`, `recipientCount`, `readCount` | | Kept by the qbit |
Record screen: identity (title, severity, category, status), Message (body, link), Audience, Schedule,
the Recipients child list, and System.
Edits after sending change what recipients see (the bell reads the notification). Retracting hides it
everywhere. Deleting it deletes its recipient rows.
### 3.2 `alerts_notification_recipient` (Notification Recipients)
A child of the notification: one row per user, unique (`notificationId`, `userId`).
`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.
### 3.3 `alerts_notification_preference` (My Notification Settings)
One row per user and category, unique (`userId`, `category`); a missing row means the defaults.
`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:
| Audience | Resolved to |
|---|---|
| ALL_USERS | Every active user in the directory |
| ROLES | Directory users with any of the named roles or groups |
| PERMISSION | Directory users whose session holds the permission (`createAutomatedSessionForUser`) |
| USERS | The named user ids, as given |
**The user directory** is an SPI, `AlertsUserDirectoryInterface` (`listUsers()` returning id, label,
email and roles), set with `AlertsQBitConfig.withUserDirectory(QCodeReference)`. Without one, the directory
is every user who has used Alerts (a row in `alerts_user_preference`, created the first time a user opens
the bell). That is "all users who sign in", with no host code.
Resolution is capped by `maxAudienceSize` (default 50,000). Recipient rows are inserted in batches of 500.
## 5. Admin
The Notifications table is a main nav entry in the Alerts app (it needs `alerts.notification.admin`).
Admins use the standard create, edit and delete screens. Record actions are streamed-ETL processes with the
standard Review and Result summary lines, never nav entries:
| Action | On | Review, then Result |
|---|---|---|
| **Preview audience** | DRAFT or SCHEDULED (one) | "Would reach 42 users", plus up to 10 sample names. Writes nothing. |
| **Send now** | DRAFT or SCHEDULED (bulk) | "Will send 2 notifications to 57 recipients (1 already sent, skipped)", then "Sent" |
| **Schedule** | DRAFT (bulk), needing a future `publishAt` | Sets SCHEDULED. The delivery job sends it when `publishAt` passes. |
| **Retract** | SENT (bulk) | Sets RETRACTED, which hides it from every bell |
The Alerts home gets one link-only line, "Notifications: 3 scheduled, 12 sent this week", for
notification admins. The home stays the only Alerts dashboard.
## 6. Producer SPI
The notification API from [QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)
ships in 4.1.0-RC.6. This branch targets that release and delivers core `QAlerts.notify`
calls through the installed qbit sink:
```java
QAlerts.notify(new NotificationSignal("Your export failed")
.withBody("**Orders export** stopped after 1,200 rows: the file server refused the upload.")
.withSeverity(NotificationSeverity.ERROR)
.withCategory(NotificationCategory.JOB)
.withSourceKey("process:" + processUUID)
.withLink(AlertLink.toUrl("/app/orders", "Orders"))
.withAudience(NotificationAudience.user(userId)));
```
- `AlertSinkInterface` has a default method, `notify(NotificationSignal)`, that logs; existing sinks keep
working. `LoggingAlertSink` logs at INFO (WARN for ERROR/CRITICAL). The qbit's sink queues the signal like an alert signal.
- The guarantees match `raise`: it never throws or blocks, it waits for the commit when a transaction is
open, and bad input is counted.
- `NotificationAudience`: `allUsers()`, `roles(...)`, `permission(p)`, `users(...)`, `user(id)`.
- All producer types, including `AlertLink`, are from `com.kingsrook.qqq.backend.core.alerts`.
The qbit implements core's `notify` override in `AlertsSinkAdapter` and `QbitAlertsSink`;
signals use the existing bounded queue and `NotificationService` for audience resolution,
inbox recipients, scheduling and category/email preferences. Alert subscription channels
keep their existing behavior. Quiet-hour scheduling for alerts remains unimplemented (see README).
- **Breaking migration:** the local `spi` package and `AlertsRuntime.sink()` are removed.
Change imports to core, call `QAlerts.notify(signal)` in the producer's `QContext`, and
rebuild. qbit-easypost-tracking#16 uses core. The runtime's `getSink()` supports adapter
delegation and queue lifecycle operations; producers use `QAlerts`.
- Stored audience types and comma/newline-separated values remain readable. The service
retains trimming, duplicate removal and null storage for empty audiences, so existing
notification dedup keys and rows need no migration. Core types do not own database parsing.
- Verification covers core producer calls through the asynchronous queue to the strict HTTP
inbox, all audience factories, commit/rollback, category mutes, immediate email and digests.
The UI fragment and all six HTTP operations are unchanged by this notification migration.
### 6.1 Automatic sources
| Source | How | Category |
|---|---|---|
| Failed or partial jobs | The qbit's process lifecycle listener: a run that fails, or completes with error summary lines, notifies the user who started it (the session user; system runs are skipped). The link is the run's table, or `jobRunLinkPattern` with `{processName}` and `{processUUID}`. Turn it off with `notifyFailedJobs(false)`. | JOB |
| Alerts they subscribe to | The router's in-app channel writes an ALERT notification per alert (sourceKey `alert:<id>`) and a recipient row per routed user. A repeat or reopen marks it unread again. | ALERT |
| Shares, record locks, approvals | Producers call `QAlerts.notify(... user(id))`. The first wiring is the SQL Workbench notebook share in kof22. | SHARE |
| Security notices | `AlertsSecurityNotices` (new sign-in, API key created, API key expiring, permission change). Core has no authentication events today, so hosts call it from their authentication customizer. A core hook is follow-up work (section 12, Q5). Security is never mutable in-app. | SECURITY |
| Releases and marketing | Admin-authored notifications, or producers with an audience | RELEASE, MARKETING |
## 7. Delivery
- **In-app:** recipient rows. The bell shows rows that are not dismissed, of SENT notifications whose
`publishAt` has passed and whose `expireAt` has not.
- **Email** (phase e): through the QQQ messaging provider when configured; otherwise inert. IMMEDIATE
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** (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
Gating is unchanged from #85: no bell and zero requests unless `metaData.apps.alerts` is present.
- **Badge:** the unread count (99+), red when an unread item is CRITICAL. The poller is tiny and lives in the
main bundle. It polls every `pollSeconds` (60), backs off to 5 minutes on errors, and pauses while the tab
is hidden. QQQ has no SSE today.
- **Popover** (lazy chunk): newest first, a severity icon, the title, the source, a relative time, and a
link. You can mark one read or unread, or dismiss it, and the footer has "Mark all read" and "View all".
- **Page** `/app/notifications` (lazy route): filters (All or Unread, category, severity), paging, a markdown
body, and the same actions.
- **Banner:** sticky, undismissed, unexpired items show above the page content until dismissed.
- **Budget:** total JS 1100 KB. The badge adds well under 1 KB to first load; the popover and the page are
lazy chunks with their own `lazyChunks` budget entry.
### 8.1 API (`/qqq/v1/alerts`, session-authenticated, `alerts.view`)
| Method | Path | Body or query | Returns |
|---|---|---|---|
| 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}` |
| POST | `/inbox/dismiss` | `{id}` or `{ids:[..]}` or `{all:true}` | `{updated}` |
An item is `{id, notificationId, title, body, severity, category, source, href, linkLabel,
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
- `alerts.notification.admin`: author, send, schedule, retract and delete notifications, and read every
recipient row (the `alerts_user` all-access key).
- `alerts.view`: the bell, the page, their own recipient rows (read and edit) and their own notification
settings.
- Recipient and preference rows are owner-locked on `userId`. Notification bodies reach a user only
through their own recipient rows.
- Markdown is rendered without raw HTML. Links must be relative app paths or https.
## 10. Retention
`expireAt` hides a notification at once. The delivery job's purge step deletes notifications (and their
recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED notifications older than
`notificationRetentionDays` (default 180).
## 11. Phases
| Phase | Scope |
|---|---|
| a | Tables, the rename and retirement, admin screens and actions, audiences and recipients, the bell API, ALERT notifications |
| b | Next UI: badge, popover, page, banner |
| c | Core RC.6 producer SPI and sink integration, job failures |
| d | Security notices, shares, release and marketing producers |
| e | Email: immediate and daily digest |
## 12. Questions, with the recommendations the owner accepted
1. Rename the delivery log rather than pick another name? **Yes**, before GA.
2. Copy notification content per recipient? **No**: recipients read the notification, so edits and
retractions apply at once.
3. Users added after a send? **They don't get it.** Resolution happens at send time, per decision 2.
4. Default email? **Off** for every category until the user opts in.
5. Core authentication events? **Later.** Hosts call `AlertsSecurityNotices` from their authentication
customizer until core offers a sign-in event.
6. Server-sent events? **Later.** Polling with backoff is enough at 60 s.
## UI contract
The implemented UI routes and additive action/follow metadata are specified in [UI-API.md](UI-API.md),
with the generated fragment at `src/main/resources/openapi/qqq-v1/alerts.yaml`. Record following
belongs to collaboration; the old alerts panel watch route is removed.
Record-linked alerts fan out through core's RC.6 followers provider with source-record access
checks and the ordinary alert preference/suppression pipeline; see [record follower routing](UI-API.md#record-follower-routing).
+151
View File
@@ -0,0 +1,151 @@
# Alerts UI API and webhook migration
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.
The deterministic fragment is `src/main/resources/openapi/qqq-v1/alerts.yaml`. Next should
vendor it. The live `/qqq/v1/openapi.yaml` includes these operations, and `/qqq/v1/version`
discovers the `alerts` extension so clients need not infer installation from an app name.
| Method | Path | operationId |
| --- | --- | --- |
| GET | `/qqq/v1/alerts/inbox/summary` | `alertsInboxSummary` |
| GET | `/qqq/v1/alerts/inbox` | `alertsInbox` |
| POST | `/qqq/v1/alerts/inbox/read` | `alertsInboxRead` |
| POST | `/qqq/v1/alerts/inbox/unread` | `alertsInboxUnread` |
| POST | `/qqq/v1/alerts/inbox/dismiss` | `alertsInboxDismiss` |
| GET | `/qqq/v1/alerts/record-panel` | `alertsRecordPanel` |
All existing query parameters, selector precedence, paging defaults and clamps, status
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`.
An unread item offers `read` (`alertsInboxRead`); a read item offers `unread`
(`alertsInboxUnread`); both offer `dismiss` (`alertsInboxDismiss`). Invoke the operation
from the fragment with `{ "id": item.id }`. Existing item `href` semantics are unchanged.
- Every record-panel alert adds `actions`: `name`, `label`, `processName`, `href`.
Managers see `acknowledge` for OPEN alerts and `snooze` for panel alerts; viewers get an
empty list. The href is complete, including the process, alert id and table arguments:
`/app/alertsAcknowledge?recordsParam=recordIds&recordIds=9&tableName=alerts_alert`.
Clients follow the provided href without constructing a process or table name. Server
process authorization still applies when an action executes.
- Panel adds `follow: { "available": boolean }`, true exactly when core supplemental
`MiddlewareSpecExtensionsMetaData` contains an extension named `collaboration`. No
collaboration dependency or URL is embedded here. Next uses its collaboration module
to determine the follow endpoint and current follow state.
The provided Next `main` checkout had no alerts client at implementation time. The reviewed
client was `feature/notification-center:src/lib/api/alerts.ts` and its
`src/components/alerts/*` components. Its current process href and watch assumptions are
covered by the migration above.
## Removed watch operation
`POST /qqq/v1/alerts/record-panel/watch` and `AlertsApi.watchRecord` are removed. No panel
request creates a RECORD subscription. `alerts_subscription` continues to handle alert-type
and scope subscriptions; existing record-scoped watch rows are deprecated and remain readable
(and retain their existing routing behavior). No rows are migrated or deleted. The panel's
`watching` boolean is retained for compatibility and reports only those legacy rows; it must
not be used as collaboration follow state.
## Record follower routing
For every active record ref on an alert, `AlertRouter.destinations` calls RC.6's
`RecordFollowers.get(instance, ref, FollowEvent.ALERT)`. Register a core
`RecordFollowersProviderInterface` through instance `RecordFeaturesMetaData.withFollowersProvider`
and enable `RecordFeature.FOLLOW` for the source table (or by instance default). The provider
selects followers for ALERT; core recommends treating ALERT like CHANGE unless the provider
has a finer preference. The alerts qbit does not depend on qbit-collaboration.
Each follower must have a known automated recipient session and be able to query the source
record through its table permissions and record security locks. Missing records and unknown
or denied access do not add follower destinations. The query uses the source table's configured
primary key. Eligible followers join the existing destination map, deduplicated by user and
channel across subscriptions and record refs. Existing subscription destinations take precedence.
Followers use ordinary in-app/email settings, vacation pause for external delivery, alert
visibility checks, type notification switches, mutes, flapping, channel limits and duplicate
suppression. Quiet-hour scheduling remains unimplemented for all alert recipients, as listed
in the README; follower routing does not introduce a separate delivery policy. With no provider,
FOLLOW disabled, or a provider failure, existing subscription routing is unchanged.
## Alertmanager machine webhook
Canonical operation: **POST `/qqq/hooks/alerts/alertmanager`**. This is a raw machine route
outside the UI API and has no UI operationId. It preserves the bearer token, secret reference,
JSON request and `{raised, resolved, ignored}` response. No QQQ user session is required.
The previous **POST `/qqq/v1/alerts/ingest/alertmanager`** is an opt-in raw alias:
The architecture guardrail's static G3 scan reports this intentional raw UI-prefix route
as an advisory. Runtime strict-mode coverage rejects the alias; only the migration test
starts a standalone Javalin instance with it enabled. Normal application tests remain strict.
```java
new AlertsQBitConfig()
.withLegacyAlertmanagerPath(true)
.withAlertmanagerIngestTokenSecretRef("ALERTMANAGER_TOKEN");
```
`legacyAlertmanagerPath` defaults to false. Core `UiApiRouteAuditor` compares every raw v1
route against specs and offers no machine-webhook exemption. Therefore enabling the alias
requires non-strict UI route mode; strict startup rejects it. It is intentionally absent
from the UI fragment. Default hosts and all application-server tests run strict successfully.
The migration-only raw provider is tested separately, including strict-auditor rejection.
Both paths call the same handler and authenticate against the same secret. Every alias call,
including rejected authentication, logs a warning and carries:
```http
Deprecation: true
Sunset: Tue, 13 Apr 2027 00:00:00 GMT
```
The alias is supported for the 0.1 minor line only and scheduled for removal in 0.2; the
published calendar sunset is 13 April 2027. Move receivers before either retirement boundary.
No automatic runtime cutoff is applied by these informational headers.
## Core notification migration (#15 part 2)
Core `QAlerts.notify` now feeds the same inbox service through the registered sink. This
migration changes Java imports and producer entry points only: no methods, paths,
operation IDs, request fields, response fields or schemas change relative to PR #19.
The earlier client changes above still apply when adopting the full stacked branch.
The supplied Next `main` checkout still has no alerts client; the
`feature/notification-center:src/lib/api/alerts.ts` client was checked again for this work.
## Verification
Use the Maven wrapper offline against the locally installed migration build:
```sh
./mvnw -o verify -Dqqq.snapshot.version=4.1.99-SNAPSHOT -Denforcer.skipRules=requireReleaseDeps
```
Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test`
(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.
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

+285
View File
@@ -0,0 +1,285 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<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.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>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-alerts</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QBit Alerts</name>
<description>QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables)</description>
<url>https://github.com/QRun-IO/qbit-alerts</url>
<modelVersion>4.0.0</modelVersion>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qbit-alerts.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-alerts.git</developerConnection>
<url>https://github.com/QRun-IO/qbit-alerts</url>
<tag>HEAD</tag>
</scm>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</dependency>
<!-- The /qqq/v1/alerts routes (bell, record panel). Hosts that serve QQQ -->
<!-- through Javalin already have it; the qbit works without it. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<optional>true</optional>
</dependency>
<!-- Email through qbit-email when it is produced in the same instance; -->
<!-- otherwise core SendMessageAction and messagingProviderName. -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<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>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
</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>
<version>3.6.0</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>14.3.0</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
<violationSeverity>warning</violationSeverity>
</configuration>
<executions>
<execution>
<id>checkstyle</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<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.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.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</profile>
</profiles>
</project>
@@ -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;
}
@@ -0,0 +1,106 @@
/*
* 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.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsType;
/*******************************************************************************
** Opt-in hooks for wiring the main Alerts tables into a host's ESB and
** Quick Search without the qbit depending on either module: the host passes
** these names and field lists to its own qqq-esb (EsbTableMetaData
** publications) and qbit-quick-search (SearchableTableConfig) configuration.
**
** The main tables are Alerts, Monitors and Alert Types. What leaves the
** database: an alert event carries the alert row (title, summary, details,
** status, severity, ...); search hits are re-read through the tables'
** record locks, so a user only finds alerts they could open.
*******************************************************************************/
public final class AlertsIntegrations
{
/*******************************************************************************
** A table for Quick Search: field weights (most important first), the
** field basepull compares, and how a hit is labelled.
**
** @param tableName the table
** @param fieldWeights searchable fields and weights
** @param basepullTimestamp the field that moves when a row changes
** @param recordLabelFormat the hit label format
** @param recordLabelFields fields for the format
*******************************************************************************/
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
{
}
/*******************************************************************************
** Hooks only.
*******************************************************************************/
private AlertsIntegrations()
{
}
/*******************************************************************************
** The tables whose insert / update / delete events a host may publish to
** its ESB.
*******************************************************************************/
public static List<String> esbTableNames()
{
return (List.of(AlertsAlert.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME));
}
/*******************************************************************************
** The main tables as Quick Search tables.
*******************************************************************************/
public static List<SearchableTable> quickSearchTables()
{
Map<String, Integer> alert = new LinkedHashMap<>();
alert.put("title", 10);
alert.put("summary", 5);
alert.put("alertTypeName", 4);
alert.put("dedupKey", 3);
alert.put("sourceName", 1);
Map<String, Integer> monitor = new LinkedHashMap<>();
monitor.put("name", 10);
monitor.put("description", 4);
monitor.put("sourceName", 1);
Map<String, Integer> type = new LinkedHashMap<>();
type.put("label", 10);
type.put("name", 8);
type.put("description", 4);
type.put("sourceName", 1);
return (List.of(
new SearchableTable(AlertsAlert.TABLE_NAME, alert, "modifyDate", "%s", List.of("title")),
new SearchableTable(AlertsMonitor.TABLE_NAME, monitor, "modifyDate", "Monitor: %s", List.of("name")),
new SearchableTable(AlertsType.TABLE_NAME, type, "modifyDate", "Alert type: %s", List.of("label"))));
}
}
@@ -0,0 +1,112 @@
/*
* 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.Map;
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** Gates the Alerts app, widgets and processes on one exact permission name
** (alerts.view, alerts.manage or alerts.admin), and lists that name among
** the instance's available permissions so hosts can grant it.
*******************************************************************************/
public class AlertsPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
{
public static final String PERMISSION_PROPERTY = "permissionName";
private String permissionName;
/*******************************************************************************
** Permission rules requiring the named permission.
*******************************************************************************/
public static QPermissionRules rules(String permissionName)
{
return (new QPermissionRules()
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withCustomPermissionChecker(new QCodeReferenceWithProperties(AlertsPermissionChecker.class, Map.of(PERMISSION_PROPERTY, permissionName))));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void initialize(QCodeReference codeReference)
{
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
{
this.permissionName = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSION_PROPERTY));
}
}
/*******************************************************************************
** Deny unless the session holds the permission (fail closed).
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(permissionName == null || session == null || !session.hasPermission(permissionName))
{
throw (new QPermissionDeniedException("Permission denied."));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean handlesBuildAvailablePermission()
{
return (true);
}
/*******************************************************************************
** The exact permission name.
*******************************************************************************/
@Override
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
{
AvailablePermission permission = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType);
return (permission == null || permissionName == null ? null : permission.withName(permissionName));
}
}
@@ -0,0 +1,159 @@
/*
* 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.Serializable;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryMode;
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;
/*******************************************************************************
** First visit set-up for a user (the home, the bell or the record panel):
** - My Alert Settings is created with defaults (in-app on; email on once
** they add an address);
** - the bootstrap rule (design 5.4): an alerts.admin holder with no
** subscriptions gets one in-app subscription to everything at the
** configured minimum severity (ERROR by default). With no subscriptions
** otherwise, a fresh install notifies nobody.
*******************************************************************************/
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();
/*******************************************************************************
** Set-up only.
*******************************************************************************/
private AlertsPreferences()
{
}
/*******************************************************************************
** Make sure the session's user has settings (and, for admins, the
** bootstrap subscription). Cheap after the first call per user.
*******************************************************************************/
public static void ensure(AlertsRuntime runtime, QSession session) throws QException
{
String userId = AlertsSecurity.userId(session);
if(userId == null || !session.hasPermission(AlertsQBitConfig.PERMISSION_VIEW) || ENSURED.contains(userId))
{
return;
}
boolean admin = session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN);
String fullName = session.getUser() == null ? null : session.getUser().getFullName();
////////////////////////////////////////////////////////////////////////////
// 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))
{
return;
}
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()));
}
/*******************************************************************************
** Forget who was set up (tests).
*******************************************************************************/
public static void reset()
{
ENSURED.clear();
}
}
@@ -0,0 +1,923 @@
/*
* 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.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;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
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.AlertsRecordRef;
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;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
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.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Host-facing configuration for the Alerts QBit (design section 8).
**
** Tables are named alerts_(name) (lowercase snake_case; the backend table and
** column names are the snake_case forms, matching the bundled Liquibase
** changelog). Permissions:
** - alerts.view: the Alerts app, home and bell; read the alerts the user may
** see; comment; subscribe themselves; their own settings and inbox.
** - alerts.manage: acknowledge, snooze, resolve, reopen, assign, mute.
** - alerts.admin: monitors, types, groups, teams, channels, every
** subscription, the delivery log, retries.
** - alerts.notification.admin: write, send, schedule, retract and delete
** user notifications (docs/NOTIFICATIONS.md).
** Each table also has QQQ's standard per-table permissions
** (alerts_alert.read, ...); getViewerPermissionNames,
** getManagerPermissionNames and getAdminPermissionNames list what to grant
** per role. Nothing is granted by default.
*******************************************************************************/
public class AlertsQBitConfig implements QBitConfig
{
public static final String TABLE_NAME_PREFIX = "alerts_";
public static final String PERMISSION_BASE_NAME = "alerts";
public static final String PERMISSION_VIEW = "alerts.view";
public static final String PERMISSION_MANAGE = "alerts.manage";
public static final String PERMISSION_ADMIN = "alerts.admin";
public static final String PERMISSION_NOTIFICATION_ADMIN = "alerts.notification.admin";
public static final String APP_NAME = "alerts";
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;
private Integer flapThreshold = 5;
private Integer flapWindowMinutes = 60;
private Integer maxOpenPerMonitor = 50;
private Integer monitorTimeoutSeconds = 15;
private AlertSeverity bootstrapMinSeverity = AlertSeverity.ERROR;
private Integer evaluateEverySeconds = 30;
private Integer deliverEverySeconds = 15;
private List<QCodeReference> channelTypes = new ArrayList<>();
private String alertmanagerIngestTokenSecretRef;
private Boolean preferencesSectionEnabled = true;
private Boolean registerRoutes = true;
private Boolean synchronousSink = false;
private QCodeReference userDirectory;
private Integer maxAudienceSize = 50_000;
private Integer notificationRetentionDays = 180;
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.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, List<String> errors)
{
if(assertCondition(StringUtils.hasContent(backendName), "backendName is required for the Alerts QBit", errors))
{
assertCondition(qInstance.getBackend(backendName) != null, "Alerts QBit backend not found: " + backendName, errors);
}
if(StringUtils.hasContent(schedulerName))
{
assertCondition(qInstance.getScheduler(schedulerName) != null, "Alerts QBit scheduler not found: " + schedulerName, errors);
}
if(StringUtils.hasContent(messagingProviderName))
{
assertCondition(qInstance.getMessagingProvider(messagingProviderName) != null, "Alerts QBit messaging provider not found: " + messagingProviderName, errors);
}
for(Integer value : new Integer[] {reopenWindowMinutes, flapThreshold, flapWindowMinutes, maxOpenPerMonitor, monitorTimeoutSeconds, evaluateEverySeconds, deliverEverySeconds,
maxAudienceSize, notificationRetentionDays})
{
assertCondition(value != null && value > 0, "Alerts QBit timing and limit settings must be positive", errors);
}
}
/*******************************************************************************
** Tables without their own backend use the configured backend.
*******************************************************************************/
@Override
public String getDefaultBackendNameForTables()
{
return (backendName);
}
/*******************************************************************************
** Every table the qbit defines.
*******************************************************************************/
public static List<String> allTableNames()
{
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));
}
/*******************************************************************************
** What an ordinary alerts user needs: alerts.view, read on the alert,
** timeline, record, type, group, monitor, team and mute tables, and full
** access to their own subscriptions, settings and inbox (record locks keep
** those to their own rows).
*******************************************************************************/
public List<String> getViewerPermissionNames()
{
List<String> names = new ArrayList<>();
names.add(PERMISSION_VIEW);
for(String table : List.of(AlertsAlert.TABLE_NAME, AlertsEvent.TABLE_NAME, AlertsRecordRef.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME,
AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsTeamMember.TABLE_NAME, AlertsMute.TABLE_NAME))
{
names.add(table + ".read");
}
for(String table : List.of(AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME))
{
names.add(table + ".read");
names.add(table + ".insert");
names.add(table + ".edit");
names.add(table + ".delete");
}
names.add(AlertsNotificationRecipient.TABLE_NAME + ".read");
names.add(AlertsNotificationRecipient.TABLE_NAME + ".edit");
return (names);
}
/*******************************************************************************
** A viewer's permissions plus alerts.manage and mute editing.
*******************************************************************************/
public List<String> getManagerPermissionNames()
{
List<String> names = new ArrayList<>(getViewerPermissionNames());
names.add(PERMISSION_MANAGE);
names.add(AlertsMute.TABLE_NAME + ".insert");
names.add(AlertsMute.TABLE_NAME + ".edit");
names.add(AlertsMute.TABLE_NAME + ".delete");
return (names);
}
/*******************************************************************************
** Everything: all three alerts permissions and every table permission.
*******************************************************************************/
public List<String> getAdminPermissionNames()
{
List<String> names = new ArrayList<>(List.of(PERMISSION_VIEW, PERMISSION_MANAGE, PERMISSION_ADMIN, PERMISSION_NOTIFICATION_ADMIN));
for(String table : allTableNames())
{
for(String suffix : List.of("read", "insert", "edit", "delete"))
{
names.add(table + "." + suffix);
}
}
return (names);
}
/*******************************************************************************
** What a notification author needs on top of a viewer's permissions:
** alerts.notification.admin and full access to the notifications table
** (they read every recipient row through the alerts_user all-access key).
*******************************************************************************/
public List<String> getNotificationAdminPermissionNames()
{
List<String> names = new ArrayList<>(getViewerPermissionNames());
names.add(PERMISSION_NOTIFICATION_ADMIN);
for(String suffix : List.of("read", "insert", "edit", "delete"))
{
names.add(AlertsNotification.TABLE_NAME + "." + suffix);
}
return (names);
}
/*******************************************************************************
** Getter for backendName
*******************************************************************************/
public String getBackendName()
{
return (this.backendName);
}
/*******************************************************************************
** Setter for backendName
*******************************************************************************/
public void setBackendName(String backendName)
{
this.backendName = backendName;
}
/*******************************************************************************
** Fluent setter for backendName (required: the RDBMS backend for all tables)
*******************************************************************************/
public AlertsQBitConfig withBackendName(String backendName)
{
this.backendName = backendName;
return (this);
}
/*******************************************************************************
** Getter for schedulerName
*******************************************************************************/
public String getSchedulerName()
{
return (this.schedulerName);
}
/*******************************************************************************
** Fluent setter for schedulerName. Without it, producer signals still work,
** but monitors and delivery do not run on their own (the home says so).
*******************************************************************************/
public AlertsQBitConfig withSchedulerName(String schedulerName)
{
this.schedulerName = schedulerName;
return (this);
}
/*******************************************************************************
** Getter for messagingProviderName
*******************************************************************************/
public String getMessagingProviderName()
{
return (this.messagingProviderName);
}
/*******************************************************************************
** Fluent setter for messagingProviderName (a QMessagingProviderMetaData for
** email; without it, email channels are disabled with an explanation).
*******************************************************************************/
public AlertsQBitConfig withMessagingProviderName(String messagingProviderName)
{
this.messagingProviderName = messagingProviderName;
return (this);
}
/*******************************************************************************
** 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
*******************************************************************************/
public String getFromAddress()
{
return (this.fromAddress);
}
/*******************************************************************************
** Fluent setter for fromAddress (the From of alert emails)
*******************************************************************************/
public AlertsQBitConfig withFromAddress(String fromAddress)
{
this.fromAddress = fromAddress;
return (this);
}
/*******************************************************************************
** Getter for publicBaseUrl
*******************************************************************************/
public String getPublicBaseUrl()
{
return (this.publicBaseUrl);
}
/*******************************************************************************
** Fluent setter for publicBaseUrl (the base for links in messages, e.g.
** https://admin.example.com)
*******************************************************************************/
public AlertsQBitConfig withPublicBaseUrl(String publicBaseUrl)
{
this.publicBaseUrl = publicBaseUrl;
return (this);
}
/*******************************************************************************
** Getter for reopenWindowMinutes
*******************************************************************************/
public Integer getReopenWindowMinutes()
{
return (this.reopenWindowMinutes);
}
/*******************************************************************************
** Fluent setter for reopenWindowMinutes (a raise this soon after a resolve
** reopens the same alert; default 30)
*******************************************************************************/
public AlertsQBitConfig withReopenWindowMinutes(Integer reopenWindowMinutes)
{
this.reopenWindowMinutes = reopenWindowMinutes;
return (this);
}
/*******************************************************************************
** Getter for flapThreshold
*******************************************************************************/
public Integer getFlapThreshold()
{
return (this.flapThreshold);
}
/*******************************************************************************
** Fluent setter for flapThreshold (open/resolve changes within the flap
** window that mark an alert flapping; default 5)
*******************************************************************************/
public AlertsQBitConfig withFlapThreshold(Integer flapThreshold)
{
this.flapThreshold = flapThreshold;
return (this);
}
/*******************************************************************************
** Getter for flapWindowMinutes
*******************************************************************************/
public Integer getFlapWindowMinutes()
{
return (this.flapWindowMinutes);
}
/*******************************************************************************
** Fluent setter for flapWindowMinutes (default 60)
*******************************************************************************/
public AlertsQBitConfig withFlapWindowMinutes(Integer flapWindowMinutes)
{
this.flapWindowMinutes = flapWindowMinutes;
return (this);
}
/*******************************************************************************
** Getter for maxOpenPerMonitor
*******************************************************************************/
public Integer getMaxOpenPerMonitor()
{
return (this.maxOpenPerMonitor);
}
/*******************************************************************************
** Fluent setter for maxOpenPerMonitor (the per-record cap; beyond it a
** monitor raises one aggregate alert instead; default 50)
*******************************************************************************/
public AlertsQBitConfig withMaxOpenPerMonitor(Integer maxOpenPerMonitor)
{
this.maxOpenPerMonitor = maxOpenPerMonitor;
return (this);
}
/*******************************************************************************
** Getter for monitorTimeoutSeconds
*******************************************************************************/
public Integer getMonitorTimeoutSeconds()
{
return (this.monitorTimeoutSeconds);
}
/*******************************************************************************
** Fluent setter for monitorTimeoutSeconds (default 15)
*******************************************************************************/
public AlertsQBitConfig withMonitorTimeoutSeconds(Integer monitorTimeoutSeconds)
{
this.monitorTimeoutSeconds = monitorTimeoutSeconds;
return (this);
}
/*******************************************************************************
** Getter for bootstrapMinSeverity
*******************************************************************************/
public AlertSeverity getBootstrapMinSeverity()
{
return (this.bootstrapMinSeverity);
}
/*******************************************************************************
** Fluent setter for bootstrapMinSeverity: the minimum severity of the
** in-app subscription given to alerts.admin holders who have none (default
** ERROR; null turns the bootstrap rule off).
*******************************************************************************/
public AlertsQBitConfig withBootstrapMinSeverity(AlertSeverity bootstrapMinSeverity)
{
this.bootstrapMinSeverity = bootstrapMinSeverity;
return (this);
}
/*******************************************************************************
** Getter for evaluateEverySeconds
*******************************************************************************/
public Integer getEvaluateEverySeconds()
{
return (this.evaluateEverySeconds);
}
/*******************************************************************************
** Fluent setter for evaluateEverySeconds (the monitor evaluator's tick;
** default 30)
*******************************************************************************/
public AlertsQBitConfig withEvaluateEverySeconds(Integer evaluateEverySeconds)
{
this.evaluateEverySeconds = evaluateEverySeconds;
return (this);
}
/*******************************************************************************
** Getter for deliverEverySeconds
*******************************************************************************/
public Integer getDeliverEverySeconds()
{
return (this.deliverEverySeconds);
}
/*******************************************************************************
** Fluent setter for deliverEverySeconds (the delivery worker's tick;
** default 15)
*******************************************************************************/
public AlertsQBitConfig withDeliverEverySeconds(Integer deliverEverySeconds)
{
this.deliverEverySeconds = deliverEverySeconds;
return (this);
}
/*******************************************************************************
** Getter for channelTypes
*******************************************************************************/
public List<QCodeReference> getChannelTypes()
{
return (this.channelTypes);
}
/*******************************************************************************
** Add a channel type (an AlertChannelInterface), beyond the built-ins.
*******************************************************************************/
public AlertsQBitConfig withChannelType(QCodeReference channelType)
{
this.channelTypes.add(channelType);
return (this);
}
/*******************************************************************************
** Getter for registerRoutes
*******************************************************************************/
public Boolean getRegisterRoutes()
{
return (this.registerRoutes);
}
/*******************************************************************************
** Fluent setter for registerRoutes (the /qqq/v1/alerts routes for the bell
** and record panel, when qqq-middleware-javalin is present; default true)
*******************************************************************************/
public AlertsQBitConfig withRegisterRoutes(Boolean registerRoutes)
{
this.registerRoutes = registerRoutes;
return (this);
}
/*******************************************************************************
** Getter for synchronousSink
*******************************************************************************/
public Boolean getSynchronousSink()
{
return (this.synchronousSink);
}
/*******************************************************************************
** Fluent setter for synchronousSink: process signals on the caller's
** thread instead of the background queue (tests; default false).
*******************************************************************************/
public AlertsQBitConfig withSynchronousSink(Boolean synchronousSink)
{
this.synchronousSink = synchronousSink;
return (this);
}
/*******************************************************************************
** Getter for alertmanagerIngestTokenSecretRef
*******************************************************************************/
public String getAlertmanagerIngestTokenSecretRef()
{
return (this.alertmanagerIngestTokenSecretRef);
}
/*******************************************************************************
** Fluent setter for alertmanagerIngestTokenSecretRef: the reference (an
** environment variable name or ${...} expression, never the value) of the
** bearer token Alertmanager sends to POST /qqq/hooks/alerts/alertmanager.
** Null (the default) turns ingest off.
*******************************************************************************/
public AlertsQBitConfig withAlertmanagerIngestTokenSecretRef(String alertmanagerIngestTokenSecretRef)
{
this.alertmanagerIngestTokenSecretRef = alertmanagerIngestTokenSecretRef;
return (this);
}
/*******************************************************************************
** 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);
}
/*******************************************************************************
** Setter for runtime
*******************************************************************************/
public void setRuntime(AlertsRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** Getter for userDirectory
*******************************************************************************/
public QCodeReference getUserDirectory()
{
return (this.userDirectory);
}
/*******************************************************************************
** Setter for userDirectory
*******************************************************************************/
public void setUserDirectory(QCodeReference userDirectory)
{
this.userDirectory = userDirectory;
}
/*******************************************************************************
** Fluent setter for userDirectory: the host's user directory (AlertsUserDirectoryInterface) for notification audiences; default: everyone who has used Alerts
*******************************************************************************/
public AlertsQBitConfig withUserDirectory(QCodeReference userDirectory)
{
this.userDirectory = userDirectory;
return (this);
}
/*******************************************************************************
** Getter for maxAudienceSize
*******************************************************************************/
public Integer getMaxAudienceSize()
{
return (this.maxAudienceSize);
}
/*******************************************************************************
** Setter for maxAudienceSize
*******************************************************************************/
public void setMaxAudienceSize(Integer maxAudienceSize)
{
this.maxAudienceSize = maxAudienceSize;
}
/*******************************************************************************
** Fluent setter for maxAudienceSize: the most users one notification reaches (default 50,000)
*******************************************************************************/
public AlertsQBitConfig withMaxAudienceSize(Integer maxAudienceSize)
{
this.maxAudienceSize = maxAudienceSize;
return (this);
}
/*******************************************************************************
** Getter for notificationRetentionDays
*******************************************************************************/
public Integer getNotificationRetentionDays()
{
return (this.notificationRetentionDays);
}
/*******************************************************************************
** Setter for notificationRetentionDays
*******************************************************************************/
public void setNotificationRetentionDays(Integer notificationRetentionDays)
{
this.notificationRetentionDays = notificationRetentionDays;
}
/*******************************************************************************
** Fluent setter for notificationRetentionDays: how long sent and retracted notifications are kept (default 180)
*******************************************************************************/
public AlertsQBitConfig withNotificationRetentionDays(Integer notificationRetentionDays)
{
this.notificationRetentionDays = notificationRetentionDays;
return (this);
}
/*******************************************************************************
** Getter for notifyFailedJobs
*******************************************************************************/
public Boolean getNotifyFailedJobs()
{
return (this.notifyFailedJobs);
}
/*******************************************************************************
** Setter for notifyFailedJobs
*******************************************************************************/
public void setNotifyFailedJobs(Boolean notifyFailedJobs)
{
this.notifyFailedJobs = notifyFailedJobs;
}
/*******************************************************************************
** Fluent setter for notifyFailedJobs: notify a user when a process they started fails or completes with errors (default true)
*******************************************************************************/
public AlertsQBitConfig withNotifyFailedJobs(Boolean notifyFailedJobs)
{
this.notifyFailedJobs = notifyFailedJobs;
return (this);
}
/*******************************************************************************
** Getter for jobRunLinkPattern
*******************************************************************************/
public String getJobRunLinkPattern()
{
return (this.jobRunLinkPattern);
}
/*******************************************************************************
** Setter for jobRunLinkPattern
*******************************************************************************/
public void setJobRunLinkPattern(String jobRunLinkPattern)
{
this.jobRunLinkPattern = jobRunLinkPattern;
}
/*******************************************************************************
** Fluent setter for jobRunLinkPattern: where a job notification links, with {processName} and {processUUID} (default: the process's table)
*******************************************************************************/
public AlertsQBitConfig withJobRunLinkPattern(String jobRunLinkPattern)
{
this.jobRunLinkPattern = jobRunLinkPattern;
return (this);
}
/*******************************************************************************
** 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.
*******************************************************************************/
public Boolean getLegacyAlertmanagerPath()
{
return (this.legacyAlertmanagerPath);
}
/*******************************************************************************
** Opt in to the deprecated alias while migrating Alertmanager receivers.
*******************************************************************************/
public AlertsQBitConfig withLegacyAlertmanagerPath(Boolean legacyAlertmanagerPath)
{
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);
}
}
@@ -0,0 +1,360 @@
/*
* 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.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;
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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;
/*******************************************************************************
** Entry point of the Alerts QBit, a QQQ 4.1 QBitMetaDataProducer.
**
** produce() validates the config, runs the component producers in the
** metadata package (tables, joins, possible values, security keys, record
** actions, scheduled processes, home widgets, the Alerts app) and then
** postProduceActions: declares the qbit's own alert types, builds the
** runtime (engine, router, delivery, monitors, sink), registers the process
** listener that feeds PROCESS_FAILED and process heartbeat monitors, and,
** when qqq-middleware-javalin is present, the /qqq/v1/alerts routes.
**
** Host usage:
** new AlertsQBitProducer().withQBitConfig(new AlertsQBitConfig()
** .withBackendName("rdbms").withSchedulerName("quartz")
** .withMessagingProviderName("smtp").withFromAddress("alerts@example.com")
** .withPublicBaseUrl("https://admin.example.com")).produce(qInstance);
*******************************************************************************/
public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig>
{
private static final QLogger LOG = QLogger.getLogger(AlertsQBitProducer.class);
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-alerts";
public static final String VERSION = loadVersion();
private static final String JAVALIN_CLASS = "com.kingsrook.qqq.middleware.javalin.QJavalinMetaData";
private AlertsQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** 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.
*******************************************************************************/
public AlertsQBitProducer withQBitConfig(AlertsQBitConfig config)
{
this.qBitConfig = config;
return (this);
}
/*******************************************************************************
** Getter for the config.
*******************************************************************************/
@Override
public AlertsQBitConfig getQBitConfig()
{
return (qBitConfig);
}
/*******************************************************************************
** Fluent setter for the namespace.
*******************************************************************************/
public AlertsQBitProducer withNamespace(String namespace)
{
this.namespace = namespace;
return (this);
}
/*******************************************************************************
** Getter for the namespace.
*******************************************************************************/
@Override
public String getNamespace()
{
return (namespace);
}
/*******************************************************************************
** The qbit's identity.
*******************************************************************************/
@Override
public QBitMetaData getQBitMetaData()
{
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
}
/*******************************************************************************
** Component producers live in the metadata package only (the api package
** needs qqq-middleware-javalin, which is optional).
*******************************************************************************/
@Override
public String getPackageNameForFindingMetaDataProducers()
{
return (AlertsTableMetaDataHelper.class.getPackageName());
}
/*******************************************************************************
** Producers found by package scanning have no config: skip them.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (qBitConfig != null);
}
/*******************************************************************************
** Produce and add everything to the instance. Returns an empty output so a
** host that also calls addSelfToInstance on the result adds nothing twice.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
{
if(qBitConfig == null)
{
throw (new QException("Alerts QBit configuration validation failed: no config was supplied (call withQBitConfig)"));
}
List<String> errors = new ArrayList<>();
qBitConfig.validate(qInstance, errors);
if(!errors.isEmpty())
{
throw (new QException("Alerts QBit configuration validation failed: " + String.join("; ", errors)));
}
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());
}
/*******************************************************************************
** Declare the qbit's own types, build the runtime, register the process
** listener and the routes.
*******************************************************************************/
@Override
public void postProduceActions(MetaDataProducerMultiOutput output, QInstance qInstance) throws QException
{
AlertsInstanceMetaData alertsMetaData = AlertsInstanceMetaData.ofOrWithNew(qInstance);
for(QAlertTypeMetaData type : ownAlertTypes())
{
if(alertsMetaData.getAlertType(type.getName()) == null)
{
alertsMetaData.withAlertType(type);
}
}
AlertsRuntime previous = qBitConfig.getRuntime();
if(previous != null)
{
previous.getSink().stop();
}
AlertsRuntime runtime = new AlertsRuntime(qBitConfig, qInstance);
qBitConfig.setRuntime(runtime);
alertsMetaData.withSink(new QCodeReference(AlertsSinkAdapter.class));
String listener = AlertsProcessListener.class.getName();
if(qInstance.getProcessLifecycleListeners() == null || qInstance.getProcessLifecycleListeners().stream().noneMatch(ref -> ref != null && listener.equals(ref.getName())))
{
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())
{
com.kingsrook.qbits.alerts.api.AlertsUiApi.register(qInstance);
com.kingsrook.qbits.alerts.api.AlertsRouteProvider.register(qInstance);
routes = true;
}
LOG.info("Alerts QBit produced", logPair("version", VERSION), logPair("backendName", qBitConfig.getBackendName()), logPair("scheduler", qBitConfig.getSchedulerName()),
logPair("routes", routes));
}
/*******************************************************************************
** The alert types the qbit raises about itself.
*******************************************************************************/
public static List<QAlertTypeMetaData> ownAlertTypes()
{
return (List.of(
new QAlertTypeMetaData()
.withName(MonitorEvaluator.MONITOR_FAILING_TYPE)
.withLabel("Monitor cannot run")
.withSourceName(ARTIFACT_ID)
.withGroupName("alerts")
.withDefaultSeverity(AlertSeverity.ERROR)
.withVisibilityPermission(AlertsQBitConfig.PERMISSION_ADMIN)
.withSubjectTableName(AlertsMonitor.TABLE_NAME)
.withResolveMode(ResolveMode.PRODUCER)
.withDescription("A monitor's check failed three times in a row, so the condition it watches is not being checked.")
.withHowToFix("Open the monitor, read Last Error, fix its definition (or what it checks), and choose Run check now."),
new QAlertTypeMetaData()
.withName(DeliveryWorker.CHANNEL_FAILING_TYPE)
.withLabel("Alert channel failing")
.withSourceName(ARTIFACT_ID)
.withGroupName("alerts")
.withDefaultSeverity(AlertSeverity.ERROR)
.withVisibilityPermission(AlertsQBitConfig.PERMISSION_ADMIN)
.withSubjectTableName(AlertsChannel.TABLE_NAME)
.withResolveMode(ResolveMode.PRODUCER)
.withExternalDetailCeiling(DetailLevel.MINIMAL)
.withDescription("Three deliveries in a row through a channel failed. This alert is only ever shown in-app, so a broken channel can't loop.")
.withHowToFix("Open the channel, check its settings and secret reference, then choose Send test notification and Retry delivery on the failed notifications."),
new QAlertTypeMetaData()
.withName(QbitAlertsSink.OVERFLOW_TYPE)
.withLabel("Alert signals dropped")
.withSourceName(ARTIFACT_ID)
.withGroupName("alerts")
.withDefaultSeverity(AlertSeverity.ERROR)
.withVisibilityPermission(AlertsQBitConfig.PERMISSION_ADMIN)
.withResolveMode(ResolveMode.MANUAL)
.withDescription("The in-memory queue of producer signals was full, so some signals were not recorded.")
.withHowToFix("Find the producer that floods (see Noisiest Alert Types), and check the database is keeping up. Resolve this alert when it has stopped."),
new QAlertTypeMetaData()
.withName(QbitAlertsSink.FLOOD_TYPE)
.withLabel("Producer flooding alerts")
.withSourceName(ARTIFACT_ID)
.withGroupName("alerts")
.withDefaultSeverity(AlertSeverity.WARNING)
.withVisibilityPermission(AlertsQBitConfig.PERMISSION_ADMIN)
.withQuietPeriod(java.time.Duration.ofHours(1))
.withDescription("More than 1,000 signals in a minute arrived for one alert type; the rest were counted, not stored.")
.withHowToFix("Fix the producer to raise once per condition (with a stable dedup key) instead of once per event.")));
}
/*******************************************************************************
**
*******************************************************************************/
static boolean isJavalinAvailable()
{
try
{
Class.forName(JAVALIN_CLASS, false, AlertsQBitProducer.class.getClassLoader());
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
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);
}
}
@@ -0,0 +1,286 @@
/*
* 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.time.Clock;
import java.time.Instant;
import com.kingsrook.qbits.alerts.delivery.AlertChannels;
import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
import com.kingsrook.qbits.alerts.engine.AlertRouter;
import com.kingsrook.qbits.alerts.engine.AlertTypeSync;
import com.kingsrook.qbits.alerts.engine.AlertsEngine;
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
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.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
/*******************************************************************************
** One installed Alerts qbit at run time: its config and instance, the clock,
** and the engine parts (sink, engine, router, delivery worker, monitor
** evaluator, type sync). Built by the producer, kept on the config.
*******************************************************************************/
public class AlertsRuntime
{
private final AlertsQBitConfig config;
private final QInstance qInstance;
private Clock clock = Clock.systemUTC();
private final AlertTypeSync typeSync;
private final AlertsEngine engine;
private final AlertRouter router;
private final AlertChannels channels;
private final DeliveryWorker deliveryWorker;
private final MonitorEvaluator evaluator;
private final QbitAlertsSink sink;
private final NotificationService notifications;
/*******************************************************************************
** Something to run as the system user.
*******************************************************************************/
@FunctionalInterface
public interface SystemWork<R>
{
/***************************************************************************
**
***************************************************************************/
R run() throws QException;
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertsRuntime(AlertsQBitConfig config, QInstance qInstance)
{
this.config = config;
this.qInstance = qInstance;
this.typeSync = new AlertTypeSync(this);
this.router = new AlertRouter(this);
this.engine = new AlertsEngine(this);
this.channels = new AlertChannels(this);
this.deliveryWorker = new DeliveryWorker(this);
this.evaluator = new MonitorEvaluator(this);
this.sink = new QbitAlertsSink(this);
this.notifications = new NotificationService(this);
}
/*******************************************************************************
** The runtime of the Alerts qbit in the current context's instance.
*******************************************************************************/
public static AlertsRuntime get() throws QNotFoundException
{
return (get(QContext.getQInstance()));
}
/*******************************************************************************
** The runtime of the Alerts qbit installed in an instance.
*******************************************************************************/
public static AlertsRuntime get(QInstance qInstance) throws QNotFoundException
{
AlertsRuntime runtime = find(qInstance);
if(runtime == null)
{
throw (new QNotFoundException("The Alerts qbit is not installed in this instance."));
}
return (runtime);
}
/*******************************************************************************
** The runtime, or null when the qbit is not installed.
*******************************************************************************/
public static AlertsRuntime find(QInstance qInstance)
{
if(qInstance == null || qInstance.getQBits() == null)
{
return (null);
}
for(QBitMetaData qBit : qInstance.getQBits().values())
{
if(qBit.getConfig() instanceof AlertsQBitConfig alertsConfig && alertsConfig.getRuntime() != null)
{
return (alertsConfig.getRuntime());
}
}
return (null);
}
/*******************************************************************************
** Run work as the system user (the engine sees everything; what people are
** shown and sent is filtered separately).
*******************************************************************************/
public <R> R asSystem(SystemWork<R> work) throws QException
{
Object[] result = new Object[1];
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession(qInstance)), () ->
{
result[0] = work.run();
});
@SuppressWarnings("unchecked")
R typed = (R) result[0];
return (typed);
}
/*******************************************************************************
** Now, by the runtime's clock.
*******************************************************************************/
public Instant now()
{
return (clock.instant());
}
/*******************************************************************************
** Getter for config
*******************************************************************************/
public AlertsQBitConfig getConfig()
{
return (this.config);
}
/*******************************************************************************
** Getter for qInstance
*******************************************************************************/
public QInstance getQInstance()
{
return (this.qInstance);
}
/*******************************************************************************
** Getter for clock
*******************************************************************************/
public Clock getClock()
{
return (this.clock);
}
/*******************************************************************************
** Setter for clock (tests)
*******************************************************************************/
public void setClock(Clock clock)
{
this.clock = clock;
}
/*******************************************************************************
** Getter for typeSync
*******************************************************************************/
public AlertTypeSync getTypeSync()
{
return (this.typeSync);
}
/*******************************************************************************
** Getter for engine
*******************************************************************************/
public AlertsEngine getEngine()
{
return (this.engine);
}
/*******************************************************************************
** Getter for notifications (user notifications: send, retract, the bell)
*******************************************************************************/
public NotificationService getNotifications()
{
return (this.notifications);
}
/*******************************************************************************
** Getter for router
*******************************************************************************/
public AlertRouter getRouter()
{
return (this.router);
}
/*******************************************************************************
** Getter for channels
*******************************************************************************/
public AlertChannels getChannels()
{
return (this.channels);
}
/*******************************************************************************
** Getter for deliveryWorker
*******************************************************************************/
public DeliveryWorker getDeliveryWorker()
{
return (this.deliveryWorker);
}
/*******************************************************************************
** Getter for evaluator
*******************************************************************************/
public MonitorEvaluator getEvaluator()
{
return (this.evaluator);
}
/*******************************************************************************
** The runtime sink for adapter delegation and queue lifecycle management.
** Producers use core QAlerts for both alerts and notifications.
*******************************************************************************/
public QbitAlertsSink getSink()
{
return (this.sink);
}
}
@@ -0,0 +1,335 @@
/*
* 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.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
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;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
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;
/*******************************************************************************
** The Alerts record-security keys (design section 7).
**
** Four security key types guard the qbit's tables:
** - alerts_user: the session's user id. Personal rows (subscriptions,
** settings, inbox items) are locked to it. alerts.admin holders get its
** all-access key.
** - alerts_permission: every permission the session holds. An alert whose
** type sets a visibility permission is only readable with it.
** - alerts_readable_table: every table the session can read. An alert about
** a subject table is only readable by users who can read that table.
** - alerts_recipient: the session's user id, for notification recipient
** rows. Notification authors (alerts.notification.admin) and alerts.admin
** holders get its all-access key.
** alerts_permission and alerts_readable_table follow the logged-in user, so counts, lists, the bell and
** the home are all filtered in the query. (The system user, which the
** engine runs as, holds every all-access key.)
**
** Hosts call applyToSession from their authentication customizer, after the
** session's user and permissions are set. The qbit's own endpoints and
** widgets also call ensureSessionKeys, so a host that forgets still gets
** correct (never wider) results there. Without the keys, gated alerts (with
** a visibility permission or a subject table) are hidden.
*******************************************************************************/
public final class AlertsSecurity
{
public static final String KEY_USER = "alerts_user";
public static final String KEY_USER_ALL_ACCESS = "alerts_user_all_access";
public static final String KEY_PERMISSION = "alerts_permission";
public static final String KEY_PERMISSION_ALL = "alerts_permission_all_access";
public static final String KEY_READABLE_TABLE = "alerts_readable_table";
public static final String KEY_READABLE_TABLE_ALL = "alerts_readable_table_all_access";
public static final String KEY_RECIPIENT = "alerts_recipient";
public static final String KEY_RECIPIENT_ALL = "alerts_recipient_all_access";
private static final String ALERT_TABLE = "alerts_alert";
/*******************************************************************************
** Keys only.
*******************************************************************************/
private AlertsSecurity()
{
}
/*******************************************************************************
** The three key types, for the producer.
*******************************************************************************/
public static List<QSecurityKeyType> buildSecurityKeyTypes()
{
return (List.of(
new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS),
new QSecurityKeyType().withName(KEY_PERMISSION).withAllAccessKeyName(KEY_PERMISSION_ALL),
new QSecurityKeyType().withName(KEY_READABLE_TABLE).withAllAccessKeyName(KEY_READABLE_TABLE_ALL),
new QSecurityKeyType().withName(KEY_RECIPIENT).withAllAccessKeyName(KEY_RECIPIENT_ALL)));
}
/*******************************************************************************
** Owner lock: the session's alerts_user value must equal the field.
*******************************************************************************/
public static RecordSecurityLock ownerLock(String fieldName)
{
return (new RecordSecurityLock()
.withSecurityKeyType(KEY_USER)
.withFieldName(fieldName)
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE));
}
/*******************************************************************************
** Recipient lock: a notification recipient row is its user's (notification
** authors and alerts admins hold the all-access key).
*******************************************************************************/
public static RecordSecurityLock recipientLock(String fieldName)
{
return (new RecordSecurityLock()
.withSecurityKeyType(KEY_RECIPIENT)
.withFieldName(fieldName)
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE));
}
/*******************************************************************************
** Visibility lock on an alert (joinFromAlert null) or on a child of an
** alert (through that join): the session must hold the alert's
** visibilityPermission and be able to read its subjectTableName (each
** check passes when the alert has none).
*******************************************************************************/
public static MultiRecordSecurityLock visibilityLock(String joinFromAlert)
{
String prefix = joinFromAlert == null ? "" : ALERT_TABLE + ".";
RecordSecurityLock permission = new RecordSecurityLock()
.withSecurityKeyType(KEY_PERMISSION)
.withFieldName(prefix + "visibilityPermission")
.withNullValueBehavior(RecordSecurityLock.NullValueBehavior.ALLOW)
.withLockScope(RecordSecurityLock.LockScope.READ);
RecordSecurityLock table = new RecordSecurityLock()
.withSecurityKeyType(KEY_READABLE_TABLE)
.withFieldName(prefix + "subjectTableName")
.withNullValueBehavior(RecordSecurityLock.NullValueBehavior.ALLOW)
.withLockScope(RecordSecurityLock.LockScope.READ);
if(joinFromAlert != null)
{
permission.setJoinNameChain(List.of(joinFromAlert));
table.setJoinNameChain(List.of(joinFromAlert));
}
return (new MultiRecordSecurityLock()
.withOperator(MultiRecordSecurityLock.BooleanOperator.AND)
.withLock(permission)
.withLock(table));
}
/*******************************************************************************
** Put the alerts keys into a session: the user id (and, for alerts.admin,
** the all-access key), every permission it holds, and every table it can
** read. Idempotent: earlier alerts keys are replaced. A session without
** alerts.view gets none, so the locks deny.
*******************************************************************************/
public static void applyToSession(QInstance qInstance, QSession session)
{
if(session == null || session instanceof QSystemUserSession || qInstance == null)
{
return;
}
Map<String, List<Serializable>> keys = session.getSecurityKeyValues();
if(keys != null)
{
for(String key : List.of(KEY_USER, KEY_USER_ALL_ACCESS, KEY_PERMISSION, KEY_READABLE_TABLE, KEY_RECIPIENT, KEY_RECIPIENT_ALL))
{
keys.remove(key);
}
}
if(!session.hasPermission(AlertsQBitConfig.PERMISSION_VIEW))
{
return;
}
String userId = userId(session);
if(StringUtils.hasContent(userId))
{
session.withSecurityKeyValue(KEY_USER, userId);
session.withSecurityKeyValue(KEY_RECIPIENT, userId);
}
if(session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN) || session.hasPermission(AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN))
{
session.withSecurityKeyValue(KEY_RECIPIENT_ALL, true);
}
if(session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN))
{
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
}
Set<String> permissions = session.getPermissions();
if(permissions != null)
{
for(String permission : permissions)
{
session.withSecurityKeyValue(KEY_PERMISSION, permission);
}
}
for(String tableName : readableTables(qInstance, session))
{
session.withSecurityKeyValue(KEY_READABLE_TABLE, tableName);
}
}
/*******************************************************************************
** Apply the keys only if the session has none yet.
*******************************************************************************/
public static void ensureSessionKeys(QInstance qInstance, QSession session)
{
if(session == null || session instanceof QSystemUserSession)
{
return;
}
Map<String, List<Serializable>> keys = session.getSecurityKeyValues();
if(keys == null || !keys.containsKey(KEY_READABLE_TABLE))
{
applyToSession(qInstance, session);
}
}
/*******************************************************************************
** The tables the session may read (by its QQQ table permissions).
*******************************************************************************/
public static List<String> readableTables(QInstance qInstance, QSession session)
{
List<String> names = new ArrayList<>();
QContext.withTemporaryContext(new CapturedContext(qInstance, session), () ->
{
AbstractActionInput input = new AbstractActionInput();
for(QTableMetaData table : qInstance.getTables().values())
{
if(PermissionsHelper.hasTablePermission(input, table.getName(), TablePermissionSubType.READ))
{
names.add(table.getName());
}
}
});
return (names);
}
/*******************************************************************************
** The session's user id (null when there is none).
*******************************************************************************/
public static String userId(QSession session)
{
if(session == null || session.getUser() == null)
{
return (null);
}
return (session.getUser().getIdReference());
}
/*******************************************************************************
** A system-user session holding the alerts all-access keys (QQQ caches the
** system user's keys from whatever instance it saw first, so add ours
** explicitly).
*******************************************************************************/
public static QSystemUserSession systemSession()
{
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);
session.withSecurityKeyValue(KEY_RECIPIENT_ALL, true);
return (session);
}
/*******************************************************************************
** True when the current session is the system user.
*******************************************************************************/
public static boolean isSystemUser()
{
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));
}
}
@@ -0,0 +1,697 @@
/*
* 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.api;
import java.io.Serializable;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
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;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.home.AlertsHome;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qbits.alerts.processes.AlertsAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
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.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;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
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.session.QSession;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
/*******************************************************************************
** The bell, notifications page and record-panel endpoints' logic (design
** sections 6.3, 6.4; docs/NOTIFICATIONS.md 8.1),
** independent of the HTTP layer. Everything is read and written in the
** caller's session (record locks: their own recipient rows, alerts they can
** see); notification content is read for the rows they hold.
*******************************************************************************/
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;
static final int MAX_BATCH = 500;
static final int DEFAULT_PAGE = 25;
static final int MAX_PAGE = 100;
static final int POLL_SECONDS = 60;
public static final String NOTIFICATIONS_HREF = "/app/notifications";
static final int PANEL_ROWS = 5;
static final int RESOLVED_LOOKBACK = 30;
private final AlertsRuntime runtime;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertsApi(AlertsRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** 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
{
QSession session = QContext.getQSession();
if(session == null || !session.hasPermission(AlertsQBitConfig.PERMISSION_VIEW))
{
throw (new QPermissionDeniedException("Alerts are not available to you."));
}
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));
}
/*******************************************************************************
** The bell (docs/NOTIFICATIONS.md 8.1): the unread count, the highest
** unread severity, the latest items, and sticky banners.
*******************************************************************************/
public Map<String, Object> inboxSummary() throws QException
{
String userId = requireViewer();
Instant now = runtime.now();
List<Entry> entries = entries(userId, now);
int unread = 0;
NotificationSeverity highest = null;
List<Map<String, Object>> latest = new ArrayList<>();
List<Map<String, Object>> banners = new ArrayList<>();
for(Entry entry : entries)
{
if(entry.unread())
{
unread++;
NotificationSeverity severity = NotificationService.severityOf(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
highest = highest == null || severity.compareTo(highest) > 0 ? severity : highest;
}
if(latest.size() < INBOX_ITEMS)
{
latest.add(item(entry, now, false));
}
if(Boolean.TRUE.equals(entry.notification().getValueBoolean("sticky")) && banners.size() < MAX_BANNERS)
{
banners.add(item(entry, now, true));
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("unreadCount", unread);
result.put("highestUnreadSeverity", highest == null ? null : highest.name());
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);
return (result);
}
/*******************************************************************************
** The notifications page: the user's notifications (not dismissed),
** filtered by status (all or unread), category and severity, a page at a
** time, with bodies.
*******************************************************************************/
public Map<String, Object> inbox(Map<String, String> params) throws QException
{
String userId = requireViewer();
Instant now = runtime.now();
boolean onlyNew = "unread".equalsIgnoreCase(params.get("status"));
String category = StringUtils.hasContent(params.get("category")) ? params.get("category").toUpperCase(Locale.ROOT) : null;
String severity = StringUtils.hasContent(params.get("severity")) ? params.get("severity").toUpperCase(Locale.ROOT) : null;
int offset = Math.max(0, parseInt(params.get("offset"), 0));
int limit = Math.min(MAX_PAGE, Math.max(1, parseInt(params.get("limit"), DEFAULT_PAGE)));
List<Entry> matching = new ArrayList<>();
for(Entry entry : entries(userId, now))
{
if((!onlyNew || entry.unread())
&& (category == null || category.equals(entry.notification().getValueString("category")))
&& (severity == null || severity.equals(entry.notification().getValueString("severity"))))
{
matching.add(entry);
}
}
List<Map<String, Object>> items = new ArrayList<>();
for(Entry entry : matching.subList(Math.min(offset, matching.size()), Math.min(offset + limit, matching.size())))
{
items.add(item(entry, now, true));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("items", items);
result.put("total", matching.size());
result.put("offset", offset);
result.put("limit", limit);
return (result);
}
/*******************************************************************************
** Mark read: {"id": n}, {"ids": [..]}, {"all": true}, or (0.1 clients)
** {"alertId": n}.
*******************************************************************************/
public Map<String, Object> markRead(Map<String, Object> body) throws QException
{
String userId = requireViewer();
Instant now = runtime.now();
return (updateOwn(userId, target(userId, body, true), "readAt", now, record -> record.getValue("readAt") == null));
}
/*******************************************************************************
** Mark unread: {"id": n} or {"ids": [..]}.
*******************************************************************************/
public Map<String, Object> markUnread(Map<String, Object> body) throws QException
{
String userId = requireViewer();
return (updateOwn(userId, target(userId, body, false), "readAt", null, record -> record.getValue("readAt") != null));
}
/*******************************************************************************
** Dismiss (hide from the bell and the page; also marks read): {"id": n},
** {"ids": [..]} or {"all": true}.
*******************************************************************************/
public Map<String, Object> dismiss(Map<String, Object> body) throws QException
{
String userId = requireViewer();
Instant now = runtime.now();
return (updateOwn(userId, target(userId, body, true), "dismissedAt", now, record -> record.getValue("dismissedAt") == null));
}
/*******************************************************************************
** The open alerts in a user's bell (ALERT notifications not dismissed),
** for the Alerts home's "My alerts".
*******************************************************************************/
public static List<Integer> myAlertIds(String userId, int limit) throws QException
{
List<Integer> notificationIds = new ArrayList<>();
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK).withLimit(limit)))
{
notificationIds.add(recipient.getValueInteger("notificationId"));
}
List<Integer> alertIds = new ArrayList<>();
if(notificationIds.isEmpty())
{
return (alertIds);
}
for(QRecord notification : AlertsRuntime.get().asSystem(() -> AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
.withCriteria("id", QCriteriaOperator.IN, notificationIds)
.withCriteria("category", QCriteriaOperator.EQUALS, NotificationCategory.ALERT.name())
.withCriteria("status", QCriteriaOperator.EQUALS, NotificationService.STATUS_SENT))))
{
Integer alertId = parseInteger(notification.getValueString("sourceRecordId"));
if(alertId != null)
{
alertIds.add(alertId);
}
}
return (alertIds);
}
/*******************************************************************************
** One recipient row with its (live) notification.
*******************************************************************************/
record Entry(QRecord recipient, QRecord notification)
{
/***************************************************************************
**
***************************************************************************/
boolean unread()
{
return (recipient.getValue("readAt") == null);
}
}
/*******************************************************************************
** The user's bell: their recipient rows (the owner lock applies) that are
** not dismissed, joined with notifications that are SENT, published and
** not expired, newest first. ALERT notifications are kept only while the
** user can still see the alert.
*******************************************************************************/
private List<Entry> entries(String userId, Instant now) throws QException
{
if(userId == null)
{
return (List.of());
}
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK)
.withOrderBy(new QFilterOrderBy("deliveredAt", false))
.withOrderBy(new QFilterOrderBy("id", false))
.withLimit(SCAN_LIMIT));
if(recipients.isEmpty())
{
return (List.of());
}
List<Integer> ids = recipients.stream().map(recipient -> recipient.getValueInteger("notificationId")).distinct().toList();
Map<Integer, QRecord> notifications = new HashMap<>();
runtime.asSystem(() ->
{
for(QRecord notification : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
.withCriteria("id", QCriteriaOperator.IN, ids)
.withCriteria("status", QCriteriaOperator.EQUALS, NotificationService.STATUS_SENT)))
{
Instant publishAt = notification.getValueInstant("publishAt");
Instant expireAt = notification.getValueInstant("expireAt");
if((publishAt == null || !publishAt.isAfter(now)) && (expireAt == null || expireAt.isAfter(now)))
{
notifications.put(notification.getValueInteger("id"), notification);
}
}
return (null);
});
List<Integer> alertIds = new ArrayList<>();
for(QRecord notification : notifications.values())
{
if(NotificationCategory.ALERT.name().equals(notification.getValueString("category")) && parseInteger(notification.getValueString("sourceRecordId")) != null)
{
alertIds.add(parseInteger(notification.getValueString("sourceRecordId")));
}
}
Set<String> visibleAlerts = new HashSet<>();
if(!alertIds.isEmpty())
{
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, alertIds)))
{
visibleAlerts.add(String.valueOf(alert.getValue("id")));
}
}
List<Entry> entries = new ArrayList<>();
for(QRecord recipient : recipients)
{
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
if(notification != null && (!NotificationCategory.ALERT.name().equals(notification.getValueString("category")) || visibleAlerts.contains(notification.getValueString("sourceRecordId"))))
{
entries.add(new Entry(recipient, notification));
}
}
return (entries);
}
/*******************************************************************************
** One bell or page item.
*******************************************************************************/
private static Map<String, Object> item(Entry entry, Instant now, boolean withBody)
{
QRecord notification = entry.notification();
Instant deliveredAt = entry.recipient().getValueInstant("deliveredAt");
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", entry.recipient().getValueInteger("id"));
row.put("notificationId", notification.getValueInteger("id"));
row.put("title", notification.getValueString("title"));
if(withBody)
{
row.put("body", notification.getValueString("body"));
}
row.put("severity", notification.getValueString("severity"));
row.put("category", notification.getValueString("category"));
row.put("source", notification.getValueString("sourceName"));
row.put("href", NotificationService.safeLink(notification.getValueString("linkUrl")));
row.put("linkLabel", notification.getValueString("linkLabel"));
row.put("deliveredAt", deliveredAt == null ? null : deliveredAt.toString());
row.put("age", AlertMessageRenderer.age(deliveredAt, now));
row.put("unread", entry.unread());
row.put("sticky", Boolean.TRUE.equals(notification.getValueBoolean("sticky")));
if(NotificationCategory.ALERT.name().equals(notification.getValueString("category")))
{
row.put("alertId", parseInteger(notification.getValueString("sourceRecordId")));
}
List<Map<String, Object>> actions = new ArrayList<>();
actions.add(Map.of("name", entry.unread() ? "read" : "unread", "label", entry.unread() ? "Mark read" : "Mark unread",
"operationId", entry.unread() ? InboxReadSpec.OPERATION_ID : InboxUnreadSpec.OPERATION_ID));
actions.add(Map.of("name", "dismiss", "label", "Dismiss", "operationId", InboxDismissSpec.OPERATION_ID));
row.put("actions", actions);
return (row);
}
/*******************************************************************************
** Which of the user's recipient rows a request names (null: all of them).
*******************************************************************************/
private List<Integer> target(String userId, Map<String, Object> body, boolean allowAll) throws QException
{
if(body != null && body.get("id") != null)
{
return (List.of(toInteger(body.get("id"))));
}
if(body != null && body.get("ids") instanceof List<?> list && !list.isEmpty())
{
List<Integer> ids = new ArrayList<>();
for(Object value : list.subList(0, Math.min(list.size(), MAX_BATCH)))
{
ids.add(toInteger(value));
}
return (ids);
}
if(body != null && body.get("alertId") != null)
{
Integer alertId = toInteger(body.get("alertId"));
QRecord notification = runtime.asSystem(() -> AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", "alert:" + alertId)));
if(notification == null || userId == null)
{
return (List.of());
}
return (AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("notificationId", QCriteriaOperator.EQUALS, notification.getValue("id"))).stream().map(recipient -> recipient.getValueInteger("id")).toList());
}
if(allowAll && body != null && Boolean.TRUE.equals(body.get("all")))
{
return (null);
}
throw (new QBadRequestException(allowAll ? "Send id, ids, or all: true." : "Send id or ids."));
}
/*******************************************************************************
** Set one field on the user's own recipient rows (the named ones, or all
** not dismissed), where the row still needs it. Dismissing also marks
** read. Recounts the notifications' readers.
*******************************************************************************/
private Map<String, Object> updateOwn(String userId, List<Integer> ids, String field, Instant value, java.util.function.Predicate<QRecord> needs) throws QException
{
if(userId == null || (ids != null && ids.isEmpty()))
{
return (Map.of("updated", 0));
}
QQueryFilter filter = AlertsDb.eq("userId", userId).withLimit(SCAN_LIMIT);
if(ids != null)
{
filter.withCriteria("id", QCriteriaOperator.IN, ids);
}
else
{
filter.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK);
}
List<QRecord> updates = new ArrayList<>();
Set<Integer> affected = new HashSet<>();
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, filter))
{
if(needs.test(recipient))
{
QRecord update = new QRecord().withValue("id", recipient.getValue("id")).withValue(field, value);
if("dismissedAt".equals(field) && recipient.getValue("readAt") == null)
{
update.setValue("readAt", value);
}
updates.add(update);
affected.add(recipient.getValueInteger("notificationId"));
}
}
if(!updates.isEmpty())
{
UpdateAction.executeForRecords(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(updates));
runtime.getNotifications().recountRead(affected);
}
return (Map.of("updated", updates.size()));
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer toInteger(Object value) throws QBadRequestException
{
Integer parsed = value == null ? null : parseInteger(String.valueOf(value));
if(parsed == null)
{
throw (new QBadRequestException("Ids must be numbers."));
}
return (parsed);
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer parseInteger(String value)
{
try
{
return (value == null ? null : Integer.valueOf(value.trim().replaceAll("\\.0+$", "")));
}
catch(NumberFormatException e)
{
return (null);
}
}
/*******************************************************************************
**
*******************************************************************************/
private static int parseInt(String value, int fallback)
{
Integer parsed = parseInteger(value);
return (parsed == null ? fallback : parsed);
}
/*******************************************************************************
** The "Open alerts" panel for a record: visible open alerts that reference
** it (at most 5), how many were resolved in 30 days, and links. visible is
** false when there is nothing to show (the panel is then hidden).
*******************************************************************************/
public Map<String, Object> recordPanel(String tableName, String recordId) throws QException
{
requireViewer();
if(!StringUtils.hasContent(tableName) || !StringUtils.hasContent(recordId) || runtime.getQInstance().getTable(tableName) == null)
{
throw (new QBadRequestException("Send a table name and record id."));
}
if(!PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.READ))
{
throw (new QPermissionDeniedException("You can't read that table."));
}
Instant now = runtime.now();
List<Integer> openIds = new ArrayList<>();
List<Integer> allIds = new ArrayList<>();
for(QRecord ref : AlertsDb.query(AlertsRecordRef.TABLE_NAME, AlertsDb.eq("tableName", tableName).withCriteria("recordId", QCriteriaOperator.EQUALS, recordId).withLimit(500)))
{
allIds.add(ref.getValueInteger("alertId"));
if(Boolean.TRUE.equals(ref.getValueBoolean("active")))
{
openIds.add(ref.getValueInteger("alertId"));
}
}
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, AlertsDb.eq("subjectTableName", tableName).withCriteria("subjectRecordId", QCriteriaOperator.EQUALS, recordId).withLimit(500)))
{
allIds.add(alert.getValueInteger("id"));
openIds.add(alert.getValueInteger("id"));
}
List<QRecord> open = openIds.isEmpty() ? new ArrayList<>() : new ArrayList<>(AlertsDb.query(AlertsAlert.TABLE_NAME, new QQueryFilter()
.withCriteria("id", QCriteriaOperator.IN, openIds)
.withCriteria("status", QCriteriaOperator.IN, Status.openNames())));
open.sort((a, b) -> AlertSeverity.fromLabel(b.getValueString("severity"), AlertSeverity.INFO).compareTo(AlertSeverity.fromLabel(a.getValueString("severity"), AlertSeverity.INFO)));
int resolved = allIds.isEmpty() ? 0 : AlertsDb.count(AlertsAlert.TABLE_NAME, new QQueryFilter()
.withCriteria("id", QCriteriaOperator.IN, allIds)
.withCriteria("status", QCriteriaOperator.EQUALS, Status.RESOLVED.name())
.withCriteria("resolvedAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(RESOLVED_LOOKBACK, ChronoUnit.DAYS)));
Map<Integer, String> groups = groupLabels();
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord alert : open.subList(0, Math.min(PANEL_ROWS, open.size())))
{
rows.add(alertRow(alert, groups, now));
}
QSession session = QContext.getQSession();
Map<String, Object> result = new LinkedHashMap<>();
result.put("visible", !open.isEmpty() || resolved > 0);
result.put("openCount", open.size());
result.put("open", rows);
result.put("resolvedLast30Days", resolved);
result.put("viewAllHref", allIds.isEmpty() ? null : AlertsHome.listUrl(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, dedupe(allIds))));
result.put("canManage", session.hasPermission(AlertsQBitConfig.PERMISSION_MANAGE));
result.put("watching", isWatching(tableName, recordId));
MiddlewareSpecExtensionsMetaData extensions = MiddlewareSpecExtensionsMetaData.of(runtime.getQInstance());
result.put("follow", Map.of("available", extensions != null && extensions.getExtensions().containsKey("collaboration")));
return (result);
}
/*******************************************************************************
** Whether the user already has a RECORD subscription for this record.
*******************************************************************************/
private boolean isWatching(String tableName, String recordId) throws QException
{
String userId = AlertsSecurity.userId(QContext.getQSession());
if(userId == null || !PermissionsHelper.hasTablePermission(new AbstractActionInput(), AlertsSubscription.TABLE_NAME, TablePermissionSubType.READ))
{
return (false);
}
return (AlertsDb.count(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("scopeType", QCriteriaOperator.EQUALS, ScopeType.RECORD.name())
.withCriteria("scopeValue", QCriteriaOperator.EQUALS, tableName)
.withCriteria("scopeRecordId", QCriteriaOperator.EQUALS, recordId)) > 0);
}
/*******************************************************************************
** One alert as the bell and panel show it.
*******************************************************************************/
private static Map<String, Object> alertRow(QRecord alert, Map<Integer, String> groups, Instant now)
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("alertId", alert.getValueInteger("id"));
row.put("title", alert.getValueString("title"));
row.put("severity", alert.getValueString("severity"));
row.put("status", alert.getValueString("status"));
row.put("group", groups.get(alert.getValueInteger("groupId")));
row.put("age", AlertMessageRenderer.age(alert.getValueInstant("firstRaisedAt"), now));
row.put("raisedAt", alert.getValueInstant("firstRaisedAt") == null ? null : alert.getValueInstant("firstRaisedAt").toString());
row.put("href", "/app/" + AlertsAlert.TABLE_NAME + "/" + alert.getValue("id"));
List<Map<String, Object>> actions = new ArrayList<>();
if(QContext.getQSession().hasPermission(AlertsQBitConfig.PERMISSION_MANAGE))
{
if(Status.OPEN.name().equals(alert.getValueString("status")))
{
actions.add(processAction("acknowledge", AlertsAction.ACKNOWLEDGE, alert.getValueInteger("id")));
}
actions.add(processAction("snooze", AlertsAction.SNOOZE, alert.getValueInteger("id")));
}
row.put("actions", actions);
return (row);
}
/*******************************************************************************
** Complete navigation target so clients need no process or table conventions.
*******************************************************************************/
private static Map<String, Object> processAction(String name, AlertsAction action, Integer alertId)
{
return (Map.of("name", name, "label", action.getLabel(), "processName", action.getProcessName(),
"href", "/app/" + action.getProcessName() + "?recordsParam=recordIds&recordIds=" + alertId + "&tableName=" + AlertsAlert.TABLE_NAME));
}
/*******************************************************************************
** Group labels by id (empty without read permission).
*******************************************************************************/
private static Map<Integer, String> groupLabels() throws QException
{
Map<Integer, String> labels = new HashMap<>();
if(PermissionsHelper.hasTablePermission(new AbstractActionInput(), AlertsGroup.TABLE_NAME, TablePermissionSubType.READ))
{
for(QRecord group : AlertsDb.query(AlertsGroup.TABLE_NAME, new QQueryFilter()))
{
labels.put(group.getValueInteger("id"), group.getValueString("label"));
}
}
return (labels);
}
/*******************************************************************************
**
*******************************************************************************/
private static List<Serializable> dedupe(List<Integer> ids)
{
return (new ArrayList<>(new java.util.LinkedHashSet<>(ids)));
}
}
@@ -0,0 +1,98 @@
/*
* 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.api;
import java.util.List;
import com.kingsrook.qbits.alerts.AlertsRuntime;
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;
/*******************************************************************************
** Shared JSON transport; AlertsApi owns authorization and behavior.
*******************************************************************************/
abstract class AlertsJsonSpec extends AbstractJsonEndpointSpec
{
/*******************************************************************************
** The stable operation identifier exposed to clients.
*******************************************************************************/
protected abstract String operationId();
/*******************************************************************************
** Publish operation identifiers in both the fragment and served document.
*******************************************************************************/
@Override
public Method defineMethod()
{
return (super.defineMethod().withOperationId(operationId()));
}
/*******************************************************************************
** Keep the existing Content-Type requirement, including empty POST bodies.
*******************************************************************************/
@Override
public JsonInput buildInput(Context context) throws Exception
{
if(defineRequestBodySchema() != null && (context.contentType() == null || !context.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("application/json")))
{
throw (new QBadRequestException("The request body must be JSON (Content-Type: application/json)."));
}
try
{
return (super.buildInput(context));
}
catch(QBadRequestException e)
{
throw (new QBadRequestException("The request body is not valid JSON."));
}
}
/*******************************************************************************
** Framework error responses retain their existing HTTP status mapping.
*******************************************************************************/
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
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())));
}
/*******************************************************************************
** Resolve the runtime in the authenticated caller context.
*******************************************************************************/
protected AlertsApi api() throws QException
{
return (new AlertsApi(AlertsRuntime.get()));
}
}
@@ -0,0 +1,160 @@
/*
* 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.api;
import java.util.Map;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
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.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
import io.javalin.config.JavalinConfig;
import io.javalin.http.ContentType;
import io.javalin.http.Context;
/*******************************************************************************
** Machine webhook routes. The UI routes are registered through AlertsUiApi.
*******************************************************************************/
public class AlertsRouteProvider implements QJavalinRouteProviderInterface
{
private static final QLogger LOG = QLogger.getLogger(AlertsRouteProvider.class);
public static final String WEBHOOK_PATH = "/qqq/hooks/alerts/alertmanager";
public static final String LEGACY_SUNSET = "Tue, 13 Apr 2027 00:00:00 GMT";
public static final String BASE_PATH = "/qqq/v1/alerts";
static final int MAX_INGEST_BYTES = 1024 * 1024;
private QInstance qInstance;
/*******************************************************************************
** Register this provider once with the instance's Javalin meta-data.
*******************************************************************************/
public static void register(QInstance qInstance)
{
QJavalinMetaData javalinMetaData = QJavalinMetaData.ofOrWithNew(qInstance);
boolean registered = CollectionUtils.nonNullList(javalinMetaData.getAdditionalRouteProviderReferences()).stream()
.anyMatch(reference -> reference != null && AlertsRouteProvider.class.getName().equals(reference.getName()));
if(!registered)
{
javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(AlertsRouteProvider.class));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void setQInstance(QInstance qInstance)
{
this.qInstance = qInstance;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void acceptJavalinConfig(JavalinConfig config)
{
config.routes.post(WEBHOOK_PATH, this::ingestAlertmanager);
if(Boolean.TRUE.equals(AlertsRuntime.find(qInstance).getConfig().getLegacyAlertmanagerPath()))
{
config.routes.post(BASE_PATH + "/ingest/alertmanager", context ->
{
context.header("Deprecation", "true");
context.header("Sunset", LEGACY_SUNSET);
LOG.warn("Deprecated Alertmanager webhook called; migrate to " + WEBHOOK_PATH);
ingestAlertmanager(context);
});
}
}
/*******************************************************************************
** Alertmanager's webhook (no QQQ session: a bearer token instead).
*******************************************************************************/
private void ingestAlertmanager(Context context)
{
try
{
if(context.body().length() > MAX_INGEST_BYTES)
{
throw (new QBadRequestException("The payload is larger than 1 MB."));
}
com.kingsrook.qbits.alerts.engine.AlertmanagerIngest ingest = new com.kingsrook.qbits.alerts.engine.AlertmanagerIngest(AlertsRuntime.get(qInstance));
ingest.authenticate(context.header("Authorization"));
com.kingsrook.qbits.alerts.engine.AlertmanagerIngest.Result result = ingest.ingest(readJsonBody(context));
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJson(Map.of("raised", result.raised(), "resolved", result.resolved(), "ignored", result.ignored())));
}
catch(Exception e)
{
QJavalinUtils.handleException(null, context, e);
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The JSON body (an object), or 400.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static Map<String, Object> readJsonBody(Context context) throws QBadRequestException
{
String contentType = context.contentType();
if(contentType == null || !contentType.toLowerCase().startsWith("application/json"))
{
throw (new QBadRequestException("The request body must be JSON (Content-Type: application/json)."));
}
String body = context.body();
if(!StringUtils.hasContent(body))
{
return (Map.of());
}
try
{
return (JsonUtils.toObject(body, Map.class));
}
catch(Exception e)
{
throw (new QBadRequestException("The request body is not valid JSON."));
}
}
}
@@ -0,0 +1,307 @@
/*
* 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.api;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Explicit versioned wire schemas, independent of persistence entities.
*******************************************************************************/
public final class AlertsSchemas
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsSchemas()
{
}
/*******************************************************************************
** The complete component set shared by the six operations.
*******************************************************************************/
public static Map<String, Schema> components()
{
return (new TreeMap<>(Map.ofEntries(
Map.entry("AlertsActionV1", new AlertsActionV1(null, null, null, null, null).toSchema()),
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, 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()),
Map.entry("AlertsInboxTargetV1", new AlertsInboxTargetV1(null, null, null, null).toSchema()),
Map.entry("AlertsUnreadTargetV1", new AlertsUnreadTargetV1(null, null, null).toSchema()),
Map.entry("AlertsErrorV1", new AlertsErrorV1(null).toSchema()))));
}
/*******************************************************************************
** An available action: operationId targets an inbox operation with this item id; process actions provide a complete href.
*******************************************************************************/
public record AlertsActionV1(String name, String label, String operationId, String processName, String href) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("An available action: operationId targets an inbox operation with this item id; process actions provide a complete href.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("name", new Schema().withType(Type.STRING)),
Map.entry("label", new Schema().withType(Type.STRING)),
Map.entry("operationId", new Schema().withType(Type.STRING)),
Map.entry("processName", new Schema().withType(Type.STRING)),
Map.entry("href", new Schema().withType(Type.STRING))))));
}
}
/*******************************************************************************
** A notification recipient. body is included only in page items and banners; alertId only for ALERT notifications.
*******************************************************************************/
public record AlertsInboxItemV1(Integer id, Integer notificationId, String title, String body, String severity, String category, String source, String href, String linkLabel, String deliveredAt, String age, Boolean unread, Boolean sticky, Integer alertId, List<AlertsActionV1> actions) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("A notification recipient. body is included only in page items and banners; alertId only for ALERT notifications.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("notificationId", new Schema().withType(Type.INTEGER)),
Map.entry("title", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("body", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("category", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("source", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("href", new Schema().withType(Type.STRING).withNullable(true).withDescription("Existing safe link, or null.")),
Map.entry("linkLabel", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("deliveredAt", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("age", new Schema().withType(Type.STRING)),
Map.entry("unread", new Schema().withType(Type.BOOLEAN)),
Map.entry("sticky", new Schema().withType(Type.BOOLEAN)),
Map.entry("alertId", new Schema().withType(Type.INTEGER).withNullable(true)),
Map.entry("actions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsActionV1")))))));
}
}
/*******************************************************************************
** A visible open alert with permission-aware process actions.
*******************************************************************************/
public record AlertsPanelAlertV1(Integer alertId, String title, String severity, String status, String group, String age, String raisedAt, String href, List<AlertsActionV1> actions) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("A visible open alert with permission-aware process actions.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("alertId", new Schema().withType(Type.INTEGER)),
Map.entry("title", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("status", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("group", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("age", new Schema().withType(Type.STRING)),
Map.entry("raisedAt", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("href", new Schema().withType(Type.STRING)),
Map.entry("actions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsActionV1")))))));
}
}
/*******************************************************************************
** Capability discovery only. Next obtains the follow URL from its collaboration module.
*******************************************************************************/
public record AlertsFollowV1(Boolean available) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Capability discovery only. Next obtains the follow URL from its collaboration module.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("available", new Schema().withType(Type.BOOLEAN).withDescription("True exactly when the collaboration UI API extension is registered."))))));
}
}
/*******************************************************************************
** 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 preferencesSection, String alertsHref, Integer pollSeconds) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Bell summary, latest ten items and up to three sticky banners.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("unreadCount", new Schema().withType(Type.INTEGER)),
Map.entry("highestUnreadSeverity", new Schema().withType(Type.STRING).withNullable(true)),
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))))));
}
}
/*******************************************************************************
** Filtered notification page.
*******************************************************************************/
public record AlertsInboxPageV1(List<AlertsInboxItemV1> items, Integer total, Integer offset, Integer limit) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Filtered notification page.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("total", new Schema().withType(Type.INTEGER)),
Map.entry("offset", new Schema().withType(Type.INTEGER)),
Map.entry("limit", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Open alerts, recent resolutions and collaboration capability. watching is deprecated legacy subscription state.
*******************************************************************************/
public record AlertsRecordPanelV1(Boolean visible, Integer openCount, List<AlertsPanelAlertV1> open, Integer resolvedLast30Days, String viewAllHref, Boolean canManage, Boolean watching, AlertsFollowV1 follow) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Open alerts, recent resolutions and collaboration capability. watching is deprecated legacy subscription state.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("visible", new Schema().withType(Type.BOOLEAN)),
Map.entry("openCount", new Schema().withType(Type.INTEGER)),
Map.entry("open", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsPanelAlertV1"))),
Map.entry("resolvedLast30Days", new Schema().withType(Type.INTEGER)),
Map.entry("viewAllHref", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("canManage", new Schema().withType(Type.BOOLEAN)),
Map.entry("watching", new Schema().withType(Type.BOOLEAN).withDescription("Deprecated: existing RECORD subscriptions only; does not represent collaboration follow state.")),
Map.entry("follow", new Schema().withRefToSchema("AlertsFollowV1"))))));
}
}
/*******************************************************************************
** Number of the caller own recipient rows changed.
*******************************************************************************/
public record AlertsMutationResultV1(Integer updated) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Number of the caller own recipient rows changed.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("updated", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Select id, nonempty ids (first 500), legacy alertId, or all:true in that precedence. All scans are capped at 1000 rows.
*******************************************************************************/
public record AlertsInboxTargetV1(Integer id, List<Integer> ids, Integer alertId, Boolean all) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Select id, nonempty ids (first 500), legacy alertId, or all:true in that precedence. All scans are capped at 1000 rows.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER))),
Map.entry("alertId", new Schema().withType(Type.INTEGER)),
Map.entry("all", new Schema().withType(Type.BOOLEAN))))));
}
}
/*******************************************************************************
** Select id or nonempty ids (first 500), in that precedence. Legacy alertId is also accepted for compatibility. all is not supported.
*******************************************************************************/
public record AlertsUnreadTargetV1(Integer id, List<Integer> ids, Integer alertId) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Select id or nonempty ids (first 500), in that precedence. Legacy alertId is also accepted for compatibility. all is not supported.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER))),
Map.entry("alertId", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Framework error response.
*******************************************************************************/
public record AlertsErrorV1(String error) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
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))))));
}
}
}
@@ -0,0 +1,60 @@
/*
* 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.api;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
/*******************************************************************************
** The alerts UI API, retaining the established /alerts namespace.
*******************************************************************************/
public final class AlertsUiApi
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsUiApi()
{
}
/*******************************************************************************
** Register once, alongside the optional Javalin webhook provider.
*******************************************************************************/
public static void register(QInstance instance)
{
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/*******************************************************************************
** The independently versioned, discoverable UI contract.
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
.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));
}
}
@@ -0,0 +1,87 @@
/*
* 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.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Dismiss own notifications.
*******************************************************************************/
public class InboxDismissSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxDismiss";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/dismiss")
.withShortSummary("Dismiss own notifications").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().dismiss(input.getBody()));
}
}
@@ -0,0 +1,87 @@
/*
* 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.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Mark own notifications read.
*******************************************************************************/
public class InboxReadSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxRead";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/read")
.withShortSummary("Mark own notifications read").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().markRead(input.getBody()));
}
}
@@ -0,0 +1,96 @@
/*
* 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.api;
import java.util.List;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Page the notification inbox.
*******************************************************************************/
public class InboxSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInbox";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox")
.withShortSummary("Page the notification inbox").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsInboxPageV1");
}
/*******************************************************************************
** Existing query parameters and paging semantics.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(
new Parameter().withName("status").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("all or unread; other values behave as all."),
new Parameter().withName("category").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive category filter."),
new Parameter().withName("severity").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive severity filter."),
new Parameter().withName("offset").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 0; negatives clamp to 0; malformed values use the default."),
new Parameter().withName("limit").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 25; clamped to 1..100; malformed values use the default.")));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().inbox(input.getQueryParams()));
}
}
@@ -0,0 +1,88 @@
/*
* 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.api;
import java.util.Map;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Read the bell summary.
*******************************************************************************/
public class InboxSummarySpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxSummary";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox/summary")
.withShortSummary("Read the bell summary").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsInboxSummaryV1");
}
/*******************************************************************************
** Publish shared components once; all six extension specs register together.
*******************************************************************************/
@Override
public Map<String, Schema> defineComponentSchemas()
{
return (AlertsSchemas.components());
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().inboxSummary());
}
}
@@ -0,0 +1,87 @@
/*
* 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.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Mark own notifications unread.
*******************************************************************************/
public class InboxUnreadSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxUnread";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/unread")
.withShortSummary("Mark own notifications unread").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsUnreadTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().markUnread(input.getBody()));
}
}
@@ -0,0 +1,93 @@
/*
* 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.api;
import java.util.List;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Read visible alerts for a record.
*******************************************************************************/
public class RecordPanelSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsRecordPanel";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/record-panel")
.withShortSummary("Read visible alerts for a record").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsRecordPanelV1");
}
/*******************************************************************************
** Existing query parameters and paging semantics.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(
new Parameter().withName("tableName").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent table name."),
new Parameter().withName("recordId").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent record key.")));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().recordPanel(input.getQueryParam("tableName"), input.getQueryParam("recordId")));
}
}
@@ -0,0 +1,255 @@
/*
* 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.customizers;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
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.notifications.NotificationService;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
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.model.actions.tables.delete.DeleteInput;
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.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Rules for the notification tables, in the core actions (so the admin, the
** API and the bell all follow them):
** - Notifications: the author is the session user; new ones start as
** DRAFT (sending is a record action); status and the send bookkeeping
** can't be edited; links must be app paths or https; an audience needs
** its values. Deleting one deletes its recipient rows.
** - Recipients: people change only read and dismissed on their own rows
** (the owner lock); the qbit inserts and deletes them.
** - Notification settings: the user id is the session's; SECURITY can't be
** muted in-app.
** The system user (the qbit itself) is not restricted.
*******************************************************************************/
public class AlertsNotificationCustomizer implements TableCustomizerInterface
{
private static final Set<String> NOTIFICATION_SYSTEM_FIELDS = Set.of("status", "authorUserId", "sentAt", "retractedAt", "recipientCount", "readCount");
private static final Set<String> RECIPIENT_EDITABLE_FIELDS = Set.of("id", "readAt", "dismissedAt");
/*******************************************************************************
** Stamp the user and author before QQQ's required-field and security-lock
** checks, so they check the values this customizer sets.
*******************************************************************************/
@Override
public AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput insertInput, boolean isPreview)
{
return (AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
{
if(AlertsSecurity.isSystemUser())
{
return (records);
}
String tableName = insertInput.getTableName();
if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
{
return (AlertsWriteGuardCustomizer.deny(tableName, Capability.TABLE_INSERT, records));
}
String userId = AlertsSecurity.userId(QContext.getQSession());
for(QRecord record : records)
{
if(AlertsNotification.TABLE_NAME.equals(tableName))
{
record.setValue("authorUserId", userId);
record.setValue("status", NotificationService.STATUS_DRAFT);
for(String field : List.of("sentAt", "retractedAt", "recipientCount", "readCount"))
{
record.removeValue(field);
}
if(!StringUtils.hasContent(record.getValueString("sourceName")))
{
record.setValue("sourceName", "admin");
}
checkNotification(record);
}
else if(AlertsNotificationPreference.TABLE_NAME.equals(tableName))
{
if(!isAlertsAdmin() || record.getValue("userId") == null)
{
record.setValue("userId", userId);
}
checkPreference(record);
}
}
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
if(AlertsSecurity.isSystemUser())
{
return (records);
}
String tableName = updateInput.getTableName();
for(QRecord record : records)
{
if(AlertsNotification.TABLE_NAME.equals(tableName))
{
for(String field : NOTIFICATION_SYSTEM_FIELDS)
{
record.removeValue(field);
}
checkNotification(record);
}
else if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
{
for(String field : new ArrayList<>(record.getValues().keySet()))
{
if(!RECIPIENT_EDITABLE_FIELDS.contains(field))
{
record.removeValue(field);
}
}
}
else if(AlertsNotificationPreference.TABLE_NAME.equals(tableName))
{
record.removeValue("userId");
QRecord merged = new QRecord(record);
oldRecordList.flatMap(old -> old.stream().filter(o -> o.getValue("id") != null && o.getValue("id").equals(record.getValue("id"))).findFirst())
.ifPresent(old -> old.getValues().forEach((field, value) -> merged.getValues().putIfAbsent(field, value)));
checkPreference(merged);
merged.getErrors().forEach(record::addError);
}
}
return (records);
}
/*******************************************************************************
** People never delete recipient rows; deleting a notification deletes its
** recipients first (as the qbit).
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
{
String tableName = deleteInput.getTableName();
if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
{
return (AlertsSecurity.isSystemUser() ? records : AlertsWriteGuardCustomizer.deny(tableName, Capability.TABLE_DELETE, records));
}
if(AlertsNotification.TABLE_NAME.equals(tableName) && !isPreview)
{
List<Serializable> ids = records.stream().map(record -> (Serializable) record.getValueInteger("id")).filter(id -> id != null).toList();
if(!ids.isEmpty())
{
AlertsRuntime.get().asSystem(() -> new DeleteAction().execute(new DeleteInput(AlertsNotificationRecipient.TABLE_NAME)
.withQueryFilter(new QQueryFilter(new QFilterCriteria("notificationId", QCriteriaOperator.IN, ids)))));
}
}
return (records);
}
/*******************************************************************************
** A notification's link and audience (the fields present on the record).
*******************************************************************************/
static void checkNotification(QRecord record)
{
if(record.getValues().containsKey("linkUrl") && StringUtils.hasContent(record.getValueString("linkUrl")) && NotificationService.safeLink(record.getValueString("linkUrl")) == null)
{
record.addError(new BadInputStatusMessage("The link must be an app path (starting with /) or an https URL."));
}
if(record.getValues().containsKey("audienceType"))
{
NotificationAudience audience = NotificationService.audienceOf(record);
if(audience == null)
{
record.addError(new BadInputStatusMessage("Choose who the notification is for."));
}
else if(audience.getType() != NotificationAudience.Type.ALL_USERS && audience.getValues().isEmpty())
{
record.addError(new BadInputStatusMessage("List the " + switch(audience.getType())
{
case ROLES -> "roles or groups";
case PERMISSION -> "permission";
default -> "user ids";
} + " in Audience Members."));
}
}
}
/*******************************************************************************
** SECURITY notices can't be muted in-app.
*******************************************************************************/
static void checkPreference(QRecord record)
{
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."));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isAlertsAdmin()
{
QSession session = QContext.getQSession();
return (session != null && session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN));
}
}
@@ -0,0 +1,152 @@
/*
* 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.customizers;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
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.insert.InsertInput;
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.session.QSession;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage;
/*******************************************************************************
** Keeps personal rows personal (design section 7.1), in the core actions:
** - Subscriptions: anyone with alerts.view subscribes themselves (the user
** id is set from the session); only alerts.admin may create team or
** channel subscriptions or subscribe someone else.
** - My Alert Settings: the user id is the session's.
** - Mutes: the creator is recorded; only alerts.admin may suppress alerts
** (SUPPRESS_ALERTS, a maintenance window), others only silence.
*******************************************************************************/
public class AlertsSubscriptionCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
{
for(QRecord record : records)
{
apply(insertInput.getTableName(), record, true);
}
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
for(QRecord record : records)
{
apply(updateInput.getTableName(), record, false);
}
return (records);
}
/*******************************************************************************
** Stamp or check one record.
*******************************************************************************/
static void apply(String tableName, QRecord record, boolean isInsert)
{
if(AlertsSecurity.isSystemUser())
{
return;
}
QSession session = QContext.getQSession();
String userId = AlertsSecurity.userId(session);
boolean admin = session != null && session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN);
switch(tableName)
{
case AlertsSubscription.TABLE_NAME ->
{
String subscriberType = record.getValueString("subscriberType");
if(isInsert)
{
record.setValue("createdByUserId", userId);
if(subscriberType == null)
{
subscriberType = AlertsPossibleValues.SubscriberType.USER.name();
record.setValue("subscriberType", subscriberType);
}
if(AlertsPossibleValues.SubscriberType.USER.name().equals(subscriberType) && (!admin || record.getValue("userId") == null))
{
record.setValue("userId", userId);
}
}
if(!admin && subscriberType != null && !AlertsPossibleValues.SubscriberType.USER.name().equals(subscriberType))
{
record.addError(new PermissionDeniedMessage("Only alerts admins can subscribe a team or a channel."));
}
if(AlertsPossibleValues.SubscriberType.TEAM.name().equals(subscriberType) && record.getValue("teamId") == null)
{
record.addError(new BadInputStatusMessage("Choose the team to subscribe."));
}
if(AlertsPossibleValues.SubscriberType.CHANNEL.name().equals(subscriberType) && record.getValue("channelId") == null)
{
record.addError(new BadInputStatusMessage("Choose the channel to subscribe."));
}
}
case AlertsUserPreference.TABLE_NAME ->
{
if(isInsert && (!admin || record.getValue("userId") == null))
{
record.setValue("userId", userId);
}
}
case AlertsMute.TABLE_NAME ->
{
if(isInsert)
{
record.setValue("createdByUserId", userId);
}
if(!admin && AlertsPossibleValues.MuteEffect.SUPPRESS_ALERTS.name().equals(record.getValueString("effect")))
{
record.addError(new PermissionDeniedMessage("Only alerts admins can create maintenance windows that suppress alerts."));
}
}
default ->
{
//////////////////////////////
// nothing for other tables //
//////////////////////////////
}
}
}
}
@@ -0,0 +1,99 @@
/*
* 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.customizers;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
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.insert.InsertInput;
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.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage;
/*******************************************************************************
** Enforces, in the core actions, what a table's capabilities hide in the UI
** and API: an insert, update or delete the table does not offer is rejected
** unless the qbit itself (the system user) makes it. So alerts, timelines
** and notifications only change through the engine and record actions,
** whoever calls the table actions (admins included).
*******************************************************************************/
public class AlertsWriteGuardCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
{
return (deny(insertInput.getTableName(), Capability.TABLE_INSERT, records));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
return (deny(updateInput.getTableName(), Capability.TABLE_UPDATE, records));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
{
return (deny(deleteInput.getTableName(), Capability.TABLE_DELETE, records));
}
/*******************************************************************************
** Add an error to every record when the table lacks the capability and the
** caller is not the system user.
*******************************************************************************/
static List<QRecord> deny(String tableName, Capability capability, List<QRecord> records)
{
if(AlertsSecurity.isSystemUser())
{
return (records);
}
QTableMetaData table = QContext.getQInstance().getTable(tableName);
if(table != null && table.getDisabledCapabilities() != null && table.getDisabledCapabilities().contains(capability))
{
for(QRecord record : records)
{
record.addError(new PermissionDeniedMessage("Only the Alerts qbit changes this table; use the record actions instead."));
}
}
return (records);
}
}
@@ -0,0 +1,53 @@
/*
* 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.util.List;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** A channel type (design section 5.6): in-app, email, an email list, and
** later Slack, Teams, webhooks, PagerDuty and Alertmanager. Register more
** with AlertsQBitConfig.withChannelType, so another qbit can add one without
** touching this one.
**
** The message is already rendered and redacted for the channel's detail
** level; a channel never sees an alert's raw details. Secrets come only from
** the channel's secretRef, resolved at send time.
*******************************************************************************/
public interface AlertChannelInterface
{
/***************************************************************************
** The channel type this implements (an alerts_channel channelType).
***************************************************************************/
String getType();
/***************************************************************************
** Problems with a channel row's settings (empty when it can send).
***************************************************************************/
List<String> validate(QRecord channel, AlertSecretResolver secrets);
/***************************************************************************
** Send one message to one recipient (an address for email; null for
** channels that post to a fixed place). Never throws: the result says
** SENT, RETRY or FAILED (permanent).
***************************************************************************/
DeliveryResult deliver(AlertMessage message, QRecord channel, String recipientAddress, AlertSecretResolver secrets);
}
@@ -0,0 +1,164 @@
/*
* 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.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The channel types (built-in in-app, email, email list, Slack, Teams,
** webhook, PagerDuty and Alertmanager, plus any the config adds) and the two default channel rows every install has: "in-app"
** (the bell; always available) and "email" (a person's own address).
*******************************************************************************/
public class AlertChannels
{
private static final QLogger LOG = QLogger.getLogger(AlertChannels.class);
public static final String DEFAULT_IN_APP = "in-app";
public static final String DEFAULT_EMAIL = "email";
private final AlertsRuntime runtime;
private final Map<String, AlertChannelInterface> types = new LinkedHashMap<>();
private final AlertSecretResolver secrets = new AlertSecretResolver();
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertChannels(AlertsRuntime runtime)
{
this.runtime = runtime;
register(new InAppChannel());
register(new EmailChannel(runtime.getConfig(), ChannelType.EMAIL));
register(new EmailChannel(runtime.getConfig(), ChannelType.EMAIL_LIST));
for(AlertChannelInterface external : com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels.all())
{
register(external);
}
for(QCodeReference reference : runtime.getConfig().getChannelTypes())
{
try
{
AlertChannelInterface channel = QCodeLoader.getAdHoc(AlertChannelInterface.class, reference);
if(channel != null)
{
register(channel);
}
}
catch(Exception e)
{
LOG.warn("Could not load an alerts channel type", e, logPair("channelType", reference.getName()));
}
}
}
/*******************************************************************************
** Add (or replace) a channel type.
*******************************************************************************/
public void register(AlertChannelInterface channel)
{
types.put(channel.getType(), channel);
}
/*******************************************************************************
** The implementation for a channel type, or null.
*******************************************************************************/
public AlertChannelInterface forType(String channelType)
{
return (channelType == null ? null : types.get(channelType));
}
/*******************************************************************************
** The registered type names.
*******************************************************************************/
public List<String> typeNames()
{
return (List.copyOf(types.keySet()));
}
/*******************************************************************************
** The secret resolver.
*******************************************************************************/
public AlertSecretResolver getSecrets()
{
return (secrets);
}
/*******************************************************************************
** The default in-app or email channel row, created if missing. Runs in the
** caller's (system) context.
*******************************************************************************/
public QRecord defaultChannel(ChannelType channelType) throws QException
{
String name = channelType == ChannelType.IN_APP ? DEFAULT_IN_APP : DEFAULT_EMAIL;
QRecord existing = AlertsDb.first(AlertsChannel.TABLE_NAME, AlertsDb.eq("name", name));
if(existing != null)
{
return (existing);
}
return (AlertsDb.insert(AlertsChannel.TABLE_NAME, new QRecord()
.withValue("name", name)
.withValue("label", channelType == ChannelType.IN_APP ? "In-App (Bell)" : "Email (Personal Address)")
.withValue("channelType", channelType.name())
.withValue("enabled", true)
.withValue("detailLevel", channelType == ChannelType.IN_APP ? "FULL" : "MINIMAL")
.withValue("minSeverity", "INFO")
.withValue("rateLimitPerHour", channelType == ChannelType.IN_APP ? 0 : 60)
.withValue("healthStatus", HealthStatus.HEALTHY.name())
.withValue("consecutiveFailures", 0)));
}
/*******************************************************************************
** Make sure both default channels exist.
*******************************************************************************/
public void ensureDefaults() throws QException
{
runtime.asSystem(() ->
{
defaultChannel(ChannelType.IN_APP);
defaultChannel(ChannelType.EMAIL);
return (null);
});
}
}
@@ -0,0 +1,89 @@
/*
* 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.util.List;
/*******************************************************************************
** A rendered, redacted message for one channel and recipient: one alert, or
** a batch of several that arrived together ("3 new alerts").
**
** @param subject one line (email subject, chat headline)
** @param text plain-text body
** @param html simple HTML body (escaped)
** @param severity the highest severity in the message
** @param kind RAISED, RESOLVED, REMINDER, ESCALATED, DIGEST or TEST
** @param alertIds the alerts in the message
** @param link a link to the alert (or the Alerts list) in the app
** @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, 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.
*******************************************************************************/
public AlertMessage(String subject, String text, String html, String severity, String kind, List<Integer> alertIds, String link)
{
this(subject, text, html, severity, kind, alertIds, link, List.of());
}
/*******************************************************************************
** One alert, as structured channels send it.
**
** @param alertId the alert's id (null for a test message)
** @param alertTypeName the type, e.g. cdn.purge.dead
** @param typeLabel the type's label
** @param dedupKey the producer's dedup key
** @param severity INFO, WARNING, ERROR or CRITICAL
** @param status OPEN, ACKNOWLEDGED, SNOOZED or RESOLVED
** @param title the redacted title
** @param summary the redacted summary (null at MINIMAL)
** @param group the group's label
** @param itemCount the record count (null at MINIMAL)
** @param link the alert in the app
** @param firstRaisedAt when it opened (ISO)
** @param resolvedAt when it resolved (ISO), or null
*******************************************************************************/
public record Entry(Integer alertId, String alertTypeName, String typeLabel, String dedupKey, String severity, String status, String title, String summary,
String group, Integer itemCount, String link, String firstRaisedAt, String resolvedAt)
{
/***************************************************************************
** The stable key for systems that dedup (PagerDuty, Alertmanager).
***************************************************************************/
public String stableKey()
{
return ((alertTypeName == null ? "alerts.test" : alertTypeName) + ":" + (dedupKey == null ? String.valueOf(alertId) : dedupKey));
}
}
}
@@ -0,0 +1,271 @@
/*
* 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.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.home.HomeHtml;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Renders alerts into a message for an external channel, from fixed
** templates (producers supply only data), at a detail level (design 7.4):
** - MINIMAL: severity, type label, group, title, age and one link;
** - STANDARD: adds the summary and the counts;
** - FULL: adds the details.
** The level is the lower of the channel's and the type's ceiling. Every
** string except the generated link goes through AlertRedactor.
*******************************************************************************/
public final class AlertMessageRenderer
{
/*******************************************************************************
** What the renderer knows about one alert.
**
** @param alert the alerts_alert row
** @param typeLabel its type's label
** @param groupLabel its group's label
** @param level the effective detail level for this channel
*******************************************************************************/
public record Item(QRecord alert, String typeLabel, String groupLabel, DetailLevel level)
{
}
/*******************************************************************************
** Rendering only.
*******************************************************************************/
private AlertMessageRenderer()
{
}
/*******************************************************************************
** One message for one or more alerts.
*******************************************************************************/
public static AlertMessage render(List<Item> items, DeliveryKind kind, String publicBaseUrl, Instant now)
{
AlertSeverity highest = AlertSeverity.INFO;
List<Integer> alertIds = new ArrayList<>();
for(Item item : items)
{
AlertSeverity severity = AlertSeverity.fromLabel(item.alert().getValueString("severity"), AlertSeverity.WARNING);
if(severity.compareTo(highest) > 0)
{
highest = severity;
}
alertIds.add(item.alert().getValueInteger("id"));
}
String base = StringUtils.hasContent(publicBaseUrl) ? publicBaseUrl.replaceAll("/+$", "") : "";
String prefix = kind == DeliveryKind.TEST ? "TEST: " : "";
String subject;
String link;
if(items.size() == 1)
{
QRecord alert = items.get(0).alert();
String state = kind == DeliveryKind.RESOLVED ? "RESOLVED" : AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING).name();
subject = prefix + "[" + state + "] " + AlertRedactor.redact(alert.getValueString("title"));
link = base + "/app/" + AlertsAlert.TABLE_NAME + "/" + alert.getValue("id");
}
else
{
String group = commonGroup(items);
subject = prefix + items.size() + (kind == DeliveryKind.RESOLVED ? " alerts resolved" : " new alerts") + (group == null ? "" : " in " + group);
link = base + "/app/" + AlertsAlert.TABLE_NAME;
}
StringBuilder text = new StringBuilder();
StringBuilder html = new StringBuilder("<div style=\"font-family:sans-serif;font-size:14px;line-height:1.5\">");
if(kind == DeliveryKind.TEST)
{
text.append("This is a test notification from Alerts.\n\n");
html.append("<p><strong>This is a test notification from Alerts.</strong></p>");
}
for(Item item : items)
{
QRecord alert = item.alert();
DetailLevel level = item.level() == null ? DetailLevel.MINIMAL : item.level();
String line = "[" + alert.getValueString("severity") + "] " + safe(item.typeLabel()) + (item.groupLabel() == null ? "" : " / " + safe(item.groupLabel()))
+ ": " + safe(alert.getValueString("title")) + " (" + age(alert.getValueInstant("firstRaisedAt"), now) + ")";
text.append(line).append('\n');
html.append("<p><strong>").append(HomeHtml.escape(line)).append("</strong>");
if(level.compareTo(DetailLevel.STANDARD) >= 0)
{
if(StringUtils.hasContent(alert.getValueString("summary")))
{
text.append(safe(alert.getValueString("summary"))).append('\n');
html.append("<br>").append(HomeHtml.escape(safe(alert.getValueString("summary"))));
}
Integer count = alert.getValueInteger("itemCount");
if(count != null)
{
text.append(count == 1 ? "1 record" : count + " records").append('\n');
html.append("<br>").append(count == 1 ? "1 record" : count + " records");
}
}
if(level == DetailLevel.FULL && StringUtils.hasContent(alert.getValueString("details")))
{
text.append(safe(alert.getValueString("details"))).append('\n');
html.append("<br><span style=\"white-space:pre-wrap\">").append(HomeHtml.escape(safe(alert.getValueString("details")))).append("</span>");
}
html.append("</p>");
text.append('\n');
}
text.append("Open: ").append(link).append("\n\n");
text.append("Change or stop these emails: ").append(base).append("/app/alerts_subscription\n");
text.append("Personal data and secrets are removed from alert messages.\n");
html.append("<p><a href=\"").append(HomeHtml.escape(link)).append("\">Open in Alerts</a></p>");
html.append("<p style=\"font-size:12px;opacity:.75\"><a href=\"").append(HomeHtml.escape(base + "/app/alerts_subscription")).append("\">Change or stop these emails</a>. ")
.append("Personal data and secrets are removed from alert messages.</p></div>");
List<AlertMessage.Entry> entries = new ArrayList<>();
for(Item item : items)
{
QRecord alert = item.alert();
DetailLevel level = item.level() == null ? DetailLevel.MINIMAL : item.level();
boolean standard = level.compareTo(DetailLevel.STANDARD) >= 0;
Integer id = alert.getValueInteger("id");
entries.add(new AlertMessage.Entry(id, alert.getValueString("alertTypeName"), safe(item.typeLabel()), alert.getValueString("dedupKey"),
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING).name(),
kind == DeliveryKind.RESOLVED ? "RESOLVED" : (alert.getValueString("status") == null ? "OPEN" : alert.getValueString("status")),
safe(alert.getValueString("title")), standard ? AlertRedactor.redact(alert.getValueString("summary")) : null, item.groupLabel(),
standard ? alert.getValueInteger("itemCount") : null, id == null ? base + "/app/" + AlertsAlert.TABLE_NAME : base + "/app/" + AlertsAlert.TABLE_NAME + "/" + id,
alert.getValueInstant("firstRaisedAt") == null ? null : alert.getValueInstant("firstRaisedAt").toString(),
alert.getValueInstant("resolvedAt") == null ? null : alert.getValueInstant("resolvedAt").toString()));
}
return (new AlertMessage(subject, text.toString(), html.toString(), highest.name(), kind.name(), alertIds, link, entries));
}
/*******************************************************************************
** 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.
*******************************************************************************/
private static String commonGroup(List<Item> items)
{
String group = items.get(0).groupLabel();
for(Item item : items)
{
if(group == null || !group.equals(item.groupLabel()))
{
return (null);
}
}
return (group);
}
/*******************************************************************************
** Redacted text (null is empty).
*******************************************************************************/
private static String safe(String text)
{
return (text == null ? "" : AlertRedactor.redact(text));
}
/*******************************************************************************
** "3 h", "12 min", "2 d".
*******************************************************************************/
public static String age(Instant since, Instant now)
{
if(since == null || now == null)
{
return ("now");
}
Duration duration = Duration.between(since, now);
if(duration.isNegative() || duration.toMinutes() < 1)
{
return ("just now");
}
if(duration.toMinutes() < 60)
{
return (duration.toMinutes() + " min");
}
if(duration.toHours() < 48)
{
return (duration.toHours() + " h");
}
return (duration.toDays() + " d");
}
/*******************************************************************************
** Labels by id, for items (helper for callers that cache them).
*******************************************************************************/
public static String label(Map<Integer, String> labels, Integer id)
{
return (id == null ? null : labels.get(id));
}
}
@@ -0,0 +1,78 @@
/*
* 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.util.List;
import java.util.regex.Pattern;
/*******************************************************************************
** Masks personal data and secrets in every outbound string (design section
** 7.4): URLs with credentials, JWTs, bearer tokens, key= / secret= /
** password= / token= values, email addresses, IPv4 and IPv6 addresses,
** phone numbers, and long hex or base64 strings (32+ characters). Titles and
** summaries are meant to be written without personal data; this is the
** safety net.
*******************************************************************************/
public final class AlertRedactor
{
public static final String MASK = "[redacted]";
private static final List<Pattern> PATTERNS = List.of(
Pattern.compile("\\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s/:@]+:[^\\s/@]+@[^\\s]+"),
Pattern.compile("\\beyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+"),
Pattern.compile("(?i)\\bbearer\\s+[A-Za-z0-9._~+/=-]+"),
Pattern.compile("(?i)\\b(api[_-]?key|key|secret|password|passwd|pwd|token|access[_-]?token)\\s*[=:]\\s*[^\\s,;&]+"),
Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"),
Pattern.compile("\\b(?:(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\.){3}(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)\\b"),
Pattern.compile("(?i)\\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\\b"),
Pattern.compile("\\+\\d[\\d ().-]{7,}\\d"),
Pattern.compile("\\(\\d{3}\\)\\s?\\d{3}[-.\\s]\\d{4}\\b"),
Pattern.compile("\\b\\d{3}[-.\\s]\\d{3}[-.\\s]\\d{4}\\b"),
Pattern.compile("\\b[A-Fa-f0-9]{32,}\\b"),
Pattern.compile("\\b[A-Za-z0-9+/_-]{32,}={0,2}"));
/*******************************************************************************
** Masking only.
*******************************************************************************/
private AlertRedactor()
{
}
/*******************************************************************************
** The text with every sensitive match replaced by [redacted] (null stays
** null).
*******************************************************************************/
public static String redact(String text)
{
if(text == null || text.isEmpty())
{
return (text);
}
String result = text;
for(Pattern pattern : PATTERNS)
{
result = pattern.matcher(result).replaceAll(MASK);
}
return (result);
}
}
@@ -0,0 +1,89 @@
/*
* 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 com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Resolves a channel's secretRef at send time (design section 7.3). The
** reference is a name, never a value: an environment variable name
** (SLACK_ALERTS_WEBHOOK) or a QQQ variable expression (${env.NAME},
** ${prop.name}), which also covers Kubernetes secrets injected as
** environment variables and .env files. Values are never stored, logged or
** shown.
*******************************************************************************/
public class AlertSecretResolver
{
private final QMetaDataVariableInterpreter interpreter;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertSecretResolver()
{
this(new QMetaDataVariableInterpreter());
}
/*******************************************************************************
** Constructor (tests can pass an interpreter with value maps)
*******************************************************************************/
public AlertSecretResolver(QMetaDataVariableInterpreter interpreter)
{
this.interpreter = interpreter;
}
/*******************************************************************************
** The secret's value, or null when the reference is blank or unresolved.
*******************************************************************************/
public String resolve(String secretRef)
{
if(!StringUtils.hasContent(secretRef))
{
return (null);
}
String expression = secretRef.trim().startsWith("${") ? secretRef.trim() : "${env." + secretRef.trim() + "}";
String value = interpreter.interpret(expression);
if(value == null || value.equals(expression) || value.isBlank())
{
return (null);
}
return (value);
}
/*******************************************************************************
** "present" or "missing", for display.
*******************************************************************************/
public String describe(String secretRef)
{
if(!StringUtils.hasContent(secretRef))
{
return ("none");
}
return (resolve(secretRef) == null ? "missing" : "present");
}
}
@@ -0,0 +1,66 @@
/*
* 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;
/*******************************************************************************
** The outcome of one delivery attempt.
**
** @param outcome SENT, RETRY (try again later) or FAILED (permanent)
** @param providerMessageId the provider's id for the message, when it has one
** @param error why it did not send (already redacted)
*******************************************************************************/
public record DeliveryResult(Outcome outcome, String providerMessageId, String error)
{
/*******************************************************************************
** Delivery outcomes.
*******************************************************************************/
public enum Outcome
{
SENT, RETRY, FAILED
}
/*******************************************************************************
** Sent.
*******************************************************************************/
public static DeliveryResult sent(String providerMessageId)
{
return (new DeliveryResult(Outcome.SENT, providerMessageId, null));
}
/*******************************************************************************
** Failed for now; try again.
*******************************************************************************/
public static DeliveryResult retry(String error)
{
return (new DeliveryResult(Outcome.RETRY, null, error));
}
/*******************************************************************************
** Failed for good (bad address, channel not configured).
*******************************************************************************/
public static DeliveryResult failed(String error)
{
return (new DeliveryResult(Outcome.FAILED, null, error));
}
}
@@ -0,0 +1,372 @@
/*
* 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.io.Serializable;
import java.time.Duration;
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.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.AlertsGroup;
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.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsType;
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.DetailLevel;
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.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;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Delivers PENDING and due RETRYING notifications (design section 5.5).
** Notifications for the same channel, recipient and kind that are due
** together go out as one message ("3 new alerts in CDN"). Retries back off
** 1 min, 5 min, 15 min, 1 h, 4 h, then the row is DEAD; a permanent failure
** is FAILED at once. Channel health follows the qbit-webhooks model: a
** failure puts a channel on PROBATION, three in a row make it UNHEALTHY and
** raise alerts.channel.failing (routed in-app only, so a broken channel can
** never loop), and a success makes it HEALTHY again.
**
** Claims: a row is marked SENDING with a 2-minute lease before it is sent.
** Two replicas that read the same row in the same instant could both send it
** (at-least-once); run the delivery process on one scheduler node to avoid
** that.
*******************************************************************************/
public class DeliveryWorker
{
private static final QLogger LOG = QLogger.getLogger(DeliveryWorker.class);
public static final String CHANNEL_FAILING_TYPE = "alerts.channel.failing";
static final List<Duration> BACKOFF = List.of(Duration.ofMinutes(1), Duration.ofMinutes(5), Duration.ofMinutes(15), Duration.ofHours(1), Duration.ofHours(4));
private static final int BATCH_LIMIT = 200;
private static final int UNHEALTHY_AFTER = 3;
private final AlertsRuntime runtime;
/*******************************************************************************
** Constructor
*******************************************************************************/
public DeliveryWorker(AlertsRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** Send everything due now. Returns how many notifications were attempted.
*******************************************************************************/
public int deliverDue() throws QException
{
return (runtime.asSystem(() ->
{
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));
Map<String, List<QRecord>> groups = new LinkedHashMap<>();
for(QRecord row : due)
{
Instant claimedUntil = row.getValueInstant("claimedUntil");
if(claimedUntil != null && claimedUntil.isAfter(now) && DeliveryStatus.SENDING.name().equals(row.getValueString("status")))
{
continue;
}
AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("status", DeliveryStatus.SENDING.name())
.withValue("claimedUntil", now.plus(Duration.ofMinutes(2))));
groups.computeIfAbsent(row.getValue("channelId") + "|" + row.getValueString("recipientUserId") + "|" + row.getValueString("kind"), key -> new ArrayList<>()).add(row);
}
int attempted = 0;
for(List<QRecord> group : groups.values())
{
attempted += sendGroup(group, now);
}
return (attempted);
}));
}
/*******************************************************************************
** Queue failed or dead notifications again ("Retry delivery").
*******************************************************************************/
public int retry(List<Serializable> notificationIds) throws QException
{
return (runtime.asSystem(() ->
{
int count = 0;
for(Serializable id : notificationIds)
{
QRecord row = AlertsDb.byId(AlertsDelivery.TABLE_NAME, id);
if(row != null && List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()).contains(row.getValueString("status")))
{
AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", DeliveryStatus.PENDING.name())
.withValue("nextAttemptAt", runtime.now()).withValue("attempts", 0).withValue("claimedUntil", null).withValue("lastError", null));
count++;
}
}
return (count);
}));
}
/*******************************************************************************
** Send a TEST message through a channel now, through the real pipeline (a
** logged notification row). Personal channels (in-app, email) go to the
** person running the test. Returns the notification row.
*******************************************************************************/
public QRecord sendTest(Integer channelId, String actorUserId) throws QException
{
return (runtime.asSystem(() ->
{
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, channelId);
if(channel == null)
{
throw (new QUserFacingException("The channel was not found."));
}
String channelType = channel.getValueString("channelType");
boolean personal = ChannelType.IN_APP.name().equals(channelType) || ChannelType.EMAIL.name().equals(channelType);
Instant now = runtime.now();
QRecord row = AlertsDb.insert(AlertsDelivery.TABLE_NAME, new QRecord()
.withValue("channelId", channelId)
.withValue("recipientUserId", personal ? actorUserId : null)
.withValue("recipientLabel", personal ? actorUserId : channel.getValueString("label"))
.withValue("kind", DeliveryKind.TEST.name())
.withValue("status", DeliveryStatus.SENDING.name())
.withValue("attempts", 0)
.withValue("nextAttemptAt", now)
.withValue("claimedUntil", now.plus(Duration.ofMinutes(2))));
sendGroup(List.of(row), now);
return (AlertsDb.byId(AlertsDelivery.TABLE_NAME, row.getValue("id")));
}));
}
/*******************************************************************************
** Render and send one group (same channel, recipient and kind).
*******************************************************************************/
private int sendGroup(List<QRecord> rows, Instant now) throws QException
{
QRecord first = rows.get(0);
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, first.getValue("channelId"));
AlertChannelInterface impl = channel == null ? null : runtime.getChannels().forType(channel.getValueString("channelType"));
if(impl == null)
{
for(QRecord row : rows)
{
finish(row, DeliveryResult.failed(channel == null ? "The channel no longer exists." : "No implementation for channel type " + channel.getValueString("channelType") + "."), now);
}
return (rows.size());
}
DeliveryKind kind = DeliveryKind.valueOf(first.getValueString("kind"));
List<AlertMessageRenderer.Item> items = new ArrayList<>();
Map<Integer, QRecord> types = new HashMap<>();
Map<Integer, QRecord> groups = new HashMap<>();
for(QRecord row : rows)
{
QRecord alert = row.getValue("alertId") == null ? testAlert(now) : AlertsDb.byId(AlertsAlert.TABLE_NAME, row.getValue("alertId"));
if(alert == null)
{
continue;
}
QRecord type = alert.getValue("alertTypeId") == null ? null : types.computeIfAbsent(alert.getValueInteger("alertTypeId"), id -> quietly(AlertsType.TABLE_NAME, id));
QRecord group = alert.getValue("groupId") == null ? null : groups.computeIfAbsent(alert.getValueInteger("groupId"), id -> quietly(AlertsGroup.TABLE_NAME, id));
DetailLevel level = DetailLevel.lowerOf(DetailLevel.valueOf(channel.getValueString("detailLevel") == null ? "MINIMAL" : channel.getValueString("detailLevel")),
type == null || type.getValueString("externalDetailCeiling") == null ? DetailLevel.STANDARD : DetailLevel.valueOf(type.getValueString("externalDetailCeiling")));
items.add(new AlertMessageRenderer.Item(alert, type == null ? "Alerts" : type.getValueString("label"), group == null ? null : group.getValueString("label"), level));
}
if(items.isEmpty())
{
for(QRecord row : rows)
{
finish(row, DeliveryResult.failed("The alert no longer exists."), now);
}
return (rows.size());
}
AlertMessage message = AlertMessageRenderer.render(items, kind, runtime.getConfig().getPublicBaseUrl(), now);
String address = null;
if(ChannelType.EMAIL.name().equals(channel.getValueString("channelType")) && first.getValueString("recipientUserId") != null)
{
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", first.getValueString("recipientUserId")));
address = preference == null ? null : preference.getValueString("emailAddress");
if(!StringUtils.hasContent(address))
{
for(QRecord row : rows)
{
finish(row, DeliveryResult.failed("The recipient has no email address in My Alert Settings."), now);
}
return (rows.size());
}
}
DeliveryResult result;
try
{
result = impl.deliver(message, channel, address, runtime.getChannels().getSecrets());
}
catch(Exception e)
{
result = DeliveryResult.retry(AlertRedactor.redact(e.getClass().getSimpleName() + ": " + e.getMessage()));
}
for(QRecord row : rows)
{
finish(row, result, now);
}
updateChannelHealth(channel, result, now);
return (rows.size());
}
/*******************************************************************************
** Record one row's outcome.
*******************************************************************************/
private void finish(QRecord row, DeliveryResult result, Instant now) throws QException
{
int attempts = (row.getValueInteger("attempts") == null ? 0 : row.getValueInteger("attempts")) + 1;
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("attempts", attempts).withValue("claimedUntil", null);
switch(result.outcome())
{
case SENT -> update.withValue("status", DeliveryStatus.SENT.name()).withValue("sentAt", now).withValue("providerMessageId", result.providerMessageId()).withValue("lastError", null);
case FAILED -> update.withValue("status", DeliveryStatus.FAILED.name()).withValue("lastError", truncate(result.error()));
case RETRY ->
{
if(attempts > BACKOFF.size())
{
update.withValue("status", DeliveryStatus.DEAD.name()).withValue("lastError", truncate(result.error()));
}
else
{
update.withValue("status", DeliveryStatus.RETRYING.name()).withValue("nextAttemptAt", now.plus(BACKOFF.get(attempts - 1))).withValue("lastError", truncate(result.error()));
}
}
default -> throw (new IllegalStateException("Unexpected outcome " + result.outcome()));
}
AlertsDb.update(AlertsDelivery.TABLE_NAME, update);
}
/*******************************************************************************
** HEALTHY on success; PROBATION, then UNHEALTHY (and an in-app alert) on
** consecutive failures.
*******************************************************************************/
private void updateChannelHealth(QRecord channel, DeliveryResult result, Instant now) throws QException
{
Integer channelId = channel.getValueInteger("id");
if(result.outcome() == DeliveryResult.Outcome.SENT)
{
boolean wasUnhealthy = !HealthStatus.HEALTHY.name().equals(channel.getValueString("healthStatus"));
AlertsDb.update(AlertsChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("healthStatus", HealthStatus.HEALTHY.name())
.withValue("consecutiveFailures", 0).withValue("lastSuccessAt", now));
if(wasUnhealthy)
{
runtime.getEngine().resolve(CHANNEL_FAILING_TYPE, "channel:" + channelId, Resolution.PRODUCER, null, "A later delivery succeeded");
}
return;
}
int failures = (channel.getValueInteger("consecutiveFailures") == null ? 0 : channel.getValueInteger("consecutiveFailures")) + 1;
String health = failures >= UNHEALTHY_AFTER ? HealthStatus.UNHEALTHY.name() : HealthStatus.PROBATION.name();
AlertsDb.update(AlertsChannel.TABLE_NAME, new QRecord().withValue("id", channelId).withValue("healthStatus", health)
.withValue("consecutiveFailures", failures).withValue("lastFailureAt", now).withValue("lastError", truncate(result.error())));
if(failures == UNHEALTHY_AFTER)
{
LOG.warn("Alerts channel is failing", logPair("channel", channel.getValueString("name")), logPair("failures", failures));
runtime.getEngine().raise(new AlertSignal(CHANNEL_FAILING_TYPE)
.withDedupKey("channel:" + channelId)
.withSeverity(AlertSeverity.ERROR)
.withTitle("Alert channel \"" + channel.getValueString("label") + "\" is failing")
.withSummary(failures + " deliveries in a row failed.")
.withDetails(result.error())
.withRecordRef(com.kingsrook.qqq.backend.core.alerts.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
}
}
/*******************************************************************************
** The sample alert a TEST message describes.
*******************************************************************************/
private static QRecord testAlert(Instant now)
{
return (new QRecord().withValue("title", "Test notification").withValue("severity", AlertSeverity.INFO.name()).withValue("summary", "If you can read this, the channel works.")
.withValue("firstRaisedAt", now));
}
/*******************************************************************************
** A row by id, or null (no exception), for caches.
*******************************************************************************/
private static QRecord quietly(String tableName, Integer id)
{
try
{
return (AlertsDb.byId(tableName, id));
}
catch(QException e)
{
return (null);
}
}
/*******************************************************************************
**
*******************************************************************************/
private static String truncate(String text)
{
return (text == null || text.length() <= 1900 ? text : text.substring(0, 1900) + "…");
}
}
@@ -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());
}
}
@@ -0,0 +1,278 @@
/*
* 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.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.email.AlertsEmail;
import com.kingsrook.qbits.alerts.email.AlertsEmailSender;
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty;
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.messaging.email.EmailContentRole;
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Email to a person (EMAIL: the address in their settings) or to a fixed
** list (EMAIL_LIST: settings {"addresses": ["ops@example.com", ...]}), sent
** through QQQ's SendMessageAction and the configured messaging provider
** (SMTP or SES). Plain text plus simple HTML, with a link to change or stop
** the emails. When qbit-email is produced in the instance, each recipient gets
** their own message through it (template alerts_alert, NOTIFICATION); the
** messaging provider is then not needed. Without either, the channel fails
** permanently and says why.
*******************************************************************************/
public class EmailChannel implements AlertChannelInterface
{
private final AlertsQBitConfig config;
private final String type;
/*******************************************************************************
** Constructor
*******************************************************************************/
public EmailChannel(AlertsQBitConfig config, ChannelType type)
{
this.config = config;
this.type = type.name();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return (type);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
{
List<String> problems = new ArrayList<>();
if(!AlertsEmail.isActive())
{
if(!StringUtils.hasContent(config.getMessagingProviderName()))
{
problems.add("Email is not configured: set messagingProviderName in the Alerts QBit config.");
}
if(!StringUtils.hasContent(config.getFromAddress()))
{
problems.add("Email has no From address: set fromAddress in the Alerts QBit config.");
}
}
if(ChannelType.EMAIL_LIST.name().equals(type) && listAddresses(channel).isEmpty())
{
problems.add("Add the list's addresses to the channel's settings: {\"addresses\": [\"ops@example.com\"]}.");
}
return (problems);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public DeliveryResult deliver(AlertMessage message, QRecord channel, String recipientAddress, AlertSecretResolver secrets)
{
List<String> problems = validate(channel, secrets);
if(!problems.isEmpty())
{
return (DeliveryResult.failed(problems.get(0)));
}
List<String> addresses = ChannelType.EMAIL_LIST.name().equals(type) ? listAddresses(channel) : (recipientAddress == null ? List.of() : List.of(recipientAddress));
if(addresses.isEmpty())
{
return (DeliveryResult.failed("No email address."));
}
if(AlertsEmail.isActive())
{
return (deliverThroughEmailQBit(message, addresses));
}
try
{
MultiParty to = new MultiParty();
for(String address : addresses)
{
to.addParty(new Party().withAddress(address).withRole(EmailPartyRole.TO));
}
new SendMessageAction().execute(new SendMessageInput()
.withMessagingProviderName(config.getMessagingProviderName())
.withFrom(new Party().withAddress(config.getFromAddress()).withRole(EmailPartyRole.FROM))
.withTo(to)
.withSubject(message.subject())
.withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody(message.text()))
.withContent(new Content().withContentRole(EmailContentRole.HTML).withBody(message.html())));
return (DeliveryResult.sent(null));
}
catch(Exception e)
{
return (DeliveryResult.retry(AlertRedactor.redact(e.getClass().getSimpleName() + ": " + e.getMessage())));
}
}
/*******************************************************************************
** One qbit-email message per address. A retry is safe: the idempotency key
** makes the already-queued ones DUPLICATE. Refused is permanent;
** suppressed (the person unsubscribed) is reported, not retried.
*******************************************************************************/
private DeliveryResult deliverThroughEmailQBit(AlertMessage message, List<String> addresses)
{
List<Map<String, Serializable>> items = new ArrayList<>();
for(AlertMessage.Entry entry : message.entries())
{
Map<String, Serializable> item = new LinkedHashMap<>();
put(item, "title", entry.title());
put(item, "severity", entry.severity());
put(item, "typeLabel", entry.typeLabel());
put(item, "group", entry.group());
put(item, "summary", entry.summary());
put(item, "itemCount", entry.itemCount());
items.add(item);
}
String base = StringUtils.hasContent(config.getPublicBaseUrl()) ? config.getPublicBaseUrl().replaceAll("/+$", "") : "";
Instant now = config.getRuntime() == null ? Instant.now() : config.getRuntime().now();
String process = AlertsProcessesMetaDataProducer.DELIVER_NOTIFICATIONS;
String problem = null;
int suppressed = 0;
Integer firstId = null;
try
{
for(String address : addresses)
{
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)
{
problem = AlertRedactor.redact(result.detail());
}
suppressed += result.status() == AlertsEmailSender.Status.SUPPRESSED ? 1 : 0;
}
}
catch(Exception e)
{
return (DeliveryResult.retry(AlertRedactor.redact(e.getClass().getSimpleName() + ": " + e.getMessage())));
}
if(problem != null)
{
return (DeliveryResult.failed("Email was refused: " + problem));
}
if(suppressed == addresses.size())
{
return (DeliveryResult.failed("Email was suppressed: the recipient has unsubscribed."));
}
return (DeliveryResult.sent(firstId == null ? null : String.valueOf(firstId)));
}
/*******************************************************************************
** 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.
*******************************************************************************/
private static void put(Map<String, Serializable> item, String name, Serializable value)
{
if(value != null)
{
item.put(name, value);
}
}
/*******************************************************************************
** The addresses in an email list channel's settings.
*******************************************************************************/
@SuppressWarnings("unchecked")
static List<String> listAddresses(QRecord channel)
{
List<String> addresses = new ArrayList<>();
String settings = channel == null ? null : channel.getValueString("settings");
if(!StringUtils.hasContent(settings))
{
return (addresses);
}
try
{
Map<String, Serializable> map = JsonUtils.toObject(settings, Map.class);
Object value = map.get("addresses");
if(value instanceof List<?> list)
{
for(Object address : list)
{
if(address != null && StringUtils.hasContent(String.valueOf(address)))
{
addresses.add(String.valueOf(address).trim());
}
}
}
}
catch(Exception e)
{
//////////////////////////////////////////////////////
// unreadable settings: no addresses (validate says) //
//////////////////////////////////////////////////////
}
return (addresses);
}
}
@@ -0,0 +1,63 @@
/*
* 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.util.List;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The bell. The router delivers in-app notifications itself, by writing the
** user's alerts_inbox_item, so there is nothing left to send here; a pending
** in-app row (a retried one, or a test) counts as sent.
*******************************************************************************/
public class InAppChannel implements AlertChannelInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return (ChannelType.IN_APP.name());
}
/*******************************************************************************
** Always available.
*******************************************************************************/
@Override
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
{
return (List.of());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public DeliveryResult deliver(AlertMessage message, QRecord channel, String recipientAddress, AlertSecretResolver secrets)
{
return (DeliveryResult.sent(null));
}
}
@@ -0,0 +1,448 @@
/*
* 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.channels;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.alerts.delivery.AlertChannelInterface;
import com.kingsrook.qbits.alerts.delivery.AlertMessage;
import com.kingsrook.qbits.alerts.delivery.AlertSecretResolver;
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** The optional external channels (design section 5.6). None is required;
** each is an admin-configured alerts_channel row whose secret is a
** reference (secretRef), resolved at send time:
** - SLACK: an Incoming Webhook URL (the URL is the secret); Block Kit body.
** - TEAMS: a Teams Workflows "when a webhook request is received" URL (the
** secret); an Adaptive Card. (Office 365 connectors are retired.)
** - WEBHOOK: settings {"url": ...}; the secret is the HMAC signing key; a
** documented JSON body signed with the Standard Webhooks headers (the
** shared format of QRun-IO/qqq#1105, see StandardWebhookSigner).
** - PAGERDUTY: the Events API v2 routing key (the secret); one
** trigger / acknowledge / resolve event per alert, dedup_key = type:key.
** - ALERTMANAGER: settings {"url": "https://alertmanager:9093"} (optional
** bearer secret); POST /api/v2/alerts with the qqq_* labels and
** qqq_origin="qbit-alerts" (the ingest loop guard); endsAt on resolve.
** No vendor SDKs: plain java.net.http and JSON.
*******************************************************************************/
public final class ExternalChannels
{
public static final String ORIGIN_LABEL = "qqq_origin";
public static final String ORIGIN_VALUE = "qbit-alerts";
static final String PAGERDUTY_URL = "https://events.pagerduty.com/v2/enqueue";
/*******************************************************************************
** Channels only.
*******************************************************************************/
private ExternalChannels()
{
}
/*******************************************************************************
** All five, for registration.
*******************************************************************************/
public static List<AlertChannelInterface> all()
{
return (List.of(new Slack(), new Teams(), new Webhook(), new PagerDuty(), new Alertmanager()));
}
/*******************************************************************************
** Slack Incoming Webhook.
*******************************************************************************/
public static class Slack implements AlertChannelInterface
{
/***************************************************************************
**
***************************************************************************/
@Override
public String getType()
{
return (ChannelType.SLACK.name());
}
/***************************************************************************
**
***************************************************************************/
@Override
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
{
return (secrets.resolve(channel.getValueString("secretRef")) == null ? List.of("Set the secret reference to the Slack Incoming Webhook URL.") : List.of());
}
/***************************************************************************
**
***************************************************************************/
@Override
public DeliveryResult deliver(AlertMessage message, QRecord channel, String recipientAddress, AlertSecretResolver secrets)
{
Map<String, Object> body = new LinkedHashMap<>();
body.put("text", message.subject());
body.put("blocks", List.of(
Map.of("type", "section", "text", Map.of("type", "mrkdwn", "text", "*" + slackEscape(message.subject()) + "*\n" + slackEscape(truncate(message.text(), 2800)))),
Map.of("type", "actions", "elements", List.of(Map.of("type", "button", "text", Map.of("type", "plain_text", "text", "Open in Alerts"), "url", message.link())))));
return (HttpChannelSupport.postJson(secrets.resolve(channel.getValueString("secretRef")), null, HttpChannelSupport.json(body), HttpChannelSupport.allowInsecure(channel)));
}
}
/*******************************************************************************
** Microsoft Teams through a Workflows webhook.
*******************************************************************************/
public static class Teams implements AlertChannelInterface
{
/***************************************************************************
**
***************************************************************************/
@Override
public String getType()
{
return (ChannelType.TEAMS.name());
}
/***************************************************************************
**
***************************************************************************/
@Override
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
{
return (secrets.resolve(channel.getValueString("secretRef")) == null ? List.of("Set the secret reference to the Teams Workflows webhook URL.") : List.of());
}
/***************************************************************************
**
***************************************************************************/
@Override
public DeliveryResult deliver(AlertMessage message, QRecord channel, String recipientAddress, AlertSecretResolver secrets)
{
Map<String, Object> card = new LinkedHashMap<>();
card.put("$schema", "http://adaptivecards.io/schemas/adaptive-card.json");
card.put("type", "AdaptiveCard");
card.put("version", "1.4");
card.put("body", List.of(
Map.of("type", "TextBlock", "text", message.subject(), "weight", "Bolder", "wrap", true),
Map.of("type", "TextBlock", "text", truncate(message.text(), 2800), "wrap", true)));
card.put("actions", List.of(Map.of("type", "Action.OpenUrl", "title", "Open in Alerts", "url", message.link())));
Map<String, Object> body = Map.of("type", "message", "attachments", List.of(Map.of("contentType", "application/vnd.microsoft.card.adaptive", "content", card)));
return (HttpChannelSupport.postJson(secrets.resolve(channel.getValueString("secretRef")), null, HttpChannelSupport.json(body), HttpChannelSupport.allowInsecure(channel)));
}
}
/*******************************************************************************
** Generic signed webhook.
*******************************************************************************/
public static class Webhook implements AlertChannelInterface
{
/***************************************************************************
**
***************************************************************************/
@Override
public String getType()
{
return (ChannelType.WEBHOOK.name());
}
/***************************************************************************
**
***************************************************************************/
@Override
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
{
List<String> problems = new ArrayList<>();
if(!StringUtils.hasContent(Objects.toString(HttpChannelSupport.settings(channel).get("url"), null)))
{
problems.add("Add the receiver's URL to the channel's settings: {\"url\": \"https://...\"}.");
}
if(secrets.resolve(channel.getValueString("secretRef")) == null)
{
problems.add("Set the secret reference to the signing key (a whsec_ secret).");
}
return (problems);
}
/***************************************************************************
**
***************************************************************************/
@Override
public DeliveryResult deliver(AlertMessage message, QRecord channel, String recipientAddress, AlertSecretResolver secrets)
{
List<String> problems = validate(channel, secrets);
if(!problems.isEmpty())
{
return (DeliveryResult.failed(problems.get(0)));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("type", "qqq.alert." + message.kind().toLowerCase(Locale.ROOT));
body.put("subject", message.subject());
body.put("severity", message.severity());
body.put("link", message.link());
body.put("alerts", message.entries());
byte[] bytes = HttpChannelSupport.json(body);
Map<String, String> headers = StandardWebhookSigner.headers(messageId(message), Instant.now(), bytes, secrets.resolve(channel.getValueString("secretRef")));
return (HttpChannelSupport.postJson(Objects.toString(HttpChannelSupport.settings(channel).get("url")), headers, bytes, HttpChannelSupport.allowInsecure(channel)));
}
}
/*******************************************************************************
** PagerDuty Events API v2.
*******************************************************************************/
public static class PagerDuty implements AlertChannelInterface
{
/***************************************************************************
**
***************************************************************************/
@Override
public String getType()
{
return (ChannelType.PAGERDUTY.name());
}
/***************************************************************************
**
***************************************************************************/
@Override
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
{
return (secrets.resolve(channel.getValueString("secretRef")) == null ? List.of("Set the secret reference to the PagerDuty Events API v2 routing key.") : List.of());
}
/***************************************************************************
** One event per alert; the first failure decides the outcome.
***************************************************************************/
@Override
public DeliveryResult deliver(AlertMessage message, QRecord channel, String recipientAddress, AlertSecretResolver secrets)
{
String routingKey = secrets.resolve(channel.getValueString("secretRef"));
if(routingKey == null)
{
return (DeliveryResult.failed(validate(channel, secrets).get(0)));
}
Map<String, Serializable> settings = HttpChannelSupport.settings(channel);
String url = Objects.toString(settings.get("url"), PAGERDUTY_URL);
String source = Objects.toString(settings.get("source"), ORIGIN_VALUE);
for(AlertMessage.Entry entry : entries(message))
{
String action = "TEST".equals(message.kind()) ? "trigger" : switch(entry.status())
{
case "RESOLVED" -> "resolve";
case "ACKNOWLEDGED" -> "acknowledge";
default -> "trigger";
};
Map<String, Object> event = new LinkedHashMap<>();
event.put("routing_key", routingKey);
event.put("event_action", action);
event.put("dedup_key", entry.stableKey());
if("trigger".equals(action))
{
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("summary", truncate(entry.title(), 1024));
payload.put("source", source);
payload.put("severity", entry.severity().toLowerCase(Locale.ROOT));
payload.put("component", entry.typeLabel());
payload.put("group", entry.group());
Map<String, Object> details = new LinkedHashMap<>();
details.put("summary", entry.summary());
details.put("records", entry.itemCount());
details.put("link", entry.link());
details.values().removeIf(Objects::isNull);
payload.put("custom_details", details);
payload.values().removeIf(Objects::isNull);
event.put("payload", payload);
event.put("links", List.of(Map.of("href", entry.link(), "text", "Open in Alerts")));
}
DeliveryResult result = HttpChannelSupport.postJson(url, null, HttpChannelSupport.json(event), HttpChannelSupport.allowInsecure(channel));
if(result.outcome() != DeliveryResult.Outcome.SENT)
{
return (result);
}
}
return (DeliveryResult.sent(null));
}
}
/*******************************************************************************
** Prometheus Alertmanager as a sink.
*******************************************************************************/
public static class Alertmanager implements AlertChannelInterface
{
/***************************************************************************
**
***************************************************************************/
@Override
public String getType()
{
return (ChannelType.ALERTMANAGER.name());
}
/***************************************************************************
**
***************************************************************************/
@Override
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
{
return (StringUtils.hasContent(Objects.toString(HttpChannelSupport.settings(channel).get("url"), null)) ? List.of()
: List.of("Add Alertmanager's base URL to the channel's settings: {\"url\": \"https://alertmanager:9093\"}."));
}
/***************************************************************************
** One POST with every alert. Open alerts get an endsAt a week out, so
** they stay active without a re-post; a resolve sends endsAt = when.
***************************************************************************/
@Override
public DeliveryResult deliver(AlertMessage message, QRecord channel, String recipientAddress, AlertSecretResolver secrets)
{
List<String> problems = validate(channel, secrets);
if(!problems.isEmpty())
{
return (DeliveryResult.failed(problems.get(0)));
}
List<Map<String, Object>> alerts = new ArrayList<>();
for(AlertMessage.Entry entry : entries(message))
{
Map<String, String> labels = new LinkedHashMap<>();
labels.put("alertname", entry.alertTypeName() == null ? "alerts.test" : entry.alertTypeName());
labels.put("severity", entry.severity().toLowerCase(Locale.ROOT));
labels.put("qqq_type", Objects.toString(entry.alertTypeName(), "alerts.test"));
labels.put("qqq_group", Objects.toString(entry.group(), ""));
labels.put("qqq_key", Objects.toString(entry.dedupKey(), ""));
labels.put(ORIGIN_LABEL, ORIGIN_VALUE);
Map<String, Object> alert = new LinkedHashMap<>();
alert.put("labels", labels);
alert.put("annotations", Map.of("summary", entry.title(), "qqq_url", entry.link()));
alert.put("generatorURL", entry.link());
if(entry.firstRaisedAt() != null)
{
alert.put("startsAt", entry.firstRaisedAt());
}
boolean resolved = "RESOLVED".equals(entry.status()) || "TEST".equals(message.kind());
alert.put("endsAt", resolved ? Objects.toString(entry.resolvedAt(), Instant.now().toString()) : Instant.now().plus(7, ChronoUnit.DAYS).toString());
alerts.add(alert);
}
String bearer = secrets.resolve(channel.getValueString("secretRef"));
Map<String, String> headers = bearer == null ? null : Map.of("Authorization", "Bearer " + bearer);
String base = Objects.toString(HttpChannelSupport.settings(channel).get("url")).replaceAll("/+$", "");
return (HttpChannelSupport.postJson(base + "/api/v2/alerts", headers, HttpChannelSupport.json(alerts), HttpChannelSupport.allowInsecure(channel)));
}
}
/*******************************************************************************
** The message's entries (a TEST message gets one sample entry).
*******************************************************************************/
static List<AlertMessage.Entry> entries(AlertMessage message)
{
if(message.entries() != null && !message.entries().isEmpty())
{
return (message.entries());
}
return (List.of(new AlertMessage.Entry(null, "alerts.test", "Test", "test", "INFO", "OPEN", message.subject(), null, null, null, message.link(), null, null)));
}
/*******************************************************************************
** A stable id for a webhook delivery (the same on every retry): a hash of
** the kind and each alert's key and state.
*******************************************************************************/
static String messageId(AlertMessage message)
{
StringBuilder content = new StringBuilder(message.kind());
for(AlertMessage.Entry entry : entries(message))
{
content.append('|').append(entry.alertId()).append(':').append(entry.status()).append(':').append(entry.firstRaisedAt()).append(':').append(entry.resolvedAt());
}
try
{
byte[] digest = MessageDigest.getInstance("SHA-256").digest(content.toString().getBytes(StandardCharsets.UTF_8));
return ("msg_" + HexFormat.of().formatHex(digest).substring(0, 32));
}
catch(Exception e)
{
return ("msg_" + Integer.toHexString(content.toString().hashCode()));
}
}
/*******************************************************************************
** Slack mrkdwn escaping (&, &lt;, &gt;).
*******************************************************************************/
static String slackEscape(String text)
{
return (text == null ? "" : text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"));
}
/*******************************************************************************
**
*******************************************************************************/
static String truncate(String text, int max)
{
return (text == null || text.length() <= max ? text : text.substring(0, max - 1) + "…");
}
}
@@ -0,0 +1,192 @@
/*
* 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.channels;
import java.io.Serializable;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Shared HTTP plumbing for the external channels: one pooled client, a 10
** second timeout, JSON bodies, and the outcome rules:
** - 2xx: SENT;
** - 408, 425, 429 and 5xx, or a network error: RETRY;
** - any other 4xx (a bad URL or key): FAILED, permanently.
** URLs must be https unless the channel's settings say "allowInsecureHttp"
** (for local receivers and tests). Error text is redacted and never includes
** the URL, which for Slack and Teams is itself the secret.
*******************************************************************************/
public final class HttpChannelSupport
{
static final Duration TIMEOUT = Duration.ofSeconds(10);
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
/*******************************************************************************
** Helpers only.
*******************************************************************************/
private HttpChannelSupport()
{
}
/*******************************************************************************
** POST a JSON body and classify the response.
*******************************************************************************/
public static DeliveryResult postJson(String url, Map<String, String> headers, byte[] body, boolean allowInsecure)
{
String problem = checkUrl(url, allowInsecure);
if(problem != null)
{
return (DeliveryResult.failed(problem));
}
try
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url))
.timeout(TIMEOUT)
.header("Content-Type", "application/json")
.header("User-Agent", "qbit-alerts")
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
if(headers != null)
{
headers.forEach(request::header);
}
HttpResponse<String> response = CLIENT.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return (classify(response.statusCode(), response.body()));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
return (DeliveryResult.retry("Interrupted while sending."));
}
catch(Exception e)
{
return (DeliveryResult.retry(AlertRedactor.redact(e.getClass().getSimpleName() + (e.getMessage() == null ? "" : ": " + stripUrl(e.getMessage(), url)))));
}
}
/*******************************************************************************
** SENT, RETRY or FAILED for an HTTP status.
*******************************************************************************/
static DeliveryResult classify(int status, String body)
{
if(status >= 200 && status < 300)
{
return (DeliveryResult.sent(null));
}
String detail = "HTTP " + status + (StringUtils.hasContent(body) ? ": " + AlertRedactor.redact(StringUtils.safeTruncate(body.trim(), 200, "…")) : "");
if(status == 408 || status == 425 || status == 429 || status >= 500)
{
return (DeliveryResult.retry(detail));
}
return (DeliveryResult.failed(detail));
}
/*******************************************************************************
** Why a URL can't be used (null when it can).
*******************************************************************************/
static String checkUrl(String url, boolean allowInsecure)
{
if(!StringUtils.hasContent(url))
{
return ("No URL is configured (check the channel's secret reference or settings).");
}
String lower = url.trim().toLowerCase(Locale.ROOT);
if(lower.startsWith("https://") || (allowInsecure && lower.startsWith("http://")))
{
return (null);
}
return ("The URL must use https.");
}
/*******************************************************************************
** The channel's settings JSON as a map (empty when blank or unreadable).
*******************************************************************************/
@SuppressWarnings("unchecked")
public static Map<String, Serializable> settings(QRecord channel)
{
String json = channel == null ? null : channel.getValueString("settings");
if(!StringUtils.hasContent(json))
{
return (new LinkedHashMap<>());
}
try
{
return (JsonUtils.toObject(json, LinkedHashMap.class));
}
catch(Exception e)
{
return (new LinkedHashMap<>());
}
}
/*******************************************************************************
** Whether the channel allows plain http (local receivers, tests).
*******************************************************************************/
public static boolean allowInsecure(QRecord channel)
{
return (Boolean.TRUE.equals(settings(channel).get("allowInsecureHttp")));
}
/*******************************************************************************
** JSON bytes.
*******************************************************************************/
public static byte[] json(Object body)
{
return (JsonUtils.toJson(body).getBytes(StandardCharsets.UTF_8));
}
/*******************************************************************************
** Remove a (secret) URL from an error message.
*******************************************************************************/
private static String stripUrl(String message, String url)
{
return (url == null ? message : message.replace(url, "[url]"));
}
}
@@ -0,0 +1,81 @@
/*
* 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.channels;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSigner;
/*******************************************************************************
** Compatibility adapter for the qbit's existing webhook secret strings.
** Core signs every request. Core requires at least 16 key bytes, whereas the
** previous signer accepted shorter keys. HMAC-SHA256 zero-pads keys to its
** 64-byte block size, so padding a short legacy key to 16 bytes preserves
** its signature exactly. New secrets should use WebhookSecret.generate().
*******************************************************************************/
public final class StandardWebhookSigner
{
/*******************************************************************************
** Signing only.
*******************************************************************************/
private StandardWebhookSigner()
{
}
/*******************************************************************************
** The three core Standard Webhooks headers, preserving legacy short keys.
*******************************************************************************/
public static Map<String, String> headers(String messageId, Instant timestamp, byte[] body, String secret)
{
return (WebhookSigner.headers(messageId, timestamp, body, List.of(parseSecret(secret))));
}
/*******************************************************************************
** Preserve raw UTF-8 and whsec_ base64 secrets, including legacy short keys.
*******************************************************************************/
private static WebhookSecret parseSecret(String secret)
{
if(secret == null || secret.isBlank())
{
throw (new IllegalArgumentException("A webhook signing secret is required"));
}
String trimmed = secret.trim();
byte[] key = trimmed.startsWith(WebhookSecret.PREFIX)
? Base64.getDecoder().decode(trimmed.substring(WebhookSecret.PREFIX.length()))
: trimmed.getBytes(StandardCharsets.UTF_8);
if(key.length == 0)
{
throw (new IllegalArgumentException("A webhook signing secret is required"));
}
if(key.length < WebhookSecret.MINIMUM_KEY_BYTES)
{
return (WebhookSecret.parse(WebhookSecret.PREFIX + Base64.getEncoder().encodeToString(Arrays.copyOf(key, WebhookSecret.MINIMUM_KEY_BYTES))));
}
return (WebhookSecret.parse(trimmed));
}
}
@@ -0,0 +1,75 @@
/*
* 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.email;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** The guard for sending through qbit-email. qbit-email is an optional
** dependency: this class (which names no qbit-email class) answers whether the
** email qbit is on the classpath AND produced in the current instance. Only
** then does the rest of the qbit call {@link AlertsEmailSender}; otherwise
** email goes through core SendMessageAction and messagingProviderName.
*******************************************************************************/
public final class AlertsEmail
{
private static final String EMAIL_API_CLASS = "com.kingsrook.qbits.email.api.EmailApi";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsEmail()
{
}
/*******************************************************************************
** Whether email goes through qbit-email in the current instance.
*******************************************************************************/
public static boolean isActive()
{
return (isActive(QContext.getQInstance()));
}
/*******************************************************************************
** Whether email goes through qbit-email in an instance.
*******************************************************************************/
public static boolean isActive(QInstance qInstance)
{
if(qInstance == null)
{
return (false);
}
try
{
Class.forName(EMAIL_API_CLASS);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
return (AlertsEmailSender.isInstalled(qInstance));
}
}
@@ -0,0 +1,345 @@
/*
* 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.email;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.List;
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.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;
/*******************************************************************************
** 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 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.
** Titles, summaries and bodies passed as variables are the existing
** redacted, external-safe text. No record labels are passed.
*******************************************************************************/
public final class AlertsEmailSender
{
public static final String SENDER_QBIT = "qbit-alerts";
public static final String TEMPLATE_ALERT = "alerts_alert";
public static final String TEMPLATE_NOTIFICATION = "alerts_notification";
public static final String TEMPLATE_DIGEST = "alerts_digest";
/*******************************************************************************
** The outcome of one send, in this qbit's own terms.
**
** @param status what qbit-email did
** @param messageId the email_message id (null when refused)
** @param detail plain reasons (refusals), or null
*******************************************************************************/
public record Result(Status status, Integer messageId, String detail)
{
}
/*******************************************************************************
** Mirrors qbit-email's send statuses.
*******************************************************************************/
public enum Status
{
QUEUED, DUPLICATE, SUPPRESSED, REFUSED
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsEmailSender()
{
}
/*******************************************************************************
** Whether the email qbit is produced in the instance.
*******************************************************************************/
static boolean isInstalled(QInstance qInstance)
{
return (EmailApi.isInstalled(qInstance));
}
/*******************************************************************************
** An alert channel email to one recipient. The key carries the message kind
** and the minute so a reminder, or an alert that reopens, is not mistaken
** for a repeat of the first email.
**
** @param to the address
** @param process the sending process name
** @param subject the rendered one-line subject
** @param severity the highest severity
** @param messageKind RAISED, RESOLVED, REMINDER, ESCALATED, DIGEST or TEST
** @param items one map per alert: title, severity, typeLabel, group, summary, itemCount
** @param link the alert (or list) in the app, when absolute
** @param settingsUrl where to change or stop the emails, when absolute
** @param alertIds the alerts in the message
** @param now the time, to tell repeats apart
*******************************************************************************/
public static Result sendAlert(String to, String process, String subject, String severity, String messageKind, List<Map<String, Serializable>> items, String link, String settingsUrl,
List<Integer> alertIds, Instant now) throws QException
{
ensure(TEMPLATE_ALERT);
List<Integer> ids = alertIds == null ? List.of() : alertIds.stream().filter(id -> id != null).toList();
Integer first = ids.isEmpty() ? null : ids.get(0);
String idPart = ids.isEmpty() ? "test" : String.join("-", ids.stream().map(String::valueOf).toList());
String key = "alerts:alert:" + idPart + ":" + hash(to) + ":" + messageKind + ":" + (now.getEpochSecond() / 60);
EmailApiRequest request = base(to, process, key)
.withTemplateName(TEMPLATE_ALERT)
.withVariable("subject", subject)
.withVariable("severity", severity)
.withVariable("kind", messageKind)
.withVariable("items", (Serializable) items);
if(first != null)
{
request.withLinkedRecord(AlertsAlert.TABLE_NAME, first);
}
withUrl(request, "link", link);
withUrl(request, "settings_url", settingsUrl);
return (result(EmailApi.send(request)));
}
/*******************************************************************************
** One user notification to one recipient.
**
** @param to the address
** @param process the sending process name
** @param notificationId the notification row
** @param item title, body (absent for SECURITY), link and linkLabel
** @param settingsUrl where to change these emails, when absolute
*******************************************************************************/
public static Result sendNotification(String to, String process, Integer notificationId, Map<String, Serializable> item, String settingsUrl) throws QException
{
ensure(TEMPLATE_NOTIFICATION);
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())
{
if("link".equals(entry.getKey()))
{
withUrl(request, "link", (String) entry.getValue());
}
else
{
request.withVariable(entry.getKey(), entry.getValue());
}
}
withUrl(request, "settings_url", settingsUrl);
return (result(EmailApi.send(request)));
}
/*******************************************************************************
** A user's digest.
**
** @param to the address
** @param process the sending process name
** @param userId the user
** @param windowStart the start of the digest window (the digest hour)
** @param firstNotificationId the notification row the message links to
** @param items one map per notification: title, body, link, linkLabel
** @param settingsUrl where to change these emails, when absolute
*******************************************************************************/
public static Result sendDigest(String to, String process, String userId, Instant windowStart, Integer firstNotificationId, List<Map<String, Serializable>> items,
String settingsUrl) throws QException
{
ensure(TEMPLATE_DIGEST);
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(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)));
}
/*******************************************************************************
** The parts every message has: one recipient, the sender and the key.
*******************************************************************************/
private static EmailApiRequest base(String to, String process, String idempotencyKey)
{
return (new EmailApiRequest().withTo(to).withCategory(EmailApiCategory.NOTIFICATION).withSender(SENDER_QBIT, process).withIdempotencyKey(idempotencyKey));
}
/*******************************************************************************
** 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(EmailApiRequest request, String name, String url)
{
if(url != null && (url.startsWith("https://") || url.startsWith("http://")))
{
request.withVariable(name, url);
}
}
/*******************************************************************************
** Translate qbit-email's result.
*******************************************************************************/
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));
}
/*******************************************************************************
** A short, stable, non-reversible id for an address (for idempotency keys,
** so no address is stored in them).
*******************************************************************************/
static String hash(String address)
{
try
{
byte[] digest = MessageDigest.getInstance("SHA-256").digest(address.trim().toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for(int i = 0; i < 8; i++)
{
hex.append(String.format("%02x", digest[i]));
}
return (hex.toString());
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException(e));
}
}
/*******************************************************************************
** Register the template when it does not exist yet (people's edits are
** never overwritten).
*******************************************************************************/
private static void ensure(String name) throws QException
{
EmailApi.ensureTemplate(switch(name)
{
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}}**
{{this.severity}} - {{this.typeLabel}}{{#if this.group}} / {{this.group}}{{/if}}
{{#if this.summary}}{{this.summary}}
{{/if}}{{#if this.itemCount}}{{this.itemCount}} records
{{/if}}{{/each}}{{#if link}}[Open in Alerts]({{link}}){.button}
{{/if}}{{#if settings_url}}[Change or stop these emails]({{settings_url}})
{{/if}}Personal data and secrets are removed from alert messages.""")
.withVariable("subject", "Subject", "STRING", true, null)
.withVariable("severity", "Highest severity", "STRING", false, null)
.withVariable("kind", "Message kind", "STRING", false, null)
.withVariable("items", "Alerts", "LIST", true, null)
.withVariable("link", "Link", "URL", false, null)
.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 EmailApiTemplate()
.withName(TEMPLATE_NOTIFICATION).withLabel("Alerts: notification").withCategory(EmailApiCategory.NOTIFICATION).withKind("MARKDOWN").withOwnerQbit(SENDER_QBIT)
.withSubject("{{title}}")
.withBody("""
{{#if body}}{{body}}
{{/if}}{{#if link}}[{{linkLabel|Open}}]({{link}}){.button}
{{/if}}{{#if settings_url}}Change these emails in [My Notification Settings]({{settings_url}}).{{/if}}""")
.withVariable("title", "Title", "STRING", true, null)
.withVariable("body", "Body", "STRING", false, null)
.withVariable("link", "Link", "URL", false, null)
.withVariable("linkLabel", "Link label", "STRING", false, null)
.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 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}}**
{{#if this.body}}{{this.body}}
{{/if}}{{#if this.link}}[{{this.linkLabel|Open}}]({{this.link}})
{{/if}}{{/each}}{{#if settings_url}}Change these emails in [My Notification Settings]({{settings_url}}).{{/if}}""")
.withVariable("count", "Notification count", "NUMBER", true, null)
.withVariable("items", "Notifications", "LIST", true, null)
.withVariable("settings_url", "Settings link", "URL", false, null)
.withSampleVariables("{\"count\":2,\"items\":[{\"title\":\"Version 2.5 is out\",\"body\":\"Dark mode everywhere.\",\"link\":\"https://example.com/app/widget\","
+ "\"linkLabel\":\"Open\"},{\"title\":\"Export failed\"}],\"settings_url\":\"https://example.com/app/alerts_notification_preference\"}");
default -> throw (new IllegalArgumentException("Unknown template " + name));
});
}
}
@@ -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.engine;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** A CODE monitor's check (design section 5.3): return every condition that
** is failing now. The engine raises them (on the monitor's type unless a
** signal names another) and resolves this monitor's open alerts whose keys
** the check did not return (a full-state diff). Checks run as the system
** user, with a timeout; a check that throws marks the monitor CHECK_FAILED.
*******************************************************************************/
public interface AlertCheckInterface
{
/***************************************************************************
** Every currently-failing condition. Keys not returned are resolved.
***************************************************************************/
List<AlertSignal> check(Context context) throws QException;
/*******************************************************************************
** What a check gets.
**
** @param monitor the alerts_monitor row
** @param alertTypeName the monitor's alert type
** @param params the definition's "params" object (may be empty)
** @param now the evaluation time
*******************************************************************************/
record Context(QRecord monitor, String alertTypeName, Map<String, Serializable> params, Instant now)
{
}
}
@@ -0,0 +1,994 @@
/*
* 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.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;
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.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsMute;
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;
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
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.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.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Routing and fan-out (design section 5.4). For a notifiable event:
** 1. collect the enabled subscriptions whose scope matches the alert, whose
** minimum severity it meets, and whose events include this kind;
** 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, 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
** ALERT notification recipient row per in-app recipient.
** In-app deliveries are complete when the recipient row is written; other
** channels are left PENDING for the delivery worker.
*******************************************************************************/
public class AlertRouter
{
private static final QLogger LOG = QLogger.getLogger(AlertRouter.class);
private static final int DUPLICATE_WINDOW_MINUTES = 10;
private final AlertsRuntime runtime;
/*******************************************************************************
** One destination for one notification.
*******************************************************************************/
record Destination(String userId, QRecord channel, Integer subscriptionId, String minSeverity, QRecord preference, String deliveryMode)
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertRouter(AlertsRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** The notification kind for a timeline event (null: not notifiable).
*******************************************************************************/
public static DeliveryKind kindFor(EventType eventType)
{
return switch(eventType)
{
case RAISED, REOPENED, SEVERITY_CHANGED, FLAPPING_STARTED -> DeliveryKind.RAISED;
case RESOLVED -> DeliveryKind.RESOLVED;
case ESCALATED -> DeliveryKind.ESCALATED;
default -> null;
};
}
/*******************************************************************************
** Fan an event out to its subscribers. Never throws: a routing failure is
** logged, and the alert itself is already recorded.
*******************************************************************************/
public void route(QRecord alert, EventType eventType, Integer eventId)
{
DeliveryKind kind = kindFor(eventType);
if(alert == null || kind == null)
{
return;
}
try
{
deliver(alert, eventType, eventId, kind, destinations(AlertTarget.of(alert),
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), kind, alert.getValue("id")));
}
catch(Exception e)
{
LOG.warn("Error routing alert notifications", e, logPair("alertId", alert.getValue("id")), logPair("eventType", eventType));
}
}
/*******************************************************************************
** 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.
*******************************************************************************/
public boolean isSuppressedByMaintenance(AlertTarget target, Instant now) throws QException
{
return (isMuted(target, now, MuteEffect.SUPPRESS_ALERTS));
}
/*******************************************************************************
** Whether an enabled mute with this effect is active now and covers the
** target.
*******************************************************************************/
boolean isMuted(AlertTarget target, Instant now, MuteEffect effect) throws QException
{
List<QRecord> mutes = AlertsDb.query(AlertsMute.TABLE_NAME, AlertsDb.eq("enabled", true)
.withCriteria("effect", QCriteriaOperator.EQUALS, effect.name())
.withCriteria("startsAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
.withCriteria("endsAt", QCriteriaOperator.GREATER_THAN, now));
Map<Integer, List<String>> groupNames = new HashMap<>();
for(QRecord mute : mutes)
{
if(scopeMatches(mute.getValueString("scopeType"), mute.getValueString("scopeValue"), mute.getValueString("scopeRecordId"), target, groupNames))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** 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, 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)
|| !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");
if(SubscriberType.CHANNEL.name().equals(subscriberType))
{
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, subscription.getValueString("deliveryMode")));
}
continue;
}
Set<String> userIds = new LinkedHashSet<>();
if(SubscriberType.TEAM.name().equals(subscriberType))
{
userIds.addAll(teamMembers(subscription.getValueInteger("teamId")));
}
else if(StringUtils.hasContent(subscription.getValueString("userId")))
{
userIds.add(subscription.getValueString("userId"));
}
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, subscription.getValueString("deliveryMode")));
}
}
}
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]);
for(RecordFollower follower : RecordFollowers.get(runtime.getQInstance(), reference, FollowEvent.ALERT))
{
String userId = follower == null ? null : follower.getUserId();
if(!StringUtils.hasContent(userId) || !canReadRecord(userId, reference))
{
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"));
}
}
}
return (new ArrayList<>(destinations.values()));
}
/*******************************************************************************
** 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) 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
{
List<QRecord> channels = new ArrayList<>();
String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds");
if(StringUtils.hasContent(overrides))
{
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())
{
/////////////////////////////////////////////////////////////////////////
// the bell stays (unless turned off); the list replaces external ones //
/////////////////////////////////////////////////////////////////////////
if(inApp)
{
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);
}
}
if(preference == null || !Boolean.FALSE.equals(preference.getValueBoolean("inAppEnabled")))
{
channels.add(runtime.getChannels().defaultChannel(ChannelType.IN_APP));
}
if(preference != null && Boolean.TRUE.equals(preference.getValueBoolean("emailEnabled")) && StringUtils.hasContent(preference.getValueString("emailAddress")))
{
channels.add(runtime.getChannels().defaultChannel(ChannelType.EMAIL));
}
return (channels);
}
/*******************************************************************************
** 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.
*******************************************************************************/
boolean scopeMatches(String scopeType, String scopeValue, String scopeRecordId, AlertTarget target, Map<Integer, List<String>> groupNames) throws QException
{
ScopeType type = scopeType == null ? ScopeType.ALL : ScopeType.valueOf(scopeType);
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 + ":")));
case RECORD -> scopeValue != null && scopeRecordId != null && target.recordKeys().contains(scopeValue + ":" + scopeRecordId);
case ATTRIBUTE -> attributeMatches(scopeValue, target.attributes());
case GROUP -> scopeValue != null && target.groupId() != null && groupNamesWithParents(target.groupId(), groupNames).contains(scopeValue);
};
}
/*******************************************************************************
** The group's name and id, and its parents', for GROUP scopes.
*******************************************************************************/
private List<String> groupNamesWithParents(Integer groupId, Map<Integer, List<String>> cache) throws QException
{
List<String> cached = cache.get(groupId);
if(cached != null)
{
return (cached);
}
List<String> names = new ArrayList<>();
Integer current = groupId;
int depth = 0;
while(current != null && depth++ < 10)
{
QRecord group = AlertsDb.byId(AlertsGroup.TABLE_NAME, current);
if(group == null)
{
break;
}
names.add(group.getValueString("name"));
names.add(String.valueOf(group.getValue("id")));
current = group.getValueInteger("parentGroupId");
}
cache.put(groupId, names);
return (names);
}
/*******************************************************************************
** "key=value" against the alert's attributes.
*******************************************************************************/
private static boolean attributeMatches(String scopeValue, Map<String, String> attributes)
{
if(scopeValue == null || attributes == null || !scopeValue.contains("="))
{
return (false);
}
String[] parts = scopeValue.split("=", 2);
return (parts[1].trim().equals(attributes.get(parts[0].trim())));
}
/*******************************************************************************
** Whether a subscription's events list includes this kind (empty: all).
*******************************************************************************/
private static boolean includesKind(String events, DeliveryKind kind)
{
if(!StringUtils.hasContent(events))
{
return (true);
}
for(String event : events.split("[,\\s]+"))
{
if(event.trim().equalsIgnoreCase(kind.name()))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
** Whether a user can see an alert (section 7.2), when that can be known:
** with a host whose authentication module builds sessions with permissions
** for a user id, the alert is read through that session's record locks.
** Otherwise (empty) the user's permissions are unknown, and the caller
** allows in-app (the bell re-checks the alert under the reader's session)
** but sends externally only alerts with no visibility permission and no
** subject table.
*******************************************************************************/
Optional<Boolean> knownVisibility(String userId, QRecord alert)
{
try
{
QSession session = automatedSession(userId);
if(session != null && CollectionUtils.nullSafeHasContents(session.getPermissions()))
{
QInstance qInstance = runtime.getQInstance();
AlertsSecurity.applyToSession(qInstance, session);
boolean[] visible = new boolean[1];
QContext.withTemporaryContext(new CapturedContext(qInstance, session), () ->
{
visible[0] = AlertsDb.byId(AlertsAlert.TABLE_NAME, alert.getValue("id")) != null;
});
return (Optional.of(visible[0]));
}
}
catch(Exception e)
{
LOG.debug("Could not build a session to check alert visibility", e, logPair("userId", userId));
}
return (Optional.empty());
}
/*******************************************************************************
** 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.
*******************************************************************************/
private boolean canReadRecord(String userId, RecordReference reference)
{
try
{
QSession session = automatedSession(userId);
if(session == null || !CollectionUtils.nullSafeHasContents(session.getPermissions()))
{
return (false);
}
QInstance instance = runtime.getQInstance();
AlertsSecurity.applyToSession(instance, session);
boolean[] readable = new boolean[1];
QContext.withTemporaryContext(new CapturedContext(instance, session), () ->
{
String primaryKey = instance.getTable(reference.tableName()).getPrimaryKeyField();
readable[0] = PermissionsHelper.hasTablePermission(new AbstractActionInput(), reference.tableName(), TablePermissionSubType.READ)
&& AlertsDb.first(reference.tableName(), AlertsDb.eq(primaryKey, reference.primaryKey())) != null;
});
return (readable[0]);
}
catch(Exception e)
{
LOG.debug("Could not verify follower source-record access", e, logPair("userId", userId));
return (false);
}
}
/*******************************************************************************
** 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
{
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
}
/*******************************************************************************
** Whether the user paused notifications (vacation) until after now.
*******************************************************************************/
public static boolean isPaused(QRecord preference, Instant now)
{
return (preference != null && preference.getValueInstant("pausedUntil") != null && preference.getValueInstant("pausedUntil").isAfter(now));
}
/*******************************************************************************
** Whether a channel has reached its hourly limit.
*******************************************************************************/
private boolean isRateLimited(QRecord channel, Instant now) throws QException
{
Integer limit = channel.getValueInteger("rateLimitPerHour");
if(limit == null || limit <= 0)
{
return (false);
}
int lastHour = AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
.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(1, ChronoUnit.HOURS)));
return (lastHour >= limit);
}
/*******************************************************************************
** The same alert, channel, recipient and kind within 10 minutes.
*******************************************************************************/
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());
}
return (AlertsDb.count(AlertsDelivery.TABLE_NAME, filter) > 0);
}
/*******************************************************************************
** The email address for a destination.
*******************************************************************************/
static String emailAddress(Destination destination)
{
return (destination.preference() == null ? null : destination.preference().getValueString("emailAddress"));
}
/*******************************************************************************
** Who a notification went to, masked (j***@example.com).
*******************************************************************************/
static String recipientLabel(Destination destination)
{
String channelType = destination.channel().getValueString("channelType");
if(destination.userId() == null)
{
return (destination.channel().getValueString("label"));
}
if(ChannelType.EMAIL.name().equals(channelType))
{
return (maskEmail(emailAddress(destination)));
}
QRecord preference = destination.preference();
String name = preference == null ? null : preference.getValueString("userLabel");
return ((StringUtils.hasContent(name) ? name : destination.userId()) + " (in app)");
}
/*******************************************************************************
** "jane@example.com" to "j***@example.com".
*******************************************************************************/
public static String maskEmail(String address)
{
if(address == null || !address.contains("@"))
{
return (address == null ? null : "***");
}
int at = address.indexOf('@');
return ((at > 0 ? address.charAt(0) : "") + "***" + address.substring(at));
}
}
@@ -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);
}
}
@@ -0,0 +1,265 @@
/*
* 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.io.Serializable;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
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.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Discovery (design section 3.3): upserts every declared alert type into
** alerts_type (installed = true, metadata fields refreshed; the admin fields
** enabled, severityOverride, groupId and notify are kept), creates missing
** groups, seeds default monitors once, and marks types no longer declared
** installed = false. Types are never deleted, so history keeps its labels.
** A signal with an undeclared type is recorded under the "unregistered"
** type, so a typo never loses a signal.
*******************************************************************************/
public class AlertTypeSync
{
private static final QLogger LOG = QLogger.getLogger(AlertTypeSync.class);
public static final String UNREGISTERED = "unregistered";
public static final String DEFAULT_GROUP = "other";
private final AlertsRuntime runtime;
private volatile boolean synced = false;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertTypeSync(AlertsRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** Sync once per runtime (the first signal, evaluation or page triggers it).
*******************************************************************************/
public void syncIfNeeded() throws QException
{
if(!synced)
{
synchronized(this)
{
if(!synced)
{
sync();
synced = true;
}
}
}
}
/*******************************************************************************
** Upsert every declared type, seed monitors, mark the rest uninstalled.
*******************************************************************************/
public void sync() throws QException
{
runtime.asSystem(() ->
{
AlertsInstanceMetaData metaData = AlertsInstanceMetaData.of(runtime.getQInstance());
Set<String> declared = new HashSet<>();
if(metaData != null)
{
for(QAlertTypeMetaData type : metaData.getAlertTypes().values())
{
upsertType(type);
declared.add(type.getName());
}
}
for(QRecord row : AlertsDb.query(AlertsType.TABLE_NAME, AlertsDb.eq("installed", true)))
{
String name = row.getValueString("name");
if(!declared.contains(name) && !UNREGISTERED.equals(name))
{
AlertsDb.update(AlertsType.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("installed", false));
}
}
LOG.info("Alert types synced", logPair("declared", declared.size()));
return (null);
});
}
/*******************************************************************************
** The alerts_type row for a signal's type: the declared one (synced on
** demand) or, for an undeclared name, the "unregistered" type. Runs in the
** caller's (system) context.
*******************************************************************************/
public QRecord typeFor(String alertTypeName) throws QException
{
QRecord row = AlertsDb.first(AlertsType.TABLE_NAME, AlertsDb.eq("name", alertTypeName));
if(row != null)
{
return (row);
}
AlertsInstanceMetaData metaData = AlertsInstanceMetaData.of(runtime.getQInstance());
QAlertTypeMetaData declared = metaData == null ? null : metaData.getAlertType(alertTypeName);
if(declared != null)
{
return (upsertType(declared));
}
LOG.warn("Alert raised with an undeclared type; recording it as unregistered", logPair("alertType", alertTypeName));
QRecord unregistered = AlertsDb.first(AlertsType.TABLE_NAME, AlertsDb.eq("name", UNREGISTERED));
if(unregistered == null)
{
unregistered = upsertType(new QAlertTypeMetaData()
.withName(UNREGISTERED)
.withLabel("Unregistered alert type")
.withSourceName("alerts")
.withGroupName(DEFAULT_GROUP)
.withDefaultSeverity(AlertSeverity.WARNING)
.withDescription("Signals whose type no installed qbit declares. The alert's Type Name says which name was used.")
.withHowToFix("Declare the type with QAlertTypeMetaData in the producing qbit, or fix the type name it raises."));
}
return (unregistered);
}
/*******************************************************************************
** Insert or refresh one type (and its group and seeded monitors).
*******************************************************************************/
QRecord upsertType(QAlertTypeMetaData type) throws QException
{
Map<String, Serializable> metadata = new LinkedHashMap<>();
metadata.put("label", StringUtils.hasContent(type.getLabel()) ? type.getLabel() : type.getName());
metadata.put("sourceName", type.getEffectiveSourceName());
metadata.put("defaultSeverity", type.getDefaultSeverity() == null ? AlertSeverity.WARNING.name() : type.getDefaultSeverity().name());
metadata.put("description", type.getDescription());
metadata.put("howToFix", type.getHowToFix());
metadata.put("subjectTableName", type.getSubjectTableName());
metadata.put("visibilityPermission", type.getVisibilityPermission());
metadata.put("installed", true);
QRecord existing = AlertsDb.first(AlertsType.TABLE_NAME, AlertsDb.eq("name", type.getName()));
QRecord row;
if(existing == null)
{
QRecord record = new QRecord()
.withValue("name", type.getName())
.withValue("groupId", ensureGroup(type.getGroupName()))
.withValue("enabled", true)
.withValue("notify", true)
.withValue("resolveMode", (type.getResolveMode() == null ? ResolveMode.PRODUCER : type.getResolveMode()).name())
.withValue("quietPeriodMinutes", type.getQuietPeriod() == null ? null : (int) type.getQuietPeriod().toMinutes())
.withValue("externalDetailCeiling", (type.getExternalDetailCeiling() == null ? DetailLevel.STANDARD : type.getExternalDetailCeiling()).name())
.withValue("openCount", 0);
metadata.forEach(record::setValue);
row = AlertsDb.insert(AlertsType.TABLE_NAME, record);
}
else
{
QRecord record = new QRecord().withValue("id", existing.getValue("id"));
metadata.forEach(record::setValue);
if(existing.getValue("groupId") == null)
{
record.setValue("groupId", ensureGroup(type.getGroupName()));
}
AlertsDb.update(AlertsType.TABLE_NAME, record);
row = AlertsDb.byId(AlertsType.TABLE_NAME, existing.getValue("id"));
}
for(AlertDefaultMonitor monitor : type.getDefaultMonitors())
{
seedMonitor(row, type, monitor);
}
return (row);
}
/*******************************************************************************
** The id of the group with this name, creating it if missing.
*******************************************************************************/
public Integer ensureGroup(String groupName) throws QException
{
String name = StringUtils.hasContent(groupName) ? groupName : DEFAULT_GROUP;
QRecord existing = AlertsDb.first(AlertsGroup.TABLE_NAME, AlertsDb.eq("name", name));
if(existing != null)
{
return (existing.getValueInteger("id"));
}
QRecord inserted = AlertsDb.insert(AlertsGroup.TABLE_NAME, new QRecord()
.withValue("name", name)
.withValue("label", name.length() <= 3 ? name.toUpperCase(java.util.Locale.ROOT) : AlertsPossibleValues.label(name.replace('-', '_').replace('.', '_').toUpperCase(java.util.Locale.ROOT))));
return (inserted.getValueInteger("id"));
}
/*******************************************************************************
** Insert a seeded monitor once (matched by name and the seeded flag).
*******************************************************************************/
void seedMonitor(QRecord typeRow, QAlertTypeMetaData type, AlertDefaultMonitor monitor) throws QException
{
String name = StringUtils.hasContent(monitor.getName()) ? monitor.getName() : type.getName();
QRecord existing = AlertsDb.first(AlertsMonitor.TABLE_NAME, AlertsDb.eq("name", name).withCriteria("seeded", QCriteriaOperator.EQUALS, true));
if(existing != null)
{
return;
}
AlertsDb.insert(AlertsMonitor.TABLE_NAME, new QRecord()
.withValue("name", name)
.withValue("alertTypeId", typeRow.getValue("id"))
.withValue("kind", monitor.getKind() == null ? null : monitor.getKind().name())
.withValue("enabled", true)
.withValue("seeded", true)
.withValue("sourceName", type.getEffectiveSourceName())
.withValue("description", "Seeded by " + (type.getEffectiveSourceName() == null ? "the alert type" : type.getEffectiveSourceName()) + " for " + type.getName() + ".")
.withValue("definition", JsonUtils.toJson(monitor.getDefinition()))
.withValue("perRecord", Boolean.TRUE.equals(monitor.getPerRecord()))
.withValue("severity", monitor.getSeverity() == null ? null : monitor.getSeverity().name())
.withValue("scheduleSeconds", monitor.getScheduleSeconds() == null ? 300 : monitor.getScheduleSeconds())
.withValue("forMinutes", monitor.getForMinutes() == null ? 0 : monitor.getForMinutes())
.withValue("consecutiveFailures", 0));
}
}
@@ -0,0 +1,256 @@
/*
* 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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
import com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Prometheus Alertmanager as a source (design section 5.7): the webhook
** receiver payload (version 4) becomes raises and resolves.
** - the alert's fingerprint is the dedup key; firing / resolved are raise /
** resolve; the severity label maps to a severity (unknown: WARNING);
** summary / description become title / summary (redacted); labels become
** attributes (bounded);
** - the type is prometheus.(alertname), created on first sight as an
** EXTERNAL type in group "infrastructure" with notifications OFF
** (Alertmanager already notifies people; turning QQQ notifications on is a
** per-type choice);
** - alerts carrying qqq_origin="qbit-alerts" (sent by this qbit's
** Alertmanager channel) are ignored, so nothing loops.
** Authentication: a bearer token compared (in constant time) with the
** configured secret reference. Ingest is off without one.
*******************************************************************************/
public class AlertmanagerIngest
{
public static final String TYPE_PREFIX = "prometheus.";
public static final String GROUP = "infrastructure";
private final AlertsRuntime runtime;
/*******************************************************************************
** What an ingest did.
**
** @param raised alerts raised (or refreshed)
** @param resolved alerts resolved
** @param ignored alerts ignored (loop guard, or no alertname)
*******************************************************************************/
public record Result(int raised, int resolved, int ignored)
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertmanagerIngest(AlertsRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** Check the bearer token: 404 when ingest is off, 401 when it doesn't match.
*******************************************************************************/
public void authenticate(String authorizationHeader) throws QException
{
String ref = runtime.getConfig().getAlertmanagerIngestTokenSecretRef();
if(!StringUtils.hasContent(ref))
{
throw (new QNotFoundException("Alertmanager ingest is not enabled."));
}
String expected = runtime.getChannels().getSecrets().resolve(ref);
String given = authorizationHeader != null && authorizationHeader.regionMatches(true, 0, "Bearer ", 0, 7) ? authorizationHeader.substring(7).trim() : null;
if(expected == null || given == null || !MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), given.getBytes(StandardCharsets.UTF_8)))
{
throw (new QAuthenticationException("Invalid ingest token."));
}
}
/*******************************************************************************
** Apply one webhook payload.
*******************************************************************************/
@SuppressWarnings("unchecked")
public Result ingest(Map<String, Object> payload) throws QException
{
Object alerts = payload == null ? null : payload.get("alerts");
if(!(alerts instanceof List<?> list))
{
throw (new QBadRequestException("Expected an Alertmanager webhook payload with an \"alerts\" list."));
}
int raised = 0;
int resolved = 0;
int ignored = 0;
for(Object item : list)
{
if(!(item instanceof Map<?, ?> alert))
{
ignored++;
continue;
}
Map<String, Object> labels = alert.get("labels") instanceof Map<?, ?> map ? (Map<String, Object>) map : Map.of();
Map<String, Object> annotations = alert.get("annotations") instanceof Map<?, ?> map ? (Map<String, Object>) map : Map.of();
String alertname = labels.get("alertname") == null ? null : String.valueOf(labels.get("alertname"));
if(alertname == null || ExternalChannels.ORIGIN_VALUE.equals(String.valueOf(labels.get(ExternalChannels.ORIGIN_LABEL))))
{
ignored++;
continue;
}
String typeName = typeName(alertname);
String fingerprint = alert.get("fingerprint") == null ? fingerprint(labels) : String.valueOf(alert.get("fingerprint"));
ensureType(typeName, alertname);
if("resolved".equalsIgnoreCase(String.valueOf(alert.get("status"))))
{
if(runtime.getEngine().resolve(typeName, fingerprint, Resolution.PRODUCER, null, "Resolved in Alertmanager") != null)
{
resolved++;
}
continue;
}
String title = firstNonBlank(annotations.get("summary"), annotations.get("description"), alertname);
AlertSignal signal = new AlertSignal(typeName)
.withDedupKey(fingerprint)
.withSeverity(AlertSeverity.fromLabel(labels.get("severity") == null ? null : String.valueOf(labels.get("severity")), AlertSeverity.WARNING))
.withTitle(AlertRedactor.redact(title))
.withSummary(AlertRedactor.redact(annotations.get("description") == null ? null : String.valueOf(annotations.get("description"))))
.withOccurredAt(parseInstant(alert.get("startsAt")));
for(Map.Entry<String, Object> label : new LinkedHashMap<>(labels).entrySet())
{
signal.withAttribute(label.getKey(), String.valueOf(label.getValue()));
}
runtime.getEngine().raise(signal);
raised++;
}
return (new Result(raised, resolved, ignored));
}
/*******************************************************************************
** Create the EXTERNAL type the first time (notifications off).
*******************************************************************************/
private void ensureType(String typeName, String alertname) throws QException
{
runtime.asSystem(() ->
{
if(AlertsDb.first(AlertsType.TABLE_NAME, AlertsDb.eq("name", typeName)) == null)
{
QRecord type = runtime.getTypeSync().upsertType(new QAlertTypeMetaData()
.withName(typeName)
.withLabel(alertname)
.withSourceName("prometheus")
.withGroupName(GROUP)
.withDefaultSeverity(AlertSeverity.WARNING)
.withResolveMode(ResolveMode.PRODUCER)
.withExternalDetailCeiling(DetailLevel.STANDARD)
.withDescription("Ingested from Prometheus Alertmanager (" + alertname + ").")
.withHowToFix("See the alert's runbook in Prometheus. Alertmanager already notifies its receivers; turn on Notify here only to route it through Alerts too."));
AlertsDb.update(AlertsType.TABLE_NAME, new QRecord().withValue("id", type.getValue("id")).withValue("notify", false));
}
return (null);
});
}
/*******************************************************************************
** "HighCPU Usage" to "prometheus.highcpu-usage".
*******************************************************************************/
public static String typeName(String alertname)
{
String slug = alertname.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9-]+", "-").replaceAll("^-+|-+$", "");
return (TYPE_PREFIX + (slug.isEmpty() ? "unnamed" : slug));
}
/*******************************************************************************
** A fingerprint from the labels when Alertmanager didn't send one.
*******************************************************************************/
static String fingerprint(Map<String, Object> labels)
{
return (Integer.toHexString(new java.util.TreeMap<>(labels).toString().hashCode()));
}
/*******************************************************************************
**
*******************************************************************************/
private static String firstNonBlank(Object... values)
{
for(Object value : values)
{
if(value != null && StringUtils.hasContent(String.valueOf(value)))
{
return (String.valueOf(value));
}
}
return (null);
}
/*******************************************************************************
** An ISO instant, or null (Alertmanager's zero time is null too).
*******************************************************************************/
private static Instant parseInstant(Object value)
{
try
{
Instant instant = value == null ? null : Instant.parse(String.valueOf(value));
return (instant == null || instant.getEpochSecond() <= 0 ? null : instant);
}
catch(Exception e)
{
return (null);
}
}
}
@@ -0,0 +1,154 @@
/*
* 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.io.Serializable;
import java.util.List;
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;
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, except
** that bookkeeping-only updates (RuntimeFields) are not record-audited.
*******************************************************************************/
public final class AlertsDb
{
/*******************************************************************************
** Helpers only.
*******************************************************************************/
private AlertsDb()
{
}
/*******************************************************************************
** Query a table.
*******************************************************************************/
public static List<QRecord> query(String tableName, QQueryFilter filter) throws QException
{
return (new QueryAction().execute(new QueryInput(tableName).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getRecords());
}
/*******************************************************************************
** The first row matching, or null.
*******************************************************************************/
public static QRecord first(String tableName, QQueryFilter filter) throws QException
{
List<QRecord> records = query(tableName, filter.withLimit(1));
return (CollectionUtils.nullSafeIsEmpty(records) ? null : records.get(0));
}
/*******************************************************************************
** The row with this id, or null.
*******************************************************************************/
public static QRecord byId(String tableName, Serializable id) throws QException
{
if(id == null)
{
return (null);
}
return (first(tableName, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, id))));
}
/*******************************************************************************
** Count rows.
*******************************************************************************/
public static int count(String tableName, QQueryFilter filter) throws QException
{
Integer count = new CountAction().execute(new CountInput(tableName).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getCount();
return (count == null ? 0 : count);
}
/*******************************************************************************
** Insert one row; throws when the insert reports an error.
*******************************************************************************/
public static QRecord insert(String tableName, QRecord record) throws QException
{
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction()));
QRecord result = inserted.get(0);
throwIfErrors(tableName, result);
return (result);
}
/*******************************************************************************
** 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
{
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);
}
/*******************************************************************************
** A filter: field equals value.
*******************************************************************************/
public static QQueryFilter eq(String field, Serializable value)
{
return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
}
/*******************************************************************************
**
*******************************************************************************/
private static void throwIfErrors(String tableName, QRecord record) throws QException
{
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
{
QStatusMessage error = record.getErrors().get(0);
throw (new QException("Could not write " + tableName + ": " + error.getMessage()));
}
}
}
@@ -0,0 +1,879 @@
/*
* 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.io.Serializable;
import java.time.Instant;
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.Objects;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsEvent;
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
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.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
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.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.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** The alert state machine (design section 5.1). Every transition writes its
** alerts_event, and notifiable events go to the router.
**
** - Raise on an open (type, dedupKey) updates that alert: lastRaisedAt,
** occurrences, record refs. A severity increase records SEVERITY_CHANGED,
** unsnoozes and notifies again; a decrease only records it.
** - Raise on a key resolved less than reopenWindowMinutes ago reopens the
** same alert; later, a new alert is created (history stays on the old one).
** - Flapping: flapThreshold open/resolve changes within flapWindowMinutes
** set flapping (one notice; further open/resolve notifications are
** suppressed until it is stable for the window).
** - Acknowledge stops reminders; snooze hides it until a time (it returns
** early when the severity rises); resolve closes it from any open state.
** - Concurrency: openDedupKey (type|key while not resolved) is unique, so an
** insert that loses a race re-reads and updates the winner.
*******************************************************************************/
public class AlertsEngine
{
private static final QLogger LOG = QLogger.getLogger(AlertsEngine.class);
private static final int REPEATED_EVENT_EVERY_MINUTES = 60;
private static final int MAX_KEY_LENGTH = 520;
private final AlertsRuntime runtime;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertsEngine(AlertsRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** The unique open key of (type, dedupKey).
*******************************************************************************/
public static String openKey(String alertTypeName, String dedupKey)
{
String key = alertTypeName + "|" + dedupKey;
return (key.length() > MAX_KEY_LENGTH ? key.substring(0, MAX_KEY_LENGTH) : key);
}
/*******************************************************************************
** Raise (or refresh) an alert from a producer signal.
*******************************************************************************/
public QRecord raise(AlertSignal signal) throws QException
{
return (raise(signal, null));
}
/*******************************************************************************
** Raise (or refresh) an alert, optionally on behalf of a monitor. Returns
** the alert row, or null when the type is disabled or a maintenance window
** suppresses it.
*******************************************************************************/
public QRecord raise(AlertSignal signal, Integer monitorId) throws QException
{
if(signal == null || !StringUtils.hasContent(signal.getAlertTypeName()))
{
return (null);
}
runtime.getTypeSync().syncIfNeeded();
return (runtime.asSystem(() ->
{
Instant now = signal.getOccurredAt() == null ? runtime.now() : signal.getOccurredAt();
QRecord type = runtime.getTypeSync().typeFor(signal.getAlertTypeName());
if(Boolean.FALSE.equals(type.getValueBoolean("enabled")))
{
return (null);
}
String dedupKey = signal.getEffectiveDedupKey();
AlertSeverity severity = effectiveSeverity(type, signal.getSeverity());
AlertTarget target = AlertTarget.of(type, signal, monitorId);
if(runtime.getRouter().isSuppressedByMaintenance(target, now))
{
LOG.info("Alert suppressed by a maintenance window", logPair("alertType", signal.getAlertTypeName()), logPair("dedupKey", dedupKey));
return (null);
}
String openKey = openKey(signal.getAlertTypeName(), dedupKey);
QRecord open = AlertsDb.first(AlertsAlert.TABLE_NAME, AlertsDb.eq("openDedupKey", openKey));
QRecord alert;
if(open != null)
{
alert = refresh(open, type, signal, severity, monitorId, now);
}
else
{
QRecord recent = recentlyResolved(signal.getAlertTypeName(), dedupKey, now);
if(recent != null)
{
alert = reopen(recent, type, signal, severity, monitorId, now, null, "Raised again within the reopen window");
}
else
{
alert = create(type, signal, dedupKey, openKey, severity, monitorId, now);
}
}
updateTypeCounts(type.getValueInteger("id"), now);
return (alert);
}));
}
/*******************************************************************************
** Resolve the open alert for (type, dedupKey), if there is one.
*******************************************************************************/
public QRecord resolve(String alertTypeName, String dedupKey, Resolution resolution, String userId, String note) throws QException
{
if(!StringUtils.hasContent(alertTypeName))
{
return (null);
}
String key = StringUtils.hasContent(dedupKey) ? dedupKey : alertTypeName;
return (runtime.asSystem(() ->
{
QRecord open = AlertsDb.first(AlertsAlert.TABLE_NAME, AlertsDb.eq("openDedupKey", openKey(alertTypeName, key)));
if(open == null)
{
return (null);
}
return (resolveAlert(open, resolution, userId, note));
}));
}
/*******************************************************************************
** Resolve an alert by id (manual Resolve, monitor clear). A no-op when it is
** already resolved.
*******************************************************************************/
public QRecord resolveById(Integer alertId, Resolution resolution, String userId, String note) throws QException
{
return (runtime.asSystem(() ->
{
QRecord alert = AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId);
if(alert == null || Status.RESOLVED.name().equals(alert.getValueString("status")))
{
return (alert);
}
return (resolveAlert(alert, resolution, userId, note));
}));
}
/*******************************************************************************
** Record a dead-man's switch beat.
*******************************************************************************/
public void heartbeat(String heartbeatKey, Instant at) throws QException
{
if(!StringUtils.hasContent(heartbeatKey))
{
return;
}
runtime.asSystem(() ->
{
Instant when = at == null ? runtime.now() : at;
QRecord existing = AlertsDb.first(AlertsHeartbeat.TABLE_NAME, AlertsDb.eq("heartbeatKey", heartbeatKey));
if(existing == null)
{
AlertsDb.insert(AlertsHeartbeat.TABLE_NAME, new QRecord().withValue("heartbeatKey", heartbeatKey).withValue("lastBeatAt", when).withValue("beatCount", 1));
}
else
{
AlertsDb.update(AlertsHeartbeat.TABLE_NAME, new QRecord().withValue("id", existing.getValue("id")).withValue("lastBeatAt", when)
.withValue("beatCount", ValueUtils.getValueAsInteger(existing.getValue("beatCount")) == null ? 1 : existing.getValueInteger("beatCount") + 1));
}
return (null);
});
}
/*******************************************************************************
** Acknowledge an open alert. Returns false when it was already
** acknowledged or is not open.
*******************************************************************************/
public boolean acknowledge(Integer alertId, String userId) throws QException
{
return (runtime.asSystem(() ->
{
QRecord alert = AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId);
if(alert == null || !Status.OPEN.name().equals(alert.getValueString("status")))
{
return (false);
}
Instant now = runtime.now();
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alertId).withValue("status", Status.ACKNOWLEDGED.name())
.withValue("acknowledgedByUserId", userId).withValue("acknowledgedAt", now));
event(alertId, EventType.ACKNOWLEDGED, userId, Status.OPEN.name(), Status.ACKNOWLEDGED.name(), null, now);
return (true);
}));
}
/*******************************************************************************
** Snooze an open or acknowledged alert until a time.
*******************************************************************************/
public boolean snooze(Integer alertId, Instant until, String userId) throws QException
{
if(until == null)
{
throw (new QUserFacingException("Choose when the snooze ends."));
}
return (runtime.asSystem(() ->
{
QRecord alert = AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId);
if(alert == null || Status.RESOLVED.name().equals(alert.getValueString("status")))
{
return (false);
}
Instant now = runtime.now();
if(!until.isAfter(now))
{
throw (new QUserFacingException("The snooze must end in the future."));
}
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alertId).withValue("status", Status.SNOOZED.name()).withValue("snoozedUntil", until));
event(alertId, EventType.SNOOZED, userId, alert.getValueString("status"), Status.SNOOZED.name(), "until " + until, now);
return (true);
}));
}
/*******************************************************************************
** Assign an alert to a user and/or a team.
*******************************************************************************/
public boolean assign(Integer alertId, String assigneeUserId, Integer teamId, String actorUserId) throws QException
{
return (runtime.asSystem(() ->
{
QRecord alert = AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId);
if(alert == null)
{
return (false);
}
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alertId).withValue("assignedUserId", assigneeUserId).withValue("assignedTeamId", teamId));
String to = assigneeUserId != null ? assigneeUserId : (teamId != null ? "team " + teamId : "nobody");
event(alertId, EventType.ASSIGNED, actorUserId, alert.getValueString("assignedUserId"), to, null, runtime.now());
return (true);
}));
}
/*******************************************************************************
** Add a comment to an alert's timeline.
*******************************************************************************/
public void comment(Integer alertId, String userId, String note) throws QException
{
if(!StringUtils.hasContent(note))
{
throw (new QUserFacingException("Write a comment."));
}
runtime.asSystem(() ->
{
event(alertId, EventType.COMMENTED, userId, null, null, note, runtime.now());
return (null);
});
}
/*******************************************************************************
** Reopen a resolved alert by hand (a new alert is created instead if its
** key is open again already).
*******************************************************************************/
public boolean reopenById(Integer alertId, String userId, String note) throws QException
{
return (runtime.asSystem(() ->
{
QRecord alert = AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId);
if(alert == null || !Status.RESOLVED.name().equals(alert.getValueString("status")))
{
return (false);
}
String openKey = openKey(alert.getValueString("alertTypeName"), alert.getValueString("dedupKey"));
if(AlertsDb.first(AlertsAlert.TABLE_NAME, AlertsDb.eq("openDedupKey", openKey)) != null)
{
return (false);
}
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
reopen(alert, type, null, AlertSeverity.valueOf(alert.getValueString("severity")), alert.getValueInteger("monitorId"), runtime.now(), userId, note);
return (true);
}));
}
/*******************************************************************************
** Periodic upkeep (run by the evaluator's tick): end expired snoozes,
** resolve quiet-period alerts, and end flapping that has calmed down.
** Returns how many alerts changed.
*******************************************************************************/
public int maintenance() throws QException
{
return (runtime.asSystem(() ->
{
Instant now = runtime.now();
int changed = 0;
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, AlertsDb.eq("status", Status.SNOOZED.name())
.withCriteria("snoozedUntil", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)))
{
unsnooze(alert, now, "The snooze ended");
changed++;
}
for(QRecord type : AlertsDb.query(AlertsType.TABLE_NAME, AlertsDb.eq("resolveMode", "QUIET_PERIOD").withCriteria("quietPeriodMinutes", QCriteriaOperator.IS_NOT_BLANK)))
{
Instant cutoff = now.minus(type.getValueInteger("quietPeriodMinutes"), ChronoUnit.MINUTES);
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, AlertsDb.eq("alertTypeId", type.getValue("id"))
.withCriteria("status", QCriteriaOperator.IN, Status.openNames())
.withCriteria("lastRaisedAt", QCriteriaOperator.LESS_THAN, cutoff)))
{
resolveAlert(alert, Resolution.QUIET_PERIOD, null, "No new occurrences for " + type.getValueInteger("quietPeriodMinutes") + " minutes");
changed++;
}
}
Instant calm = now.minus(runtime.getConfig().getFlapWindowMinutes(), ChronoUnit.MINUTES);
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, AlertsDb.eq("flapping", true)))
{
int recent = AlertsDb.count(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id"))
.withCriteria("eventType", QCriteriaOperator.IN, List.of(EventType.REOPENED.name(), EventType.RESOLVED.name()))
.withCriteria("at", QCriteriaOperator.GREATER_THAN, calm));
if(recent == 0)
{
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("flapping", false));
event(alert.getValueInteger("id"), EventType.FLAPPING_ENDED, null, null, null, "Stable for " + runtime.getConfig().getFlapWindowMinutes() + " minutes", now);
changed++;
}
}
return (changed);
}));
}
/*******************************************************************************
** Write a timeline entry; returns its id.
*******************************************************************************/
public Integer event(Integer alertId, EventType eventType, String userId, String from, String to, String note, Instant at) throws QException
{
QRecord inserted = AlertsDb.insert(AlertsEvent.TABLE_NAME, new QRecord()
.withValue("alertId", alertId)
.withValue("eventType", eventType.name())
.withValue("at", at)
.withValue("userId", userId)
.withValue("fromValue", from)
.withValue("toValue", to)
.withValue("note", note));
return (inserted.getValueInteger("id"));
}
/*******************************************************************************
** The severity to use: the admin's override, else the signal's, else the
** type's default.
*******************************************************************************/
static AlertSeverity effectiveSeverity(QRecord type, AlertSeverity signalSeverity)
{
AlertSeverity override = AlertSeverity.fromLabel(type.getValueString("severityOverride"), null);
if(override != null)
{
return (override);
}
if(signalSeverity != null)
{
return (signalSeverity);
}
return (AlertSeverity.fromLabel(type.getValueString("defaultSeverity"), AlertSeverity.WARNING));
}
/*******************************************************************************
** A new alert.
*******************************************************************************/
private QRecord create(QRecord type, AlertSignal signal, String dedupKey, String openKey, AlertSeverity severity, Integer monitorId, Instant now) throws QException
{
QRecord record = new QRecord()
.withValue("alertTypeId", type.getValue("id"))
.withValue("alertTypeName", signal.getAlertTypeName())
.withValue("monitorId", monitorId)
.withValue("groupId", type.getValue("groupId"))
.withValue("dedupKey", dedupKey)
.withValue("openDedupKey", openKey)
.withValue("status", Status.OPEN.name())
.withValue("severity", severity.name())
.withValue("firstRaisedAt", now)
.withValue("lastRaisedAt", now)
.withValue("occurrenceCount", 1)
.withValue("reopenCount", 0)
.withValue("flapping", false)
.withValue("muted", false)
.withValue("escalationLevel", 0)
.withValue("subjectTableName", type.getValueString("subjectTableName"))
.withValue("visibilityPermission", type.getValueString("visibilityPermission"))
.withValue("sourceName", type.getValueString("sourceName"));
applySignalText(record, type, signal);
QRecord alert;
try
{
alert = AlertsDb.insert(AlertsAlert.TABLE_NAME, record);
}
catch(QException e)
{
////////////////////////////////////////////////////////////////////
// another replica (or thread) opened the same key first: update //
// the winner instead (the unique openDedupKey makes this safe) //
////////////////////////////////////////////////////////////////////
QRecord winner = AlertsDb.first(AlertsAlert.TABLE_NAME, AlertsDb.eq("openDedupKey", openKey));
if(winner == null)
{
throw (e);
}
return (refresh(winner, type, signal, severity, monitorId, now));
}
Integer alertId = alert.getValueInteger("id");
syncRecordRefs(alertId, signal, monitorId != null);
Integer eventId = event(alertId, EventType.RAISED, null, null, severity.name(), signal.getTitle(), now);
QRecord fresh = AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId);
runtime.getRouter().route(fresh, EventType.RAISED, eventId);
return (fresh);
}
/*******************************************************************************
** A raise on an alert that is already open.
*******************************************************************************/
private QRecord refresh(QRecord open, QRecord type, AlertSignal signal, AlertSeverity severity, Integer monitorId, Instant now) throws QException
{
Integer alertId = open.getValueInteger("id");
AlertSeverity previous = AlertSeverity.fromLabel(open.getValueString("severity"), AlertSeverity.WARNING);
QRecord update = new QRecord()
.withValue("id", alertId)
.withValue("lastRaisedAt", now)
.withValue("occurrenceCount", (open.getValueInteger("occurrenceCount") == null ? 1 : open.getValueInteger("occurrenceCount")) + 1);
if(monitorId != null && open.getValue("monitorId") == null)
{
update.setValue("monitorId", monitorId);
}
applySignalText(update, type, signal);
boolean raisedSeverity = severity.compareTo(previous) > 0;
if(severity != previous)
{
update.setValue("severity", severity.name());
}
if(raisedSeverity && Status.SNOOZED.name().equals(open.getValueString("status")))
{
update.setValue("status", Status.OPEN.name());
update.setValue("snoozedUntil", null);
}
AlertsDb.update(AlertsAlert.TABLE_NAME, update);
syncRecordRefs(alertId, signal, monitorId != null);
if(severity != previous)
{
Integer eventId = event(alertId, EventType.SEVERITY_CHANGED, null, previous.name(), severity.name(), null, now);
if(raisedSeverity)
{
if(Status.SNOOZED.name().equals(open.getValueString("status")))
{
event(alertId, EventType.UNSNOOZED, null, Status.SNOOZED.name(), Status.OPEN.name(), "The severity rose", now);
}
runtime.getRouter().route(AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId), EventType.SEVERITY_CHANGED, eventId);
}
}
else if(shouldRecordRepeat(alertId, now))
{
event(alertId, EventType.REPEATED, null, null, String.valueOf(update.getValueInteger("occurrenceCount")), null, now);
}
return (AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId));
}
/*******************************************************************************
** Reopen a resolved alert (raised again within the window, or by hand).
*******************************************************************************/
private QRecord reopen(QRecord resolved, QRecord type, AlertSignal signal, AlertSeverity severity, Integer monitorId, Instant now, String userId, String note) throws QException
{
Integer alertId = resolved.getValueInteger("id");
QRecord update = new QRecord()
.withValue("id", alertId)
.withValue("status", Status.OPEN.name())
.withValue("openDedupKey", openKey(resolved.getValueString("alertTypeName"), resolved.getValueString("dedupKey")))
.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)
.withValue("resolution", null)
.withValue("resolutionNote", null)
.withValue("acknowledgedAt", null)
.withValue("acknowledgedByUserId", null)
.withValue("snoozedUntil", null);
if(monitorId != null)
{
update.setValue("monitorId", monitorId);
}
if(signal != null)
{
applySignalText(update, type, signal);
}
AlertsDb.update(AlertsAlert.TABLE_NAME, update);
if(signal != null)
{
syncRecordRefs(alertId, signal, monitorId != null);
}
Integer eventId = event(alertId, EventType.REOPENED, userId, Status.RESOLVED.name(), Status.OPEN.name(), note, now);
boolean flapStarted = checkFlapping(alertId, now);
if(!flapStarted)
{
runtime.getRouter().route(AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId), EventType.REOPENED, eventId);
}
return (AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId));
}
/*******************************************************************************
** Close an open alert.
*******************************************************************************/
private QRecord resolveAlert(QRecord alert, Resolution resolution, String userId, String note) throws QException
{
Integer alertId = alert.getValueInteger("id");
Instant now = runtime.now();
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord()
.withValue("id", alertId)
.withValue("status", Status.RESOLVED.name())
.withValue("openDedupKey", null)
.withValue("resolvedAt", now)
.withValue("resolvedByUserId", userId)
.withValue("resolution", resolution == null ? Resolution.MANUAL.name() : resolution.name())
.withValue("resolutionNote", note)
.withValue("snoozedUntil", null));
for(QRecord ref : AlertsDb.query(AlertsRecordRef.TABLE_NAME, AlertsDb.eq("alertId", alertId).withCriteria("active", QCriteriaOperator.EQUALS, true)))
{
AlertsDb.update(AlertsRecordRef.TABLE_NAME, new QRecord().withValue("id", ref.getValue("id")).withValue("active", false));
}
Integer eventId = event(alertId, EventType.RESOLVED, userId, alert.getValueString("status"), Status.RESOLVED.name(), note, now);
boolean flapStarted = checkFlapping(alertId, now);
if(!flapStarted)
{
runtime.getRouter().route(AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId), EventType.RESOLVED, eventId);
}
updateTypeCounts(alert.getValueInteger("alertTypeId"), null);
return (AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId));
}
/*******************************************************************************
** End a snooze.
*******************************************************************************/
private void unsnooze(QRecord alert, Instant now, String note) throws QException
{
String back = alert.getValue("acknowledgedAt") != null ? Status.ACKNOWLEDGED.name() : Status.OPEN.name();
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("status", back).withValue("snoozedUntil", null));
event(alert.getValueInteger("id"), EventType.UNSNOOZED, null, Status.SNOOZED.name(), back, note, now);
}
/*******************************************************************************
** Start flapping when the alert has changed between open and resolved
** flapThreshold times within the window. Returns true when it just
** started (the router then sends one flapping notice instead).
*******************************************************************************/
private boolean checkFlapping(Integer alertId, Instant now) throws QException
{
AlertsQBitConfig config = runtime.getConfig();
QRecord alert = AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId);
if(Boolean.TRUE.equals(alert.getValueBoolean("flapping")))
{
return (false);
}
int changes = AlertsDb.count(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alertId)
.withCriteria("eventType", QCriteriaOperator.IN, List.of(EventType.REOPENED.name(), EventType.RESOLVED.name()))
.withCriteria("at", QCriteriaOperator.GREATER_THAN, now.minus(config.getFlapWindowMinutes(), ChronoUnit.MINUTES)));
if(changes < config.getFlapThreshold())
{
return (false);
}
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alertId).withValue("flapping", true));
Integer eventId = event(alertId, EventType.FLAPPING_STARTED, null, null, null, changes + " open/resolve changes in " + config.getFlapWindowMinutes() + " minutes", now);
runtime.getRouter().route(AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId), EventType.FLAPPING_STARTED, eventId);
return (true);
}
/*******************************************************************************
** The most recent alert for (type, key) resolved within the reopen window.
*******************************************************************************/
private QRecord recentlyResolved(String alertTypeName, String dedupKey, Instant now) throws QException
{
Instant cutoff = now.minus(runtime.getConfig().getReopenWindowMinutes(), ChronoUnit.MINUTES);
return (AlertsDb.first(AlertsAlert.TABLE_NAME, AlertsDb.eq("alertTypeName", alertTypeName)
.withCriteria("dedupKey", QCriteriaOperator.EQUALS, dedupKey)
.withCriteria("status", QCriteriaOperator.EQUALS, Status.RESOLVED.name())
.withCriteria("resolvedAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, cutoff)
.withOrderBy(new QFilterOrderBy("resolvedAt", false))));
}
/*******************************************************************************
** Record a REPEATED entry at most once an hour per alert.
*******************************************************************************/
private boolean shouldRecordRepeat(Integer alertId, Instant now) throws QException
{
QRecord last = AlertsDb.first(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alertId)
.withCriteria("eventType", QCriteriaOperator.IN, List.of(EventType.REPEATED.name(), EventType.RAISED.name(), EventType.REOPENED.name()))
.withOrderBy(new QFilterOrderBy("at", false)));
return (last == null || last.getValueInstant("at") == null || last.getValueInstant("at").isBefore(now.minus(REPEATED_EVENT_EVERY_MINUTES, ChronoUnit.MINUTES)));
}
/*******************************************************************************
** Copy the signal's text, counts, links and attributes onto an alert row.
*******************************************************************************/
private void applySignalText(QRecord record, QRecord type, AlertSignal signal)
{
String title = StringUtils.hasContent(signal.getTitle()) ? signal.getTitle() : type.getValueString("label");
record.setValue("title", title);
record.setValue("summary", signal.getSummary());
record.setValue("details", signal.getDetails());
record.setValue("itemCount", signal.getEffectiveCount());
record.setValue("links", signal.getLinks().isEmpty() ? null : JsonUtils.toJson(linksAsMaps(signal.getLinks())));
record.setValue("attributes", signal.getAttributes().isEmpty() ? null : JsonUtils.toJson(signal.getAttributes()));
String subjectTable = type.getValueString("subjectTableName");
if(subjectTable != null)
{
List<AlertRecordRef> subjectRefs = signal.getRecordRefs().stream().filter(ref -> subjectTable.equals(ref.getTableName())).toList();
record.setValue("subjectRecordId", subjectRefs.size() == 1 ? String.valueOf(subjectRefs.get(0).getPrimaryKey()) : null);
}
}
/*******************************************************************************
** Links as plain maps (the filter as JSON), for storage.
*******************************************************************************/
static List<Map<String, Object>> linksAsMaps(List<AlertLink> links)
{
List<Map<String, Object>> maps = new ArrayList<>();
for(AlertLink link : links)
{
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", link.getLabel());
map.put("tableName", link.getTableName());
map.put("recordId", link.getRecordId() == null ? null : String.valueOf(link.getRecordId()));
map.put("filter", link.getFilter() == null ? null : JsonUtils.toJson(link.getFilter()));
map.put("path", link.getPath());
map.put("url", link.getUrl());
map.values().removeIf(Objects::isNull);
maps.add(map);
}
return (maps);
}
/*******************************************************************************
** Add the signal's record refs to the alert (marking them active). For a
** monitor (a full evaluation), refs it no longer returns become inactive;
** a producer's refs are merged. recordRefCount is the active count.
*******************************************************************************/
private void syncRecordRefs(Integer alertId, AlertSignal signal, boolean fullState) throws QException
{
List<QRecord> existing = AlertsDb.query(AlertsRecordRef.TABLE_NAME, AlertsDb.eq("alertId", alertId));
Map<String, QRecord> byKey = new HashMap<>();
for(QRecord ref : existing)
{
byKey.put(ref.getValueString("tableName") + ":" + ref.getValueString("recordId"), ref);
}
Set<String> current = new LinkedHashSet<>();
for(AlertRecordRef ref : signal.getRecordRefs())
{
String recordId = String.valueOf(ref.getPrimaryKey());
String key = ref.getTableName() + ":" + recordId;
QRecord row = byKey.get(key);
current.add(key);
if(row == null)
{
QRecord inserted = AlertsDb.insert(AlertsRecordRef.TABLE_NAME, new QRecord().withValue("alertId", alertId).withValue("tableName", ref.getTableName())
.withValue("recordId", recordId).withValue("label", ref.getLabel()).withValue("active", true));
byKey.put(key, inserted);
}
else if(!Boolean.TRUE.equals(row.getValueBoolean("active")) || (ref.getLabel() != null && !ref.getLabel().equals(row.getValueString("label"))))
{
AlertsDb.update(AlertsRecordRef.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("active", true).withValue("label", ref.getLabel() == null ? row.getValueString("label") : ref.getLabel()));
row.setValue("active", true);
}
}
if(fullState)
{
for(Map.Entry<String, QRecord> entry : byKey.entrySet())
{
if(!current.contains(entry.getKey()) && Boolean.TRUE.equals(entry.getValue().getValueBoolean("active")))
{
AlertsDb.update(AlertsRecordRef.TABLE_NAME, new QRecord().withValue("id", entry.getValue().getValue("id")).withValue("active", false));
entry.getValue().setValue("active", false);
}
}
}
int active = AlertsDb.count(AlertsRecordRef.TABLE_NAME, AlertsDb.eq("alertId", alertId).withCriteria("active", QCriteriaOperator.EQUALS, true));
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alertId).withValue("recordRefCount", active));
}
/*******************************************************************************
** Refresh a type's denormalized open count (and last-raised time).
*******************************************************************************/
private void updateTypeCounts(Integer typeId, Instant lastRaisedAt) throws QException
{
if(typeId == null)
{
return;
}
int open = AlertsDb.count(AlertsAlert.TABLE_NAME, AlertsDb.eq("alertTypeId", typeId).withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, Status.openNames())));
QRecord update = new QRecord().withValue("id", typeId).withValue("openCount", open);
if(lastRaisedAt != null)
{
update.setValue("lastRaisedAt", lastRaisedAt);
}
AlertsDb.update(AlertsType.TABLE_NAME, update);
}
/*******************************************************************************
** 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, String alertKey)
{
/***************************************************************************
** From a type row and a signal (before the alert exists).
***************************************************************************/
static AlertTarget of(QRecord type, AlertSignal signal, Integer monitorId)
{
Set<String> keys = new LinkedHashSet<>();
for(AlertRecordRef ref : signal.getRecordRefs())
{
keys.add(ref.getTableName() + ":" + ref.getPrimaryKey());
}
return (new AlertTarget(type.getValueInteger("id"), signal.getAlertTypeName(), type.getValueInteger("groupId"), monitorId,
type.getValueString("subjectTableName"), keys, signal.getAttributes(), openKey(signal.getAlertTypeName(), signal.getEffectiveDedupKey())));
}
/***************************************************************************
** From an alert row (its active record refs read from the table).
***************************************************************************/
public static AlertTarget of(QRecord alert) throws QException
{
Set<String> keys = new LinkedHashSet<>();
for(QRecord ref : AlertsDb.query(AlertsRecordRef.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id")).withCriteria("active", QCriteriaOperator.EQUALS, true)))
{
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))
{
try
{
Map<String, Serializable> parsed = JsonUtils.toObject(json, Map.class);
parsed.forEach((key, value) -> attributes.put(key, value == null ? null : String.valueOf(value)));
}
catch(java.io.IOException e)
{
LOG.debug("Unreadable alert attributes", e);
}
}
return (new AlertTarget(alert.getValueInteger("alertTypeId"), alert.getValueString("alertTypeName"), alert.getValueInteger("groupId"), alert.getValueInteger("monitorId"),
alert.getValueString("subjectTableName"), keys, attributes, openKey(alert.getValueString("alertTypeName"), alert.getValueString("dedupKey"))));
}
}
}
@@ -0,0 +1,318 @@
/*
* 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.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
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.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
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.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Feeds alerts and notifications from QQQ's process lifecycle events (no ESB
** needed). Never affects the process.
** - PROCESS_FAILED and process HEARTBEAT monitors: a failed run raises, a
** completed run resolves and beats the process's heartbeat key. Only
** processes some monitor watches are handled (the watched names are
** cached for a minute).
** - Job notifications (docs/NOTIFICATIONS.md 6.1): when a run started by a
** person fails, or completes with error summary lines (a partial run),
** that person gets a JOB notification linked to the run. System runs and
** the qbit's own processes are skipped. Off with notifyFailedJobs(false).
*******************************************************************************/
public class AlertsProcessListener implements ProcessLifecycleListenerInterface
{
private static final QLogger LOG = QLogger.getLogger(AlertsProcessListener.class);
private static final long CACHE_MILLIS = 60_000;
private static volatile Set<String> watched = null;
private static volatile long watchedAt = 0;
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean appliesTo(String processName)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime == null || processName == null)
{
return (false);
}
return (notifiesJobs(runtime, processName) || isWatched(runtime, processName));
}
/*******************************************************************************
** Whether a monitor watches this process.
*******************************************************************************/
private static boolean isWatched(AlertsRuntime runtime, String processName)
{
for(String pattern : watched(runtime))
{
if(processName.equals(pattern) || processName.matches(pattern.replace(".", "\\.").replace("*", ".*")))
{
return (true);
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void onProcessStarted(RunProcessInput input)
{
////////////////////////////////////////////
// noop - only outcomes matter for alerts //
////////////////////////////////////////////
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void onProcessCompleted(RunProcessInput input, RunProcessOutput output)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime == null)
{
return;
}
try
{
if(isWatched(runtime, input.getProcessName()))
{
runtime.getEngine().heartbeat(MonitorEvaluator.processHeartbeatKey(input.getProcessName()), runtime.now());
runtime.getEvaluator().processCompleted(input.getProcessName());
}
int errors = errorCount(output);
if(errors > 0 && notifiesJobs(runtime, input.getProcessName()))
{
notifyStarter(runtime, input, NotificationSeverity.WARNING, "%s finished with errors",
(errors == 1 ? "1 record" : errors + " records") + " had errors. Open the run's results for the details.");
}
}
catch(Exception e)
{
LOG.warn("Error recording a process completion for alerts", e, logPair("processName", input.getProcessName()));
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void onProcessFailed(RunProcessInput input, Exception exception)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime == null)
{
return;
}
try
{
if(isWatched(runtime, input.getProcessName()))
{
QProcessMetaData process = QContext.getQInstance().getProcess(input.getProcessName());
runtime.getEvaluator().processFailed(input.getProcessName(), process == null ? null : process.getLabel(),
exception == null ? null : exception.getClass().getSimpleName() + ": " + exception.getMessage());
}
if(notifiesJobs(runtime, input.getProcessName()))
{
String message = exception == null || exception.getMessage() == null ? "It stopped with an error." : exception.getMessage();
notifyStarter(runtime, input, NotificationSeverity.ERROR, "%s failed", StringUtils.safeTruncate(message, 1000));
}
}
catch(Exception e)
{
LOG.warn("Error recording a process failure for alerts", e, logPair("processName", input.getProcessName()));
}
}
/*******************************************************************************
** Whether runs of this process notify the person who started them.
*******************************************************************************/
private static boolean notifiesJobs(AlertsRuntime runtime, String processName)
{
return (Boolean.TRUE.equals(runtime.getConfig().getNotifyFailedJobs()) && !processName.startsWith(AlertsQBitConfig.PERMISSION_BASE_NAME));
}
/*******************************************************************************
** How many records a completed run reported as errors (its ERROR summary
** lines), or 1 when the output carries an exception.
*******************************************************************************/
public static int errorCount(RunProcessOutput output)
{
if(output == null)
{
return (0);
}
if(output.getException().isPresent())
{
return (1);
}
int errors = 0;
Object summary = output.getValues() == null ? null : output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
if(summary instanceof List<?> lines)
{
for(Object line : lines)
{
if(line instanceof ProcessSummaryLineInterface summaryLine && summaryLine.getStatus() == Status.ERROR)
{
Integer count = line instanceof ProcessSummaryLine counted ? counted.getCount() : null;
errors += count == null || count < 1 ? 1 : count;
}
}
}
return (errors);
}
/*******************************************************************************
** Send a JOB notification to the person who started the run (nothing for
** system runs or sessions without a user).
*******************************************************************************/
private static void notifyStarter(AlertsRuntime runtime, RunProcessInput input, NotificationSeverity severity, String titleFormat, String body)
{
QSession session = QContext.getQSession();
String userId = session == null || session instanceof QSystemUserSession ? null : AlertsSecurity.userId(session);
if(userId == null)
{
return;
}
QProcessMetaData process = QContext.getQInstance().getProcess(input.getProcessName());
String label = process == null || process.getLabel() == null ? input.getProcessName() : process.getLabel();
String tableName = process == null ? null : process.getTableName();
QAlerts.notify(new NotificationSignal(String.format(titleFormat, label))
.withBody(body)
.withSeverity(severity)
.withCategory(NotificationCategory.JOB)
.withSourceName("processes")
.withSourceKey("process:" + input.getProcessUUID())
.withSourceTableName(tableName)
.withLink(jobLink(runtime, input.getProcessName(), input.getProcessUUID(), tableName))
.withAudience(NotificationAudience.user(userId)));
}
/*******************************************************************************
** Where a job notification links: the configured run link pattern, else
** the process's table, else nowhere.
*******************************************************************************/
public static AlertLink jobLink(AlertsRuntime runtime, String processName, String processUUID, String tableName)
{
String pattern = runtime.getConfig().getJobRunLinkPattern();
if(StringUtils.hasContent(pattern))
{
return (AlertLink.toPath(pattern.replace("{processName}", processName).replace("{processUUID}", processUUID == null ? "" : processUUID), "Open the run"));
}
return (tableName == null ? null : AlertLink.toPath("/app/" + tableName, "Open " + tableName));
}
/*******************************************************************************
** Forget the cached names (after monitors change; tests).
*******************************************************************************/
public static void clearCache()
{
watched = null;
}
/*******************************************************************************
** Process names (or * patterns) that enabled PROCESS_FAILED or process
** HEARTBEAT monitors watch.
*******************************************************************************/
private static Set<String> watched(AlertsRuntime runtime)
{
Set<String> current = watched;
if(current != null && System.currentTimeMillis() - watchedAt < CACHE_MILLIS)
{
return (current);
}
Set<String> names = new HashSet<>();
try
{
for(QRecord monitor : runtime.asSystem(() -> AlertsDb.query(AlertsMonitor.TABLE_NAME, AlertsDb.eq("enabled", true)
.withCriteria("kind", QCriteriaOperator.IN, java.util.List.of(AlertMonitorKind.PROCESS_FAILED.name(), AlertMonitorKind.HEARTBEAT.name())))))
{
Map<String, java.io.Serializable> definition = MonitorEvaluator.definition(monitor);
String name = ValueUtils.getValueAsString(definition.get("processName"));
if(name != null)
{
names.add(name);
}
}
}
catch(Exception e)
{
LOG.debug("Could not read process monitors", e);
}
watched = names;
watchedAt = System.currentTimeMillis();
return (names);
}
}
@@ -0,0 +1,95 @@
/*
* 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 com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.context.QContext;
/*******************************************************************************
** The sink registered in the alerts meta-data: a stateless, loadable
** (no-argument) front for the installed runtime's QbitAlertsSink. With no
** runtime in the context the calls are dropped (the core SPI's caller has
** already logged them).
*******************************************************************************/
public class AlertsSinkAdapter implements AlertSinkInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public void raise(AlertSignal signal)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime != null)
{
runtime.getSink().raise(signal);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void resolve(AlertResolution resolution)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime != null)
{
runtime.getSink().resolve(resolution);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void heartbeat(String heartbeatKey, Instant at)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime != null)
{
runtime.getSink().heartbeat(heartbeatKey, at);
}
}
/*******************************************************************************
** Core notification producers share the runtime's bounded delivery queue.
*******************************************************************************/
@Override
public void notify(NotificationSignal signal)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime != null)
{
runtime.getSink().notify(signal);
}
}
}
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more