Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dae98dae82 | ||
|
|
aede554b25
|
||
|
|
567b4833fc |
No files matched your search
+68
-11
@@ -1,14 +1,71 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
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+$/
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
@@ -1,74 +0,0 @@
|
||||
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
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -4,7 +4,7 @@ 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/`.
|
||||
**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
|
||||
|
||||
@@ -25,7 +25,7 @@ Packages:
|
||||
- `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`.
|
||||
- `spi`: the stand-in for the core producer SPI.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
@@ -44,7 +44,7 @@ Packages:
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
+3
-50
@@ -4,61 +4,14 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- Under OAuth2, per-recipient checks (alert visibility for external channels, follower record access, audience sessions) never worked: they used token-less automated sessions, which OAuth2 rejects, so external sends fell back to the conservative rule. When the host declares the QQQ `alerts` service principal (`AlertsQBitConfig.withServicePrincipal`, default `alerts`), these checks run on the recipient's behalf through it: the recipient's current access intersected with the principal's. Alert recording and routing bookkeeping stay on the system session, so alerting does not depend on the identity provider being up. With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256).
|
||||
- Audit noise: every monitor run (lease claim, then run result) and every reminder check wrote a "Record was Edited" audit (about 7,000 `alerts_monitor` and 1,800 `alerts_alert` audits in 6 hours on one production host, most with no field details). Engine updates that touch only bookkeeping fields (`RuntimeFields`: monitor `claimedBy`, `claimedUntil`, `lastRunAt`, `nextRunAt`, `lastStatus`, `lastValue`, `lastError`, `lastElapsedMs`, `consecutiveFailures`, `conditionSince`; alert `timedNotificationCheckedAt`, `lastNotifiedAt`, `recordRefCount`) are written with `omitDmlAudit`. Configuration edits, raises and alert state changes are audited as before, as are UI and API edits of any field.
|
||||
- Read the QBit metadata version from the Maven-filtered artifact version so release builds report their release version.
|
||||
- Record-view child lists (e.g. a notification's Recipients) failed with "Error in JSON Serialization": the child table's meta-data includes the qbit config, and `AlertsQBitConfig.getRuntime()` points at a runtime that points back at the config, so Jackson recursed to its 1000-level nesting limit. `getRuntime()` and `getAudienceResolver()` are now `@JsonIgnore`, as in the other qbits' configs. A regression test renders and serializes every child-list widget.
|
||||
|
||||
### Added
|
||||
- Ship the full host-neutral Alerts help bundle through QQQ 4.1.0-RC.7 core registration, replacing the temporary loader. Cover owned tables, fields, actions, jobs, widgets and possible-value sources with a bundle coverage gate; support HOST overrides and hosts without help tables.
|
||||
- Starter defaults changelog `alerts-defaults.changelog.sql` (insert-only, guarded): the `in-app` and `email` channels and the `alerts` and `other` groups, equal to what the runtime creates on first use. Tested through real Liquibase on H2.
|
||||
- Help for the Alerts app and every `alerts_*` table (`help/qbit-alerts.yaml`): what the table is for and how its rows get created, shown when it is empty.
|
||||
- A Setup card first on the Alerts home (alerts admins): each configuration table, its state and the next step.
|
||||
- `AlertsTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help.
|
||||
|
||||
### Fixed
|
||||
- A group's `ownerTeamId` was stored but never read. An alert that no subscription matches (WARNING and above) now goes to the owner team of its group, or of the nearest parent group that has one.
|
||||
- A user's `defaultChannelIds` was stored but never read. It now adds the chosen external channels to the bell (unless In-App is off) for subscriptions that name none.
|
||||
- Alert types, groups and default monitors also sync on the first bell request, so those tables are not empty on a running install.
|
||||
|
||||
### Changed
|
||||
- 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)
|
||||
- a generic webhook, signed with Standard Webhooks headers through a stand-in for 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.
|
||||
- Prometheus Alertmanager ingest at `POST /qqq/v1/alerts/ingest/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
|
||||
@@ -90,5 +43,5 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- 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).
|
||||
- A stand-in for the core producer SPI (QRun-IO/qqq#1108), to be removed when that RC ships.
|
||||
- Repository scaffold: `AlertsQBitConfig` and `AlertsQBitProducer` (table prefix `alerts_`, permissions `alerts.*`), build, CI and repository standard files.
|
||||
@@ -26,96 +26,37 @@ Any qbit or app reports exceptional conditions ("a person should do something").
|
||||
- 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.
|
||||
- **Ingests Prometheus Alertmanager** at `POST /qqq/v1/alerts/ingest/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.
|
||||
- **Producers** send with `AlertsRuntime.get(qInstance).sink().notify(new NotificationSignal(...))` (core: `QAlerts.notify`). 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).
|
||||
- **People** see them in the Next UI bell and notifications page, and choose per-category settings in My Notification Settings (security notices can't be muted).
|
||||
|
||||
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).
|
||||
- QQQ 4.1 (built against `4.1.0-RC.4`).
|
||||
- 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.
|
||||
- A messaging provider, for email.
|
||||
- `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
|
||||
@@ -141,48 +82,31 @@ new AlertsQBitProducer()
|
||||
|
||||
4. **Optional: ESB and Quick Search.** `AlertsIntegrations` names the main tables and searchable fields for the host's ESB and Quick Search configuration.
|
||||
|
||||
## Starter defaults and setup
|
||||
|
||||
After the schema, include the starter defaults. Include order: the schema first, then the defaults. They are insert-only (a row is added only when its name is absent, so edits survive and nothing is ever updated), hold no secrets, and are guarded so a host without the tables skips them:
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
They create the `in-app` and `email` channels and the `alerts` and `other` groups, exactly the rows the runtime would otherwise create on first use. Everything else starts empty for a reason the table's own help gives: alert types and default monitors are declared in code by the installed qbits and synced on the first visit; teams, escalation policies and subscriptions name your people; the rest is runtime data and user content.
|
||||
|
||||
Every table has help that says what it is for and **how its rows get created**, shown on the table screen and when it is empty (`help/qbit-alerts.yaml`). Alerts admins also get a **Setup** card first on the home that lists each configuration table, whether it is set up, and the next step.
|
||||
|
||||
Routing details worth knowing: an alert that no subscription matches goes to the owner team of its group (or the nearest parent group that has one), at WARNING and above; a person's **Default Channels** (My Alert Settings) replace the built-in email choice for subscriptions that name no channels (the bell stays unless turned off). Follow-ups go to the owner team only if it was told about the raise.
|
||||
|
||||
## Raising alerts
|
||||
|
||||
Use the producer SPI from `com.kingsrook.qqq.backend.core.alerts`, released in QQQ 4.1.0-RC.5 ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)). `AlertsQBitProducer` registers the qbit's sink through core's `AlertsInstanceMetaData`, so host producers need only core:
|
||||
Until the core producer SPI ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)) is in a released QQQ RC, the SPI types are a stand-in in `com.kingsrook.qbits.alerts.spi` (see its `package-info.java`). In the meantime, call the qbit's sink directly:
|
||||
|
||||
```java
|
||||
QAlerts.raise(new AlertSignal("cdn.purge.dead")
|
||||
AlertsRuntime.get().sink().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.
|
||||
Once the RC ships, producers call `QAlerts.raise(...)` from core and need no dependency on this qbit.
|
||||
|
||||
## 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/summary` | Unread count, highest unread severity, the latest 10 notifications and sticky banners (the bell) |
|
||||
| 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. |
|
||||
| POST | `/qqq/v1/alerts/record-panel/watch` | `{"tableName": t, "recordId": id}`: a RECORD subscription for the user |
|
||||
| POST | `/qqq/v1/alerts/ingest/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
|
||||
|
||||
@@ -198,104 +122,20 @@ Each external channel is an `alerts_channel` row that an admin creates. Its secr
|
||||
|
||||
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()`.
|
||||
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). This is the shared format from the qbit-webhooks assessment (QRun-IO/qqq#1105). The signer is a stand-in until core's `WebhookSigner` (QRun-IO/qqq#1109) is released.
|
||||
- **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.
|
||||
- **Claims are best-effort.** Delivery and monitor claims use a lease, not an atomic conditional update. With several scheduler nodes, run the two scheduled processes on one node, or accept at-least-once delivery.
|
||||
- **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.
|
||||
- **Recipient visibility.** It is checked through `createAutomatedSessionForUser`. If a host's authentication module can't build a session with permissions for a user, email goes out only for alerts that have no visibility permission and no subject table. In-app delivery is always filtered by the record locks.
|
||||
- **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.
|
||||
- **Not built yet:** digests, quiet hours, reminders, Micrometer metrics, ROLE and IdP-group team resolvers, escalation, mirrored lock columns and a configurable table namespace. These are the rest of design phases P4 to P6.
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
# 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.
|
||||
@@ -240,7 +240,7 @@
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="2"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -1,11 +1,15 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. 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
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* 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,
|
||||
|
||||
+2
-17
@@ -1,14 +1,9 @@
|
||||
# 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.
|
||||
The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). This repository implements it. Phases P1 to P3 are built (see README.md); P4 to P6 remain.
|
||||
|
||||
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`.
|
||||
- **The core SPI is a stand-in** (`spi/`) until QRun-IO/qqq#1108 is released.
|
||||
- **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).
|
||||
@@ -16,13 +11,3 @@ Deviations from the design, and why:
|
||||
- **The delivery log is `alerts_delivery`** (the design calls it `alerts_notification`), and **`alerts_inbox_item` is retired**: user notifications took those roles (NOTIFICATIONS.md section 2).
|
||||
|
||||
User notifications (announcements, job failures, shares, security notices, releases and marketing), the recipient model and the Next UI notification center are designed in [NOTIFICATIONS.md](NOTIFICATIONS.md), with the owner decisions of 2026-10-13.
|
||||
|
||||
## Quiet hours and digest delivery (#27, part 1)
|
||||
|
||||
- Quiet days identify the start day of the window. Missing/invalid time zones use UTC; equal endpoints disable quiet hours. Pause and quiet hours apply only to personal push channels (email, email lists and other non-IN_APP channels), never to the in-app bell.
|
||||
- Existing delivery statuses are sufficient: pending `DIGEST` items are excluded from the ordinary worker. Changeset 21 adds window keys, a unique channel/window claim and the claim's last included item id to the standard `alerts_delivery` table, mirrored in its entity and H2 DDL.
|
||||
- Digest keys extend the documented user/window prefix with cadence and channel. This avoids collisions between hourly and daily subscriptions with the same start, different channels, and general notification digests. Channel-only digest keys use the separate `alerts:channel-digest` prefix and UTC windows; email adds a recipient hash for list channels. The unique claim is atomic even across schedulers; immediate-delivery leases retain their existing behavior.
|
||||
- Delivery has at-most-once dispatch semantics. A crash between claiming and recording a provider result cannot be resolved safely for every supported channel. Such claims fail after ten minutes and are not automatically resent. Transport errors are FAILED; late items after the frozen cutoff are SUPPRESSED/DUPLICATE. The shared email API also receives the durable key.
|
||||
- Digests summarize alerts represented by events in their window, rather than resending the complete outstanding-alert inventory on every run. Rendering uses MINIMAL detail, checks recipient visibility again (failing closed for unverifiable protected content), and rechecks pause/quiet hours for push channels only. In-app summaries are snapshots of the content visible at dispatch.
|
||||
- To bound memory, each message accepts 500 source events; overflow fails the window with an actionable error instead of silently dropping items or sending several messages. Each run scans at most 100 due candidates; result updates use pages of 200. This is an explicit v1 limit.
|
||||
- There is no feature-switch collection or DIGESTS switch in the current config. Reminders, escalation and team member sources are unchanged.
|
||||
+14
-107
@@ -23,9 +23,7 @@ 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).
|
||||
severity or as a daily digest. Slack stays a group or monitor channel only, never per user.
|
||||
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.
|
||||
@@ -90,9 +88,8 @@ everywhere. Deleting it deletes its recipient rows.
|
||||
|
||||
A child of the notification: one row per user, unique (`notificationId`, `userId`).
|
||||
|
||||
`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus`,
|
||||
`slackStatus`, `slackMessageId` (each status is NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED;
|
||||
`slackMessageId` is filled when the Slack provider reports one).
|
||||
`notificationId`, `userId`, `deliveredAt`, `readAt`, `dismissedAt`, `emailedAt`, `emailStatus`
|
||||
(NONE / PENDING / SENT / DIGESTED / SKIPPED / FAILED).
|
||||
|
||||
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.
|
||||
@@ -101,62 +98,10 @@ editable; inserts and deletes are the qbit's.
|
||||
|
||||
One row per user and category, unique (`userId`, `category`); a missing row means the defaults.
|
||||
|
||||
`userId`, `category`, `inAppMuted` (boolean; refused for SECURITY), `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
|
||||
`userId`, `category`, `inAppMuted` (boolean; refused for SECURITY), `emailMode` (OFF / IMMEDIATE /
|
||||
DAILY_DIGEST, default OFF), `emailMinSeverity` (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:
|
||||
@@ -193,9 +138,8 @@ 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:
|
||||
It extends the core alerts SPI (`com.kingsrook.qqq.backend.core.alerts`) through a core PR to qqq
|
||||
`develop`, for the next 4.1 RC:
|
||||
|
||||
```java
|
||||
QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
@@ -207,26 +151,13 @@ QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
.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.
|
||||
- `AlertSinkInterface` gets a default method, `notify(NotificationSignal)`, that logs; existing sinks keep
|
||||
working. `LoggingAlertSink` logs at INFO. 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.
|
||||
- Until that RC ships, the qbit carries the same classes in its `spi` stand-in package, and hosts call
|
||||
`AlertsRuntime.get(qInstance).sink().notify(...)`.
|
||||
|
||||
### 6.1 Automatic sources
|
||||
|
||||
@@ -246,20 +177,7 @@ QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
sends from the delivery job. DAILY_DIGEST sends one email per user at their digest hour, listing their
|
||||
unread notifications since the last digest. A muted category is skipped. Email never includes a body
|
||||
for SECURITY beyond the title and a link.
|
||||
- **Slack** (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.
|
||||
- **Slack** stays a channel for alert groups and monitors only.
|
||||
|
||||
## 8. Next UI bell and page
|
||||
|
||||
@@ -280,7 +198,7 @@ Gating is unchanged from #85: no bell and zero requests unless `metaData.apps.al
|
||||
|
||||
| Method | Path | Body or query | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/inbox/summary` | | `{unreadCount, highestUnreadSeverity, items[≤10], banners[], pollSeconds, notificationsHref, inboxHref, alertsHref, preferencesSection}` |
|
||||
| GET | `/inbox/summary` | | `{unreadCount, highestUnreadSeverity, items[≤10], banners[], pollSeconds, notificationsHref}` |
|
||||
| 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}` |
|
||||
@@ -290,9 +208,6 @@ An item is `{id, notificationId, title, body, severity, category, source, href,
|
||||
deliveredAt, age, unread, sticky}`. `id` is the recipient row id. `body` appears only in `/inbox` and in
|
||||
banners. `highestUnreadSeverity` is one of the five severities. Every call works in the caller's
|
||||
session, so the record locks apply.
|
||||
The alerts extension is version `1.1.0`. `inboxHref` retains the canonical inbox link.
|
||||
`preferencesSection` is `"notifications"` when that section is registered, otherwise JSON null.
|
||||
Clients can use it for the bell's settings shortcut to the core Preferences section.
|
||||
|
||||
## 9. Security
|
||||
|
||||
@@ -316,7 +231,7 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no
|
||||
|---|---|
|
||||
| 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 |
|
||||
| c | Producer SPI (core PR plus the stand-in), job failures |
|
||||
| d | Security notices, shares, release and marketing producers |
|
||||
| e | Email: immediate and daily digest |
|
||||
|
||||
@@ -330,11 +245,3 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no
|
||||
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
@@ -1,151 +0,0 @@
|
||||
# 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.
|
||||
@@ -18,22 +18,16 @@
|
||||
<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>
|
||||
<revision>0.1.0-RC.2</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>
|
||||
<version>2.0.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -86,15 +80,6 @@
|
||||
<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>
|
||||
@@ -106,39 +91,7 @@
|
||||
<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>
|
||||
@@ -153,62 +106,7 @@
|
||||
</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>
|
||||
@@ -260,14 +158,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.4 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>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
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;
|
||||
@@ -28,12 +26,7 @@ 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;
|
||||
|
||||
@@ -49,9 +42,6 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
*******************************************************************************/
|
||||
public final class AlertsPreferences
|
||||
{
|
||||
/** marks the bootstrap rule's subscription (createdByUserId). */
|
||||
static final String BOOTSTRAP_CREATOR = "alerts (bootstrap rule)";
|
||||
|
||||
private static final Set<String> ENSURED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
|
||||
@@ -79,72 +69,36 @@ public final class AlertsPreferences
|
||||
|
||||
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)
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
if(ENSURED.contains(userId))
|
||||
if(AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
|
||||
{
|
||||
return;
|
||||
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));
|
||||
}
|
||||
runtime.asSystem(() ->
|
||||
|
||||
if(admin && runtime.getConfig().getBootstrapMinSeverity() != null && AlertsDb.first(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
|
||||
{
|
||||
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()));
|
||||
QRecord inApp = runtime.getChannels().defaultChannel(ChannelType.IN_APP);
|
||||
AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
|
||||
.withValue("subscriberType", SubscriberType.USER.name())
|
||||
.withValue("userId", userId)
|
||||
.withValue("scopeType", ScopeType.ALL.name())
|
||||
.withValue("minSeverity", runtime.getConfig().getBootstrapMinSeverity().name())
|
||||
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
|
||||
.withValue("deliveryMode", DeliveryMode.IMMEDIATE.name())
|
||||
.withValue("channelOverrideIds", String.valueOf(inApp.getValue("id")))
|
||||
.withValue("enabled", true)
|
||||
.withValue("createdByUserId", "alerts (bootstrap rule)"));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
ENSURED.add(userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,12 +19,9 @@ 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;
|
||||
@@ -39,7 +36,7 @@ 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.qbits.alerts.spi.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;
|
||||
@@ -77,14 +74,10 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
|
||||
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;
|
||||
@@ -102,7 +95,6 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
|
||||
private String alertmanagerIngestTokenSecretRef;
|
||||
|
||||
private Boolean preferencesSectionEnabled = true;
|
||||
private Boolean registerRoutes = true;
|
||||
private Boolean synchronousSink = false;
|
||||
|
||||
@@ -112,34 +104,10 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
private Boolean notifyFailedJobs = true;
|
||||
private String jobRunLinkPattern;
|
||||
|
||||
private transient AlertAudienceResolver audienceResolver;
|
||||
|
||||
private transient AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional host resolver for ROLE and IDP_GROUP team members.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public AlertAudienceResolver getAudienceResolver()
|
||||
{
|
||||
return (audienceResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the host resolver before producing the qbit's metadata.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withAudienceResolver(AlertAudienceResolver audienceResolver)
|
||||
{
|
||||
this.audienceResolver = audienceResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend must be named and exist; numbers must be positive.
|
||||
*******************************************************************************/
|
||||
@@ -183,7 +151,7 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
*******************************************************************************/
|
||||
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,
|
||||
return (List.of(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));
|
||||
@@ -346,29 +314,6 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackProviderName
|
||||
*******************************************************************************/
|
||||
public String getSlackProviderName()
|
||||
{
|
||||
return (this.slackProviderName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackProviderName: the core messaging provider that
|
||||
** sends per-user Slack direct messages (default "slack", registered by
|
||||
** qbit-slack). Without a provider of that name Slack is skipped.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withSlackProviderName(String slackProviderName)
|
||||
{
|
||||
this.slackProviderName = slackProviderName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fromAddress
|
||||
*******************************************************************************/
|
||||
@@ -665,7 +610,7 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
** bearer token Alertmanager sends to POST /qqq/v1/alerts/ingest/alertmanager.
|
||||
** Null (the default) turns ingest off.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withAlertmanagerIngestTokenSecretRef(String alertmanagerIngestTokenSecretRef)
|
||||
@@ -677,11 +622,8 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
** Getter for runtime (built by the producer)
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public AlertsRuntime getRuntime()
|
||||
{
|
||||
return (this.runtime);
|
||||
@@ -851,73 +793,4 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,8 @@
|
||||
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;
|
||||
@@ -30,17 +27,16 @@ 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.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.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;
|
||||
@@ -69,7 +65,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-alerts";
|
||||
public static final String VERSION = loadVersion();
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private static final String JAVALIN_CLASS = "com.kingsrook.qqq.middleware.javalin.QJavalinMetaData";
|
||||
|
||||
@@ -78,34 +74,6 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = AlertsQBitProducer.class.getClassLoader().getResourceAsStream("qbit-alerts.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing qbit-alerts.properties; build with Maven to generate the artifact version"));
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw (new IllegalStateException("Missing or invalid version in qbit-alerts.properties; build with Maven resource filtering enabled"));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not read artifact version from qbit-alerts.properties", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
@@ -205,8 +173,6 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
// Registration is metadata-only; core guards startup sync when helpContent is absent.
|
||||
HelpBundles.register(qInstance, "help/qbit-alerts.yaml");
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
@@ -231,7 +197,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
AlertsRuntime previous = qBitConfig.getRuntime();
|
||||
if(previous != null)
|
||||
{
|
||||
previous.getSink().stop();
|
||||
previous.sink().stop();
|
||||
}
|
||||
AlertsRuntime runtime = new AlertsRuntime(qBitConfig, qInstance);
|
||||
qBitConfig.setRuntime(runtime);
|
||||
@@ -243,15 +209,9 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
qInstance.withProcessLifecycleListener(new QCodeReference(AlertsProcessListener.class));
|
||||
}
|
||||
|
||||
if(Boolean.TRUE.equals(qBitConfig.getPreferencesSectionEnabled()) && isPreferencesAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.alerts.preferences.AlertsNotificationPreferencesProvider.register(qInstance, qBitConfig);
|
||||
}
|
||||
|
||||
boolean routes = false;
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.alerts.api.AlertsUiApi.register(qInstance);
|
||||
com.kingsrook.qbits.alerts.api.AlertsRouteProvider.register(qInstance);
|
||||
routes = true;
|
||||
}
|
||||
@@ -329,32 +289,4 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Older core versions can run without the optional preferences integration.
|
||||
*******************************************************************************/
|
||||
private static boolean isPreferencesAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData", false, AlertsQBitProducer.class.getClassLoader());
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bell shortcut is present only when the section is registered.
|
||||
*******************************************************************************/
|
||||
public static String preferencesSectionName(QInstance instance)
|
||||
{
|
||||
return (isPreferencesAvailable() && com.kingsrook.qbits.alerts.preferences.AlertsNotificationPreferencesProvider.isRegistered(instance) ? "notifications" : null);
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ public class AlertsRuntime
|
||||
public <R> R asSystem(SystemWork<R> work) throws QException
|
||||
{
|
||||
Object[] result = new Object[1];
|
||||
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession(qInstance)), () ->
|
||||
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession()), () ->
|
||||
{
|
||||
result[0] = work.run();
|
||||
});
|
||||
@@ -276,10 +276,10 @@ public class AlertsRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime sink for adapter delegation and queue lifecycle management.
|
||||
** Producers use core QAlerts for both alerts and notifications.
|
||||
** The sink producers send signals to (until the core SPI ships, hosts call
|
||||
** it directly: AlertsRuntime.get().sink().raise(...)).
|
||||
*******************************************************************************/
|
||||
public QbitAlertsSink getSink()
|
||||
public QbitAlertsSink sink()
|
||||
{
|
||||
return (this.sink);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ 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;
|
||||
@@ -35,9 +34,6 @@ 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;
|
||||
|
||||
|
||||
@@ -282,19 +278,7 @@ public final class AlertsSecurity
|
||||
*******************************************************************************/
|
||||
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);
|
||||
QSystemUserSession session = new QSystemUserSession();
|
||||
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
|
||||
session.withSecurityKeyValue(KEY_PERMISSION_ALL, true);
|
||||
session.withSecurityKeyValue(KEY_READABLE_TABLE_ALL, true);
|
||||
@@ -311,25 +295,4 @@ public final class AlertsSecurity
|
||||
{
|
||||
return (QContext.getQSession() instanceof QSystemUserSession);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A session for a recipient: on their behalf through the alerts service
|
||||
** principal when the host declares it (QQQ service identities), else the
|
||||
** authentication module's automated session (previous behavior). Null when
|
||||
** neither can be made - callers fail closed.
|
||||
*******************************************************************************/
|
||||
public static QSession recipientSession(QInstance qInstance, String principalName, String userId) throws QException
|
||||
{
|
||||
if(qInstance == null || qInstance.getAuthentication() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(StringUtils.hasContent(principalName) && ServiceIdentities.isDeclared(qInstance, principalName))
|
||||
{
|
||||
return (ServiceIdentities.sessionOnBehalfOf(qInstance, principalName, userId));
|
||||
}
|
||||
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
|
||||
return (module.createAutomatedSessionForUser(qInstance, userId));
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ 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;
|
||||
@@ -42,30 +41,31 @@ 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.AlertsPossibleValues.SubscriberType;
|
||||
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.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
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.InsertAction;
|
||||
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.insert.InsertInput;
|
||||
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.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -77,8 +77,6 @@ import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExten
|
||||
*******************************************************************************/
|
||||
public class AlertsApi
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsApi.class);
|
||||
|
||||
static final int INBOX_ITEMS = 10;
|
||||
static final int MAX_BANNERS = 3;
|
||||
static final int SCAN_LIMIT = 1000;
|
||||
@@ -106,9 +104,7 @@ public class AlertsApi
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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).
|
||||
** Check alerts.view and set up the session (keys, settings).
|
||||
*******************************************************************************/
|
||||
public String requireViewer() throws QException
|
||||
{
|
||||
@@ -119,17 +115,6 @@ public class AlertsApi
|
||||
}
|
||||
AlertsSecurity.ensureSessionKeys(runtime.getQInstance(), session);
|
||||
AlertsPreferences.ensure(runtime, session);
|
||||
try
|
||||
{
|
||||
runtime.getTypeSync().syncIfNeeded();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// best effort: the bell must work even if the sync fails; next call retries //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
LOG.warn("Alert type sync failed on the first visit; will retry on the next request", e);
|
||||
}
|
||||
return (AlertsSecurity.userId(session));
|
||||
}
|
||||
|
||||
@@ -154,7 +139,7 @@ public class AlertsApi
|
||||
if(entry.unread())
|
||||
{
|
||||
unread++;
|
||||
NotificationSeverity severity = NotificationService.severityOf(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
NotificationSeverity severity = NotificationSeverity.fromName(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
highest = highest == null || severity.compareTo(highest) > 0 ? severity : highest;
|
||||
}
|
||||
if(latest.size() < INBOX_ITEMS)
|
||||
@@ -173,8 +158,6 @@ public class AlertsApi
|
||||
result.put("items", latest);
|
||||
result.put("banners", banners);
|
||||
result.put("notificationsHref", NOTIFICATIONS_HREF);
|
||||
result.put("preferencesSection", AlertsQBitProducer.preferencesSectionName(QContext.getQInstance()));
|
||||
result.put("inboxHref", NOTIFICATIONS_HREF);
|
||||
result.put("alertsHref", "/app/" + AlertsQBitConfig.APP_NAME);
|
||||
result.put("pollSeconds", POLL_SECONDS);
|
||||
return (result);
|
||||
@@ -408,11 +391,6 @@ public class AlertsApi
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -604,13 +582,46 @@ public class AlertsApi
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Watch alerts for this record": a RECORD-scope subscription for the user.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> watchRecord(Map<String, Object> body) throws QException
|
||||
{
|
||||
String userId = requireViewer();
|
||||
String tableName = body == null ? null : (String) body.get("tableName");
|
||||
Object recordId = body == null ? null : body.get("recordId");
|
||||
if(userId == null || !StringUtils.hasContent(tableName) || recordId == null || runtime.getQInstance().getTable(tableName) == null)
|
||||
{
|
||||
throw (new QBadRequestException("Send a table name and record id."));
|
||||
}
|
||||
if(isWatching(tableName, String.valueOf(recordId)))
|
||||
{
|
||||
return (Map.of("watching", true, "created", false));
|
||||
}
|
||||
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(AlertsSubscription.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("subscriberType", SubscriberType.USER.name())
|
||||
.withValue("userId", userId)
|
||||
.withValue("scopeType", ScopeType.RECORD.name())
|
||||
.withValue("scopeValue", tableName)
|
||||
.withValue("scopeRecordId", String.valueOf(recordId))
|
||||
.withValue("minSeverity", AlertSeverity.WARNING.name())
|
||||
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
|
||||
.withValue("deliveryMode", "IMMEDIATE")
|
||||
.withValue("enabled", true)));
|
||||
if(CollectionUtils.nullSafeHasContents(inserted.get(0).getErrors()))
|
||||
{
|
||||
throw (new QBadRequestException(inserted.get(0).getErrors().get(0).getMessage()));
|
||||
}
|
||||
return (Map.of("watching", true, "created", true, "subscriptionId", inserted.get(0).getValue("id")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the user already has a RECORD subscription for this record.
|
||||
*******************************************************************************/
|
||||
@@ -643,32 +654,11 @@ public class AlertsApi
|
||||
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).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
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;
|
||||
@@ -30,20 +30,32 @@ 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 com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
|
||||
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.
|
||||
** Javalin routes under /qqq/v1/alerts for the Next UI bell and record panel:
|
||||
** - GET /inbox/summary unread count, highest unread severity, latest 10,
|
||||
** sticky banners
|
||||
** - GET /inbox the notifications page (status, category,
|
||||
** severity, offset, limit)
|
||||
** - POST /inbox/read {"id": n}, {"ids": [..]}, {"all": true} or {"alertId": n}
|
||||
** - POST /inbox/unread {"id": n} or {"ids": [..]}
|
||||
** - POST /inbox/dismiss {"id": n}, {"ids": [..]} or {"all": true}
|
||||
** - GET /record-panel?tableName=t&recordId=id the "Open alerts" panel
|
||||
** - POST /record-panel/watch {"tableName": t, "recordId": id}
|
||||
** - POST /ingest/alertmanager Alertmanager's webhook (bearer token; off
|
||||
** unless alertmanagerIngestTokenSecretRef is configured)
|
||||
** Each request gets a session from the instance's authentication module;
|
||||
** AlertsApi checks alerts.view and works in that session. Errors map
|
||||
** through QJavalinUtils.handleException (400, 401, 403, 404). POST bodies
|
||||
** must be JSON.
|
||||
*******************************************************************************/
|
||||
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;
|
||||
@@ -85,17 +97,14 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
@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);
|
||||
});
|
||||
}
|
||||
config.routes.get(BASE_PATH + "/inbox/summary", context -> respond(context, false, (api, body) -> api.inboxSummary()));
|
||||
config.routes.get(BASE_PATH + "/inbox", context -> respond(context, false, (api, body) -> api.inbox(queryParams(context))));
|
||||
config.routes.post(BASE_PATH + "/inbox/read", context -> respond(context, true, AlertsApi::markRead));
|
||||
config.routes.post(BASE_PATH + "/inbox/unread", context -> respond(context, true, AlertsApi::markUnread));
|
||||
config.routes.post(BASE_PATH + "/inbox/dismiss", context -> respond(context, true, AlertsApi::dismiss));
|
||||
config.routes.get(BASE_PATH + "/record-panel", context -> respond(context, false, (api, body) -> api.recordPanel(context.queryParam("tableName"), context.queryParam("recordId"))));
|
||||
config.routes.post(BASE_PATH + "/record-panel/watch", context -> respond(context, true, AlertsApi::watchRecord));
|
||||
config.routes.post(BASE_PATH + "/ingest/alertmanager", this::ingestAlertmanager);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +138,43 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session, body, handler, JSON - or the error's status.
|
||||
*******************************************************************************/
|
||||
private void respond(Context context, boolean readsBody, Handler handler)
|
||||
{
|
||||
try
|
||||
{
|
||||
ExecutorSessionUtils.setupSession(context, qInstance);
|
||||
Map<String, Object> body = readsBody ? readJsonBody(context) : Map.of();
|
||||
Object result = handler.handle(new AlertsApi(AlertsRuntime.get(qInstance)), body);
|
||||
context.contentType(ContentType.APPLICATION_JSON);
|
||||
context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
QJavalinUtils.handleException(null, context, e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first value of each query parameter.
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> queryParams(Context context)
|
||||
{
|
||||
Map<String, String> params = new java.util.HashMap<>();
|
||||
context.queryParamMap().forEach((name, values) -> params.put(name, values == null || values.isEmpty() ? null : values.get(0)));
|
||||
return (params);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The JSON body (an object), or 400.
|
||||
*******************************************************************************/
|
||||
@@ -157,4 +203,15 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One endpoint's work.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface Handler
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
Object handle(AlertsApi api, Map<String, Object> body) throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
/*
|
||||
* 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))))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* 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")));
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -30,11 +30,11 @@ 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.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
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;
|
||||
@@ -212,7 +212,7 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
|
||||
}
|
||||
if(record.getValues().containsKey("audienceType"))
|
||||
{
|
||||
NotificationAudience audience = NotificationService.audienceOf(record);
|
||||
NotificationAudience audience = NotificationAudience.parse(record.getValueString("audienceType"), record.getValueString("audienceValue"));
|
||||
if(audience == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Choose who the notification is for."));
|
||||
@@ -220,11 +220,11 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
|
||||
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."));
|
||||
{
|
||||
case ROLES -> "roles or groups";
|
||||
case PERMISSION -> "permission";
|
||||
default -> "user ids";
|
||||
} + " in Audience Members."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,7 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
|
||||
*******************************************************************************/
|
||||
static void checkPreference(QRecord record)
|
||||
{
|
||||
if(NotificationCategory.SECURITY.name().equals(record.getValueString("category")) && (Boolean.TRUE.equals(record.getValueBoolean("inAppMuted")) || record.getValue("inAppMinSeverity") != null))
|
||||
if(NotificationCategory.SECURITY.name().equals(record.getValueString("category")) && Boolean.TRUE.equals(record.getValueBoolean("inAppMuted")))
|
||||
{
|
||||
record.addError(new PermissionDeniedMessage("Security notices can't be muted in-app; you can still choose how they are emailed."));
|
||||
}
|
||||
|
||||
@@ -34,20 +34,9 @@ import java.util.List;
|
||||
** @param entries one structured entry per alert, for channels that send
|
||||
** one event per alert (PagerDuty, Alertmanager, webhooks);
|
||||
** already redacted and capped by the detail level
|
||||
** @param idempotencyKey durable digest dispatch key, null for immediate messages
|
||||
*******************************************************************************/
|
||||
public record AlertMessage(String subject, String text, String html, String severity, String kind, List<Integer> alertIds, String link, List<Entry> entries, String idempotencyKey)
|
||||
public record AlertMessage(String subject, String text, String html, String severity, String kind, List<Integer> alertIds, String link, List<Entry> entries)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -25,8 +25,8 @@ 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.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
@@ -167,45 +167,6 @@ public final class AlertMessageRenderer
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded digest: open alerts by severity, plus new and resolved events
|
||||
** in this window. Every entry has its own link and uses the normal redactor.
|
||||
*******************************************************************************/
|
||||
public static AlertMessage renderDigest(List<Item> items, String publicBaseUrl, Instant now, String key)
|
||||
{
|
||||
List<Item> ordered = items.stream().sorted((a, b) -> AlertSeverity.fromLabel(b.alert().getValueString("severity"), AlertSeverity.WARNING)
|
||||
.compareTo(AlertSeverity.fromLabel(a.alert().getValueString("severity"), AlertSeverity.WARNING))).toList();
|
||||
AlertMessage base = render(ordered, DeliveryKind.DIGEST, publicBaseUrl, now);
|
||||
StringBuilder text = new StringBuilder();
|
||||
StringBuilder html = new StringBuilder();
|
||||
for(String section : List.of("Open alerts by severity", "New since the last digest", "Resolved since the last digest"))
|
||||
{
|
||||
text.append(section).append('\n');
|
||||
html.append("<h3>").append(section).append("</h3>");
|
||||
for(int i = 0; i < ordered.size(); i++)
|
||||
{
|
||||
QRecord alert = ordered.get(i).alert();
|
||||
boolean included = switch(section)
|
||||
{
|
||||
case "Open alerts by severity" -> !"RESOLVED".equals(alert.getValueString("status"));
|
||||
case "New since the last digest" -> Boolean.TRUE.equals(alert.getValueBoolean("digestNew"));
|
||||
default -> Boolean.TRUE.equals(alert.getValueBoolean("digestResolved"));
|
||||
};
|
||||
if(included)
|
||||
{
|
||||
AlertMessage.Entry entry = base.entries().get(i);
|
||||
String line = "[" + entry.severity() + "] " + entry.title();
|
||||
text.append(line).append(" - [Open alert](").append(entry.link()).append(")\n");
|
||||
html.append("<p><a href=\"").append(HomeHtml.escape(entry.link())).append("\">").append(HomeHtml.escape(line)).append("</a></p>");
|
||||
}
|
||||
}
|
||||
text.append('\n');
|
||||
}
|
||||
return (new AlertMessage("Alert digest: " + items.size() + " alerts", text.toString(), html.toString(), base.severity(), "DIGEST", base.alertIds(), base.link(), base.entries(), key));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The group all items share, or null.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -38,9 +38,9 @@ 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.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.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;
|
||||
@@ -102,7 +102,6 @@ public class DeliveryWorker
|
||||
Instant now = runtime.now();
|
||||
List<QRecord> due = AlertsDb.query(AlertsDelivery.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.RETRYING.name()))
|
||||
.withCriteria("kind", QCriteriaOperator.NOT_EQUALS, DeliveryKind.DIGEST.name())
|
||||
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
|
||||
.withOrderBy(new QFilterOrderBy("id"))
|
||||
.withLimit(BATCH_LIMIT));
|
||||
@@ -328,14 +327,14 @@ public class DeliveryWorker
|
||||
.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)));
|
||||
.withRecordRef(com.kingsrook.qbits.alerts.spi.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sample alert a TEST message describes.
|
||||
** The stand-in alert a TEST message describes.
|
||||
*******************************************************************************/
|
||||
private static QRecord testAlert(Instant now)
|
||||
{
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
/*
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,10 @@ 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;
|
||||
@@ -45,10 +40,8 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
** 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.
|
||||
** the emails. Without a messaging provider the channel fails permanently and
|
||||
** says why.
|
||||
*******************************************************************************/
|
||||
public class EmailChannel implements AlertChannelInterface
|
||||
{
|
||||
@@ -86,16 +79,13 @@ public class EmailChannel implements AlertChannelInterface
|
||||
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
if(!AlertsEmail.isActive())
|
||||
if(!StringUtils.hasContent(config.getMessagingProviderName()))
|
||||
{
|
||||
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.");
|
||||
}
|
||||
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())
|
||||
{
|
||||
@@ -124,11 +114,6 @@ public class EmailChannel implements AlertChannelInterface
|
||||
return (DeliveryResult.failed("No email address."));
|
||||
}
|
||||
|
||||
if(AlertsEmail.isActive())
|
||||
{
|
||||
return (deliverThroughEmailQBit(message, addresses));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MultiParty to = new MultiParty();
|
||||
@@ -153,93 +138,6 @@ public class EmailChannel implements AlertChannelInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -391,7 +391,7 @@ public final class ExternalChannels
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The message's entries (a TEST message gets one sample entry).
|
||||
** The message's entries (a TEST message gets one stand-in entry).
|
||||
*******************************************************************************/
|
||||
static List<AlertMessage.Entry> entries(AlertMessage message)
|
||||
{
|
||||
|
||||
+55
-25
@@ -17,25 +17,36 @@
|
||||
package com.kingsrook.qbits.alerts.delivery.channels;
|
||||
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
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().
|
||||
** STAND-IN for core's WebhookSigner (QRun-IO/qqq#1109, the shared signer of
|
||||
** the qbit-webhooks assessment, QRun-IO/qqq#1105 D2). Same format: the
|
||||
** Standard Webhooks headers webhook-id, webhook-timestamp and
|
||||
** webhook-signature = "v1," + base64(HMAC-SHA256(key, id + "." + timestamp +
|
||||
** "." + body)); a "whsec_" secret is base64 after the prefix, any other text
|
||||
** is the raw key. Replace with com.kingsrook.qqq.backend.core.utils.webhooks
|
||||
** .WebhookSigner when the QQQ RC with #1109 ships, so receivers verify Alerts
|
||||
** and qbit-webhooks deliveries the same way.
|
||||
*******************************************************************************/
|
||||
public final class StandardWebhookSigner
|
||||
{
|
||||
public static final String HEADER_ID = "webhook-id";
|
||||
public static final String HEADER_TIMESTAMP = "webhook-timestamp";
|
||||
public static final String HEADER_SIGNATURE = "webhook-signature";
|
||||
|
||||
private static final String PREFIX = "whsec_";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signing only.
|
||||
*******************************************************************************/
|
||||
@@ -46,36 +57,55 @@ public final class StandardWebhookSigner
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The three core Standard Webhooks headers, preserving legacy short keys.
|
||||
** The three signature headers.
|
||||
*******************************************************************************/
|
||||
public static Map<String, String> headers(String messageId, Instant timestamp, byte[] body, String secret)
|
||||
{
|
||||
return (WebhookSigner.headers(messageId, timestamp, body, List.of(parseSecret(secret))));
|
||||
long seconds = timestamp.getEpochSecond();
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put(HEADER_ID, messageId);
|
||||
headers.put(HEADER_TIMESTAMP, Long.toString(seconds));
|
||||
headers.put(HEADER_SIGNATURE, "v1," + sign(messageId, seconds, body, secret));
|
||||
return (headers);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve raw UTF-8 and whsec_ base64 secrets, including legacy short keys.
|
||||
** base64 HMAC-SHA256 over id.timestamp.body.
|
||||
*******************************************************************************/
|
||||
private static WebhookSecret parseSecret(String secret)
|
||||
public static String sign(String messageId, long timestampSeconds, byte[] body, String secret)
|
||||
{
|
||||
if(messageId == null || messageId.isEmpty() || messageId.contains(".") || messageId.chars().anyMatch(Character::isWhitespace))
|
||||
{
|
||||
throw (new IllegalArgumentException("A webhook message id is required and may not contain '.' or whitespace"));
|
||||
}
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(keyBytes(secret), "HmacSHA256"));
|
||||
mac.update((messageId + "." + timestampSeconds + ".").getBytes(StandardCharsets.UTF_8));
|
||||
mac.update(body == null ? new byte[0] : body);
|
||||
return (Base64.getEncoder().encodeToString(mac.doFinal()));
|
||||
}
|
||||
catch(GeneralSecurityException e)
|
||||
{
|
||||
throw (new IllegalStateException("HMAC-SHA256 is unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The key bytes of a secret's text form.
|
||||
*******************************************************************************/
|
||||
static byte[] keyBytes(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));
|
||||
return (trimmed.startsWith(PREFIX) ? Base64.getDecoder().decode(trimmed.substring(PREFIX.length())) : trimmed.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
/*
|
||||
* 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ 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.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
@@ -22,12 +22,9 @@ 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;
|
||||
@@ -40,32 +37,25 @@ 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.qbits.alerts.spi.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.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
@@ -78,7 +68,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** 2. expand team subscriptions to their members; keep channel subscriptions;
|
||||
** 3. check each person can see the alert (section 7.2);
|
||||
** 4. apply, in order: the type's notify switch, mutes, flapping, the user's
|
||||
** pause, quiet hours, channel minimum severity / enabled / address / rate limit, and a
|
||||
** pause, channel enabled / minimum severity / address / rate limit, and a
|
||||
** 10-minute duplicate check;
|
||||
** 5. write an alerts_delivery row per destination (SUPPRESSED rows keep
|
||||
** their reason, so "why didn't I get it?" is answered by a table), and an
|
||||
@@ -99,7 +89,7 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** One destination for one notification.
|
||||
*******************************************************************************/
|
||||
record Destination(String userId, QRecord channel, Integer subscriptionId, String minSeverity, QRecord preference, String deliveryMode)
|
||||
record Destination(String userId, QRecord channel, Integer subscriptionId, String minSeverity, QRecord preference)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -145,8 +135,129 @@ public class AlertRouter
|
||||
|
||||
try
|
||||
{
|
||||
deliver(alert, eventType, eventId, kind, destinations(AlertTarget.of(alert),
|
||||
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), kind, alert.getValue("id")));
|
||||
Instant now = runtime.now();
|
||||
AlertTarget target = AlertTarget.of(alert);
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
AlertSeverity severity = AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING);
|
||||
|
||||
List<Destination> destinations = destinations(target, severity, kind);
|
||||
if(destinations.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
boolean silent = type != null && Boolean.FALSE.equals(type.getValueBoolean("notify"));
|
||||
boolean muted = isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS);
|
||||
boolean flapping = Boolean.TRUE.equals(alert.getValueBoolean("flapping")) && eventType != EventType.FLAPPING_STARTED;
|
||||
if(muted && !Boolean.TRUE.equals(alert.getValueBoolean("muted")))
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("muted", true));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.MUTED, null, null, null, "A mute silenced notifications", now);
|
||||
}
|
||||
|
||||
Map<String, Optional<Boolean>> visibleByUser = new HashMap<>();
|
||||
int queued = 0;
|
||||
for(Destination destination : destinations)
|
||||
{
|
||||
SuppressedReason reason = null;
|
||||
String channelType = destination.channel().getValueString("channelType");
|
||||
boolean inApp = ChannelType.IN_APP.name().equals(channelType);
|
||||
|
||||
if(severity == AlertSeverity.INFO && !inApp && !AlertSeverity.INFO.name().equals(destination.minSeverity()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!inApp && "alerts.channel.failing".equals(alert.getValueString("alertTypeName")))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a broken channel is only ever reported in-app (no loops) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
AlertSeverity channelMin = AlertSeverity.fromLabel(destination.channel().getValueString("minSeverity"), null);
|
||||
if(channelMin != null && !severity.isAtLeast(channelMin) && !inApp)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(silent)
|
||||
{
|
||||
reason = SuppressedReason.TYPE_SILENT;
|
||||
}
|
||||
else if(muted)
|
||||
{
|
||||
reason = SuppressedReason.MUTED;
|
||||
}
|
||||
else if(flapping)
|
||||
{
|
||||
reason = SuppressedReason.FLAPPING;
|
||||
}
|
||||
else if(destination.userId() != null && !visibleByUser.computeIfAbsent(destination.userId(), userId -> knownVisibility(userId, alert))
|
||||
.orElse(inApp || (alert.getValue("visibilityPermission") == null && alert.getValue("subjectTableName") == null)))
|
||||
{
|
||||
reason = SuppressedReason.NOT_VISIBLE;
|
||||
}
|
||||
else if(destination.userId() != null && !inApp && isPaused(destination.preference(), now))
|
||||
{
|
||||
reason = SuppressedReason.USER_PAUSED;
|
||||
}
|
||||
else if(Boolean.FALSE.equals(destination.channel().getValueBoolean("enabled")))
|
||||
{
|
||||
reason = SuppressedReason.CHANNEL_DISABLED;
|
||||
}
|
||||
else if(ChannelType.EMAIL.name().equals(channelType) && !StringUtils.hasContent(emailAddress(destination)))
|
||||
{
|
||||
reason = SuppressedReason.NO_ADDRESS;
|
||||
}
|
||||
else if(!inApp && isRateLimited(destination.channel(), now))
|
||||
{
|
||||
reason = SuppressedReason.RATE_LIMITED;
|
||||
}
|
||||
else if(isDuplicate(alert, destination, kind, now))
|
||||
{
|
||||
reason = SuppressedReason.DUPLICATE;
|
||||
}
|
||||
|
||||
QRecord notification = new QRecord()
|
||||
.withValue("alertId", alert.getValue("id"))
|
||||
.withValue("alertEventId", eventId)
|
||||
.withValue("subscriptionId", destination.subscriptionId())
|
||||
.withValue("channelId", destination.channel().getValue("id"))
|
||||
.withValue("recipientUserId", destination.userId())
|
||||
.withValue("recipientLabel", recipientLabel(destination))
|
||||
.withValue("kind", kind.name())
|
||||
.withValue("attempts", 0);
|
||||
|
||||
if(reason != null)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
|
||||
notification.setValue("suppressedReason", reason.name());
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
}
|
||||
else if(inApp)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SENT.name());
|
||||
notification.setValue("sentAt", now);
|
||||
QRecord inserted = AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
runtime.getNotifications().deliverAlert(destination.userId(), alert, now);
|
||||
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")));
|
||||
queued++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.PENDING.name());
|
||||
notification.setValue("nextAttemptAt", now);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
if(queued > 0)
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("lastNotifiedAt", now));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.NOTIFIED, null, null, String.valueOf(queued),
|
||||
queued == 1 ? "1 notification" : queued + " notifications", now);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -156,226 +267,6 @@ public class AlertRouter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A due reminder uses subscription event filters and the normal delivery checks.
|
||||
*******************************************************************************/
|
||||
public int remind(QRecord alert) throws QException
|
||||
{
|
||||
return (deliver(alert, null, null, DeliveryKind.REMINDER, destinations(AlertTarget.of(alert),
|
||||
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), DeliveryKind.REMINDER, alert.getValue("id"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A policy target uses the same channel, permission and suppression checks.
|
||||
** Team members are read in bounded pages; no membership source is changed.
|
||||
*******************************************************************************/
|
||||
public void escalate(QRecord alert, QRecord step, Integer eventId) throws QException
|
||||
{
|
||||
String targetType = step.getValueString("targetType");
|
||||
if(SubscriberType.CHANNEL.name().equals(targetType))
|
||||
{
|
||||
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, step.getValue("channelId"));
|
||||
if(channel != null)
|
||||
{
|
||||
deliver(alert, EventType.ESCALATED, eventId, DeliveryKind.ESCALATED,
|
||||
List.of(new Destination(null, channel, null, null, null, "IMMEDIATE")));
|
||||
}
|
||||
}
|
||||
else if(SubscriberType.USER.name().equals(targetType))
|
||||
{
|
||||
escalateToUser(alert, step.getValueString("userId"), eventId);
|
||||
}
|
||||
else if(SubscriberType.TEAM.name().equals(targetType))
|
||||
{
|
||||
int lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> members = AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", step.getValue("teamId"))
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)
|
||||
.withOrderBy(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy("id", true)).withLimit(100));
|
||||
if(members.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord member : members)
|
||||
{
|
||||
escalateToUser(alert, member.getValueString("userId"), eventId);
|
||||
lastId = member.getValueInteger("id");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User and team targets use the user's normal destinations.
|
||||
*******************************************************************************/
|
||||
private void escalateToUser(QRecord alert, String userId, Integer eventId) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(userId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
for(QRecord channel : userChannels(null, preference))
|
||||
{
|
||||
deliver(alert, EventType.ESCALATED, eventId, DeliveryKind.ESCALATED,
|
||||
List.of(new Destination(userId, channel, null, null, preference, "IMMEDIATE")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared fan-out. Scheduled callers propagate failures so their transaction rolls back.
|
||||
*******************************************************************************/
|
||||
private int deliver(QRecord alert, EventType eventType, Integer eventId, DeliveryKind kind, List<Destination> destinations) throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
AlertTarget target = AlertTarget.of(alert);
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
AlertSeverity severity = AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING);
|
||||
|
||||
if(destinations.isEmpty())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
|
||||
boolean silent = type != null && Boolean.FALSE.equals(type.getValueBoolean("notify"));
|
||||
boolean muted = isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS);
|
||||
boolean flapping = Boolean.TRUE.equals(alert.getValueBoolean("flapping")) && eventType != EventType.FLAPPING_STARTED;
|
||||
if(muted && !Boolean.TRUE.equals(alert.getValueBoolean("muted")))
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("muted", true));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.MUTED, null, null, null, "A mute silenced notifications", now);
|
||||
}
|
||||
|
||||
Map<String, Optional<Boolean>> visibleByUser = new HashMap<>();
|
||||
int queued = 0;
|
||||
for(Destination destination : destinations)
|
||||
{
|
||||
boolean digest = kind != DeliveryKind.REMINDER && kind != DeliveryKind.ESCALATED
|
||||
&& List.of("DIGEST_HOURLY", "DIGEST_DAILY").contains(destination.deliveryMode() == null ? "IMMEDIATE" : destination.deliveryMode());
|
||||
SuppressedReason reason = null;
|
||||
String channelType = destination.channel().getValueString("channelType");
|
||||
boolean inApp = ChannelType.IN_APP.name().equals(channelType);
|
||||
|
||||
if(severity == AlertSeverity.INFO && !inApp && !AlertSeverity.INFO.name().equals(destination.minSeverity()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(!inApp && "alerts.channel.failing".equals(alert.getValueString("alertTypeName")))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a broken channel is only ever reported in-app (no loops) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
AlertSeverity channelMin = AlertSeverity.fromLabel(destination.channel().getValueString("minSeverity"), null);
|
||||
|
||||
if(silent)
|
||||
{
|
||||
reason = SuppressedReason.TYPE_SILENT;
|
||||
}
|
||||
else if(muted)
|
||||
{
|
||||
reason = SuppressedReason.MUTED;
|
||||
}
|
||||
else if(flapping)
|
||||
{
|
||||
reason = SuppressedReason.FLAPPING;
|
||||
}
|
||||
else if(destination.userId() != null && !visibleByUser.computeIfAbsent(destination.userId(), userId -> knownVisibility(userId, alert))
|
||||
.orElse(inApp || (alert.getValue("visibilityPermission") == null && alert.getValue("subjectTableName") == null)))
|
||||
{
|
||||
reason = SuppressedReason.NOT_VISIBLE;
|
||||
}
|
||||
else if(destination.userId() != null && !inApp && isPaused(destination.preference(), now))
|
||||
{
|
||||
reason = SuppressedReason.USER_PAUSED;
|
||||
}
|
||||
else if(destination.userId() != null && !inApp && AlertTiming.isQuiet(destination.preference(), severity, now))
|
||||
{
|
||||
reason = SuppressedReason.QUIET_HOURS;
|
||||
}
|
||||
else if(channelMin != null && !severity.isAtLeast(channelMin) && !inApp)
|
||||
{
|
||||
reason = SuppressedReason.MIN_SEVERITY;
|
||||
}
|
||||
else if(Boolean.FALSE.equals(destination.channel().getValueBoolean("enabled")))
|
||||
{
|
||||
reason = SuppressedReason.CHANNEL_DISABLED;
|
||||
}
|
||||
else if(ChannelType.EMAIL.name().equals(channelType) && !StringUtils.hasContent(emailAddress(destination)))
|
||||
{
|
||||
reason = SuppressedReason.NO_ADDRESS;
|
||||
}
|
||||
else if(!inApp && isRateLimited(destination.channel(), now))
|
||||
{
|
||||
reason = SuppressedReason.RATE_LIMITED;
|
||||
}
|
||||
else if(kind != DeliveryKind.REMINDER && isDuplicate(alert, destination, digest ? DeliveryKind.DIGEST : kind, eventId, now))
|
||||
{
|
||||
reason = SuppressedReason.DUPLICATE;
|
||||
}
|
||||
|
||||
QRecord notification = new QRecord()
|
||||
.withValue("alertId", alert.getValue("id"))
|
||||
.withValue("alertEventId", eventId)
|
||||
.withValue("subscriptionId", destination.subscriptionId())
|
||||
.withValue("channelId", destination.channel().getValue("id"))
|
||||
.withValue("recipientUserId", destination.userId())
|
||||
.withValue("recipientLabel", recipientLabel(destination))
|
||||
.withValue("kind", kind.name())
|
||||
.withValue("attempts", 0);
|
||||
|
||||
if(reason != null)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
|
||||
notification.setValue("suppressedReason", reason.name());
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
}
|
||||
else if(digest)
|
||||
{
|
||||
Instant end = AlertTiming.digestEnd(destination.deliveryMode(), destination.preference(), now);
|
||||
Instant start = AlertTiming.digestStart(destination.deliveryMode(), destination.preference(), end);
|
||||
String recipientKey = destination.userId() == null ? "alerts:channel-digest:" + destination.channel().getValue("id") : "alerts:digest:" + destination.userId();
|
||||
notification.withValue("kind", DeliveryKind.DIGEST.name()).withValue("status", DeliveryStatus.PENDING.name())
|
||||
.withValue("digestKey", recipientKey + ":" + start + ":" + destination.deliveryMode()).withValue("nextAttemptAt", end);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
}
|
||||
else if(inApp)
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.SENT.name());
|
||||
notification.setValue("sentAt", now);
|
||||
QRecord inserted = AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
runtime.getNotifications().deliverAlert(destination.userId(), alert, now);
|
||||
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")));
|
||||
queued++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notification.setValue("status", DeliveryStatus.PENDING.name());
|
||||
notification.setValue("nextAttemptAt", now);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
if(queued > 0)
|
||||
{
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("lastNotifiedAt", now));
|
||||
runtime.getEngine().event(alert.getValueInteger("id"), EventType.NOTIFIED, null, null, String.valueOf(queued),
|
||||
queued == 1 ? "1 notification" : queued + " notifications", now);
|
||||
}
|
||||
return (queued);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an active SUPPRESS_ALERTS maintenance window covers the target.
|
||||
*******************************************************************************/
|
||||
@@ -399,7 +290,7 @@ public class AlertRouter
|
||||
Map<Integer, List<String>> groupNames = new HashMap<>();
|
||||
for(QRecord mute : mutes)
|
||||
{
|
||||
if(scopeMatches(mute.getValueString("scopeType"), mute.getValueString("scopeValue"), mute.getValueString("scopeRecordId"), target, groupNames))
|
||||
if(scopeMatches(mute.getValueString("scopeType"), mute.getValueString("scopeValue"), null, target, groupNames))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
@@ -412,33 +303,21 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** Every destination for an alert: subscribed users (directly or through a
|
||||
** team) with their in-app and email channels (or the subscription's
|
||||
** chosen channels), subscribed channels, and readable record followers.
|
||||
** An alert that no subscription matches (at WARNING or above) goes to the
|
||||
** members of its group's owner team, so an unowned problem is not silent.
|
||||
** chosen channels), and subscribed channels.
|
||||
*******************************************************************************/
|
||||
List<Destination> destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind, Object alertId) throws QException
|
||||
List<Destination> destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) 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)
|
||||
if(!severity.isAtLeast(min) || !includesKind(subscription.getValueString("events"), kind)
|
||||
|| !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");
|
||||
@@ -447,15 +326,18 @@ public class AlertRouter
|
||||
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, subscription.getValue("channelId"));
|
||||
if(channel != null)
|
||||
{
|
||||
destinations.putIfAbsent("channel:" + channel.getValue("id"), new Destination(null, channel, subscriptionId, minName, null, subscription.getValueString("deliveryMode")));
|
||||
destinations.putIfAbsent("channel:" + channel.getValue("id"), new Destination(null, channel, subscriptionId, minName, null));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<String> userIds = new LinkedHashSet<>();
|
||||
List<String> userIds = new ArrayList<>();
|
||||
if(SubscriberType.TEAM.name().equals(subscriberType))
|
||||
{
|
||||
userIds.addAll(teamMembers(subscription.getValueInteger("teamId")));
|
||||
for(QRecord member : AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", subscription.getValue("teamId"))))
|
||||
{
|
||||
userIds.add(member.getValueString("userId"));
|
||||
}
|
||||
}
|
||||
else if(StringUtils.hasContent(subscription.getValueString("userId")))
|
||||
{
|
||||
@@ -464,36 +346,10 @@ public class AlertRouter
|
||||
|
||||
for(String userId : userIds)
|
||||
{
|
||||
if(!StringUtils.hasContent(userId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
for(QRecord channel : userChannels(subscription, preference))
|
||||
{
|
||||
destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, subscriptionId, minName, preference, 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"));
|
||||
destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, subscriptionId, minName, preference));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,172 +358,28 @@ public class AlertRouter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fallback for an alert nobody subscribed to: the members of the owner
|
||||
** team of its group (or of the nearest parent group that has one), through
|
||||
** their normal channels and the usual suppression checks.
|
||||
*******************************************************************************/
|
||||
private void addOwnerTeam(Map<String, Destination> destinations, AlertTarget target, DeliveryKind kind, Object alertId) throws QException
|
||||
{
|
||||
Integer teamId = ownerTeamId(target.groupId());
|
||||
if(teamId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(String userId : teamMembers(teamId))
|
||||
{
|
||||
if(!StringUtils.hasContent(userId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if((kind == DeliveryKind.RESOLVED || kind == DeliveryKind.REMINDER) && !wasToldOfRaise(alertId, userId))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// a follow-up only goes to owners who were told about the alert itself //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
continue;
|
||||
}
|
||||
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
for(QRecord channel : userChannels(null, preference))
|
||||
{
|
||||
destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference, "IMMEDIATE"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the user was sent (not suppressed) the RAISED notice for this alert.
|
||||
*******************************************************************************/
|
||||
private boolean wasToldOfRaise(Object alertId, String userId) throws QException
|
||||
{
|
||||
return (alertId != null && AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("alertId", (java.io.Serializable) alertId)
|
||||
.withCriteria("recipientUserId", QCriteriaOperator.EQUALS, userId).withCriteria("kind", QCriteriaOperator.EQUALS, DeliveryKind.RAISED.name())
|
||||
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, DeliveryStatus.SUPPRESSED.name())) > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The owner team of a group, or of the nearest parent that has one (null:
|
||||
** none).
|
||||
*******************************************************************************/
|
||||
Integer ownerTeamId(Integer groupId) throws QException
|
||||
{
|
||||
Integer current = groupId;
|
||||
int depth = 0;
|
||||
while(current != null && depth++ < 10)
|
||||
{
|
||||
QRecord group = AlertsDb.byId(AlertsGroup.TABLE_NAME, current);
|
||||
if(group == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(group.getValueInteger("ownerTeamId") != null)
|
||||
{
|
||||
return (group.getValueInteger("ownerTeamId"));
|
||||
}
|
||||
current = group.getValueInteger("parentGroupId");
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit members plus the team's optional host-managed source. Missing
|
||||
** or failed resolvers contribute nobody and leave an actionable log entry.
|
||||
*******************************************************************************/
|
||||
private Set<String> teamMembers(Integer teamId) throws QException
|
||||
{
|
||||
Set<String> userIds = new LinkedHashSet<>();
|
||||
QRecord team = AlertsDb.byId(AlertsTeam.TABLE_NAME, teamId);
|
||||
if(team == null)
|
||||
{
|
||||
return (userIds);
|
||||
}
|
||||
for(QRecord member : AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", teamId).withLimit(Math.addExact(runtime.getConfig().getMaxAudienceSize(), 1))))
|
||||
{
|
||||
if(userIds.size() >= runtime.getConfig().getMaxAudienceSize())
|
||||
{
|
||||
LOG.warn("Team audience exceeds maxAudienceSize; extra members are not notified", logPair("teamId", teamId));
|
||||
break;
|
||||
}
|
||||
userIds.add(member.getValueString("userId"));
|
||||
}
|
||||
String source = team.getValueString("memberSource");
|
||||
if(MemberSource.ROLE.name().equals(source) || MemberSource.IDP_GROUP.name().equals(source))
|
||||
{
|
||||
AlertAudienceResolver resolver = runtime.getConfig().getAudienceResolver();
|
||||
if(resolver == null)
|
||||
{
|
||||
LOG.warn("Team source resolves to nobody: no host audience resolver is configured", logPair("teamId", teamId), logPair("source", source));
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Set<String> resolved = resolver.resolve(source, team.getValueString("memberSourceValue"));
|
||||
if(resolved != null)
|
||||
{
|
||||
if(resolved.size() > runtime.getConfig().getMaxAudienceSize()
|
||||
|| resolved.stream().filter(userId -> !userIds.contains(userId)).count() > runtime.getConfig().getMaxAudienceSize() - userIds.size())
|
||||
{
|
||||
throw (new QException("Team audience exceeds maxAudienceSize"));
|
||||
}
|
||||
userIds.addAll(resolved);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Team source resolves to nobody: host audience resolver failed", logPair("teamId", teamId), logPair("source", source));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (userIds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's channels for a subscription: the ones it names, else in-app
|
||||
** (unless turned off) 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).
|
||||
** (unless turned off) 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");
|
||||
String overrides = 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())
|
||||
for(String id : overrides.split("[,\\s]+"))
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// the bell stays (unless turned off); the list replaces external ones //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(inApp)
|
||||
if(!id.isBlank())
|
||||
{
|
||||
channels.add(runtime.getChannels().defaultChannel(ChannelType.IN_APP));
|
||||
}
|
||||
for(QRecord channel : chosen)
|
||||
{
|
||||
if(!ChannelType.IN_APP.name().equals(channel.getValueString("channelType")))
|
||||
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, Integer.valueOf(id.trim()));
|
||||
if(channel != null)
|
||||
{
|
||||
channels.add(channel);
|
||||
}
|
||||
}
|
||||
return (channels);
|
||||
}
|
||||
return (channels);
|
||||
}
|
||||
|
||||
if(preference == null || !Boolean.FALSE.equals(preference.getValueBoolean("inAppEnabled")))
|
||||
@@ -683,37 +395,6 @@ public class AlertRouter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The channel rows for a list of ids ("3, 5"); ids that are not numbers or
|
||||
** no longer exist are skipped.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> channelsById(String ids) throws QException
|
||||
{
|
||||
List<QRecord> channels = new ArrayList<>();
|
||||
for(String id : ids.split("[,\\s]+"))
|
||||
{
|
||||
if(id.isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, Integer.valueOf(id.trim()));
|
||||
if(channel != null)
|
||||
{
|
||||
channels.add(channel);
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
LOG.debug("Ignoring a channel id that is not a number", logPair("id", id));
|
||||
}
|
||||
}
|
||||
return (channels);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a subscription or mute scope covers the target.
|
||||
*******************************************************************************/
|
||||
@@ -723,7 +404,6 @@ public class AlertRouter
|
||||
return switch(type)
|
||||
{
|
||||
case ALL -> true;
|
||||
case ALERT -> scopeValue != null && scopeValue.equals(target.alertKey());
|
||||
case ALERT_TYPE -> scopeValue != null && scopeValue.equals(target.typeName());
|
||||
case MONITOR -> scopeValue != null && target.monitorId() != null && scopeValue.equals(String.valueOf(target.monitorId()));
|
||||
case TABLE -> scopeValue != null && (scopeValue.equals(target.subjectTableName()) || target.recordKeys().stream().anyMatch(key -> key.startsWith(scopeValue + ":")));
|
||||
@@ -837,55 +517,18 @@ public class AlertRouter
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recheck queued digest content before sending it to a user.
|
||||
*******************************************************************************/
|
||||
public boolean canReceiveDigest(String userId, QRecord alert)
|
||||
{
|
||||
return (knownVisibility(userId, alert).orElse(alert.getValue("visibilityPermission") == null && alert.getValue("subjectTableName") == null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Followers must be able to read the source record, including its record locks.
|
||||
** Reuse recipient sessions; unknown access fails closed before adding channels.
|
||||
*******************************************************************************/
|
||||
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).
|
||||
** A session for a user, from the instance's authentication module (null
|
||||
** when it can't make one).
|
||||
*******************************************************************************/
|
||||
private QSession automatedSession(String userId) throws QException
|
||||
{
|
||||
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
|
||||
QInstance qInstance = runtime.getQInstance();
|
||||
if(qInstance.getAuthentication() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
|
||||
return (module.createAutomatedSessionForUser(qInstance, userId));
|
||||
}
|
||||
|
||||
|
||||
@@ -893,7 +536,7 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** Whether the user paused notifications (vacation) until after now.
|
||||
*******************************************************************************/
|
||||
public static boolean isPaused(QRecord preference, Instant now)
|
||||
private static boolean isPaused(QRecord preference, Instant now)
|
||||
{
|
||||
return (preference != null && preference.getValueInstant("pausedUntil") != null && preference.getValueInstant("pausedUntil").isAfter(now));
|
||||
}
|
||||
@@ -921,24 +564,13 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
private boolean isDuplicate(QRecord alert, Destination destination, DeliveryKind kind, 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());
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,12 @@ 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.qbits.alerts.spi.AlertDefaultMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.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;
|
||||
|
||||
@@ -29,11 +29,11 @@ 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.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.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;
|
||||
|
||||
@@ -23,14 +23,11 @@ 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;
|
||||
@@ -40,8 +37,7 @@ 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.
|
||||
** customizers, audits and ESB listeners apply as for any other write.
|
||||
*******************************************************************************/
|
||||
public final class AlertsDb
|
||||
{
|
||||
@@ -60,7 +56,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(tableName).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getRecords());
|
||||
return (QueryAction.execute(tableName, filter));
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +66,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static QRecord first(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
List<QRecord> records = query(tableName, filter.withLimit(1));
|
||||
List<QRecord> records = QueryAction.execute(tableName, filter.withLimit(1));
|
||||
return (CollectionUtils.nullSafeIsEmpty(records) ? null : records.get(0));
|
||||
}
|
||||
|
||||
@@ -95,7 +91,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static int count(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
Integer count = new CountAction().execute(new CountInput(tableName).withFilter(filter).withTransaction(QContext.getQBackendTransaction())).getCount();
|
||||
Integer count = CountAction.execute(tableName, filter);
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
@@ -106,7 +102,7 @@ public final class AlertsDb
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String tableName, QRecord record) throws QException
|
||||
{
|
||||
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction()));
|
||||
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(tableName).withRecord(record));
|
||||
QRecord result = inserted.get(0);
|
||||
throwIfErrors(tableName, result);
|
||||
return (result);
|
||||
@@ -115,14 +111,11 @@ public final class AlertsDb
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update one row (it must carry its id); throws on an error. An update of
|
||||
** engine bookkeeping fields only (RuntimeFields) is not record-audited.
|
||||
** Update one row (it must carry its id); throws on an error.
|
||||
*******************************************************************************/
|
||||
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);
|
||||
List<QRecord> updated = UpdateAction.executeForRecords(new UpdateInput(tableName).withRecord(record));
|
||||
QRecord result = updated.get(0);
|
||||
throwIfErrors(tableName, result);
|
||||
return (result);
|
||||
|
||||
@@ -38,10 +38,10 @@ 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.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.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;
|
||||
@@ -568,8 +568,6 @@ public class AlertsEngine
|
||||
.withValue("severity", severity.name())
|
||||
.withValue("lastRaisedAt", now)
|
||||
.withValue("occurrenceCount", (resolved.getValueInteger("occurrenceCount") == null ? 0 : resolved.getValueInteger("occurrenceCount")) + 1)
|
||||
.withValue("escalationLevel", 0)
|
||||
.withValue("timedNotificationCheckedAt", null)
|
||||
.withValue("reopenCount", (resolved.getValueInteger("reopenCount") == null ? 0 : resolved.getValueInteger("reopenCount")) + 1)
|
||||
.withValue("resolvedAt", null)
|
||||
.withValue("resolvedByUserId", null)
|
||||
@@ -826,7 +824,7 @@ public class AlertsEngine
|
||||
/*******************************************************************************
|
||||
** What a subscription or mute scope is matched against.
|
||||
*******************************************************************************/
|
||||
public record AlertTarget(Integer typeId, String typeName, Integer groupId, Integer monitorId, String subjectTableName, Set<String> recordKeys, Map<String, String> attributes, String alertKey)
|
||||
public record AlertTarget(Integer typeId, String typeName, Integer groupId, Integer monitorId, String subjectTableName, Set<String> recordKeys, Map<String, String> attributes)
|
||||
{
|
||||
/***************************************************************************
|
||||
** From a type row and a signal (before the alert exists).
|
||||
@@ -839,7 +837,7 @@ public class AlertsEngine
|
||||
keys.add(ref.getTableName() + ":" + ref.getPrimaryKey());
|
||||
}
|
||||
return (new AlertTarget(type.getValueInteger("id"), signal.getAlertTypeName(), type.getValueInteger("groupId"), monitorId,
|
||||
type.getValueString("subjectTableName"), keys, signal.getAttributes(), openKey(signal.getAlertTypeName(), signal.getEffectiveDedupKey())));
|
||||
type.getValueString("subjectTableName"), keys, signal.getAttributes()));
|
||||
}
|
||||
|
||||
|
||||
@@ -854,10 +852,6 @@ public class AlertsEngine
|
||||
{
|
||||
keys.add(ref.getValueString("tableName") + ":" + ref.getValueString("recordId"));
|
||||
}
|
||||
if(StringUtils.hasContent(alert.getValueString("subjectTableName")) && StringUtils.hasContent(alert.getValueString("subjectRecordId")))
|
||||
{
|
||||
keys.add(alert.getValueString("subjectTableName") + ":" + alert.getValueString("subjectRecordId"));
|
||||
}
|
||||
Map<String, String> attributes = new HashMap<>();
|
||||
String json = alert.getValueString("attributes");
|
||||
if(StringUtils.hasContent(json))
|
||||
@@ -873,7 +867,7 @@ public class AlertsEngine
|
||||
}
|
||||
}
|
||||
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"))));
|
||||
alert.getValueString("subjectTableName"), keys, attributes));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,13 @@ 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.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
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;
|
||||
@@ -243,7 +242,7 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
|
||||
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))
|
||||
runtime.sink().notify(new NotificationSignal(String.format(titleFormat, label))
|
||||
.withBody(body)
|
||||
.withSeverity(severity)
|
||||
.withCategory(NotificationCategory.JOB)
|
||||
|
||||
@@ -19,10 +19,10 @@ 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.qbits.alerts.spi.AlertResolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.getSink().raise(signal);
|
||||
runtime.sink().raise(signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.getSink().resolve(resolution);
|
||||
runtime.sink().resolve(resolution);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,14 +74,14 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.getSink().heartbeat(heartbeatKey, at);
|
||||
runtime.sink().heartbeat(heartbeatKey, at);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core notification producers share the runtime's bounded delivery queue.
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(NotificationSignal signal)
|
||||
@@ -89,7 +89,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.getSink().notify(signal);
|
||||
runtime.sink().notify(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.engine;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed notifications for open, unacknowledged alerts. Progress and fan-out
|
||||
** commit together per alert. Run on one scheduler node, like the other jobs.
|
||||
*******************************************************************************/
|
||||
public class EscalationAndReminder
|
||||
{
|
||||
private static final int PAGE_SIZE = 100;
|
||||
|
||||
private final AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the installed runtime, including its controllable clock.
|
||||
*******************************************************************************/
|
||||
public EscalationAndReminder(AlertsRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read bounded pages. A persisted checkpoint makes repeated ticks harmless,
|
||||
** including attempts with no eligible recipients. Serialize this runtime's ticks.
|
||||
*******************************************************************************/
|
||||
public int runDue() throws QException
|
||||
{
|
||||
synchronized(runtime)
|
||||
{
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
int changed = 0;
|
||||
int lastId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> alerts = AlertsDb.query(AlertsAlert.TABLE_NAME, AlertsDb.eq("status", Status.OPEN.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(PAGE_SIZE));
|
||||
if(alerts.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord alert : alerts)
|
||||
{
|
||||
lastId = alert.getValueInteger("id");
|
||||
changed += process(lastId);
|
||||
}
|
||||
}
|
||||
return (changed);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One alert is an atomic unit, including in-app recipients and delivery rows.
|
||||
*******************************************************************************/
|
||||
private int process(Integer alertId) throws QException
|
||||
{
|
||||
QBackendTransaction previous = QContext.getQBackendTransaction();
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new QueryInput(AlertsAlert.TABLE_NAME)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
int changed = apply(AlertsDb.byId(AlertsAlert.TABLE_NAME, alertId), runtime.now());
|
||||
transaction.commit();
|
||||
return (changed);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (new QException("Could not apply timed notifications for alert " + alertId, e));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setTransaction(previous);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Acknowledge, snooze, resolve, either mute effect, and disabled types stop work.
|
||||
*******************************************************************************/
|
||||
private int apply(QRecord alert, Instant now) throws QException
|
||||
{
|
||||
if(alert == null || !Status.OPEN.name().equals(alert.getValueString("status")) || alert.getValue("acknowledgedAt") != null)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
Instant checked = alert.getValueInstant("timedNotificationCheckedAt");
|
||||
if(checked != null && !checked.truncatedTo(ChronoUnit.MINUTES).isBefore(now.truncatedTo(ChronoUnit.MINUTES)))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
QRecord type = AlertsDb.byId(AlertsType.TABLE_NAME, alert.getValue("alertTypeId"));
|
||||
if(type == null || Boolean.FALSE.equals(type.getValueBoolean("enabled")) || Boolean.FALSE.equals(type.getValueBoolean("notify")))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
AlertsEngine.AlertTarget target = AlertsEngine.AlertTarget.of(alert);
|
||||
if(Boolean.TRUE.equals(alert.getValueBoolean("muted")) || Boolean.TRUE.equals(alert.getValueBoolean("flapping"))
|
||||
|| runtime.getRouter().isMuted(target, now, MuteEffect.SILENCE_NOTIFICATIONS)
|
||||
|| runtime.getRouter().isSuppressedByMaintenance(target, now))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
|
||||
int changed = escalate(alert, type, now) ? 1 : 0;
|
||||
QRecord fresh = AlertsDb.byId(AlertsAlert.TABLE_NAME, alert.getValue("id"));
|
||||
Integer interval = type.getValueInteger("reminderIntervalMinutes");
|
||||
if(interval == null && "CRITICAL".equals(fresh.getValueString("severity")))
|
||||
{
|
||||
interval = 60;
|
||||
}
|
||||
Instant raised = latest(fresh.getValueInstant("lastRaisedAt"), fresh.getValueInstant("firstRaisedAt"));
|
||||
Instant since = latest(fresh.getValueInstant("lastNotifiedAt"), raised);
|
||||
if(interval != null && interval > 0 && since != null && !since.plus(interval, ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
if(runtime.getRouter().remind(fresh) > 0)
|
||||
{
|
||||
changed = 1;
|
||||
}
|
||||
}
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("timedNotificationCheckedAt", now));
|
||||
return (changed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Type policy wins; otherwise use the alert's group policy. Steps are ordered
|
||||
** by stepOrder and measured from the most recent reopen (else first raise).
|
||||
** At most one step is taken per minute; missed steps are not burst-delivered.
|
||||
*******************************************************************************/
|
||||
private boolean escalate(QRecord alert, QRecord type, Instant now) throws QException
|
||||
{
|
||||
Integer policyId = type.getValueInteger("escalationPolicyId");
|
||||
if(policyId == null)
|
||||
{
|
||||
QRecord group = AlertsDb.byId(AlertsGroup.TABLE_NAME, alert.getValue("groupId"));
|
||||
policyId = group == null ? null : group.getValueInteger("escalationPolicyId");
|
||||
}
|
||||
QRecord policy = AlertsDb.byId(AlertsEscalationPolicy.TABLE_NAME, policyId);
|
||||
if(policy == null || !Boolean.TRUE.equals(policy.getValueBoolean("enabled")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QRecord reopened = AlertsDb.first(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.REOPENED.name()).withOrderBy(new QFilterOrderBy("id", false)));
|
||||
Instant start = reopened == null ? alert.getValueInstant("firstRaisedAt") : reopened.getValueInstant("at");
|
||||
int level = alert.getValueInteger("escalationLevel") == null ? 0 : alert.getValueInteger("escalationLevel");
|
||||
QRecord step = AlertsDb.first(AlertsEscalationStep.TABLE_NAME, AlertsDb.eq("policyId", policyId)
|
||||
.withCriteria("stepOrder", QCriteriaOperator.GREATER_THAN, level).withOrderBy(new QFilterOrderBy("stepOrder", true)));
|
||||
if(step == null)
|
||||
{
|
||||
step = repeatStep(alert, policy, reopened, level, now);
|
||||
}
|
||||
if(step == null || start == null || step.getValueInteger("afterMinutes") == null || step.getValueInteger("afterMinutes") < 0
|
||||
|| !Status.OPEN.name().equals(step.getValueString("onlyIfStatus"))
|
||||
|| start.plus(step.getValueInteger("afterMinutes"), ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int nextLevel = step.getValueInteger("stepOrder");
|
||||
Integer eventId = runtime.getEngine().event(alert.getValueInteger("id"), EventType.ESCALATED, null,
|
||||
String.valueOf(level), String.valueOf(nextLevel), level == nextLevel ? "Repeated final escalation step" : "Escalation step " + nextLevel, now);
|
||||
runtime.getRouter().escalate(alert, step, eventId);
|
||||
AlertsDb.update(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", alert.getValue("id")).withValue("escalationLevel", nextLevel));
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeat only the final step, capped by the number of same-level events in
|
||||
** this open cycle. Repeat spacing uses escalation time, never reminder time.
|
||||
*******************************************************************************/
|
||||
private QRecord repeatStep(QRecord alert, QRecord policy, QRecord reopened, int level, Instant now) throws QException
|
||||
{
|
||||
Integer interval = policy.getValueInteger("repeatAfterMinutes");
|
||||
Integer maxRepeats = policy.getValueInteger("maxRepeats");
|
||||
if(level == 0 || interval == null || interval <= 0 || maxRepeats == null || maxRepeats <= 0)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QQueryFilter cycle = AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.ESCALATED.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, reopened == null ? 0 : reopened.getValueInteger("id"));
|
||||
QRecord last = AlertsDb.first(AlertsEvent.TABLE_NAME, cycle.withOrderBy(new QFilterOrderBy("id", false)));
|
||||
if(last == null || last.getValueInstant("at").plus(interval, ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
int repeats = AlertsDb.count(AlertsEvent.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id"))
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, EventType.ESCALATED.name())
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, reopened == null ? 0 : reopened.getValueInteger("id"))
|
||||
.withCriteria("fromValue", QCriteriaOperator.EQUALS, String.valueOf(level))
|
||||
.withCriteria("toValue", QCriteriaOperator.EQUALS, String.valueOf(level)));
|
||||
return (repeats >= maxRepeats ? null : AlertsDb.first(AlertsEscalationStep.TABLE_NAME, AlertsDb.eq("policyId", policy.getValue("id"))
|
||||
.withCriteria("stepOrder", QCriteriaOperator.EQUALS, level)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Later of two optional timestamps.
|
||||
*******************************************************************************/
|
||||
private static Instant latest(Instant left, Instant right)
|
||||
{
|
||||
return (left == null ? right : right == null || left.isAfter(right) ? left : right);
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
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.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -216,7 +216,7 @@ public class MonitorEvaluator
|
||||
long started = System.currentTimeMillis();
|
||||
String typeName = type.getValueString("name");
|
||||
|
||||
AlertTarget target = new AlertTarget(type.getValueInteger("id"), typeName, type.getValueInteger("groupId"), monitorId, type.getValueString("subjectTableName"), Set.of(), Map.of(), null);
|
||||
AlertTarget target = new AlertTarget(type.getValueInteger("id"), typeName, type.getValueInteger("groupId"), monitorId, type.getValueString("subjectTableName"), Set.of(), Map.of());
|
||||
if(runtime.getRouter().isSuppressedByMaintenance(target, now))
|
||||
{
|
||||
Plan plan = new Plan(MonitorStatus.SKIPPED_MUTED, "skipped: maintenance window", List.of(), List.of(), List.of(), List.of(), null);
|
||||
|
||||
@@ -28,11 +28,11 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
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.qbits.alerts.spi.AlertResolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.engine;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Engine bookkeeping columns: evaluation leases, run results and notification
|
||||
** timers that change on every scheduler pass. An update that touches only
|
||||
** these (plus the primary key and modifyDate) is not record-audited; the core
|
||||
** FIELD audit would otherwise write an audit row per monitor run and per
|
||||
** reminder check. Any update that also changes a configuration or alert-state
|
||||
** field (status, severity, acknowledgement, assignment, a monitor's settings)
|
||||
** is audited in full, as before. Run history lives in the run fields
|
||||
** themselves; alert raises, transitions and notifications have their own
|
||||
** event and delivery tables.
|
||||
*******************************************************************************/
|
||||
public final class RuntimeFields
|
||||
{
|
||||
private static final Set<String> ALWAYS = Set.of("id", "modifyDate");
|
||||
|
||||
private static final Map<String, Set<String>> BY_TABLE = Map.of(
|
||||
AlertsMonitor.TABLE_NAME, Set.of("claimedBy", "claimedUntil", "lastRunAt", "nextRunAt", "lastStatus", "lastValue", "lastError",
|
||||
"lastElapsedMs", "consecutiveFailures", "conditionSince"),
|
||||
AlertsAlert.TABLE_NAME, Set.of("timedNotificationCheckedAt", "lastNotifiedAt", "recordRefCount"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constants only.
|
||||
*******************************************************************************/
|
||||
private RuntimeFields()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bookkeeping fields of a table (empty when it has none).
|
||||
*******************************************************************************/
|
||||
public static Set<String> of(String tableName)
|
||||
{
|
||||
return (BY_TABLE.getOrDefault(tableName, Set.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an update record carries nothing but bookkeeping fields.
|
||||
*******************************************************************************/
|
||||
public static boolean onlyRuntime(String tableName, QRecord update)
|
||||
{
|
||||
Set<String> runtime = of(tableName);
|
||||
return (!runtime.isEmpty() && update.getValues().keySet().stream().allMatch(field -> runtime.contains(field) || ALWAYS.contains(field)));
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
|
||||
@@ -52,9 +52,9 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
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.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
@@ -80,7 +80,6 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
*******************************************************************************/
|
||||
public final class AlertsHome
|
||||
{
|
||||
public static final String KIND_SETUP = "setup";
|
||||
public static final String KIND_SEVERITY = "severity";
|
||||
public static final String KIND_MINE = "mine";
|
||||
public static final String KIND_ACTION = "needsAction";
|
||||
@@ -122,7 +121,6 @@ public final class AlertsHome
|
||||
Instant now = runtime.now();
|
||||
return switch(kind == null ? "" : kind)
|
||||
{
|
||||
case KIND_SETUP -> setup(runtime, now);
|
||||
case KIND_SEVERITY -> severity(runtime, now);
|
||||
case KIND_MINE -> mine(now);
|
||||
case KIND_ACTION -> needsAction(now);
|
||||
@@ -138,34 +136,6 @@ public final class AlertsHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup (alerts admins): each configuration table, whether it is set up, and
|
||||
** the next step, linked to its table.
|
||||
*******************************************************************************/
|
||||
static QWidgetData setup(AlertsRuntime runtime, Instant now) throws QException
|
||||
{
|
||||
List<AlertsSetupStatus.Item> items = AlertsSetupStatus.load(runtime, now);
|
||||
long todo = items.stream().filter(item -> item.state() == AlertsSetupStatus.State.TODO).count();
|
||||
StringBuilder html = new StringBuilder(HomeHtml.note(todo == 0 ? "Everything Alerts needs is set up." : HomeHtml.plural(todo, "item") + (todo == 1 ? " needs" : " need") + " attention."));
|
||||
for(AlertsSetupStatus.Item item : items)
|
||||
{
|
||||
String badge = switch(item.state())
|
||||
{
|
||||
case OK -> HomeHtml.badge("Set up", HomeHtml.OK_COLOR);
|
||||
case TODO -> HomeHtml.badge("Needs setup", HomeHtml.BAD_COLOR);
|
||||
case DEFAULT -> HomeHtml.badge("Using defaults", HomeHtml.WARN_COLOR);
|
||||
case OPTIONAL -> HomeHtml.badge("Optional", HomeHtml.OFF_COLOR);
|
||||
};
|
||||
String label = item.tableName() == null ? HomeHtml.escape(item.label()) : HomeHtml.link("/app/" + item.tableName(), item.label());
|
||||
html.append("<div style=\"padding:8px 0;border-top:1px solid rgba(127,127,127,.2)\">")
|
||||
.append(HomeHtml.card("<strong>" + label + "</strong> " + badge, HomeHtml.escape(item.detail()), item.nextStep() == null ? null : "Next: " + HomeHtml.escape(item.nextStep())))
|
||||
.append("</div>");
|
||||
}
|
||||
return (new RawHTML(null, html.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open by severity: four tiles, each with the unacknowledged count and the
|
||||
** oldest age, linking to the filtered list.
|
||||
@@ -779,6 +749,6 @@ public final class AlertsHome
|
||||
*******************************************************************************/
|
||||
static Set<String> kinds()
|
||||
{
|
||||
return (Set.of(KIND_SETUP, KIND_SEVERITY, KIND_MINE, KIND_ACTION, KIND_BY_GROUP, KIND_TREND, KIND_NOISY, KIND_DELIVERY, KIND_RESOLVED));
|
||||
return (Set.of(KIND_SEVERITY, KIND_MINE, KIND_ACTION, KIND_BY_GROUP, KIND_TREND, KIND_NOISY, KIND_DELIVERY, KIND_RESOLVED));
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.home;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeam;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Alerts home: each configuration table, whether it is
|
||||
** set up, and the next step. Reads only the qbit's own small configuration
|
||||
** tables, in the signed-in administrator's session.
|
||||
*******************************************************************************/
|
||||
public final class AlertsSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** OK: working. TODO: needs attention. DEFAULT: working on built-in defaults.
|
||||
** OPTIONAL: a feature that is off until set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, TODO, DEFAULT, OPTIONAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card. tableName is null for an item with no table.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AlertsSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(AlertsRuntime runtime, Instant now) throws QException
|
||||
{
|
||||
List<Item> items = new ArrayList<>();
|
||||
|
||||
/////////////
|
||||
// channels //
|
||||
/////////////
|
||||
List<QRecord> channels = AlertsDb.query(AlertsChannel.TABLE_NAME, new QQueryFilter());
|
||||
long external = channels.stream().filter(c -> !List.of(ChannelType.IN_APP.name(), ChannelType.EMAIL.name()).contains(c.getValueString("channelType"))).count();
|
||||
long unhealthy = channels.stream().filter(c -> HealthStatus.UNHEALTHY.name().equals(c.getValueString("healthStatus"))).count();
|
||||
if(channels.isEmpty())
|
||||
{
|
||||
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.DEFAULT, "No channel rows yet: the bell and email channels are created when the first alert is routed.",
|
||||
"Include alerts-defaults.changelog.sql to create them now, then add Slack, Teams or a webhook if you want them."));
|
||||
}
|
||||
else if(unhealthy > 0)
|
||||
{
|
||||
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.TODO, unhealthy + (unhealthy == 1 ? " channel is" : " channels are") + " failing.",
|
||||
"Open it, check its settings and secret reference, then Send test notification."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.OK, plural(channels.size(), "channel") + (external == 0 ? ": the bell and email only." : ", " + external + " of them outside the bell and email."),
|
||||
external == 0 ? "Optional: add Slack, Teams, a webhook or PagerDuty." : null));
|
||||
}
|
||||
|
||||
///////////////////////
|
||||
// types and monitors //
|
||||
///////////////////////
|
||||
int types = AlertsDb.count(AlertsType.TABLE_NAME, AlertsDb.eq("installed", true));
|
||||
items.add(types == 0
|
||||
? new Item("Alert types", AlertsType.TABLE_NAME, State.TODO, "No installed alert type.", "Types are declared in code by the installed qbits (QAlertTypeMetaData); open the Alerts home once so they sync.")
|
||||
: new Item("Alert types", AlertsType.TABLE_NAME, State.OK, plural(types, "type") + " declared by the installed qbits.", null));
|
||||
|
||||
int monitors = AlertsDb.count(AlertsMonitor.TABLE_NAME, AlertsDb.eq("enabled", true));
|
||||
items.add(monitors == 0
|
||||
? new Item("Monitors", AlertsMonitor.TABLE_NAME, State.OPTIONAL, "No monitor is turned on: alerts come only from code that raises them.", "On an alert type, choose Create monitor, or add one with New.")
|
||||
: new Item("Monitors", AlertsMonitor.TABLE_NAME, State.OK, plural(monitors, "monitor") + " turned on.", null));
|
||||
|
||||
/////////////////////////////////
|
||||
// groups, teams and owner teams //
|
||||
/////////////////////////////////
|
||||
List<QRecord> groups = AlertsDb.query(AlertsGroup.TABLE_NAME, new QQueryFilter());
|
||||
long ownedGroups = groups.stream().filter(g -> g.getValueInteger("ownerTeamId") != null).count();
|
||||
items.add(ownedGroups == 0
|
||||
? new Item("Groups", AlertsGroup.TABLE_NAME, State.OPTIONAL, plural(groups.size(), "group") + ", none with an owner team.", "Give a group an Owner Team so alerts that nobody subscribed to still reach someone.")
|
||||
: new Item("Groups", AlertsGroup.TABLE_NAME, State.OK, plural(groups.size(), "group") + ", " + ownedGroups + " with an owner team.", null));
|
||||
|
||||
int teams = AlertsDb.count(AlertsTeam.TABLE_NAME, new QQueryFilter());
|
||||
items.add(teams == 0
|
||||
? new Item("Teams", AlertsTeam.TABLE_NAME, State.OPTIONAL, "No team.", "Add a team and its members to subscribe people together or to own a group.")
|
||||
: new Item("Teams", AlertsTeam.TABLE_NAME, State.OK, plural(teams, "team") + ".", null));
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// subscriptions: nobody hears anything without one //
|
||||
/////////////////////////////////////////////////////
|
||||
int subscriptions = AlertsDb.count(AlertsSubscription.TABLE_NAME, AlertsDb.eq("enabled", true));
|
||||
if(subscriptions > 0)
|
||||
{
|
||||
items.add(new Item("Subscriptions", AlertsSubscription.TABLE_NAME, State.OK, plural(subscriptions, "subscription") + " turned on.", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Subscriptions", AlertsSubscription.TABLE_NAME, ownedGroups > 0 ? State.DEFAULT : State.TODO, "No subscription: " + (ownedGroups > 0 ? "alerts reach only the owner teams of their groups." : "alerts reach nobody."),
|
||||
"Use Subscribe me on a type, group or monitor, or add one with New. An alerts admin gets an in-app subscription to errors on first visit."));
|
||||
}
|
||||
|
||||
///////////////////////////////////////
|
||||
// escalation, maintenance, scheduler //
|
||||
///////////////////////////////////////
|
||||
int policies = AlertsDb.count(AlertsEscalationPolicy.TABLE_NAME, AlertsDb.eq("enabled", true));
|
||||
int steps = AlertsDb.count(AlertsEscalationStep.TABLE_NAME, new QQueryFilter());
|
||||
items.add(policies == 0
|
||||
? new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.OPTIONAL, "None: alerts are reminded, not escalated.", "Add a policy and its steps, then pick it on a type or group.")
|
||||
: steps == 0
|
||||
? new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.TODO, plural(policies, "policy") + ", but no steps, so nothing escalates.", "Add steps to the policy.")
|
||||
: new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.OK, plural(policies, "policy") + ", " + plural(steps, "step") + ".", null));
|
||||
|
||||
int mutes = AlertsDb.count(AlertsMute.TABLE_NAME, AlertsDb.eq("enabled", true).withCriteria("endsAt", QCriteriaOperator.GREATER_THAN, now));
|
||||
items.add(new Item("Mutes and maintenance", AlertsMute.TABLE_NAME, State.OPTIONAL, mutes == 0 ? "Nothing is muted." : plural(mutes, "mute") + " active or scheduled.",
|
||||
mutes == 0 ? "Add a window before planned maintenance." : null));
|
||||
|
||||
items.add(runtime.getConfig().getSchedulerName() == null
|
||||
? new Item("Scheduler", null, State.TODO, "No scheduler is configured, so monitors, delivery, digests and escalation only run when started by hand.", "Set schedulerName in the Alerts config.")
|
||||
: new Item("Scheduler", null, State.OK, "Monitors, delivery, digests and escalation run on the " + runtime.getConfig().getSchedulerName() + " scheduler.", null));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "1 channel", "2 channels".
|
||||
*******************************************************************************/
|
||||
private static String plural(long count, String noun)
|
||||
{
|
||||
return (count + " " + noun + (count == 1 ? "" : "s"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
@@ -56,7 +55,7 @@ public class AlertsAppMetaDataProducer extends QBitComponentMetaDataProducer<QAp
|
||||
{
|
||||
/** the main tables, in nav order. */
|
||||
public static final List<String> MAIN_TABLES = List.of(AlertsAlert.TABLE_NAME, AlertsNotification.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME,
|
||||
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsEscalationPolicy.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsDelivery.TABLE_NAME);
|
||||
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsDelivery.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
|
||||
+2
-4
@@ -33,12 +33,11 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaData
|
||||
|
||||
/*******************************************************************************
|
||||
** The Alerts home dashboard (design section 6.2), in page order, all full
|
||||
** width: setup (admins), open by severity, my alerts, needs action, by group, trend, noisy
|
||||
** width: open by severity, my alerts, needs action, by group, trend, noisy
|
||||
** types, delivery health (admins) and recently resolved.
|
||||
*******************************************************************************/
|
||||
public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, AlertsQBitConfig>
|
||||
{
|
||||
public static final String SETUP = "alertsHomeSetup";
|
||||
public static final String SEVERITY = "alertsHomeOpenBySeverity";
|
||||
public static final String MINE = "alertsHomeMyAlerts";
|
||||
public static final String ACTION = "alertsHomeNeedsAction";
|
||||
@@ -56,7 +55,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
|
||||
*******************************************************************************/
|
||||
public static List<String> homeWidgetNames()
|
||||
{
|
||||
return (List.of(SETUP, SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED));
|
||||
return (List.of(SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED));
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +70,6 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
|
||||
String table = WidgetType.TABLE.getType();
|
||||
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
output.add(widget(SETUP, "Setup", html, AlertsHome.KIND_SETUP, AlertsQBitConfig.PERMISSION_ADMIN));
|
||||
output.add(widget(SEVERITY, "Open Alerts", html, AlertsHome.KIND_SEVERITY, AlertsQBitConfig.PERMISSION_VIEW));
|
||||
output.add(widget(MINE, "My Alerts", table, AlertsHome.KIND_MINE, AlertsQBitConfig.PERMISSION_VIEW));
|
||||
output.add(widget(ACTION, "Needs Action", table, AlertsHome.KIND_ACTION, AlertsQBitConfig.PERMISSION_VIEW));
|
||||
|
||||
+2
-7
@@ -46,16 +46,13 @@ import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwith
|
||||
** - the record and table actions (AlertsAction), each a standard streamed
|
||||
** ETL process on its table: optional input, Review summary lines, Result
|
||||
** summary lines;
|
||||
** - four hidden scheduled processes: alertsEvaluateMonitors (monitors, plus
|
||||
** snooze / quiet-period / flapping upkeep), alertsDeliverNotifications,
|
||||
** alertsEscalateAndRemind and alertsSendDigests.
|
||||
** - two hidden scheduled processes: alertsEvaluateMonitors (monitors, plus
|
||||
** snooze / quiet-period / flapping upkeep) and alertsDeliverNotifications.
|
||||
** They exist without a scheduler and run on one only when the config names
|
||||
** it.
|
||||
*******************************************************************************/
|
||||
public class AlertsProcessesMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, AlertsQBitConfig>
|
||||
{
|
||||
public static final String SEND_DIGESTS = "alertsSendDigests";
|
||||
public static final String ESCALATE_AND_REMIND = "alertsEscalateAndRemind";
|
||||
public static final String EVALUATE_MONITORS = "alertsEvaluateMonitors";
|
||||
public static final String DELIVER_NOTIFICATIONS = "alertsDeliverNotifications";
|
||||
|
||||
@@ -90,8 +87,6 @@ public class AlertsProcessesMetaDataProducer extends QBitComponentMetaDataProduc
|
||||
}
|
||||
processes.add(scheduled(config, EVALUATE_MONITORS, "Evaluate alert monitors", AlertsScheduledSteps.EvaluateMonitors.class, config.getEvaluateEverySeconds()));
|
||||
processes.add(scheduled(config, DELIVER_NOTIFICATIONS, "Deliver alert notifications", AlertsScheduledSteps.DeliverNotifications.class, config.getDeliverEverySeconds()));
|
||||
processes.add(scheduled(config, SEND_DIGESTS, "Send alert digests", AlertsScheduledSteps.SendDigests.class, 3600));
|
||||
processes.add(scheduled(config, ESCALATE_AND_REMIND, "Escalate and remind", AlertsScheduledSteps.EscalateAndRemind.class, 60));
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.AlertsPermissionChecker;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.customizers.AlertsNotificationCustomizer;
|
||||
@@ -30,8 +29,6 @@ import com.kingsrook.qbits.alerts.customizers.AlertsWriteGuardCustomizer;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
@@ -48,11 +45,11 @@ import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@@ -65,7 +62,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
@@ -98,7 +94,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
*******************************************************************************/
|
||||
public final class AlertsTableMetaDataHelper
|
||||
{
|
||||
public static final String JOIN_POLICY_STEP = "alerts_escalation_policy_join_alerts_escalation_step";
|
||||
public static final String JOIN_ALERT_EVENT = "alerts_alert_join_alerts_event";
|
||||
public static final String JOIN_ALERT_RECORD_REF = "alerts_alert_join_alerts_record_ref";
|
||||
public static final String JOIN_ALERT_DELIVERY = "alerts_alert_join_alerts_delivery";
|
||||
@@ -139,7 +134,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(widgetSection(JOIN_ALERT_RECORD_REF, "Records", "link"))
|
||||
.withSection(widgetSection(JOIN_ALERT_DELIVERY, "Notifications", "send"))
|
||||
.withSection(new QFieldSection("occurrence", "Occurrences", new QIcon("repeat"), Tier.T3, List.of("firstRaisedAt", "lastRaisedAt", "occurrenceCount", "reopenCount", "flapping",
|
||||
"lastNotifiedAt", "timedNotificationCheckedAt", "monitorId", "dedupKey", "alertTypeName", "visibilityPermission", "openDedupKey", "escalationLevel", "links", "attributes", "createDate", "modifyDate")));
|
||||
"lastNotifiedAt", "monitorId", "dedupKey", "alertTypeName", "visibilityPermission", "openDedupKey", "escalationLevel", "links", "attributes", "createDate", "modifyDate")));
|
||||
return (readOnly(table));
|
||||
}
|
||||
|
||||
@@ -158,7 +153,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "groupId", "sourceName", "openCount")))
|
||||
.withSection(new QFieldSection("about", "About", new QIcon("info"), Tier.T2, List.of("description", "howToFix", "subjectTableName", "visibilityPermission")))
|
||||
.withSection(new QFieldSection("tuning", "Tuning", new QIcon("tune"), Tier.T2, List.of("enabled", "notify", "defaultSeverity", "severityOverride", "resolveMode",
|
||||
"quietPeriodMinutes", "reminderIntervalMinutes", "escalationPolicyId", "externalDetailCeiling")))
|
||||
"quietPeriodMinutes", "reminderIntervalMinutes", "externalDetailCeiling")))
|
||||
.withSection(widgetSection(JOIN_TYPE_MONITOR, "Monitors", "monitor_heart"))
|
||||
.withSection(widgetSection(JOIN_TYPE_ALERT, "Alerts", "notifications_active"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("installed", "lastRaisedAt", "createDate", "modifyDate")));
|
||||
@@ -179,7 +174,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "parentGroupId")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "ownerTeamId", "escalationPolicyId", "icon", "sortOrder")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "ownerTeamId", "icon", "sortOrder")))
|
||||
.withSection(widgetSection(JOIN_GROUP_TYPE, "Alert Types", "category"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
return (table);
|
||||
@@ -309,10 +304,6 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description")))
|
||||
.withSection(widgetSection(JOIN_TEAM_MEMBER, "Members", "person"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
if(config.getAudienceResolver() == null)
|
||||
{
|
||||
table.getField("memberSource").withHelpContent(new QHelpContent("ROLE and IdP Group members need a host resolver; none is configured"));
|
||||
}
|
||||
table.getField("memberSource").setDefaultValue(AlertsPossibleValues.MemberSource.MANUAL.name());
|
||||
return (table);
|
||||
}
|
||||
@@ -331,10 +322,6 @@ public final class AlertsTableMetaDataHelper
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "teamId", "userId", "role")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
if(config.getAudienceResolver() == null)
|
||||
{
|
||||
table.getField("teamId").withHelpContent(new QHelpContent("ROLE and IdP Group members need a host resolver; none is configured"));
|
||||
}
|
||||
table.getField("role").setDefaultValue(AlertsPossibleValues.TeamRole.MEMBER.name());
|
||||
return (table);
|
||||
}
|
||||
@@ -377,7 +364,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withRecordLabelFields("name")
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "name", "enabled", "effect")))
|
||||
.withSection(new QFieldSection("scope", "What and When", new QIcon("filter_alt"), Tier.T2, List.of("scopeType", "scopeValue", "scopeRecordId", "startsAt", "endsAt", "reason")))
|
||||
.withSection(new QFieldSection("scope", "What and When", new QIcon("filter_alt"), Tier.T2, List.of("scopeType", "scopeValue", "startsAt", "endsAt", "reason")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createdByUserId", "createDate", "modifyDate")));
|
||||
table.getField("effect").setDefaultValue(AlertsPossibleValues.MuteEffect.SILENCE_NOTIFICATIONS.name());
|
||||
table.getField("enabled").setDefaultValue(true);
|
||||
@@ -393,12 +380,11 @@ public final class AlertsTableMetaDataHelper
|
||||
public static QTableMetaData buildDeliveryTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, AlertsDelivery.TABLE_NAME, "Delivery Log", "send", AlertsDelivery.class)
|
||||
.withUniqueKey(new UniqueKey("digestClaimKey"))
|
||||
.withRecordLabelFormat("%s %s")
|
||||
.withRecordLabelFields("kind", "recipientLabel")
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "alertId", "kind", "status", "channelId", "recipientLabel")))
|
||||
.withSection(new QFieldSection("delivery", "Delivery", new QIcon("send"), Tier.T2, List.of("suppressedReason", "attempts", "nextAttemptAt", "sentAt", "lastError", "providerMessageId")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("recipientUserId", "subscriptionId", "alertEventId", "digestKey", "digestClaimKey", "digestLastItemId", "claimedUntil", "createDate", "modifyDate")));
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("recipientUserId", "subscriptionId", "alertEventId", "claimedUntil", "createDate", "modifyDate")));
|
||||
return (readOnly(table));
|
||||
}
|
||||
|
||||
@@ -445,7 +431,6 @@ public final class AlertsTableMetaDataHelper
|
||||
.withRecordSecurityLock(AlertsSecurity.recipientLock("userId"))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notificationId", "userId", "deliveredAt", "readAt", "dismissedAt")))
|
||||
.withSection(new QFieldSection("email", "Email", new QIcon("mail"), Tier.T2, List.of("emailStatus", "emailedAt")))
|
||||
.withSection(new QFieldSection("slack", "Slack", new QIcon("chat"), Tier.T2, List.of("slackStatus", "slackMessageId")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_DELETE);
|
||||
return (notificationCustomizer(table, true));
|
||||
@@ -464,14 +449,12 @@ public final class AlertsTableMetaDataHelper
|
||||
.withUniqueKey(new UniqueKey("userId", "category"))
|
||||
.withRecordSecurityLock(AlertsSecurity.ownerLock("userId"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "inAppMinSeverity", "emailMode", "emailMinSeverity", "slackMode", "slackMinSeverity")))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "emailMode", "emailMinSeverity")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("userId", "createDate", "modifyDate")));
|
||||
table.getField("category").setDefaultValue(NotificationCategory.SYSTEM.name());
|
||||
table.getField("inAppMuted").setDefaultValue(false);
|
||||
table.getField("emailMode").setDefaultValue(AlertsPossibleValues.EmailMode.OFF.name());
|
||||
table.getField("emailMinSeverity").setDefaultValue(NotificationSeverity.WARNING.name());
|
||||
table.getField("slackMode").setDefaultValue(AlertsPossibleValues.EmailMode.OFF.name());
|
||||
table.getField("slackMinSeverity").setDefaultValue(NotificationSeverity.WARNING.name());
|
||||
return (notificationCustomizer(table, false));
|
||||
}
|
||||
|
||||
@@ -514,52 +497,12 @@ public final class AlertsTableMetaDataHelper
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escalation policies, with ordered steps on the record screen.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildEscalationPolicyTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = text(base(config, AlertsEscalationPolicy.TABLE_NAME, "Escalation Policies", "trending_up", AlertsEscalationPolicy.class), "description")
|
||||
.withPermissionRules(AlertsPermissionChecker.rules(AlertsQBitConfig.PERMISSION_ADMIN))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "name", "enabled")))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description", "repeatAfterMinutes", "maxRepeats")))
|
||||
.withSection(widgetSection(JOIN_POLICY_STEP, "Steps", "trending_up"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.getField("enabled").setDefaultValue(true);
|
||||
table.getField("maxRepeats").setDefaultValue(0);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escalation steps are standard admin tables, reached from their policy.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildEscalationStepTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, AlertsEscalationStep.TABLE_NAME, "Escalation Steps", "trending_up", AlertsEscalationStep.class)
|
||||
.withPermissionRules(AlertsPermissionChecker.rules(AlertsQBitConfig.PERMISSION_ADMIN))
|
||||
.withRecordLabelFormat("Step %s").withRecordLabelFields("stepOrder")
|
||||
.withUniqueKey(new UniqueKey("policyId", "stepOrder"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "policyId", "stepOrder", "afterMinutes")))
|
||||
.withSection(new QFieldSection("target", new QIcon("person"), Tier.T2, List.of("targetType", "userId", "teamId", "channelId", "onlyIfStatus")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.getField("onlyIfStatus").setDefaultValue(AlertsPossibleValues.Status.OPEN.name());
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All the tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> buildAllTables(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
return (List.of(buildEscalationPolicyTable(config), buildEscalationStepTable(config), buildAlertTable(config), buildMonitorTable(config), buildTypeTable(config), buildGroupTable(config), buildSubscriptionTable(config),
|
||||
return (List.of(buildAlertTable(config), buildMonitorTable(config), buildTypeTable(config), buildGroupTable(config), buildSubscriptionTable(config),
|
||||
buildUserPreferenceTable(config), buildTeamTable(config), buildTeamMemberTable(config), buildChannelTable(config), buildMuteTable(config),
|
||||
buildDeliveryTable(config), buildEventTable(config), buildRecordRefTable(config), buildHeartbeatTable(config), buildNotificationTable(config),
|
||||
buildNotificationRecipientTable(config), buildNotificationPreferenceTable(config)));
|
||||
@@ -573,7 +516,7 @@ public final class AlertsTableMetaDataHelper
|
||||
public static List<QPossibleValueSource> buildPossibleValueSources()
|
||||
{
|
||||
List<QPossibleValueSource> sources = new ArrayList<>(AlertsPossibleValues.buildEnumSources());
|
||||
for(String table : List.of(AlertsEscalationPolicy.TABLE_NAME, AlertsAlert.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME,
|
||||
for(String table : List.of(AlertsAlert.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME,
|
||||
AlertsChannel.TABLE_NAME, AlertsSubscription.TABLE_NAME, AlertsNotification.TABLE_NAME))
|
||||
{
|
||||
sources.add(QPossibleValueSource.newForTable(table));
|
||||
@@ -589,7 +532,6 @@ public final class AlertsTableMetaDataHelper
|
||||
public static List<QJoinMetaData> buildJoins()
|
||||
{
|
||||
List<QJoinMetaData> joins = new ArrayList<>();
|
||||
joins.add(oneToMany(JOIN_POLICY_STEP, AlertsEscalationPolicy.TABLE_NAME, AlertsEscalationStep.TABLE_NAME, "policyId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_EVENT, AlertsAlert.TABLE_NAME, AlertsEvent.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_RECORD_REF, AlertsAlert.TABLE_NAME, AlertsRecordRef.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_DELIVERY, AlertsAlert.TABLE_NAME, AlertsDelivery.TABLE_NAME, "alertId"));
|
||||
@@ -618,9 +560,6 @@ public final class AlertsTableMetaDataHelper
|
||||
}
|
||||
|
||||
List<QWidgetMetaDataInterface> widgets = new ArrayList<>();
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(JOIN_POLICY_STEP)).withLabel("Steps").withCanAddChildRecord(true).withMaxRows(100)
|
||||
.withOrderBys(List.of(new QFilterOrderBy("stepOrder", true)))
|
||||
.withOnlyIncludeFieldNames(List.of("stepOrder", "afterMinutes", "targetType", "onlyIfStatus")).getWidgetMetaData());
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_EVENT), "Timeline", 50, new QFilterOrderBy("at", false), List.of("at", "eventType", "userId", "toValue", "note")));
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_RECORD_REF), "Records", 25, new QFilterOrderBy("id", true), List.of("tableName", "recordId", "label", "active")));
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
|
||||
@@ -631,7 +570,7 @@ public final class AlertsTableMetaDataHelper
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(JOIN_TEAM_MEMBER)).withLabel("Members").withCanAddChildRecord(true).withMaxRows(100)
|
||||
.withOnlyIncludeFieldNames(List.of("userId", "role")).getWidgetMetaData());
|
||||
widgets.add(childList(joins.get(JOIN_CHANNEL_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
|
||||
widgets.add(childList(joins.get(JOIN_NOTIFICATION_RECIPIENT), "Recipients", 25, new QFilterOrderBy("id", true), List.of("userId", "deliveredAt", "readAt", "dismissedAt", "emailStatus", "slackStatus")));
|
||||
widgets.add(childList(joins.get(JOIN_NOTIFICATION_RECIPIENT), "Recipients", 25, new QFilterOrderBy("id", true), List.of("userId", "deliveredAt", "readAt", "dismissedAt", "emailStatus")));
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,9 +147,6 @@ public class AlertsAlert extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer escalationLevel;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant timedNotificationCheckedAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -1383,35 +1380,4 @@ public class AlertsAlert extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public Instant getTimedNotificationCheckedAt()
|
||||
{
|
||||
return (this.timedNotificationCheckedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public void setTimedNotificationCheckedAt(Instant timedNotificationCheckedAt)
|
||||
{
|
||||
this.timedNotificationCheckedAt = timedNotificationCheckedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for timedNotificationCheckedAt
|
||||
*******************************************************************************/
|
||||
public AlertsAlert withTimedNotificationCheckedAt(Instant timedNotificationCheckedAt)
|
||||
{
|
||||
this.timedNotificationCheckedAt = timedNotificationCheckedAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -66,15 +66,6 @@ public class AlertsDelivery extends QRecordEntity
|
||||
@QField(isEditable = false, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_suppressed_reason")
|
||||
private String suppressedReason;
|
||||
|
||||
@QField(isEditable = false, maxLength = 200)
|
||||
private String digestKey;
|
||||
|
||||
@QField(isEditable = false, maxLength = 240)
|
||||
private String digestClaimKey;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer digestLastItemId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer attempts;
|
||||
|
||||
@@ -675,97 +666,4 @@ public class AlertsDelivery extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for digestKey
|
||||
*******************************************************************************/
|
||||
public String getDigestKey()
|
||||
{
|
||||
return (this.digestKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for digestKey
|
||||
*******************************************************************************/
|
||||
public void setDigestKey(String digestKey)
|
||||
{
|
||||
this.digestKey = digestKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for digestKey
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withDigestKey(String digestKey)
|
||||
{
|
||||
this.digestKey = digestKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for digestClaimKey
|
||||
*******************************************************************************/
|
||||
public String getDigestClaimKey()
|
||||
{
|
||||
return (this.digestClaimKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for digestClaimKey
|
||||
*******************************************************************************/
|
||||
public void setDigestClaimKey(String digestClaimKey)
|
||||
{
|
||||
this.digestClaimKey = digestClaimKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for digestClaimKey
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withDigestClaimKey(String digestClaimKey)
|
||||
{
|
||||
this.digestClaimKey = digestClaimKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for digestLastItemId
|
||||
*******************************************************************************/
|
||||
public Integer getDigestLastItemId()
|
||||
{
|
||||
return (this.digestLastItemId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for digestLastItemId
|
||||
*******************************************************************************/
|
||||
public void setDigestLastItemId(Integer digestLastItemId)
|
||||
{
|
||||
this.digestLastItemId = digestLastItemId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for digestLastItemId
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withDigestLastItemId(Integer digestLastItemId)
|
||||
{
|
||||
this.digestLastItemId = digestLastItemId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Timed notification steps for an unacknowledged alert.
|
||||
*******************************************************************************/
|
||||
public class AlertsEscalationPolicy extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_escalation_policy";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100)
|
||||
private String name;
|
||||
|
||||
@QField()
|
||||
private String description;
|
||||
|
||||
@QField()
|
||||
private Integer repeatAfterMinutes;
|
||||
|
||||
@QField()
|
||||
private Integer maxRepeats;
|
||||
|
||||
@QField()
|
||||
private Boolean enabled;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for description
|
||||
*******************************************************************************/
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public Integer getRepeatAfterMinutes()
|
||||
{
|
||||
return (this.repeatAfterMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public void setRepeatAfterMinutes(Integer repeatAfterMinutes)
|
||||
{
|
||||
this.repeatAfterMinutes = repeatAfterMinutes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for repeatAfterMinutes
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withRepeatAfterMinutes(Integer repeatAfterMinutes)
|
||||
{
|
||||
this.repeatAfterMinutes = repeatAfterMinutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public Integer getMaxRepeats()
|
||||
{
|
||||
return (this.maxRepeats);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public void setMaxRepeats(Integer maxRepeats)
|
||||
{
|
||||
this.maxRepeats = maxRepeats;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxRepeats
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withMaxRepeats(Integer maxRepeats)
|
||||
{
|
||||
this.maxRepeats = maxRepeats;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enabled
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (this.enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for enabled
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enabled
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationPolicy withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,6 @@ public class AlertsGroup extends QRecordEntity
|
||||
@QField(possibleValueSourceName = "alerts_team", label = "Owner Team")
|
||||
private Integer ownerTeamId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer escalationPolicyId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -397,35 +394,4 @@ public class AlertsGroup extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public Integer getEscalationPolicyId()
|
||||
{
|
||||
return (this.escalationPolicyId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public void setEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public AlertsGroup withEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -47,12 +47,9 @@ public class AlertsMute extends QRecordEntity
|
||||
@QField(isRequired = true, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_scope_type")
|
||||
private String scopeType;
|
||||
|
||||
@QField(maxLength = 520, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
@QField(maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String scopeValue;
|
||||
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Scope Record")
|
||||
private String scopeRecordId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Instant startsAt;
|
||||
|
||||
@@ -250,37 +247,6 @@ public class AlertsMute extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scopeRecordId
|
||||
*******************************************************************************/
|
||||
public String getScopeRecordId()
|
||||
{
|
||||
return (this.scopeRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scopeRecordId
|
||||
*******************************************************************************/
|
||||
public void setScopeRecordId(String scopeRecordId)
|
||||
{
|
||||
this.scopeRecordId = scopeRecordId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scopeRecordId
|
||||
*******************************************************************************/
|
||||
public AlertsMute withScopeRecordId(String scopeRecordId)
|
||||
{
|
||||
this.scopeRecordId = scopeRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startsAt
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -25,7 +25,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's settings for one notification category: in-app minimum severity (never for security), and email/Slack off, immediate or in a daily digest, from a minimum severity.
|
||||
** A user's settings for one notification category: muted in-app (never for security), and email off, immediate or in a daily digest, from a minimum severity.
|
||||
**
|
||||
** Generated from the qbit-alerts schema (one definition for the entity,
|
||||
** the Liquibase changelog and the test DDL).
|
||||
@@ -46,21 +46,12 @@ public class AlertsNotificationPreference extends QRecordEntity
|
||||
@QField(label = "Muted In-App")
|
||||
private Boolean inAppMuted;
|
||||
|
||||
@QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "In-App From Severity")
|
||||
private String inAppMinSeverity;
|
||||
|
||||
@QField(maxLength = 16, possibleValueSourceName = "alerts_email_mode", label = "Email")
|
||||
private String emailMode;
|
||||
|
||||
@QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "Email From Severity")
|
||||
private String emailMinSeverity;
|
||||
|
||||
@QField(maxLength = 16, possibleValueSourceName = "alerts_email_mode", label = "Slack")
|
||||
private String slackMode;
|
||||
|
||||
@QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "Slack From Severity")
|
||||
private String slackMinSeverity;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -308,93 +299,4 @@ public class AlertsNotificationPreference extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for inAppMinSeverity
|
||||
*******************************************************************************/
|
||||
public String getInAppMinSeverity()
|
||||
{
|
||||
return (this.inAppMinSeverity);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for inAppMinSeverity
|
||||
*******************************************************************************/
|
||||
public void setInAppMinSeverity(String inAppMinSeverity)
|
||||
{
|
||||
this.inAppMinSeverity = inAppMinSeverity;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for inAppMinSeverity
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withInAppMinSeverity(String inAppMinSeverity)
|
||||
{
|
||||
this.inAppMinSeverity = inAppMinSeverity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackMode
|
||||
*******************************************************************************/
|
||||
public String getSlackMode()
|
||||
{
|
||||
return (this.slackMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for slackMode
|
||||
*******************************************************************************/
|
||||
public void setSlackMode(String slackMode)
|
||||
{
|
||||
this.slackMode = slackMode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackMode
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withSlackMode(String slackMode)
|
||||
{
|
||||
this.slackMode = slackMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackMinSeverity
|
||||
*******************************************************************************/
|
||||
public String getSlackMinSeverity()
|
||||
{
|
||||
return (this.slackMinSeverity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for slackMinSeverity
|
||||
*******************************************************************************/
|
||||
public void setSlackMinSeverity(String slackMinSeverity)
|
||||
{
|
||||
this.slackMinSeverity = slackMinSeverity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackMinSeverity
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withSlackMinSeverity(String slackMinSeverity)
|
||||
{
|
||||
this.slackMinSeverity = slackMinSeverity;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,6 @@ public class AlertsNotificationRecipient extends QRecordEntity
|
||||
@QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_email_status")
|
||||
private String emailStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_slack_status")
|
||||
private String slackStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String slackMessageId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -367,64 +361,4 @@ public class AlertsNotificationRecipient extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackStatus
|
||||
*******************************************************************************/
|
||||
public String getSlackStatus()
|
||||
{
|
||||
return (this.slackStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for slackStatus
|
||||
*******************************************************************************/
|
||||
public void setSlackStatus(String slackStatus)
|
||||
{
|
||||
this.slackStatus = slackStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackStatus
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationRecipient withSlackStatus(String slackStatus)
|
||||
{
|
||||
this.slackStatus = slackStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for slackMessageId
|
||||
*******************************************************************************/
|
||||
public String getSlackMessageId()
|
||||
{
|
||||
return (this.slackMessageId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for slackMessageId
|
||||
*******************************************************************************/
|
||||
public void setSlackMessageId(String slackMessageId)
|
||||
{
|
||||
this.slackMessageId = slackMessageId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for slackMessageId
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationRecipient withSlackMessageId(String slackMessageId)
|
||||
{
|
||||
this.slackMessageId = slackMessageId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,13 @@ package com.kingsrook.qbits.alerts.model;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
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.ResolveMode;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
@@ -63,7 +63,6 @@ public final class AlertsPossibleValues
|
||||
public static final String AUDIENCE_TYPE = "alerts_audience_type";
|
||||
public static final String EMAIL_STATUS = "alerts_email_status";
|
||||
public static final String EMAIL_MODE = "alerts_email_mode";
|
||||
public static final String SLACK_STATUS = "alerts_slack_status";
|
||||
|
||||
|
||||
|
||||
@@ -133,7 +132,7 @@ public final class AlertsPossibleValues
|
||||
*******************************************************************************/
|
||||
public enum ScopeType
|
||||
{
|
||||
ALL, GROUP, ALERT_TYPE, MONITOR, TABLE, RECORD, ATTRIBUTE, ALERT
|
||||
ALL, GROUP, ALERT_TYPE, MONITOR, TABLE, RECORD, ATTRIBUTE
|
||||
}
|
||||
|
||||
|
||||
@@ -224,7 +223,7 @@ public final class AlertsPossibleValues
|
||||
*******************************************************************************/
|
||||
public enum SuppressedReason
|
||||
{
|
||||
QUIET_HOURS, MUTED, FLAPPING, RATE_LIMITED, NOT_VISIBLE, CHANNEL_DISABLED, DUPLICATE, USER_PAUSED, TYPE_SILENT, NO_ADDRESS, MIN_SEVERITY
|
||||
QUIET_HOURS, MUTED, FLAPPING, RATE_LIMITED, NOT_VISIBLE, CHANNEL_DISABLED, DUPLICATE, USER_PAUSED, TYPE_SILENT, NO_ADDRESS
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +251,7 @@ public final class AlertsPossibleValues
|
||||
enumSource(MONITOR_KIND, AlertMonitorKind.values()),
|
||||
enumSource(MONITOR_STATUS, MonitorStatus.values()),
|
||||
enumSource(EVENT_TYPE, EventType.values()),
|
||||
enumSource(RESOLUTION, new Resolution[] { Resolution.PRODUCER, Resolution.MONITOR, Resolution.QUIET_PERIOD, Resolution.MANUAL }),
|
||||
enumSource(RESOLUTION, Resolution.values()),
|
||||
enumSource(SUBSCRIBER_TYPE, SubscriberType.values()),
|
||||
enumSource(SCOPE_TYPE, ScopeType.values()),
|
||||
enumSource(DELIVERY_MODE, DeliveryMode.values()),
|
||||
@@ -269,8 +268,7 @@ public final class AlertsPossibleValues
|
||||
enumSource(NOTIFICATION_STATUS, NotificationStatus.values()),
|
||||
enumSource(AUDIENCE_TYPE, NotificationAudience.Type.values()),
|
||||
enumSource(EMAIL_STATUS, EmailStatus.values()),
|
||||
enumSource(EMAIL_MODE, EmailMode.values()),
|
||||
enumSource(SLACK_STATUS, SlackStatus.values())));
|
||||
enumSource(EMAIL_MODE, EmailMode.values())));
|
||||
}
|
||||
|
||||
|
||||
@@ -289,16 +287,6 @@ public final class AlertsPossibleValues
|
||||
** Whether (and how) a recipient was emailed.
|
||||
*******************************************************************************/
|
||||
public enum EmailStatus
|
||||
{
|
||||
NONE, PENDING, REQUESTED, SENT, DIGESTED, SKIPPED, FAILED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether (and how) a recipient was sent a Slack direct message.
|
||||
*******************************************************************************/
|
||||
public enum SlackStatus
|
||||
{
|
||||
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
|
||||
}
|
||||
@@ -306,7 +294,7 @@ public final class AlertsPossibleValues
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How a user wants a category by email (also used for Slack: same modes).
|
||||
** How a user wants a category by email.
|
||||
*******************************************************************************/
|
||||
public enum EmailMode
|
||||
{
|
||||
@@ -324,7 +312,7 @@ public final class AlertsPossibleValues
|
||||
List<QPossibleValue<?>> possibleValues = new ArrayList<>();
|
||||
for(Enum<?> value : values)
|
||||
{
|
||||
possibleValues.add(new QPossibleValue<>(value.name(), value == EmailMode.DAILY_DIGEST ? "Daily digest" : label(value.name())));
|
||||
possibleValues.add(new QPossibleValue<>(value.name(), label(value.name())));
|
||||
}
|
||||
return (new QPossibleValueSource()
|
||||
.withName(name)
|
||||
|
||||
@@ -96,9 +96,6 @@ public class AlertsType extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer openCount;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer escalationPolicyId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -805,35 +802,4 @@ public class AlertsType extends QRecordEntity
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public Integer getEscalationPolicyId()
|
||||
{
|
||||
return (this.escalationPolicyId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public void setEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for escalationPolicyId
|
||||
*******************************************************************************/
|
||||
public AlertsType withEscalationPolicyId(Integer escalationPolicyId)
|
||||
{
|
||||
this.escalationPolicyId = escalationPolicyId;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,15 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -217,14 +218,20 @@ public class AlertsAudienceResolver
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A session for a user: on their behalf via the alerts service principal
|
||||
** when declared, else the authentication module's (null when neither).
|
||||
** A session for a user from the instance's authentication module (null
|
||||
** when it can't make one).
|
||||
*******************************************************************************/
|
||||
private QSession automatedSession(String userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
|
||||
QInstance qInstance = runtime.getQInstance();
|
||||
if(qInstance.getAuthentication() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
|
||||
return (module.createAutomatedSessionForUser(qInstance, userId));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
|
||||
@@ -21,26 +21,30 @@ package com.kingsrook.qbits.alerts.notifications;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ready-made notifications for the automatic sources hosts wire up
|
||||
** (docs/NOTIFICATIONS.md 6.1): security notices, shares and assignments,
|
||||
** and release announcements. Each one is a NotificationSignal sent through
|
||||
** core QAlerts (queued; it never throws into the caller). With no Alerts
|
||||
** qbit installed core uses its logging sink.
|
||||
** the alerts sink (queued; it never throws into the caller). With no Alerts
|
||||
** qbit installed they do nothing.
|
||||
**
|
||||
** Core has no sign-in or API key events yet, so a host calls the security
|
||||
** notices from its authentication customizer or API key screens.
|
||||
*******************************************************************************/
|
||||
public final class AlertsNotices
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsNotices.class);
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
@@ -110,7 +114,7 @@ public final class AlertsNotices
|
||||
*******************************************************************************/
|
||||
public static void shared(String userId, String title, AlertLink link, String sourceName)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
sendSignal(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
@@ -121,7 +125,7 @@ public final class AlertsNotices
|
||||
*******************************************************************************/
|
||||
public static void release(String version, String notesMarkdown, AlertLink link)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS)
|
||||
sendSignal(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS)
|
||||
.withCategory(NotificationCategory.RELEASE).withSourceName("release").withSourceKey("release:" + version).withLink(link)
|
||||
.withAudience(NotificationAudience.allUsers()));
|
||||
}
|
||||
@@ -137,7 +141,29 @@ public final class AlertsNotices
|
||||
{
|
||||
return;
|
||||
}
|
||||
QAlerts.notify(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
sendSignal(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Through the installed qbit's sink (nothing when it isn't installed).
|
||||
*******************************************************************************/
|
||||
private static void sendSignal(NotificationSignal signal)
|
||||
{
|
||||
try
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
AlertsRuntime runtime = AlertsRuntime.find(qInstance);
|
||||
if(runtime != null && signal.getAudience() != null)
|
||||
{
|
||||
runtime.sink().notify(signal);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not send a notification", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
package com.kingsrook.qbits.alerts.notifications;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
@@ -29,26 +28,18 @@ import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
|
||||
import com.kingsrook.qbits.alerts.email.AlertsEmail;
|
||||
import com.kingsrook.qbits.alerts.email.AlertsEmailSender;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertTiming;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
@@ -58,7 +49,6 @@ import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
@@ -71,15 +61,7 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** - IMMEDIATE: one email per notification at or above the minimum severity;
|
||||
** - DAILY_DIGEST: one email at the user's digest hour (in their time zone,
|
||||
** from My Alert Settings) listing the day's unread notifications.
|
||||
** A recipient with email status REQUESTED (a per-notification opt-in, e.g. by
|
||||
** a job) is emailed immediately without a standing category preference. It
|
||||
** still honours the category's email minimum severity when one is set, the
|
||||
** user's pause (SECURITY bypasses) and quiet hours (held, not dropped), is
|
||||
** skipped without a known address, and is only eligible for a day.
|
||||
** Sent through qbit-email when it is produced in the instance (one message
|
||||
** per recipient; templates alerts_notification and alerts_digest,
|
||||
** NOTIFICATION), else through core SendMessageAction. Inert without either
|
||||
** (a messaging provider and a From address). ALERT
|
||||
** Inert without a messaging provider and a From address. ALERT
|
||||
** notifications are never emailed here (alert subscriptions already email
|
||||
** them). SECURITY emails carry the title and link only. Runs as the system
|
||||
** user from the delivery job.
|
||||
@@ -118,10 +100,6 @@ public class NotificationEmailer
|
||||
*******************************************************************************/
|
||||
public boolean isConfigured()
|
||||
{
|
||||
if(AlertsEmail.isActive())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
AlertsQBitConfig config = runtime.getConfig();
|
||||
return (StringUtils.hasContent(config.getMessagingProviderName()) && StringUtils.hasContent(config.getFromAddress()));
|
||||
}
|
||||
@@ -146,84 +124,50 @@ public class NotificationEmailer
|
||||
{
|
||||
preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
|
||||
}
|
||||
|
||||
QQueryFilter eligible = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withSubFilter(new QQueryFilter()
|
||||
.withCriteria("emailStatus", QCriteriaOperator.EQUALS, EmailStatus.REQUESTED.name())
|
||||
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)));
|
||||
if(!preferences.isEmpty())
|
||||
if(preferences.isEmpty())
|
||||
{
|
||||
eligible.withSubFilter(new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
|
||||
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name())));
|
||||
return (new Result(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
Map<String, QRecord> settings = new HashMap<>();
|
||||
int sent = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
|
||||
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name()))
|
||||
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS))
|
||||
.withLimit(SCAN));
|
||||
Map<Integer, QRecord> notifications = notifications(recipients, now);
|
||||
Map<String, QRecord> settings = userSettings(preferences.keySet());
|
||||
|
||||
int sent = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
Map<String, List<QRecord[]>> digests = new LinkedHashMap<>();
|
||||
int afterId = 0;
|
||||
while(true)
|
||||
for(QRecord recipient : recipients)
|
||||
{
|
||||
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
|
||||
.withSubFilter(eligible)
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId)
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(SCAN));
|
||||
if(recipients.isEmpty())
|
||||
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
|
||||
String userId = recipient.getValueString("userId");
|
||||
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
|
||||
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|
||||
|| !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))
|
||||
{
|
||||
break;
|
||||
mark(recipient, EmailStatus.SKIPPED, null);
|
||||
skipped++;
|
||||
}
|
||||
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
|
||||
Map<Integer, QRecord> notifications = notifications(recipients, now);
|
||||
Set<String> pageUsers = recipients.stream().map(recipient -> recipient.getValueString("userId")).collect(Collectors.toSet());
|
||||
settings.putAll(userSettings(pageUsers));
|
||||
Map<String, Map<String, QRecord>> requestedPreferences = new HashMap<>();
|
||||
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(pageUsers))))
|
||||
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
|
||||
{
|
||||
requestedPreferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
|
||||
boolean ok = send(address(settings.get(userId)), notification.getValueString("title"), List.of(notification));
|
||||
mark(recipient, ok ? EmailStatus.SENT : EmailStatus.FAILED, ok ? now : null);
|
||||
if(ok)
|
||||
{
|
||||
sent++;
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
for(QRecord recipient : recipients)
|
||||
else if(recipient.getValue("readAt") == null)
|
||||
{
|
||||
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
|
||||
String userId = recipient.getValueString("userId");
|
||||
boolean requested = EmailStatus.REQUESTED.name().equals(recipient.getValueString("emailStatus"));
|
||||
String category = notification == null ? null : notification.getValueString("category");
|
||||
QRecord preference = category == null ? null : (requested ? requestedPreferences : preferences).getOrDefault(userId, Map.of()).get(category);
|
||||
QRecord setting = settings.get(userId);
|
||||
if(notification == null || NotificationCategory.ALERT.name().equals(category)
|
||||
|| (requested ? (!StringUtils.hasContent(address(setting)) || (preference != null && StringUtils.hasContent(preference.getValueString("emailMinSeverity"))
|
||||
&& !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity"))))
|
||||
: (preference == null || !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))))
|
||||
{
|
||||
mark(recipient, EmailStatus.SKIPPED, null);
|
||||
skipped++;
|
||||
}
|
||||
else if(!NotificationCategory.SECURITY.name().equals(category)
|
||||
&& setting != null && setting.getValueInstant("pausedUntil") != null
|
||||
&& setting.getValueInstant("pausedUntil").isAfter(now))
|
||||
{
|
||||
// Leave the recipient pending so a later run can deliver it.
|
||||
skipped++;
|
||||
}
|
||||
else if(requested && AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now))
|
||||
{
|
||||
// Quiet hours hold a requested email; it stays REQUESTED until the window ends or it ages out.
|
||||
skipped++;
|
||||
}
|
||||
else if(requested || EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
|
||||
{
|
||||
EmailStatus status = sendImmediate(address(settings.get(userId)), notification);
|
||||
mark(recipient, status, status == EmailStatus.SENT ? now : null);
|
||||
sent += status == EmailStatus.SENT ? 1 : 0;
|
||||
skipped += status == EmailStatus.SKIPPED ? 1 : 0;
|
||||
failed += status == EmailStatus.FAILED ? 1 : 0;
|
||||
}
|
||||
else if(recipient.getValue("readAt") == null)
|
||||
{
|
||||
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
|
||||
}
|
||||
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,14 +180,13 @@ public class NotificationEmailer
|
||||
continue;
|
||||
}
|
||||
List<QRecord> items = digest.getValue().stream().map(pair -> pair[1]).toList();
|
||||
Instant window = now.atZone(zone(setting)).truncatedTo(ChronoUnit.HOURS).toInstant();
|
||||
EmailStatus status = sendDigest(address(setting), digest.getKey(), window, items);
|
||||
boolean ok = send(address(setting), items.size() == 1 ? "1 new notification" : items.size() + " new notifications", items);
|
||||
for(QRecord[] pair : digest.getValue())
|
||||
{
|
||||
mark(pair[0], status == EmailStatus.SENT ? EmailStatus.DIGESTED : status, status == EmailStatus.SENT ? now : null);
|
||||
mark(pair[0], ok ? EmailStatus.DIGESTED : EmailStatus.FAILED, ok ? now : null);
|
||||
}
|
||||
digestCount += status == EmailStatus.SENT ? 1 : 0;
|
||||
failed += status == EmailStatus.FAILED ? 1 : 0;
|
||||
digestCount += ok ? 1 : 0;
|
||||
failed += ok ? 0 : 1;
|
||||
}
|
||||
return (new Result(sent, digestCount, skipped, failed));
|
||||
}));
|
||||
@@ -257,28 +200,19 @@ public class NotificationEmailer
|
||||
static boolean isDigestHour(QRecord setting, Instant now)
|
||||
{
|
||||
Integer hour = setting == null ? null : setting.getValueInteger("digestHour");
|
||||
return (now.atZone(zone(setting)).getHour() == (hour == null ? 8 : hour));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user time zone (default UTC).
|
||||
*******************************************************************************/
|
||||
private static ZoneId zone(QRecord setting)
|
||||
{
|
||||
ZoneId zone = ZoneOffset.UTC;
|
||||
try
|
||||
{
|
||||
if(setting != null && StringUtils.hasContent(setting.getValueString("timeZone")))
|
||||
{
|
||||
return (ZoneId.of(setting.getValueString("timeZone")));
|
||||
zone = ZoneId.of(setting.getValueString("timeZone"));
|
||||
}
|
||||
}
|
||||
catch(DateTimeException e)
|
||||
{
|
||||
return (ZoneOffset.UTC);
|
||||
zone = ZoneOffset.UTC;
|
||||
}
|
||||
return (ZoneOffset.UTC);
|
||||
return (now.atZone(zone).getHour() == (hour == null ? 8 : hour));
|
||||
}
|
||||
|
||||
|
||||
@@ -288,132 +222,14 @@ public class NotificationEmailer
|
||||
*******************************************************************************/
|
||||
static boolean atLeast(String severity, String minimum)
|
||||
{
|
||||
return (NotificationService.severityOf(severity, NotificationSeverity.INFO).compareTo(NotificationService.severityOf(minimum, NotificationSeverity.WARNING)) >= 0);
|
||||
return (NotificationSeverity.fromName(severity, NotificationSeverity.INFO).compareTo(NotificationSeverity.fromName(minimum, NotificationSeverity.WARNING)) >= 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One notification emailed to one user: SENT, SKIPPED (the person
|
||||
** unsubscribed) or FAILED.
|
||||
*******************************************************************************/
|
||||
private EmailStatus sendImmediate(String address, QRecord notification)
|
||||
{
|
||||
if(!StringUtils.hasContent(address))
|
||||
{
|
||||
return (EmailStatus.FAILED);
|
||||
}
|
||||
if(!AlertsEmail.isActive())
|
||||
{
|
||||
return (send(address, notification.getValueString("title"), List.of(notification)) ? EmailStatus.SENT : EmailStatus.FAILED);
|
||||
}
|
||||
try
|
||||
{
|
||||
AlertsEmailSender.Result result = AlertsEmailSender.sendNotification(address, AlertsProcessesMetaDataProducer.DELIVER_NOTIFICATIONS, notification.getValueInteger("id"),
|
||||
item(notification), settingsLink());
|
||||
return (status(result));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not email a notification", e, logPair("notificationId", notification.getValue("id")));
|
||||
return (EmailStatus.FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user digest emailed: SENT, SKIPPED (the person unsubscribed) or FAILED.
|
||||
*******************************************************************************/
|
||||
private EmailStatus sendDigest(String address, String userId, Instant windowStart, List<QRecord> notifications)
|
||||
{
|
||||
if(!StringUtils.hasContent(address))
|
||||
{
|
||||
return (EmailStatus.FAILED);
|
||||
}
|
||||
if(!AlertsEmail.isActive())
|
||||
{
|
||||
return (send(address, notifications.size() == 1 ? "1 new notification" : notifications.size() + " new notifications", notifications) ? EmailStatus.SENT : EmailStatus.FAILED);
|
||||
}
|
||||
try
|
||||
{
|
||||
List<Map<String, Serializable>> items = new ArrayList<>();
|
||||
for(QRecord notification : notifications)
|
||||
{
|
||||
items.add(item(notification));
|
||||
}
|
||||
return (status(AlertsEmailSender.sendDigest(address, AlertsProcessesMetaDataProducer.DELIVER_NOTIFICATIONS, userId, windowStart, notifications.get(0).getValueInteger("id"),
|
||||
items, settingsLink())));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not email a digest", e, logPair("userId", userId));
|
||||
return (EmailStatus.FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit-email answer in this qbit email status vocabulary. A repeat
|
||||
** (DUPLICATE) was already queued, so it counts as sent.
|
||||
*******************************************************************************/
|
||||
private static EmailStatus status(AlertsEmailSender.Result result)
|
||||
{
|
||||
return (switch(result.status())
|
||||
{
|
||||
case QUEUED, DUPLICATE -> EmailStatus.SENT;
|
||||
case SUPPRESSED -> EmailStatus.SKIPPED;
|
||||
case REFUSED ->
|
||||
{
|
||||
LOG.warn("Email was refused", logPair("reasons", AlertRedactor.redact(result.detail())));
|
||||
yield (EmailStatus.FAILED);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The template variables for one notification: title, body (not for
|
||||
** SECURITY), and an absolute link with its label.
|
||||
*******************************************************************************/
|
||||
private Map<String, Serializable> item(QRecord notification)
|
||||
{
|
||||
Map<String, Serializable> item = new LinkedHashMap<>();
|
||||
item.put("title", notification.getValueString("title"));
|
||||
String body = NotificationCategory.SECURITY.name().equals(notification.getValueString("category")) ? null : notification.getValueString("body");
|
||||
if(StringUtils.hasContent(body))
|
||||
{
|
||||
item.put("body", body);
|
||||
}
|
||||
String link = absolute(NotificationService.safeLink(notification.getValueString("linkUrl")));
|
||||
if(link != null && (link.startsWith("https://") || link.startsWith("http://")))
|
||||
{
|
||||
item.put("link", link);
|
||||
if(StringUtils.hasContent(notification.getValueString("linkLabel")))
|
||||
{
|
||||
item.put("linkLabel", notification.getValueString("linkLabel"));
|
||||
}
|
||||
}
|
||||
return (item);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a person changes these emails.
|
||||
*******************************************************************************/
|
||||
private String settingsLink()
|
||||
{
|
||||
return (absolute("/app/" + AlertsNotificationPreference.TABLE_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One email through core SendMessageAction: the notification titles,
|
||||
** bodies (not for SECURITY) and links. False when it could not be sent.
|
||||
** One email: the notifications' titles, bodies (not for SECURITY) and
|
||||
** links. False when it could not be sent (or there is no address).
|
||||
*******************************************************************************/
|
||||
private boolean send(String address, String subject, List<QRecord> items)
|
||||
{
|
||||
@@ -470,16 +286,6 @@ public class NotificationEmailer
|
||||
** A link made absolute with the public base URL (relative app paths).
|
||||
*******************************************************************************/
|
||||
private String absolute(String link)
|
||||
{
|
||||
return (absolute(runtime, link));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link made absolute with the public base URL (relative app paths).
|
||||
*******************************************************************************/
|
||||
static String absolute(AlertsRuntime runtime, String link)
|
||||
{
|
||||
String base = runtime.getConfig().getPublicBaseUrl();
|
||||
if(link == null || !link.startsWith("/") || !StringUtils.hasContent(base))
|
||||
@@ -504,7 +310,7 @@ public class NotificationEmailer
|
||||
/*******************************************************************************
|
||||
** The live (SENT, published, not expired) notifications for the rows.
|
||||
*******************************************************************************/
|
||||
static Map<Integer, QRecord> notifications(List<QRecord> recipients, Instant now) throws QException
|
||||
private static Map<Integer, QRecord> notifications(List<QRecord> recipients, Instant now) throws QException
|
||||
{
|
||||
Map<Integer, QRecord> live = new HashMap<>();
|
||||
List<Integer> ids = recipients.stream().map(recipient -> recipient.getValueInteger("notificationId")).distinct().toList();
|
||||
@@ -529,7 +335,7 @@ public class NotificationEmailer
|
||||
/*******************************************************************************
|
||||
** My Alert Settings rows (address, time zone, digest hour) by user.
|
||||
*******************************************************************************/
|
||||
static Map<String, QRecord> userSettings(java.util.Set<String> userIds) throws QException
|
||||
private static Map<String, QRecord> userSettings(java.util.Set<String> userIds) throws QException
|
||||
{
|
||||
Map<String, QRecord> settings = new HashMap<>();
|
||||
for(QRecord setting : AlertsDb.query(AlertsUserPreference.TABLE_NAME, new QQueryFilter().withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(userIds))))
|
||||
|
||||
@@ -22,26 +22,23 @@ import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.delivery.AlertMessage;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -173,7 +170,7 @@ public class NotificationService
|
||||
|
||||
AlertsAudienceResolver.Resolved resolved = resolver.resolve(audienceOf(notification));
|
||||
Set<String> existing = recipientUserIds(notificationId);
|
||||
Set<String> muted = mutedUsers(notification.getValueString("category"), notification.getValueString("severity"));
|
||||
Set<String> muted = mutedUsers(notification.getValueString("category"));
|
||||
|
||||
List<QRecord> inserts = new ArrayList<>();
|
||||
for(String userId : resolved.userIds())
|
||||
@@ -338,7 +335,7 @@ public class NotificationService
|
||||
QRecord values = new QRecord()
|
||||
.withValue("title", StringUtils.safeTruncate(alert.getValueString("title"), 200))
|
||||
.withValue("body", alert.getValueString("summary"))
|
||||
.withValue("severity", severityOf(alert.getValueString("severity"), NotificationSeverity.WARNING).name())
|
||||
.withValue("severity", NotificationSeverity.fromName(alert.getValueString("severity"), NotificationSeverity.WARNING).name())
|
||||
.withValue("category", NotificationCategory.ALERT.name())
|
||||
.withValue("sourceName", StringUtils.hasContent(alert.getValueString("sourceName")) ? alert.getValueString("sourceName") : "alerts")
|
||||
.withValue("sourceKey", sourceKey)
|
||||
@@ -382,29 +379,6 @@ public class NotificationService
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bell notification for a claimed digest, with all its alert links.
|
||||
** ALERT category keeps the general notification emailer from emailing it.
|
||||
*******************************************************************************/
|
||||
public void deliverDigest(String userId, AlertMessage message, Instant now) throws QException
|
||||
{
|
||||
QRecord existing = AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", message.idempotencyKey()));
|
||||
if(existing != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QRecord notification = AlertsDb.insert(AlertsNotification.TABLE_NAME, new QRecord()
|
||||
.withValue("title", message.subject()).withValue("body", message.text()).withValue("severity", message.severity())
|
||||
.withValue("category", NotificationCategory.ALERT.name()).withValue("status", STATUS_SENT)
|
||||
.withValue("sourceName", "alerts").withValue("sourceKey", message.idempotencyKey())
|
||||
.withValue("linkUrl", "/app/" + AlertsAlert.TABLE_NAME).withValue("linkLabel", "Open alerts")
|
||||
.withValue("audienceType", NotificationAudience.Type.USERS.name()).withValue("audienceValue", userId)
|
||||
.withValue("publishAt", now).withValue("sentAt", now).withValue("recipientCount", 1));
|
||||
AlertsDb.insert(AlertsNotificationRecipient.TABLE_NAME, newRecipient(notification.getValueInteger("id"), userId, now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recount a notification's readers (after people mark it read or unread).
|
||||
*******************************************************************************/
|
||||
@@ -428,42 +402,7 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience audienceOf(QRecord notification)
|
||||
{
|
||||
String type = notification.getValueString("audienceType");
|
||||
String value = notification.getValueString("audienceValue");
|
||||
String[] values = value == null ? new String[0] : Arrays.stream(value.split("[,\n]"))
|
||||
.map(String::trim).filter(v -> !v.isEmpty()).distinct().toArray(String[]::new);
|
||||
if(type == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (switch(type.trim().toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "ALL_USERS" -> NotificationAudience.allUsers();
|
||||
case "ROLES" -> NotificationAudience.roles(values);
|
||||
case "PERMISSION" -> NotificationAudience.permission(values.length == 0 ? null : values[0]);
|
||||
case "USERS" -> NotificationAudience.users(values);
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a stored severity without changing legacy case and fallback behavior.
|
||||
*******************************************************************************/
|
||||
public static NotificationSeverity severityOf(String name, NotificationSeverity fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationSeverity severity : NotificationSeverity.values())
|
||||
{
|
||||
if(severity.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (severity);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
return (NotificationAudience.parse(notification.getValueString("audienceType"), notification.getValueString("audienceValue")));
|
||||
}
|
||||
|
||||
|
||||
@@ -544,7 +483,7 @@ public class NotificationService
|
||||
.withValue("linkUrl", linkUrl(signal.getLink()))
|
||||
.withValue("linkLabel", signal.getLink() == null ? null : signal.getLink().getLabel())
|
||||
.withValue("audienceType", signal.getAudience().getType().name())
|
||||
.withValue("audienceValue", audienceValue(signal.getAudience()))
|
||||
.withValue("audienceValue", signal.getAudience().valuesAsString())
|
||||
.withValue("publishAt", signal.getPublishAt())
|
||||
.withValue("expireAt", signal.getExpireAt())
|
||||
.withValue("sticky", Boolean.TRUE.equals(signal.getSticky())));
|
||||
@@ -557,29 +496,18 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
private static QFilterCriteria audienceValueCriteria(NotificationAudience audience)
|
||||
{
|
||||
String value = audienceValue(audience);
|
||||
String value = audience.valuesAsString();
|
||||
return (value == null ? new QFilterCriteria("audienceValue", QCriteriaOperator.IS_BLANK) : new QFilterCriteria("audienceValue", QCriteriaOperator.EQUALS, value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the qbit's stored audience format and dedup keys with core types.
|
||||
*******************************************************************************/
|
||||
private static String audienceValue(NotificationAudience audience)
|
||||
{
|
||||
List<String> values = audience.getValues().stream().map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
return (values.isEmpty() ? null : String.join(",", values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QRecord newRecipient(Integer notificationId, String userId, Instant now)
|
||||
{
|
||||
return (new QRecord().withValue("notificationId", notificationId).withValue("userId", userId).withValue("deliveredAt", now).withValue("emailStatus", "NONE").withValue("slackStatus", "NONE"));
|
||||
return (new QRecord().withValue("notificationId", notificationId).withValue("userId", userId).withValue("deliveredAt", now).withValue("emailStatus", "NONE"));
|
||||
}
|
||||
|
||||
|
||||
@@ -602,20 +530,16 @@ public class NotificationService
|
||||
/*******************************************************************************
|
||||
** Users who muted a category in-app (never for SECURITY).
|
||||
*******************************************************************************/
|
||||
private Set<String> mutedUsers(String category, String severity) throws QException
|
||||
private Set<String> mutedUsers(String category) throws QException
|
||||
{
|
||||
Set<String> userIds = new HashSet<>();
|
||||
if(category == null || NotificationCategory.SECURITY.name().equals(category))
|
||||
{
|
||||
return (userIds);
|
||||
}
|
||||
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("category", category)))
|
||||
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("category", category).withCriteria("inAppMuted", QCriteriaOperator.EQUALS, true)))
|
||||
{
|
||||
String minimum = preference.getValueString("inAppMinSeverity");
|
||||
if(Boolean.TRUE.equals(preference.getValueBoolean("inAppMuted")) || (minimum != null && !NotificationEmailer.atLeast(severity, minimum)))
|
||||
{
|
||||
userIds.add(preference.getValueString("userId"));
|
||||
}
|
||||
userIds.add(preference.getValueString("userId"));
|
||||
}
|
||||
return (userIds);
|
||||
}
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.notifications;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertRouter;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertTiming;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SlackStatus;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.ContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Slack direct messages for user notifications (docs/NOTIFICATIONS.md section
|
||||
** 7), opt-in per user and category in My Notification Settings, alongside
|
||||
** the bell and email:
|
||||
** - IMMEDIATE: one DM per notification at or above slackMinSeverity;
|
||||
** - DAILY_DIGEST: one DM per user at their digest hour listing their unread
|
||||
** notifications.
|
||||
** Sent only through core SendMessageAction (provider slackProviderName,
|
||||
** address "user:<id>", the recipient as owner, so the DM travels through that
|
||||
** user's own Slack connection); qbit-slack is never imported. Slack is a push
|
||||
** channel: pause and quiet hours hold it (CRITICAL bypasses quiet hours),
|
||||
** never the bell. ALERT notifications are not sent here. SECURITY DMs carry
|
||||
** the title and link only. A user without a Slack connection (the provider
|
||||
** throws a QUserFacingException) is SKIPPED with no retry; other failures are
|
||||
** FAILED. Never fails the notification itself. Runs as the system user from
|
||||
** the delivery job.
|
||||
*******************************************************************************/
|
||||
public class NotificationSlackSender
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(NotificationSlackSender.class);
|
||||
|
||||
private final AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What one run did.
|
||||
*******************************************************************************/
|
||||
public record Result(int sent, int digests, int held, int skipped, int failed)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public NotificationSlackSender(AlertsRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send what is due: immediate DMs, and digest DMs for users at their hour.
|
||||
*******************************************************************************/
|
||||
public Result run() throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
Map<String, Map<String, QRecord>> preferences = new HashMap<>();
|
||||
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("slackMode", QCriteriaOperator.IN, List.of(EmailMode.IMMEDIATE.name(), EmailMode.DAILY_DIGEST.name()))))
|
||||
{
|
||||
preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
|
||||
}
|
||||
if(preferences.isEmpty())
|
||||
{
|
||||
return (new Result(0, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
Map<String, QRecord> settings = NotificationEmailer.userSettings(preferences.keySet());
|
||||
boolean available = isAvailable();
|
||||
|
||||
int sent = 0;
|
||||
int held = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
Map<String, List<QRecord[]>> digests = new LinkedHashMap<>();
|
||||
int afterId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
|
||||
.withCriteria("slackStatus", QCriteriaOperator.IN, List.of(SlackStatus.NONE.name(), SlackStatus.PENDING.name()))
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId)
|
||||
.withOrderBy(new QFilterOrderBy("id", true))
|
||||
.withLimit(NotificationEmailer.SCAN));
|
||||
if(recipients.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
|
||||
Map<Integer, QRecord> notifications = NotificationEmailer.notifications(recipients, now);
|
||||
for(QRecord recipient : recipients)
|
||||
{
|
||||
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
|
||||
String userId = recipient.getValueString("userId");
|
||||
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
|
||||
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|
||||
|| !NotificationEmailer.atLeast(notification.getValueString("severity"), preference.getValueString("slackMinSeverity")) || !available)
|
||||
{
|
||||
mark(recipient, SlackStatus.SKIPPED);
|
||||
skipped++;
|
||||
}
|
||||
else if(!NotificationCategory.SECURITY.name().equals(notification.getValueString("category")) && AlertRouter.isPaused(settings.get(userId), now))
|
||||
{
|
||||
held++;
|
||||
}
|
||||
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("slackMode")))
|
||||
{
|
||||
if(isHeld(settings.get(userId), notification, now))
|
||||
{
|
||||
held++;
|
||||
continue;
|
||||
}
|
||||
SlackStatus status = sendExternal(userId, notification.getValueString("title"), List.of(notification), notification.getValueInteger("id"));
|
||||
mark(recipient, status);
|
||||
sent += status == SlackStatus.SENT ? 1 : 0;
|
||||
skipped += status == SlackStatus.SKIPPED ? 1 : 0;
|
||||
failed += status == SlackStatus.FAILED ? 1 : 0;
|
||||
}
|
||||
else if(recipient.getValue("readAt") == null)
|
||||
{
|
||||
digests.computeIfAbsent(userId, k -> new ArrayList<>()).add(new QRecord[] {recipient, notification});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int digestCount = 0;
|
||||
for(Map.Entry<String, List<QRecord[]>> digest : digests.entrySet())
|
||||
{
|
||||
QRecord setting = settings.get(digest.getKey());
|
||||
if(!NotificationEmailer.isDigestHour(setting, now))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
List<QRecord> items = digest.getValue().stream().map(pair -> pair[1]).toList();
|
||||
if(items.stream().allMatch(item -> isHeld(setting, item, now)))
|
||||
{
|
||||
held++;
|
||||
continue;
|
||||
}
|
||||
SlackStatus status = sendExternal(digest.getKey(), items.size() == 1 ? "1 new notification" : items.size() + " new notifications", items, null);
|
||||
for(QRecord[] pair : digest.getValue())
|
||||
{
|
||||
mark(pair[0], status == SlackStatus.SENT ? SlackStatus.DIGESTED : status);
|
||||
}
|
||||
digestCount += status == SlackStatus.SENT ? 1 : 0;
|
||||
failed += status == SlackStatus.FAILED ? 1 : 0;
|
||||
}
|
||||
return (new Result(sent, digestCount, held, skipped, failed));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the Slack messaging provider is registered in this instance.
|
||||
** RC.6 core has no SendMessageAction.isAvailable, so ask the instance.
|
||||
*******************************************************************************/
|
||||
private boolean isAvailable()
|
||||
{
|
||||
String name = runtime.getConfig().getSlackProviderName();
|
||||
return (StringUtils.hasContent(name) && QContext.getQInstance().getMessagingProvider(name) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a push is held for now: the user paused notifications, or it is
|
||||
** their quiet hours (CRITICAL bypasses). SECURITY bypasses the pause.
|
||||
** The bell is never held.
|
||||
*******************************************************************************/
|
||||
private static boolean isHeld(QRecord setting, QRecord notification, Instant now)
|
||||
{
|
||||
return ((!NotificationCategory.SECURITY.name().equals(notification.getValueString("category")) && AlertRouter.isPaused(setting, now))
|
||||
|| AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The external notification channel step: one Slack DM to the user (the
|
||||
** recipient is the owner of the connection that sends it) with the
|
||||
** notification titles, bodies (not for SECURITY) and links. SENT; SKIPPED
|
||||
** when the provider says the user can't be reached (no Slack connection),
|
||||
** with no retry; FAILED for anything else.
|
||||
**
|
||||
** Pass the notification idempotency key when the provider supports it.
|
||||
** The recipient row's slackStatus also prevents a resend.
|
||||
*******************************************************************************/
|
||||
private SlackStatus sendExternal(String userId, String subject, List<QRecord> items, Integer notificationId)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
for(QRecord item : items)
|
||||
{
|
||||
String body = NotificationCategory.SECURITY.name().equals(item.getValueString("category")) ? null : item.getValueString("body");
|
||||
String link = NotificationEmailer.absolute(runtime, NotificationService.safeLink(item.getValueString("linkUrl")));
|
||||
text.append(items.size() > 1 ? "- " : "").append(item.getValueString("title")).append("\n");
|
||||
if(StringUtils.hasContent(body))
|
||||
{
|
||||
text.append(body).append("\n");
|
||||
}
|
||||
if(link != null)
|
||||
{
|
||||
text.append(link).append("\n");
|
||||
}
|
||||
}
|
||||
String key = notificationId == null ? null : "notify:" + notificationId + ":" + userId + ":slack";
|
||||
try
|
||||
{
|
||||
new SendMessageAction().execute(new SendMessageInput()
|
||||
.withMessagingProviderName(runtime.getConfig().getSlackProviderName())
|
||||
.withIdempotencyKey(SendMessageAction.capabilities(runtime.getConfig().getSlackProviderName()).contains(MessagingProviderCapability.IDEMPOTENCY) ? key : null)
|
||||
.withTo(new Party().withAddress("user:" + userId))
|
||||
.withSubject(subject)
|
||||
.withContentList(List.of(new Content().withContentRole(ContentRole.Default.DEFAULT).withBody(text.toString().stripTrailing()))));
|
||||
return (SlackStatus.SENT);
|
||||
}
|
||||
catch(QUserFacingException e)
|
||||
{
|
||||
LOG.info("Slack could not reach a user; skipped", logPair("userId", userId), logPair("key", key), logPair("reason", e.getMessage()));
|
||||
return (SlackStatus.SKIPPED);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not send a Slack notification", e, logPair("userId", userId), logPair("key", key));
|
||||
return (SlackStatus.FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void mark(QRecord recipient, SlackStatus status) throws QException
|
||||
{
|
||||
AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, new QRecord().withValue("id", recipient.getValue("id")).withValue("slackStatus", status.name()));
|
||||
}
|
||||
}
|
||||
-349
@@ -1,349 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.preferences;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRowChange;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The session user's notification settings, stored in the existing Alerts tables.
|
||||
*******************************************************************************/
|
||||
public class AlertsNotificationPreferencesProvider implements UserPreferenceProviderInterface
|
||||
{
|
||||
public static final String SECTION_NAME = "notifications";
|
||||
private static final String SECURITY_REASON = "Security notices can't be muted.";
|
||||
private static final Map<String, String> SCALARS = Map.of("pausedUntil", "pausedUntil", "digestHour", "digestHour", "timeZone", "timeZone",
|
||||
"alertInAppEnabled", "inAppEnabled", "alertEmailEnabled", "emailEnabled");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register once; no app scope, so bell users need only alerts.view.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance, AlertsQBitConfig config)
|
||||
{
|
||||
if(isRegistered(instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<QPossibleValue<?>> hours = new ArrayList<>();
|
||||
for(int hour = 0; hour < 24; hour++)
|
||||
{
|
||||
hours.add(new QPossibleValue<>(hour, (hour % 12 == 0 ? 12 : hour % 12) + ":00 " + (hour < 12 ? "AM" : "PM")));
|
||||
}
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName("alerts_digest_hour").withType(QPossibleValueSourceType.ENUM).withEnumValues(hours));
|
||||
UserPreferenceListMetaData categories = new UserPreferenceListMetaData().withName("categories").withLabel("Categories").withRowLabel("Category")
|
||||
.withField(new QFieldMetaData("inApp", QFieldType.BOOLEAN).withLabel("In-app"))
|
||||
.withField(enumField("inAppMinSeverity", "In-app from", "alerts_notification_severity"))
|
||||
.withField(enumField("emailMode", "Email", "alerts_email_mode"))
|
||||
.withField(enumField("emailMinSeverity", "Email from", "alerts_notification_severity"));
|
||||
if(slackConfigured(instance, config))
|
||||
{
|
||||
categories.withField(enumField("slack", "Slack", "alerts_email_mode"))
|
||||
.withField(enumField("slackMinSeverity", "Slack from", "alerts_notification_severity"));
|
||||
}
|
||||
UserPreferencesMetaData.ofOrWithNew(instance).withSection(new UserPreferenceSectionMetaData()
|
||||
.withName(SECTION_NAME).withLabel("Notifications").withIcon(new QIcon("notifications")).withOrder(20)
|
||||
.withDescription("What reaches you in the app, by email and on Slack.").withPermissionName(AlertsQBitConfig.PERMISSION_VIEW)
|
||||
.withProvider(new QCodeReference(AlertsNotificationPreferencesProvider.class))
|
||||
.withField(new QFieldMetaData("pausedUntil", QFieldType.DATE_TIME).withLabel("Pause notifications until")
|
||||
.withHelpContent(new QHelpContent("Security notices still arrive.")))
|
||||
.withField(new QFieldMetaData("digestHour", QFieldType.INTEGER).withLabel("Daily digest time").withPossibleValueSourceName("alerts_digest_hour"))
|
||||
.withField(new QFieldMetaData("timeZone", QFieldType.STRING).withLabel("Time zone").withMaxLength(64))
|
||||
.withField(new QFieldMetaData("alertInAppEnabled", QFieldType.BOOLEAN).withLabel("Show alerts in the app"))
|
||||
.withField(new QFieldMetaData("alertEmailEnabled", QFieldType.BOOLEAN).withLabel("Email me alerts"))
|
||||
.withList(categories)
|
||||
.withList(new UserPreferenceListMetaData().withName("subscriptions").withLabel("Subscriptions").withRowLabel("Alert rule")
|
||||
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Enabled"))
|
||||
.withField(enumField("minSeverity", "From severity", "alerts_severity"))
|
||||
.withField(enumField("deliveryMode", "Delivery mode", "alerts_delivery_mode"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only expose Slack controls when the configured messaging provider exists.
|
||||
*******************************************************************************/
|
||||
private static boolean slackConfigured(QInstance instance, AlertsQBitConfig config)
|
||||
{
|
||||
String name = config.getSlackProviderName();
|
||||
return (name != null && !name.isBlank() && instance.getMessagingProvider(name) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the section is actually installed in this instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isRegistered(QInstance instance)
|
||||
{
|
||||
UserPreferencesMetaData metadata = UserPreferencesMetaData.of(instance);
|
||||
return (metadata != null && metadata.getSections().stream().anyMatch(section -> SECTION_NAME.equals(section.getName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** String enums use ordinary fields with an ENUM possible-value source.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData enumField(String name, String label, String source)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withPossibleValueSourceName(source));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load defaults and fixed category rows, plus only this user's subscriptions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
QRecord settings = userSettings(context);
|
||||
UserPreferenceValues result = new UserPreferenceValues();
|
||||
SCALARS.forEach((name, column) -> result.getValues().put(name, settings.getValue(column)));
|
||||
Map<String, QRecord> preferences = new LinkedHashMap<>();
|
||||
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId())))
|
||||
{
|
||||
preferences.put(preference.getValueString("category"), preference);
|
||||
}
|
||||
List<UserPreferenceRow> categories = new ArrayList<>();
|
||||
for(NotificationCategory category : NotificationCategory.values())
|
||||
{
|
||||
QRecord preference = preferences.get(category.name());
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
values.put("inApp", preference == null || !Boolean.TRUE.equals(preference.getValueBoolean("inAppMuted")));
|
||||
for(String field : List.of("inAppMinSeverity", "emailMode", "emailMinSeverity"))
|
||||
{
|
||||
values.put(field, preference == null ? QContext.getQInstance().getTable(AlertsNotificationPreference.TABLE_NAME).getField(field).getDefaultValue() : preference.getValue(field));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// no in-app threshold means everything; show it as the lowest level //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
if(values.get("inAppMinSeverity") == null)
|
||||
{
|
||||
values.put("inAppMinSeverity", NotificationSeverity.INFO.name());
|
||||
}
|
||||
if(slackConfigured(QContext.getQInstance(), AlertsRuntime.get(QContext.getQInstance()).getConfig()))
|
||||
{
|
||||
for(String field : List.of("slackMode", "slackMinSeverity"))
|
||||
{
|
||||
values.put("slackMode".equals(field) ? "slack" : field, preference == null
|
||||
? QContext.getQInstance().getTable(AlertsNotificationPreference.TABLE_NAME).getField(field).getDefaultValue() : preference.getValue(field));
|
||||
}
|
||||
}
|
||||
String label = QContext.getQInstance().getPossibleValueSource("alerts_notification_category").getEnumValues().stream()
|
||||
.filter(value -> category.name().equals(value.getId())).findFirst().orElseThrow().getLabel();
|
||||
UserPreferenceRow row = new UserPreferenceRow().withKey(category.name()).withLabel(label).withValues(values);
|
||||
if(category == NotificationCategory.SECURITY)
|
||||
{
|
||||
values.put("inApp", true);
|
||||
values.put("inAppMinSeverity", NotificationSeverity.INFO.name());
|
||||
row.withReadOnlyFieldNames(List.of("inApp", "inAppMinSeverity")).withReadOnlyReason(SECURITY_REASON);
|
||||
}
|
||||
categories.add(row);
|
||||
}
|
||||
result.getLists().put("categories", categories);
|
||||
List<UserPreferenceRow> subscriptions = new ArrayList<>();
|
||||
for(QRecord subscription : ownSubscriptions(context))
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
for(String field : List.of("enabled", "minSeverity", "deliveryMode"))
|
||||
{
|
||||
values.put(field, subscription.getValue(field));
|
||||
}
|
||||
subscriptions.add(new UserPreferenceRow().withKey(subscription.getValueString("id")).withLabel(scopeLabel(subscription)).withValues(values));
|
||||
}
|
||||
result.getLists().put("subscriptions", subscriptions);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate domain rules; core invokes this outside its trusted store scope.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
Serializable zone = change.getValues().get("timeZone");
|
||||
if(zone != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ZoneId.of(zone.toString());
|
||||
}
|
||||
catch(DateTimeException e)
|
||||
{
|
||||
errors.add("Time zone: must be a valid time zone, such as America/Chicago.");
|
||||
}
|
||||
}
|
||||
for(UserPreferenceRowChange row : change.getLists().getOrDefault("categories", List.of()))
|
||||
{
|
||||
if("SECURITY".equals(row.getKey()) && (row.getValues().containsKey("inApp") || row.getValues().containsKey("inAppMinSeverity")))
|
||||
{
|
||||
errors.add(SECURITY_REASON);
|
||||
}
|
||||
}
|
||||
if(!change.getLists().getOrDefault("subscriptions", List.of()).isEmpty())
|
||||
{
|
||||
Set<String> ownIds = CorePermissionEnforcer.runTrusted("validate session user's alert subscriptions", () -> ownSubscriptions(context).stream()
|
||||
.map(row -> row.getValueString("id")).collect(Collectors.toSet()));
|
||||
for(UserPreferenceRowChange row : change.getLists().get("subscriptions"))
|
||||
{
|
||||
if(!ownIds.contains(row.getKey()))
|
||||
{
|
||||
errors.add("Alert rule: isn't one of your subscriptions.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Partial updates through normal actions preserve auditing and customizers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
|
||||
{
|
||||
List<String> errors = validate(context, change);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QBadRequestException(String.join("; ", errors)));
|
||||
}
|
||||
if(!change.getValues().isEmpty())
|
||||
{
|
||||
QRecord update = new QRecord().withValue("id", userSettings(context).getValue("id"));
|
||||
change.getValues().forEach((field, value) -> update.setValue(SCALARS.get(field), value));
|
||||
AlertsDb.update(AlertsUserPreference.TABLE_NAME, update);
|
||||
}
|
||||
for(UserPreferenceRowChange row : change.getLists().getOrDefault("categories", List.of()))
|
||||
{
|
||||
QRecord existing = AlertsDb.first(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId())
|
||||
.withCriteria("category", QCriteriaOperator.EQUALS, row.getKey()));
|
||||
QRecord update = new QRecord();
|
||||
row.getValues().forEach((field, value) -> update.setValue("inApp".equals(field) ? "inAppMuted" : "slack".equals(field) ? "slackMode" : field,
|
||||
"inApp".equals(field) ? Boolean.FALSE.equals(value) : value));
|
||||
if(existing == null)
|
||||
{
|
||||
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, update.withValue("userId", context.getUserId()).withValue("category", row.getKey()));
|
||||
}
|
||||
else
|
||||
{
|
||||
AlertsDb.update(AlertsNotificationPreference.TABLE_NAME, update.withValue("id", existing.getValue("id")));
|
||||
}
|
||||
}
|
||||
for(UserPreferenceRowChange row : change.getLists().getOrDefault("subscriptions", List.of()))
|
||||
{
|
||||
QRecord update = new QRecord().withValue("id", Integer.valueOf(row.getKey()));
|
||||
row.getValues().forEach(update::setValue);
|
||||
AlertsDb.update(AlertsSubscription.TABLE_NAME, update);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the same defaults as AlertsPreferences.ensure, retaining the user.
|
||||
*******************************************************************************/
|
||||
private QRecord userSettings(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", context.getUserId()));
|
||||
if(settings == null)
|
||||
{
|
||||
String label = context.getSession().getUser().getFullName();
|
||||
settings = AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", context.getUserId())
|
||||
.withValue("userLabel", label == null ? context.getUserId() : label).withValue("inAppEnabled", true).withValue("emailEnabled", true)
|
||||
.withValue("quietBypassSeverity", "CRITICAL").withValue("digestHour", 8));
|
||||
}
|
||||
return (settings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never expose team or channel rules, even to an administrator.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> ownSubscriptions(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
return (AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", context.getUserId()).withCriteria("subscriberType", QCriteriaOperator.EQUALS, "USER")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Readable scope without relying on access to the subscription's target table.
|
||||
*******************************************************************************/
|
||||
private String scopeLabel(QRecord subscription)
|
||||
{
|
||||
String value = subscription.getValueString("scopeValue");
|
||||
return (switch(subscription.getValueString("scopeType"))
|
||||
{
|
||||
case "ALL" -> "All alerts";
|
||||
case "TYPE" -> "Type: " + value;
|
||||
case "GROUP" -> "Group: " + value;
|
||||
case "RECORD" -> "Record: " + value + " #" + subscription.getValueString("scopeRecordId");
|
||||
case "MONITOR" -> "Monitor: " + value;
|
||||
default -> "Table: " + value;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,8 @@ public enum AlertsAction
|
||||
ASSIGN("alertsAssign", "Assign", "assignment_ind", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, null),
|
||||
COMMENT("alertsAddComment", "Add comment", "comment", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1),
|
||||
MUTE_ALERT("alertsMuteAlert", "Mute", "notifications_off", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
MUTE_ALERT_TYPE("alertsMuteAlertType", "Mute this alert type", "notifications_off", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
SUBSCRIBE_TYPE("alertsSubscribeToType", "Subscribe me", "notifications", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1),
|
||||
MUTE_TYPE("alertsMuteType", "Mute this alert type", "notifications_off", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
MUTE_TYPE("alertsMuteType", "Mute", "notifications_off", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
CREATE_MONITOR("alertsCreateMonitorFromType", "Create monitor", "add_alert", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, 1),
|
||||
SUBSCRIBE_GROUP("alertsSubscribeToGroup", "Subscribe me", "notifications", AlertsGroup.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1),
|
||||
MUTE_GROUP("alertsMuteGroup", "Mute", "notifications_off", AlertsGroup.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1),
|
||||
@@ -132,7 +131,7 @@ public enum AlertsAction
|
||||
.withPossibleValueSourceName(AlertsPossibleValues.SEVERITY),
|
||||
new QFieldMetaData(FIELD_DELIVERY_MODE, QFieldType.STRING).withLabel("Delivery").withIsRequired(true).withDefaultValue("IMMEDIATE")
|
||||
.withPossibleValueSourceName(AlertsPossibleValues.DELIVERY_MODE));
|
||||
case MUTE_ALERT, MUTE_ALERT_TYPE, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> List.of(
|
||||
case MUTE_ALERT, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> List.of(
|
||||
new QFieldMetaData(FIELD_MUTE_MINUTES, QFieldType.INTEGER).withLabel("Mute for (minutes)").withIsRequired(true).withDefaultValue(60),
|
||||
new QFieldMetaData(FIELD_NOTE, QFieldType.TEXT).withLabel("Reason").withIsRequired(true));
|
||||
default -> List.of();
|
||||
|
||||
@@ -26,7 +26,6 @@ import java.util.Objects;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsEngine;
|
||||
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
@@ -303,8 +302,7 @@ public final class AlertsActionSteps
|
||||
case MUTE_TYPE -> mute(ScopeType.ALERT_TYPE, record.getValueString("name"), "type " + record.getValueString("label"), input, actor);
|
||||
case MUTE_GROUP -> mute(ScopeType.GROUP, record.getValueString("name"), "group " + record.getValueString("label"), input, actor);
|
||||
case MUTE_MONITOR -> mute(ScopeType.MONITOR, String.valueOf(id), "monitor " + record.getValueString("name"), input, actor);
|
||||
case MUTE_ALERT -> muteAlert(record, input, actor);
|
||||
case MUTE_ALERT_TYPE -> mute(ScopeType.ALERT_TYPE, record.getValueString("alertTypeName"), "alert type " + record.getValueString("alertTypeName"), input, actor);
|
||||
case MUTE_ALERT -> mute(ScopeType.ALERT_TYPE, record.getValueString("alertTypeName"), "alert type " + record.getValueString("alertTypeName"), input, actor);
|
||||
case RUN_CHECK ->
|
||||
{
|
||||
MonitorEvaluator.Plan plan = runtime.getEvaluator().runNow(id);
|
||||
@@ -422,35 +420,10 @@ public final class AlertsActionSteps
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mute the subject record when present, otherwise this type and dedup key.
|
||||
*******************************************************************************/
|
||||
private static boolean muteAlert(QRecord alert, RunBackendStepInput input, String actor) throws QException
|
||||
{
|
||||
if(StringUtils.hasContent(alert.getValueString("subjectTableName")) && StringUtils.hasContent(alert.getValueString("subjectRecordId")))
|
||||
{
|
||||
return (mute(ScopeType.RECORD, alert.getValueString("subjectTableName"), alert.getValueString("subjectRecordId"), "alert " + alert.getValue("id"), input, actor));
|
||||
}
|
||||
return (mute(ScopeType.ALERT, AlertsEngine.openKey(alert.getValueString("alertTypeName"), alert.getValueString("dedupKey")), null,
|
||||
"alert " + alert.getValue("id"), input, actor));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a mute without a subject record ID.
|
||||
*******************************************************************************/
|
||||
private static boolean mute(ScopeType scopeType, String scopeValue, String what, RunBackendStepInput input, String actor) throws QException
|
||||
{
|
||||
return (mute(scopeType, scopeValue, null, what, input, actor));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a silence-notifications mute from now (through the table).
|
||||
*******************************************************************************/
|
||||
private static boolean mute(ScopeType scopeType, String scopeValue, String scopeRecordId, String what, RunBackendStepInput input, String actor) throws QException
|
||||
private static boolean mute(ScopeType scopeType, String scopeValue, String what, RunBackendStepInput input, String actor) throws QException
|
||||
{
|
||||
Integer minutes = input.getValueInteger(AlertsAction.FIELD_MUTE_MINUTES);
|
||||
String reason = input.getValueString(AlertsAction.FIELD_NOTE);
|
||||
@@ -464,7 +437,6 @@ public final class AlertsActionSteps
|
||||
.withValue("reason", reason)
|
||||
.withValue("scopeType", scopeType.name())
|
||||
.withValue("scopeValue", scopeValue)
|
||||
.withValue("scopeRecordId", scopeRecordId)
|
||||
.withValue("startsAt", now)
|
||||
.withValue("endsAt", now.plus(minutes == null || minutes < 1 ? 60 : minutes, ChronoUnit.MINUTES))
|
||||
.withValue("effect", MuteEffect.SILENCE_NOTIFICATIONS.name())
|
||||
@@ -614,7 +586,7 @@ public final class AlertsActionSteps
|
||||
case ASSIGN -> "assigned";
|
||||
case COMMENT -> "commented on";
|
||||
case SUBSCRIBE_TYPE, SUBSCRIBE_GROUP, SUBSCRIBE_MONITOR -> "subscribed to";
|
||||
case MUTE_ALERT, MUTE_ALERT_TYPE, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> "muted";
|
||||
case MUTE_ALERT, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> "muted";
|
||||
case RUN_CHECK -> "checked";
|
||||
case ENABLE_MONITOR -> "enabled";
|
||||
case DISABLE_MONITOR -> "disabled";
|
||||
|
||||
@@ -18,10 +18,7 @@ package com.kingsrook.qbits.alerts.processes;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.delivery.DigestWorker;
|
||||
import com.kingsrook.qbits.alerts.engine.EscalationAndReminder;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationEmailer;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationSlackSender;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
@@ -30,7 +27,7 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
|
||||
|
||||
/*******************************************************************************
|
||||
** The hidden scheduled processes' steps: evaluate due monitors (plus snooze,
|
||||
** quiet-period and flapping upkeep), deliver notifications, escalate, remind and send digests.
|
||||
** quiet-period and flapping upkeep), and deliver due notifications.
|
||||
*******************************************************************************/
|
||||
public final class AlertsScheduledSteps
|
||||
{
|
||||
@@ -76,46 +73,11 @@ public final class AlertsScheduledSteps
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AlertsRuntime runtime = AlertsRuntime.get();
|
||||
runtime.getSink().flush();
|
||||
runtime.sink().flush();
|
||||
runtime.getNotifications().sendDue();
|
||||
runtime.getNotifications().purge();
|
||||
new NotificationEmailer(runtime).run();
|
||||
new NotificationSlackSender(runtime).run();
|
||||
output.addValue(OUTPUT_COUNT, runtime.getDeliveryWorker().deliverDue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** alertsEscalateAndRemind
|
||||
*******************************************************************************/
|
||||
public static class EscalateAndRemind implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Run timed notifications using the installed runtime's clock.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.addValue(OUTPUT_COUNT, new EscalationAndReminder(AlertsRuntime.get()).runDue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** alertsSendDigests
|
||||
*******************************************************************************/
|
||||
public static class SendDigests implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Send completed digest windows through the configured channels.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.addValue(OUTPUT_COUNT, new DigestWorker(AlertsRuntime.get()).sendDue());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A monitor that an alert type ships with ("seeded"). An alerts
|
||||
** implementation inserts it once; after that it belongs to the admin, who may
|
||||
** tune or disable it. Without an implementation installed it does nothing.
|
||||
**
|
||||
** The definition's keys depend on the kind (e.g. HEARTBEAT takes heartbeatKey
|
||||
** or processName, plus expectedEverySeconds and graceSeconds; TABLE_QUERY
|
||||
** takes tableName and filter). Values must be JSON-friendly.
|
||||
*******************************************************************************/
|
||||
public class AlertDefaultMonitor implements Serializable
|
||||
{
|
||||
private String name;
|
||||
private AlertMonitorKind kind;
|
||||
private Map<String, Serializable> definition = new LinkedHashMap<>();
|
||||
private Boolean perRecord = false;
|
||||
private AlertSeverity severity;
|
||||
private Integer scheduleSeconds;
|
||||
private Integer forMinutes = 0;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A heartbeat monitor: raise when the key has not beat within the expected
|
||||
** interval plus grace.
|
||||
*******************************************************************************/
|
||||
public static AlertDefaultMonitor heartbeat(String name, String heartbeatKey, int expectedEverySeconds, int graceSeconds)
|
||||
{
|
||||
return (new AlertDefaultMonitor()
|
||||
.withName(name)
|
||||
.withKind(AlertMonitorKind.HEARTBEAT)
|
||||
.withDefinitionValue("heartbeatKey", heartbeatKey)
|
||||
.withDefinitionValue("expectedEverySeconds", expectedEverySeconds)
|
||||
.withDefinitionValue("graceSeconds", graceSeconds));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kind
|
||||
*******************************************************************************/
|
||||
public AlertMonitorKind getKind()
|
||||
{
|
||||
return (this.kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for kind
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withKind(AlertMonitorKind kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for definition
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getDefinition()
|
||||
{
|
||||
return (this.definition);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for definition
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withDefinition(Map<String, Serializable> definition)
|
||||
{
|
||||
this.definition = definition == null ? new LinkedHashMap<>() : new LinkedHashMap<>(definition);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set one definition value.
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withDefinitionValue(String key, Serializable value)
|
||||
{
|
||||
this.definition.put(key, value);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for perRecord
|
||||
*******************************************************************************/
|
||||
public Boolean getPerRecord()
|
||||
{
|
||||
return (this.perRecord);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for perRecord (one alert per matching record, instead of one
|
||||
** aggregate alert)
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withPerRecord(Boolean perRecord)
|
||||
{
|
||||
this.perRecord = perRecord;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for severity
|
||||
*******************************************************************************/
|
||||
public AlertSeverity getSeverity()
|
||||
{
|
||||
return (this.severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for severity (default: the type's severity)
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withSeverity(AlertSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scheduleSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getScheduleSeconds()
|
||||
{
|
||||
return (this.scheduleSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scheduleSeconds (how often to evaluate)
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withScheduleSeconds(Integer scheduleSeconds)
|
||||
{
|
||||
this.scheduleSeconds = scheduleSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for forMinutes
|
||||
*******************************************************************************/
|
||||
public Integer getForMinutes()
|
||||
{
|
||||
return (this.forMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for forMinutes (how long a condition must hold before it fires)
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withForMinutes(Integer forMinutes)
|
||||
{
|
||||
this.forMinutes = forMinutes;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link from an alert to where a person fixes it: a record, a table with a
|
||||
** filter, an app or widget path, or an absolute URL. Exactly one target is
|
||||
** expected, so construct these with the static factories.
|
||||
*******************************************************************************/
|
||||
public class AlertLink implements Serializable
|
||||
{
|
||||
private String label;
|
||||
private String tableName;
|
||||
private Serializable recordId;
|
||||
private QQueryFilter filter;
|
||||
private String path;
|
||||
private String url;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link to one record.
|
||||
*******************************************************************************/
|
||||
public static AlertLink toRecord(String tableName, Serializable recordId, String label)
|
||||
{
|
||||
AlertLink link = new AlertLink();
|
||||
link.tableName = tableName;
|
||||
link.recordId = recordId;
|
||||
link.label = label;
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link to a table, optionally filtered.
|
||||
*******************************************************************************/
|
||||
public static AlertLink toTable(String tableName, QQueryFilter filter, String label)
|
||||
{
|
||||
AlertLink link = new AlertLink();
|
||||
link.tableName = tableName;
|
||||
link.filter = filter;
|
||||
link.label = label;
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link to an app or widget path within the application (e.g. "/admin/cdn").
|
||||
*******************************************************************************/
|
||||
public static AlertLink toPath(String path, String label)
|
||||
{
|
||||
AlertLink link = new AlertLink();
|
||||
link.path = path;
|
||||
link.label = label;
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link to an absolute URL. Implementations only follow these for types
|
||||
** that declare them, so producer data cannot inject arbitrary links.
|
||||
*******************************************************************************/
|
||||
public static AlertLink toUrl(String url, String label)
|
||||
{
|
||||
AlertLink link = new AlertLink();
|
||||
link.url = url;
|
||||
link.label = label;
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (this.tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordId
|
||||
*******************************************************************************/
|
||||
public Serializable getRecordId()
|
||||
{
|
||||
return (this.recordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (this.filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for path
|
||||
*******************************************************************************/
|
||||
public String getPath()
|
||||
{
|
||||
return (this.path);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for url
|
||||
*******************************************************************************/
|
||||
public String getUrl()
|
||||
{
|
||||
return (this.url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The kinds of monitor an alerts implementation evaluates. Declared in core
|
||||
** so producers can describe a seeded (default) monitor for their alert types
|
||||
** without depending on the implementation.
|
||||
*******************************************************************************/
|
||||
public enum AlertMonitorKind
|
||||
{
|
||||
/** count of rows matching a filter passes a threshold. */
|
||||
TABLE_QUERY,
|
||||
|
||||
/** an aggregate (count, sum, avg, min, max) crosses a band. */
|
||||
AGGREGATE,
|
||||
|
||||
/** nothing was seen (a heartbeat, a process success, a newest row) within an interval. */
|
||||
HEARTBEAT,
|
||||
|
||||
/** a process run failed. */
|
||||
PROCESS_FAILED,
|
||||
|
||||
/** a matching ESB event arrived. */
|
||||
ESB_EVENT,
|
||||
|
||||
/** a code check returned the currently-failing conditions. */
|
||||
CODE,
|
||||
|
||||
/** alerts ingested from an external system (e.g. Alertmanager). */
|
||||
EXTERNAL
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A record that an alert is about. The label is internal (shown in-app to
|
||||
** users who can see the record), never sent to an external channel.
|
||||
*******************************************************************************/
|
||||
public class AlertRecordRef implements Serializable
|
||||
{
|
||||
private String tableName;
|
||||
private Serializable primaryKey;
|
||||
private String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef(String tableName, Serializable primaryKey)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
this.primaryKey = primaryKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One ref per primary key, all in the same table. Null keys are skipped.
|
||||
*******************************************************************************/
|
||||
public static List<AlertRecordRef> of(String tableName, Collection<? extends Serializable> primaryKeys)
|
||||
{
|
||||
List<AlertRecordRef> refs = new ArrayList<>();
|
||||
if(primaryKeys != null)
|
||||
{
|
||||
for(Serializable primaryKey : primaryKeys)
|
||||
{
|
||||
if(primaryKey != null)
|
||||
{
|
||||
refs.add(new AlertRecordRef(tableName, primaryKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (refs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One ref to one record.
|
||||
*******************************************************************************/
|
||||
public static AlertRecordRef of(String tableName, Serializable primaryKey)
|
||||
{
|
||||
return (new AlertRecordRef(tableName, primaryKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two refs are equal when they name the same table and the same key (keys
|
||||
** compared by their string form, so 1 and "1" match).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
if(this == o)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(!(o instanceof AlertRecordRef that))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (Objects.equals(tableName, that.tableName) && Objects.equals(String.valueOf(primaryKey), String.valueOf(that.primaryKey)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return (Objects.hash(tableName, String.valueOf(primaryKey)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (tableName + ":" + primaryKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (this.tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tableName
|
||||
*******************************************************************************/
|
||||
public void setTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for primaryKey
|
||||
*******************************************************************************/
|
||||
public Serializable getPrimaryKey()
|
||||
{
|
||||
return (this.primaryKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for primaryKey
|
||||
*******************************************************************************/
|
||||
public void setPrimaryKey(Serializable primaryKey)
|
||||
{
|
||||
this.primaryKey = primaryKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for primaryKey
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef withPrimaryKey(Serializable primaryKey)
|
||||
{
|
||||
this.primaryKey = primaryKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for label
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label (internal: shown in-app only, never sent out)
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A producer's "this problem is gone", passed to QAlerts.resolve. Resolving
|
||||
** a key that is not open is a no-op.
|
||||
*******************************************************************************/
|
||||
public class AlertResolution implements Serializable
|
||||
{
|
||||
private String alertTypeName;
|
||||
private String dedupKey;
|
||||
private String note;
|
||||
private Instant resolvedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertResolution()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertResolution(String alertTypeName, String dedupKey, String note)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
this.dedupKey = dedupKey;
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The dedup key to use: the one given, or the type name.
|
||||
*******************************************************************************/
|
||||
public String getEffectiveDedupKey()
|
||||
{
|
||||
return (dedupKey == null || dedupKey.isBlank() ? alertTypeName : dedupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public String getAlertTypeName()
|
||||
{
|
||||
return (this.alertTypeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public AlertResolution withAlertTypeName(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dedupKey
|
||||
*******************************************************************************/
|
||||
public String getDedupKey()
|
||||
{
|
||||
return (this.dedupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dedupKey
|
||||
*******************************************************************************/
|
||||
public AlertResolution withDedupKey(String dedupKey)
|
||||
{
|
||||
this.dedupKey = dedupKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for note
|
||||
*******************************************************************************/
|
||||
public String getNote()
|
||||
{
|
||||
return (this.note);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for note
|
||||
*******************************************************************************/
|
||||
public AlertResolution withNote(String note)
|
||||
{
|
||||
this.note = note;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resolvedAt
|
||||
*******************************************************************************/
|
||||
public Instant getResolvedAt()
|
||||
{
|
||||
return (this.resolvedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for resolvedAt (default: when QAlerts.resolve is called)
|
||||
*******************************************************************************/
|
||||
public AlertResolution withResolvedAt(Instant resolvedAt)
|
||||
{
|
||||
this.resolvedAt = resolvedAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How urgent an alert is. Ordered from least to most severe, so
|
||||
** isAtLeast / compareTo can be used for "minimum severity" checks.
|
||||
**
|
||||
** An alert means "a person should do something". INFO is for worth-a-glance
|
||||
** items that never page anyone; external channels ignore it by default.
|
||||
*******************************************************************************/
|
||||
public enum AlertSeverity
|
||||
{
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR,
|
||||
CRITICAL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when this severity is the same as, or more severe than, the other.
|
||||
** A null other is treated as INFO (so everything is at least it).
|
||||
*******************************************************************************/
|
||||
public boolean isAtLeast(AlertSeverity other)
|
||||
{
|
||||
return (other == null || this.compareTo(other) >= 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Leniently map a severity label from another system (Prometheus, a
|
||||
** producer's own enum, etc) to an AlertSeverity. Case-insensitive; accepts
|
||||
** common synonyms ("warn", "err", "fatal", "page", "info"/"informational").
|
||||
** Returns the fallback for null, blank or unknown values.
|
||||
*******************************************************************************/
|
||||
public static AlertSeverity fromLabel(String label, AlertSeverity fallback)
|
||||
{
|
||||
if(label == null || label.isBlank())
|
||||
{
|
||||
return (fallback);
|
||||
}
|
||||
|
||||
return switch(label.trim().toLowerCase(Locale.ROOT))
|
||||
{
|
||||
case "info", "informational", "notice", "low" -> INFO;
|
||||
case "warning", "warn", "medium" -> WARNING;
|
||||
case "error", "err", "high" -> ERROR;
|
||||
case "critical", "crit", "fatal", "page", "emergency" -> CRITICAL;
|
||||
default -> fallback;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One "this is a problem" signal from a producer, passed to QAlerts.raise.
|
||||
** Raising is idempotent on (alertTypeName, dedupKey): repeats refresh one open
|
||||
** alert, they never duplicate it.
|
||||
**
|
||||
** Text fields have different reach. title and summary are external-safe: a
|
||||
** producer must write them without personal data, because they may be sent
|
||||
** to email or chat. details (and record ref labels) are internal: shown
|
||||
** in-app only, to users who can see the alert.
|
||||
**
|
||||
** Bounds: at most MAX_RECORD_REFS record refs (extras are dropped, and count
|
||||
** keeps the true total) and MAX_ATTRIBUTES attributes (extras are ignored).
|
||||
*******************************************************************************/
|
||||
public class AlertSignal implements Serializable
|
||||
{
|
||||
public static final int MAX_RECORD_REFS = 100;
|
||||
public static final int MAX_ATTRIBUTES = 20;
|
||||
|
||||
private String alertTypeName;
|
||||
private String dedupKey;
|
||||
private AlertSeverity severity;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String details;
|
||||
private List<AlertRecordRef> recordRefs = new ArrayList<>();
|
||||
private List<AlertLink> links = new ArrayList<>();
|
||||
private Map<String, String> attributes = new LinkedHashMap<>();
|
||||
private Instant occurredAt;
|
||||
private Integer count;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertSignal()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertSignal(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The dedup key to use: the one given, or the type name (one alert per type).
|
||||
*******************************************************************************/
|
||||
public String getEffectiveDedupKey()
|
||||
{
|
||||
return (dedupKey == null || dedupKey.isBlank() ? alertTypeName : dedupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record count this signal describes: count when set, otherwise the
|
||||
** number of record refs (null when there are none).
|
||||
*******************************************************************************/
|
||||
public Integer getEffectiveCount()
|
||||
{
|
||||
if(count != null)
|
||||
{
|
||||
return (count);
|
||||
}
|
||||
return (recordRefs.isEmpty() ? null : recordRefs.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public String getAlertTypeName()
|
||||
{
|
||||
return (this.alertTypeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public void setAlertTypeName(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public AlertSignal withAlertTypeName(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dedupKey
|
||||
*******************************************************************************/
|
||||
public String getDedupKey()
|
||||
{
|
||||
return (this.dedupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for dedupKey
|
||||
*******************************************************************************/
|
||||
public void setDedupKey(String dedupKey)
|
||||
{
|
||||
this.dedupKey = dedupKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dedupKey - a stable key, so repeats update one open alert.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withDedupKey(String dedupKey)
|
||||
{
|
||||
this.dedupKey = dedupKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for severity
|
||||
*******************************************************************************/
|
||||
public AlertSeverity getSeverity()
|
||||
{
|
||||
return (this.severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for severity
|
||||
*******************************************************************************/
|
||||
public void setSeverity(AlertSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for severity (optional; defaults to the type's severity)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withSeverity(AlertSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title (external-safe: no personal data)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for summary
|
||||
*******************************************************************************/
|
||||
public String getSummary()
|
||||
{
|
||||
return (this.summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for summary
|
||||
*******************************************************************************/
|
||||
public void setSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for summary (external-safe: no personal data)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for details
|
||||
*******************************************************************************/
|
||||
public String getDetails()
|
||||
{
|
||||
return (this.details);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for details
|
||||
*******************************************************************************/
|
||||
public void setDetails(String details)
|
||||
{
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for details (internal: in-app only, never sent externally)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withDetails(String details)
|
||||
{
|
||||
this.details = details;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordRefs
|
||||
*******************************************************************************/
|
||||
public List<AlertRecordRef> getRecordRefs()
|
||||
{
|
||||
return (this.recordRefs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recordRefs
|
||||
*******************************************************************************/
|
||||
public void setRecordRefs(List<AlertRecordRef> recordRefs)
|
||||
{
|
||||
this.recordRefs = new ArrayList<>();
|
||||
withRecordRefs(recordRefs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add record refs. Beyond MAX_RECORD_REFS they are dropped, and count is
|
||||
** set to the true total so the alert still says how many there were.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withRecordRefs(Collection<AlertRecordRef> recordRefs)
|
||||
{
|
||||
if(recordRefs == null)
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
int total = this.recordRefs.size();
|
||||
for(AlertRecordRef recordRef : recordRefs)
|
||||
{
|
||||
if(recordRef == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
total++;
|
||||
if(this.recordRefs.size() < MAX_RECORD_REFS)
|
||||
{
|
||||
this.recordRefs.add(recordRef);
|
||||
}
|
||||
}
|
||||
|
||||
if(total > MAX_RECORD_REFS && (count == null || count < total))
|
||||
{
|
||||
count = total;
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add one record ref.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withRecordRef(AlertRecordRef recordRef)
|
||||
{
|
||||
return (withRecordRefs(recordRef == null ? null : List.of(recordRef)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for links
|
||||
*******************************************************************************/
|
||||
public List<AlertLink> getLinks()
|
||||
{
|
||||
return (this.links);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for links
|
||||
*******************************************************************************/
|
||||
public void setLinks(List<AlertLink> links)
|
||||
{
|
||||
this.links = links == null ? new ArrayList<>() : new ArrayList<>(links);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a link to where the problem is fixed.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withLink(AlertLink link)
|
||||
{
|
||||
if(link != null)
|
||||
{
|
||||
this.links.add(link);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for attributes
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getAttributes()
|
||||
{
|
||||
return (this.attributes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for attributes
|
||||
*******************************************************************************/
|
||||
public void setAttributes(Map<String, String> attributes)
|
||||
{
|
||||
this.attributes = new LinkedHashMap<>();
|
||||
if(attributes != null)
|
||||
{
|
||||
attributes.forEach(this::withAttribute);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a routing label (not displayed by default). At most MAX_ATTRIBUTES
|
||||
** keys; a new key beyond that is ignored (replacing an existing key is fine).
|
||||
*******************************************************************************/
|
||||
public AlertSignal withAttribute(String key, String value)
|
||||
{
|
||||
if(key != null && (attributes.containsKey(key) || attributes.size() < MAX_ATTRIBUTES))
|
||||
{
|
||||
attributes.put(key, value);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for occurredAt
|
||||
*******************************************************************************/
|
||||
public Instant getOccurredAt()
|
||||
{
|
||||
return (this.occurredAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public void setOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for occurredAt (default: when QAlerts.raise is called)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for count
|
||||
*******************************************************************************/
|
||||
public Integer getCount()
|
||||
{
|
||||
return (this.count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for count
|
||||
*******************************************************************************/
|
||||
public void setCount(Integer count)
|
||||
{
|
||||
this.count = count;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for count - for aggregate alerts, e.g. "37 records".
|
||||
*******************************************************************************/
|
||||
public AlertSignal withCount(Integer count)
|
||||
{
|
||||
this.count = count;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -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.spi;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where QAlerts sends signals. The default is LoggingAlertSink; an alerts
|
||||
** implementation (such as qbit-alerts) registers its own through
|
||||
** AlertsInstanceMetaData.withSink.
|
||||
**
|
||||
** Methods are called after the caller's transaction commits (or at once, when
|
||||
** there is none), on the caller's thread, so they must be fast: queue the
|
||||
** work, don't do database writes or network calls inline. Anything they
|
||||
** throw is caught, logged and counted by QAlerts; it never reaches the
|
||||
** producer.
|
||||
**
|
||||
** A new instance is not loaded per call: QAlerts loads the sink once per
|
||||
** QInstance and reuses it, so implementations must be thread-safe.
|
||||
*******************************************************************************/
|
||||
public interface AlertSinkInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
** Record (or refresh) an alert. The signal's occurredAt is always set.
|
||||
***************************************************************************/
|
||||
void raise(AlertSignal signal);
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve an open alert, if there is one. resolvedAt is always set.
|
||||
***************************************************************************/
|
||||
void resolve(AlertResolution resolution);
|
||||
|
||||
/***************************************************************************
|
||||
** Note that a dead-man's switch beat.
|
||||
***************************************************************************/
|
||||
void heartbeat(String heartbeatKey, Instant at);
|
||||
|
||||
/***************************************************************************
|
||||
** Send a notification to people (an audience). The default does nothing,
|
||||
** so sinks written before notifications keep working.
|
||||
***************************************************************************/
|
||||
default void notify(NotificationSignal signal)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////
|
||||
// noop - sinks that don't carry notifications ignore them //
|
||||
//////////////////////////////////////////////////////////////
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance-level alerts meta-data: the sink that QAlerts sends signals to
|
||||
** (default: LoggingAlertSink) and every declared QAlertTypeMetaData. It is
|
||||
** supplemental meta-data, so any module or qbit can add to it.
|
||||
*******************************************************************************/
|
||||
public class AlertsInstanceMetaData implements QSupplementalInstanceMetaData
|
||||
{
|
||||
public static final String NAME = "alertsQBitStandIn";
|
||||
|
||||
/** alert type names: lowercase, dotted, at least two segments. */
|
||||
public static final Pattern ALERT_TYPE_NAME_PATTERN = Pattern.compile("^[a-z0-9]+(\\.[a-z0-9-]+)+$");
|
||||
|
||||
private QCodeReference sink;
|
||||
private Map<String, QAlertTypeMetaData> alertTypes = new LinkedHashMap<>();
|
||||
private List<String> duplicateAlertTypes = new ArrayList<>();
|
||||
|
||||
private transient AlertSinkInterface loadedSink;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the instance's alerts meta-data, or null when nothing added it.
|
||||
*******************************************************************************/
|
||||
public static AlertsInstanceMetaData of(QInstance qInstance)
|
||||
{
|
||||
return (qInstance == null ? null : QSupplementalInstanceMetaData.of(qInstance, NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the instance's alerts meta-data, adding an empty one if it has none.
|
||||
*******************************************************************************/
|
||||
public static AlertsInstanceMetaData ofOrWithNew(QInstance qInstance)
|
||||
{
|
||||
return (QSupplementalInstanceMetaData.ofOrWithNew(qInstance, NAME, AlertsInstanceMetaData::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sink
|
||||
*******************************************************************************/
|
||||
public QCodeReference getSink()
|
||||
{
|
||||
return (this.sink);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sink - an AlertSinkInterface implementation. An
|
||||
** alerts implementation sets this; producers never do.
|
||||
*******************************************************************************/
|
||||
public AlertsInstanceMetaData withSink(QCodeReference sink)
|
||||
{
|
||||
this.sink = sink;
|
||||
this.loadedSink = null;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sink instance QAlerts loaded for this meta-data (cached; null until
|
||||
** first use).
|
||||
*******************************************************************************/
|
||||
AlertSinkInterface getLoadedSink()
|
||||
{
|
||||
return (this.loadedSink);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cache the loaded sink instance.
|
||||
*******************************************************************************/
|
||||
void setLoadedSink(AlertSinkInterface loadedSink)
|
||||
{
|
||||
this.loadedSink = loadedSink;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertTypes
|
||||
*******************************************************************************/
|
||||
public Map<String, QAlertTypeMetaData> getAlertTypes()
|
||||
{
|
||||
return (this.alertTypes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find a declared type by name, or null.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData getAlertType(String name)
|
||||
{
|
||||
return (name == null ? null : alertTypes.get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare an alert type. Adding the same object again is a no-op; adding a
|
||||
** different type with a name already taken is a validation error.
|
||||
*******************************************************************************/
|
||||
public AlertsInstanceMetaData withAlertType(QAlertTypeMetaData alertType)
|
||||
{
|
||||
if(alertType == null)
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
QAlertTypeMetaData existing = alertTypes.get(alertType.getName());
|
||||
if(existing != null && existing != alertType)
|
||||
{
|
||||
duplicateAlertTypes.add(alertType.getName());
|
||||
return (this);
|
||||
}
|
||||
|
||||
alertTypes.put(alertType.getName(), alertType);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Names must be unique and well-formed, subject tables must exist, a
|
||||
** visibility permission must not be blank, heartbeat default monitors need
|
||||
** an interval, and the sink must implement AlertSinkInterface.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, QInstanceValidator validator)
|
||||
{
|
||||
if(sink != null)
|
||||
{
|
||||
validator.validateSimpleCodeReference("Alerts sink: ", sink, AlertSinkInterface.class);
|
||||
}
|
||||
|
||||
for(String duplicate : duplicateAlertTypes)
|
||||
{
|
||||
validator.assertCondition(false, "Alert type " + duplicate + " is declared more than once.");
|
||||
}
|
||||
|
||||
for(Map.Entry<String, QAlertTypeMetaData> entry : alertTypes.entrySet())
|
||||
{
|
||||
String name = entry.getKey();
|
||||
QAlertTypeMetaData alertType = entry.getValue();
|
||||
String prefix = "Alert type " + name + ": ";
|
||||
|
||||
if(!validator.assertCondition(name != null && ALERT_TYPE_NAME_PATTERN.matcher(name).matches(), prefix + "name must be lowercase and dotted (e.g. cdn.purge.dead), matching " + ALERT_TYPE_NAME_PATTERN.pattern()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
validator.assertCondition(alertType.getDefaultSeverity() != null, prefix + "defaultSeverity is required.");
|
||||
|
||||
if(alertType.getSubjectTableName() != null)
|
||||
{
|
||||
validator.assertCondition(qInstance.getTable(alertType.getSubjectTableName()) != null, prefix + "subjectTableName " + alertType.getSubjectTableName() + " is not a table in the instance.");
|
||||
}
|
||||
|
||||
if(alertType.getVisibilityPermission() != null)
|
||||
{
|
||||
validator.assertCondition(!alertType.getVisibilityPermission().isBlank(), prefix + "visibilityPermission must not be blank.");
|
||||
}
|
||||
|
||||
if(alertType.getResolveMode() == ResolveMode.QUIET_PERIOD)
|
||||
{
|
||||
validator.assertCondition(alertType.getQuietPeriod() != null && !alertType.getQuietPeriod().isNegative() && !alertType.getQuietPeriod().isZero(), prefix + "a QUIET_PERIOD resolve mode needs a positive quietPeriod.");
|
||||
}
|
||||
|
||||
for(AlertDefaultMonitor monitor : alertType.getDefaultMonitors())
|
||||
{
|
||||
validator.assertCondition(monitor.getKind() != null, prefix + "a default monitor needs a kind.");
|
||||
if(monitor.getKind() == AlertMonitorKind.HEARTBEAT)
|
||||
{
|
||||
Object every = monitor.getDefinition().get("expectedEverySeconds");
|
||||
validator.assertCondition(every instanceof Number number && number.longValue() > 0, prefix + "a heartbeat default monitor needs a positive expectedEverySeconds.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How much of an alert an external channel (email, chat, paging, webhook) may
|
||||
** include. A channel has a level; an alert type has a ceiling, and the lower
|
||||
** of the two applies.
|
||||
*******************************************************************************/
|
||||
public enum DetailLevel
|
||||
{
|
||||
/** severity, type label, group, title, age and one link back to the alert. */
|
||||
MINIMAL,
|
||||
|
||||
/** MINIMAL plus the summary and the counts. */
|
||||
STANDARD,
|
||||
|
||||
/** STANDARD plus details and record labels (admin opt-in). */
|
||||
FULL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The lower (less revealing) of two levels. Null means "no limit".
|
||||
*******************************************************************************/
|
||||
public static DetailLevel lowerOf(DetailLevel a, DetailLevel b)
|
||||
{
|
||||
if(a == null)
|
||||
{
|
||||
return (b);
|
||||
}
|
||||
if(b == null)
|
||||
{
|
||||
return (a);
|
||||
}
|
||||
return (a.compareTo(b) <= 0 ? a : b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Who a notification is for, resolved to users when it is sent (stand-in for
|
||||
** core's com.kingsrook.qqq.backend.core.alerts.NotificationAudience).
|
||||
*******************************************************************************/
|
||||
public class NotificationAudience implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The kinds of audience.
|
||||
*******************************************************************************/
|
||||
public enum Type
|
||||
{
|
||||
ALL_USERS, ROLES, PERMISSION, USERS
|
||||
}
|
||||
|
||||
|
||||
|
||||
private final Type type;
|
||||
private final List<String> values;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor (blank values are dropped; values are trimmed).
|
||||
*******************************************************************************/
|
||||
public NotificationAudience(Type type, List<String> values)
|
||||
{
|
||||
this.type = type == null ? Type.USERS : type;
|
||||
this.values = values == null ? List.of() : values.stream().filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every user.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience allUsers()
|
||||
{
|
||||
return (new NotificationAudience(Type.ALL_USERS, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users with any of these roles or groups.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience roles(String... roles)
|
||||
{
|
||||
return (new NotificationAudience(Type.ROLES, roles == null ? null : Arrays.asList(roles)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users whose session holds this permission.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience permission(String permission)
|
||||
{
|
||||
return (new NotificationAudience(Type.PERMISSION, permission == null ? null : List.of(permission)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** These users.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience users(String... userIds)
|
||||
{
|
||||
return (new NotificationAudience(Type.USERS, userIds == null ? null : Arrays.asList(userIds)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One user.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience user(String userId)
|
||||
{
|
||||
return (users(userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** From the stored form: a type name and a comma- or line-separated value.
|
||||
** An unknown type is null.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience parse(String type, String value)
|
||||
{
|
||||
for(Type candidate : Type.values())
|
||||
{
|
||||
if(type != null && candidate.name().equalsIgnoreCase(type.trim()))
|
||||
{
|
||||
List<String> values = new ArrayList<>();
|
||||
if(value != null)
|
||||
{
|
||||
values.addAll(Arrays.asList(value.split("[,\\n]")));
|
||||
}
|
||||
return (new NotificationAudience(candidate, values));
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored form of the values (null when there are none).
|
||||
*******************************************************************************/
|
||||
public String valuesAsString()
|
||||
{
|
||||
return (values.isEmpty() ? null : String.join(",", values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for type
|
||||
*******************************************************************************/
|
||||
public Type getType()
|
||||
{
|
||||
return (this.type);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for values
|
||||
*******************************************************************************/
|
||||
public List<String> getValues()
|
||||
{
|
||||
return (this.values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
return (o instanceof NotificationAudience other && type == other.type && values.equals(other.values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return (Objects.hash(type, values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (type + (values.isEmpty() ? "" : values.toString()));
|
||||
}
|
||||
}
|
||||
+24
-12
@@ -14,24 +14,36 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
** What a notification is about (stand-in for core's
|
||||
** com.kingsrook.qqq.backend.core.alerts.NotificationCategory). Users filter
|
||||
** and mute by category; SECURITY can never be muted in-app.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface AlertAudienceResolver
|
||||
public enum NotificationCategory
|
||||
{
|
||||
SYSTEM, JOB, SECURITY, RELEASE, MARKETING, SHARE, ALERT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the team's memberSource and memberSourceValue to user IDs.
|
||||
** The category for a name (any case), or the fallback.
|
||||
*******************************************************************************/
|
||||
Set<String> resolve(String sourceType, String sourceValue) throws QException;
|
||||
public static NotificationCategory fromName(String name, NotificationCategory fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationCategory category : values())
|
||||
{
|
||||
if(category.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (category);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How a notification looks to its reader: INFO and SUCCESS are good news,
|
||||
** WARNING and ERROR need a look, CRITICAL makes the badge red (stand-in for
|
||||
** core's com.kingsrook.qqq.backend.core.alerts.NotificationSeverity).
|
||||
*******************************************************************************/
|
||||
public enum NotificationSeverity
|
||||
{
|
||||
INFO, SUCCESS, WARNING, ERROR, CRITICAL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The severity for a name (any case), or the fallback.
|
||||
*******************************************************************************/
|
||||
public static NotificationSeverity fromName(String name, NotificationSeverity fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationSeverity severity : values())
|
||||
{
|
||||
if(severity.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (severity);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification for people (stand-in for core's
|
||||
** com.kingsrook.qqq.backend.core.alerts.NotificationSignal): what to tell
|
||||
** them, how urgent it is, what it is about, and who it is for. Send it with
|
||||
** the alerts sink's notify (core: QAlerts.notify).
|
||||
*******************************************************************************/
|
||||
public class NotificationSignal implements Serializable
|
||||
{
|
||||
private String title;
|
||||
private String body;
|
||||
private NotificationSeverity severity;
|
||||
private NotificationCategory category;
|
||||
private String sourceName;
|
||||
private String sourceKey;
|
||||
private String sourceTableName;
|
||||
private String sourceRecordId;
|
||||
private AlertLink link;
|
||||
private NotificationAudience audience;
|
||||
private Instant publishAt;
|
||||
private Instant expireAt;
|
||||
private Boolean sticky;
|
||||
private Instant occurredAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public NotificationSignal()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with the title.
|
||||
*******************************************************************************/
|
||||
public NotificationSignal(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title: The headline (plain text, required).
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for body: The message (markdown).
|
||||
*******************************************************************************/
|
||||
public String getBody()
|
||||
{
|
||||
return (this.body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for body
|
||||
*******************************************************************************/
|
||||
public void setBody(String body)
|
||||
{
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for body
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withBody(String body)
|
||||
{
|
||||
this.body = body;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for severity: Default INFO.
|
||||
*******************************************************************************/
|
||||
public NotificationSeverity getSeverity()
|
||||
{
|
||||
return (this.severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for severity
|
||||
*******************************************************************************/
|
||||
public void setSeverity(NotificationSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for severity
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSeverity(NotificationSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for category: Default SYSTEM.
|
||||
*******************************************************************************/
|
||||
public NotificationCategory getCategory()
|
||||
{
|
||||
return (this.category);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for category
|
||||
*******************************************************************************/
|
||||
public void setCategory(NotificationCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for category
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withCategory(NotificationCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceName: Who emits it (a qbit or app name).
|
||||
*******************************************************************************/
|
||||
public String getSourceName()
|
||||
{
|
||||
return (this.sourceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceName
|
||||
*******************************************************************************/
|
||||
public void setSourceName(String sourceName)
|
||||
{
|
||||
this.sourceName = sourceName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceName
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceName(String sourceName)
|
||||
{
|
||||
this.sourceName = sourceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceKey: Dedup key: a second notify with the same key and audience updates the first.
|
||||
*******************************************************************************/
|
||||
public String getSourceKey()
|
||||
{
|
||||
return (this.sourceKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceKey
|
||||
*******************************************************************************/
|
||||
public void setSourceKey(String sourceKey)
|
||||
{
|
||||
this.sourceKey = sourceKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceKey
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceKey(String sourceKey)
|
||||
{
|
||||
this.sourceKey = sourceKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceTableName: The table of the record it is about.
|
||||
*******************************************************************************/
|
||||
public String getSourceTableName()
|
||||
{
|
||||
return (this.sourceTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public void setSourceTableName(String sourceTableName)
|
||||
{
|
||||
this.sourceTableName = sourceTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceTableName(String sourceTableName)
|
||||
{
|
||||
this.sourceTableName = sourceTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceRecordId: The record it is about.
|
||||
*******************************************************************************/
|
||||
public String getSourceRecordId()
|
||||
{
|
||||
return (this.sourceRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public void setSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for link: Where it takes the reader.
|
||||
*******************************************************************************/
|
||||
public AlertLink getLink()
|
||||
{
|
||||
return (this.link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for link
|
||||
*******************************************************************************/
|
||||
public void setLink(AlertLink link)
|
||||
{
|
||||
this.link = link;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for link
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withLink(AlertLink link)
|
||||
{
|
||||
this.link = link;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for audience: Who it is for (required).
|
||||
*******************************************************************************/
|
||||
public NotificationAudience getAudience()
|
||||
{
|
||||
return (this.audience);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for audience
|
||||
*******************************************************************************/
|
||||
public void setAudience(NotificationAudience audience)
|
||||
{
|
||||
this.audience = audience;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for audience
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withAudience(NotificationAudience audience)
|
||||
{
|
||||
this.audience = audience;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publishAt: Not shown before this (default now).
|
||||
*******************************************************************************/
|
||||
public Instant getPublishAt()
|
||||
{
|
||||
return (this.publishAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for publishAt
|
||||
*******************************************************************************/
|
||||
public void setPublishAt(Instant publishAt)
|
||||
{
|
||||
this.publishAt = publishAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publishAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withPublishAt(Instant publishAt)
|
||||
{
|
||||
this.publishAt = publishAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for expireAt: Hidden after this.
|
||||
*******************************************************************************/
|
||||
public Instant getExpireAt()
|
||||
{
|
||||
return (this.expireAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for expireAt
|
||||
*******************************************************************************/
|
||||
public void setExpireAt(Instant expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for expireAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withExpireAt(Instant expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sticky: A banner until dismissed (meant for CRITICAL).
|
||||
*******************************************************************************/
|
||||
public Boolean getSticky()
|
||||
{
|
||||
return (this.sticky);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sticky
|
||||
*******************************************************************************/
|
||||
public void setSticky(Boolean sticky)
|
||||
{
|
||||
this.sticky = sticky;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sticky
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSticky(Boolean sticky)
|
||||
{
|
||||
this.sticky = sticky;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for occurredAt: When it happened (default now).
|
||||
*******************************************************************************/
|
||||
public Instant getOccurredAt()
|
||||
{
|
||||
return (this.occurredAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public void setOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+186
-201
@@ -14,412 +14,397 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.model;
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.TopLevelMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One ordered destination in an escalation policy.
|
||||
** Declares a kind of problem a qbit or app can report (e.g. cdn.purge.dead),
|
||||
** so the system "knows about" every alert source. An alerts implementation
|
||||
** syncs these into a table at startup, where admins can see and tune them.
|
||||
**
|
||||
** Add it to an instance like any top-level meta-data (qInstance.add(...), or
|
||||
** from a MetaDataProducer); it lands in AlertsInstanceMetaData.alertTypes.
|
||||
** Names are lowercase and dotted: ^[a-z0-9]+(\.[a-z0-9-]+)+$
|
||||
*******************************************************************************/
|
||||
public class AlertsEscalationStep extends QRecordEntity
|
||||
public class QAlertTypeMetaData implements TopLevelMetaDataInterface, SourceQBitAware
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_escalation_step";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = "alerts_escalation_policy")
|
||||
private Integer policyId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer stepOrder;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer afterMinutes;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_subscriber_type")
|
||||
private String targetType;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String userId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_team")
|
||||
private Integer teamId;
|
||||
|
||||
@QField(possibleValueSourceName = "alerts_channel")
|
||||
private Integer channelId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_status")
|
||||
private String onlyIfStatus;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
private String name;
|
||||
private String label;
|
||||
private String sourceName;
|
||||
private String sourceQBitName;
|
||||
private String groupName;
|
||||
private AlertSeverity defaultSeverity = AlertSeverity.WARNING;
|
||||
private String description;
|
||||
private String howToFix;
|
||||
private String subjectTableName;
|
||||
private String visibilityPermission;
|
||||
private ResolveMode resolveMode = ResolveMode.PRODUCER;
|
||||
private Duration quietPeriod;
|
||||
private List<AlertDefaultMonitor> defaultMonitors = new ArrayList<>();
|
||||
private DetailLevel externalDetailCeiling = DetailLevel.STANDARD;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
** Find a declared alert type by name, or null.
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep()
|
||||
public static QAlertTypeMetaData of(QInstance qInstance, String name)
|
||||
{
|
||||
AlertsInstanceMetaData alertsInstanceMetaData = AlertsInstanceMetaData.of(qInstance);
|
||||
return (alertsInstanceMetaData == null ? null : alertsInstanceMetaData.getAlertType(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a QRecord.
|
||||
** Adds this type to the instance's AlertsInstanceMetaData (creating it).
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep(QRecord record)
|
||||
@Override
|
||||
public void addSelfToInstance(QInstance qInstance)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
AlertsInstanceMetaData.ofOrWithNew(qInstance).withAlertType(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
** The source shown to people: sourceName if set, otherwise the qbit that
|
||||
** produced this meta-data.
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
public String getEffectiveSourceName()
|
||||
{
|
||||
return (this.id);
|
||||
return (sourceName != null ? sourceName : sourceQBitName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
this.id = id;
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withId(Integer id)
|
||||
public void setName(String name)
|
||||
{
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for policyId
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public Integer getPolicyId()
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.policyId);
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for policyId
|
||||
** Fluent setter for label
|
||||
*******************************************************************************/
|
||||
public void setPolicyId(Integer policyId)
|
||||
public QAlertTypeMetaData withLabel(String label)
|
||||
{
|
||||
this.policyId = policyId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for policyId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withPolicyId(Integer policyId)
|
||||
{
|
||||
this.policyId = policyId;
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for stepOrder
|
||||
** Getter for sourceName
|
||||
*******************************************************************************/
|
||||
public Integer getStepOrder()
|
||||
public String getSourceName()
|
||||
{
|
||||
return (this.stepOrder);
|
||||
return (this.sourceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for stepOrder
|
||||
** Fluent setter for sourceName
|
||||
*******************************************************************************/
|
||||
public void setStepOrder(Integer stepOrder)
|
||||
public QAlertTypeMetaData withSourceName(String sourceName)
|
||||
{
|
||||
this.stepOrder = stepOrder;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for stepOrder
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withStepOrder(Integer stepOrder)
|
||||
{
|
||||
this.stepOrder = stepOrder;
|
||||
this.sourceName = sourceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for afterMinutes
|
||||
** Getter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
public Integer getAfterMinutes()
|
||||
@Override
|
||||
public String getSourceQBitName()
|
||||
{
|
||||
return (this.afterMinutes);
|
||||
return (this.sourceQBitName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for afterMinutes
|
||||
** Setter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
public void setAfterMinutes(Integer afterMinutes)
|
||||
@Override
|
||||
public void setSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
this.afterMinutes = afterMinutes;
|
||||
this.sourceQBitName = sourceQBitName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for afterMinutes
|
||||
** Fluent setter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withAfterMinutes(Integer afterMinutes)
|
||||
@Override
|
||||
public QAlertTypeMetaData withSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
this.afterMinutes = afterMinutes;
|
||||
this.sourceQBitName = sourceQBitName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetType
|
||||
** Getter for groupName
|
||||
*******************************************************************************/
|
||||
public String getTargetType()
|
||||
public String getGroupName()
|
||||
{
|
||||
return (this.targetType);
|
||||
return (this.groupName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for targetType
|
||||
** Fluent setter for groupName (the default group; created if missing)
|
||||
*******************************************************************************/
|
||||
public void setTargetType(String targetType)
|
||||
public QAlertTypeMetaData withGroupName(String groupName)
|
||||
{
|
||||
this.targetType = targetType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetType
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withTargetType(String targetType)
|
||||
{
|
||||
this.targetType = targetType;
|
||||
this.groupName = groupName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
** Getter for defaultSeverity
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
public AlertSeverity getDefaultSeverity()
|
||||
{
|
||||
return (this.userId);
|
||||
return (this.defaultSeverity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
** Fluent setter for defaultSeverity
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
public QAlertTypeMetaData withDefaultSeverity(AlertSeverity defaultSeverity)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
this.defaultSeverity = defaultSeverity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for teamId
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public Integer getTeamId()
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.teamId);
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for teamId
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public void setTeamId(Integer teamId)
|
||||
public QAlertTypeMetaData withDescription(String description)
|
||||
{
|
||||
this.teamId = teamId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for teamId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withTeamId(Integer teamId)
|
||||
{
|
||||
this.teamId = teamId;
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for channelId
|
||||
** Getter for howToFix
|
||||
*******************************************************************************/
|
||||
public Integer getChannelId()
|
||||
public String getHowToFix()
|
||||
{
|
||||
return (this.channelId);
|
||||
return (this.howToFix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for channelId
|
||||
** Fluent setter for howToFix
|
||||
*******************************************************************************/
|
||||
public void setChannelId(Integer channelId)
|
||||
public QAlertTypeMetaData withHowToFix(String howToFix)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for channelId
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withChannelId(Integer channelId)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
this.howToFix = howToFix;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for onlyIfStatus
|
||||
** Getter for subjectTableName
|
||||
*******************************************************************************/
|
||||
public String getOnlyIfStatus()
|
||||
public String getSubjectTableName()
|
||||
{
|
||||
return (this.onlyIfStatus);
|
||||
return (this.subjectTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for onlyIfStatus
|
||||
** Fluent setter for subjectTableName (drives visibility and the record panel)
|
||||
*******************************************************************************/
|
||||
public void setOnlyIfStatus(String onlyIfStatus)
|
||||
public QAlertTypeMetaData withSubjectTableName(String subjectTableName)
|
||||
{
|
||||
this.onlyIfStatus = onlyIfStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for onlyIfStatus
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withOnlyIfStatus(String onlyIfStatus)
|
||||
{
|
||||
this.onlyIfStatus = onlyIfStatus;
|
||||
this.subjectTableName = subjectTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
** Getter for visibilityPermission
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
public String getVisibilityPermission()
|
||||
{
|
||||
return (this.createDate);
|
||||
return (this.visibilityPermission);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
** Fluent setter for visibilityPermission (an extra gate on who may see it)
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
public QAlertTypeMetaData withVisibilityPermission(String visibilityPermission)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
this.visibilityPermission = visibilityPermission;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
** Getter for resolveMode
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
public ResolveMode getResolveMode()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
return (this.resolveMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
** Fluent setter for resolveMode
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
public QAlertTypeMetaData withResolveMode(ResolveMode resolveMode)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
this.resolveMode = resolveMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
** Resolve automatically after this long with no raise (sets the mode to
|
||||
** QUIET_PERIOD).
|
||||
*******************************************************************************/
|
||||
public AlertsEscalationStep withModifyDate(Instant modifyDate)
|
||||
public QAlertTypeMetaData withQuietPeriod(Duration quietPeriod)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
this.quietPeriod = quietPeriod;
|
||||
this.resolveMode = ResolveMode.QUIET_PERIOD;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for quietPeriod
|
||||
*******************************************************************************/
|
||||
public Duration getQuietPeriod()
|
||||
{
|
||||
return (this.quietPeriod);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultMonitors
|
||||
*******************************************************************************/
|
||||
public List<AlertDefaultMonitor> getDefaultMonitors()
|
||||
{
|
||||
return (this.defaultMonitors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a seeded monitor for this type.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withDefaultMonitor(AlertDefaultMonitor defaultMonitor)
|
||||
{
|
||||
if(defaultMonitor != null)
|
||||
{
|
||||
this.defaultMonitors.add(defaultMonitor);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for externalDetailCeiling
|
||||
*******************************************************************************/
|
||||
public DetailLevel getExternalDetailCeiling()
|
||||
{
|
||||
return (this.externalDetailCeiling);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for externalDetailCeiling (the most any channel may include)
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withExternalDetailCeiling(DetailLevel externalDetailCeiling)
|
||||
{
|
||||
this.externalDetailCeiling = externalDetailCeiling;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How an open alert of a type is closed automatically. Any mode also allows
|
||||
** a person to resolve an alert manually, with a note.
|
||||
*******************************************************************************/
|
||||
public enum ResolveMode
|
||||
{
|
||||
/** the producer calls QAlerts.resolve when the condition clears. */
|
||||
PRODUCER,
|
||||
|
||||
/** the monitor that raised it resolves it when its next evaluation is clean. */
|
||||
MONITOR,
|
||||
|
||||
/** it resolves when no raise has been seen for the type's quiet period. */
|
||||
QUIET_PERIOD,
|
||||
|
||||
/** only a person resolves it. */
|
||||
MANUAL
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN for the QQQ core alerts producer SPI (QRun-IO/qqq#1108), which is
|
||||
** not yet in a released QQQ 4.1.0 RC. These classes are copies of
|
||||
** com.kingsrook.qqq.backend.core.alerts (AlertSignal, AlertSeverity,
|
||||
** QAlertTypeMetaData and friends) under this package, so the qbit can be
|
||||
** built and tested against 4.1.0-RC.4 now.
|
||||
**
|
||||
** Replace when the RC with qqq#1108 ships: delete this package, change the
|
||||
** imports to com.kingsrook.qqq.backend.core.alerts, and register
|
||||
** QbitAlertsSink in core's AlertsInstanceMetaData (see AlertsQBitProducer).
|
||||
** Until then, producers in a host reach the qbit through
|
||||
** AlertsRuntime.sink() (or its monitors), not through core's QAlerts.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
@@ -1,59 +0,0 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- qbit-alerts starter defaults: the two channels every install needs, and the
|
||||
-- two groups the qbit itself files alerts under, so Delivery health and the
|
||||
-- group lists are not empty before the first alert.
|
||||
-- Include it from the host's master changelog AFTER alerts.changelog.sql:
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
|
||||
--
|
||||
-- Rollback is empty on purpose: a name-based delete would also remove rows the host already had.
|
||||
-- Rules for this file:
|
||||
-- * insert-only: every row is inserted only when its natural key (the name) is
|
||||
-- absent, so an administrator's edits always survive and nothing is updated;
|
||||
-- * no secrets: the channels hold settings only, never a secret;
|
||||
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
|
||||
-- * every changeset has a precondition on its table, so a host that has not
|
||||
-- created a table yet simply skips it (MARK_RAN).
|
||||
-- The runtime creates the same rows when they are missing (AlertChannels.defaultChannel,
|
||||
-- AlertTypeSync.ensureGroup); this file only makes them exist from the first deploy.
|
||||
-- Alert types, monitors, escalation policies, teams and subscriptions are NOT seeded
|
||||
-- here: types and default monitors are declared by the installed qbits and synced at
|
||||
-- startup of the Alerts home, and the rest is the host's own people and policy.
|
||||
-- AlertsDefaultsChangelogTest runs this file twice against a fresh schema.
|
||||
|
||||
-- changeset qbit-alerts:defaults-1
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_channel WHERE name = 'in-app'
|
||||
-- comment: The in-app channel (the bell): every user has it, full detail, no rate limit.
|
||||
INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date)
|
||||
SELECT 'in-app', 'In-App (Bell)', 'IN_APP', TRUE, 'FULL', 'INFO', 0, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'in-app');
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-alerts:defaults-2
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_channel WHERE name = 'email'
|
||||
-- comment: The personal email channel: a user's own address (My Alert Settings), minimal detail, 60 an hour.
|
||||
INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date)
|
||||
SELECT 'email', 'Email (Personal Address)', 'EMAIL', TRUE, 'MINIMAL', 'INFO', 60, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'email');
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-alerts:defaults-3
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_group WHERE name = 'alerts'
|
||||
-- comment: The group the qbit files its own alerts under (a monitor that cannot run, a failing channel, dropped signals).
|
||||
INSERT INTO alerts_group (name, label, create_date, modify_date)
|
||||
SELECT 'alerts', 'Alerts', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'alerts');
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-alerts:defaults-4
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_group WHERE name = 'other'
|
||||
-- comment: The catch-all group for alert types that name no group (and the unregistered type).
|
||||
INSERT INTO alerts_group (name, label, create_date, modify_date)
|
||||
SELECT 'other', 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'other');
|
||||
-- rollback empty
|
||||
@@ -8,10 +8,6 @@
|
||||
-- Rules for this file: the host runs it, the qbit never runs DDL; additive only
|
||||
-- (nothing is ever dropped or rewritten, except the one pre-GA rename in changesets 16-17);
|
||||
-- every statement is idempotent (IF NOT EXISTS, or a precondition). Column names are the snake_case form of the field names.
|
||||
-- Foreign keys (changesets 30-53): one changeset per constraint, named fk_<child_table>_<child_column> and guarded by a
|
||||
-- precondition on that name, so a host that already added it (e.g. from its own changelog) marks it ran. Before adding the
|
||||
-- constraint each one records orphan rows in qqq_fk_quarantine (the same DDL in every qbit), then nulls a nullable column
|
||||
-- or deletes a row whose column is NOT NULL (row_data keeps the full row), so the migration never fails on old data.
|
||||
|
||||
-- changeset qbit-alerts:1
|
||||
-- comment: A category of alerts for people ("Backups", "CDN"), optionally nested. The Alerts home groups by these, subscriptions target them, and every alert type belongs to one.
|
||||
@@ -431,347 +427,3 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference (
|
||||
CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category)
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:21
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME) = 'alerts_mute' AND LOWER(COLUMN_NAME) = 'scope_record_id'
|
||||
-- comment: Record mutes store the subject record ID separately from its table name.
|
||||
ALTER TABLE alerts_mute ADD COLUMN scope_record_id VARCHAR(100);
|
||||
|
||||
-- changeset qbit-alerts:22
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME) = 'alerts_mute' AND LOWER(COLUMN_NAME) = 'scope_value' AND CHARACTER_MAXIMUM_LENGTH < 520
|
||||
-- comment: ALERT mutes hold the complete type and dedup key, including long keys.
|
||||
ALTER TABLE alerts_mute ALTER COLUMN scope_value SET DATA TYPE VARCHAR(520);
|
||||
|
||||
-- changeset qbit-alerts:23
|
||||
-- comment: Admin escalation policies and their ordered destinations.
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_policy (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
repeat_after_minutes INTEGER,
|
||||
max_repeats INTEGER DEFAULT 0,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_policy PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_policy UNIQUE (name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alerts_escalation_step (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
policy_id INTEGER NOT NULL,
|
||||
step_order INTEGER NOT NULL,
|
||||
after_minutes INTEGER NOT NULL,
|
||||
target_type VARCHAR(16) NOT NULL,
|
||||
user_id VARCHAR(100),
|
||||
team_id INTEGER,
|
||||
channel_id INTEGER,
|
||||
only_if_status VARCHAR(16) DEFAULT 'OPEN' NOT NULL,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_escalation_step PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_escalation_step UNIQUE (policy_id, step_order),
|
||||
CONSTRAINT ck_alerts_escalation_step_order CHECK (step_order > 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_minutes CHECK (after_minutes >= 0),
|
||||
CONSTRAINT ck_alerts_escalation_step_target CHECK (
|
||||
(target_type = 'USER' AND user_id IS NOT NULL AND team_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'TEAM' AND team_id IS NOT NULL AND user_id IS NULL AND channel_id IS NULL) OR
|
||||
(target_type = 'CHANNEL' AND channel_id IS NOT NULL AND user_id IS NULL AND team_id IS NULL))
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:24
|
||||
-- comment: Type policies override the group's policy.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_type' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_type ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:25
|
||||
-- comment: A group policy is used when the type has none.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_group' AND LOWER(column_name) = 'escalation_policy_id'
|
||||
ALTER TABLE alerts_group ADD COLUMN escalation_policy_id INTEGER;
|
||||
|
||||
-- changeset qbit-alerts:26
|
||||
-- comment: Persist the timed job checkpoint, including attempts without recipients.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_alert' AND LOWER(column_name) = 'timed_notification_checked_at'
|
||||
ALTER TABLE alerts_alert ADD COLUMN timed_notification_checked_at TIMESTAMP;
|
||||
|
||||
-- changeset qbit-alerts:27
|
||||
-- comment: Digest items share a window key. A separate log row claims each channel/window exactly once.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_delivery' AND LOWER(column_name) = 'digest_key'
|
||||
ALTER TABLE alerts_delivery ADD COLUMN digest_key VARCHAR(200);
|
||||
ALTER TABLE alerts_delivery ADD COLUMN digest_claim_key VARCHAR(240);
|
||||
ALTER TABLE alerts_delivery ADD COLUMN digest_last_item_id INTEGER;
|
||||
ALTER TABLE alerts_delivery ADD CONSTRAINT uq_alerts_delivery_digest_claim UNIQUE (digest_claim_key);
|
||||
CREATE INDEX ix_alerts_delivery_digest ON alerts_delivery (digest_key, channel_id, status);
|
||||
|
||||
-- changeset qbit-alerts:28
|
||||
-- comment: Per-user Slack direct messages for notification categories: the Slack mode and minimum severity, and each recipient's Slack status and message id.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_notification_preference' AND LOWER(column_name) = 'slack_mode'
|
||||
ALTER TABLE alerts_notification_preference ADD COLUMN slack_mode VARCHAR(16);
|
||||
ALTER TABLE alerts_notification_preference ADD COLUMN slack_min_severity VARCHAR(16);
|
||||
ALTER TABLE alerts_notification_recipient ADD COLUMN slack_status VARCHAR(16);
|
||||
ALTER TABLE alerts_notification_recipient ADD COLUMN slack_message_id VARCHAR(100);
|
||||
CREATE INDEX ix_alerts_notification_recipient_slack_status ON alerts_notification_recipient (slack_status);
|
||||
|
||||
-- changeset qbit-alerts:29
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_notification_preference' AND LOWER(column_name) = 'in_app_min_severity'
|
||||
-- comment: Optional minimum severity for in-app notifications (security always arrives).
|
||||
ALTER TABLE alerts_notification_preference ADD COLUMN in_app_min_severity VARCHAR(16);
|
||||
|
||||
-- changeset qbit-alerts:30
|
||||
-- comment: Indexes for the join child columns: a per-record monitor finds its open alerts by (monitor_id, status) on every evaluation (MonitorEvaluator), and the record pages list a type's monitors (alert_type_id) and a group's types (group_id).
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_alert_monitor_id_status ON alerts_alert (monitor_id, status);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_monitor_alert_type_id ON alerts_monitor (alert_type_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_type_group_id ON alerts_type (group_id);
|
||||
|
||||
-- changeset qbit-alerts:31
|
||||
-- comment: Indexes for the foreign-key columns no earlier index leads with, so FK checks and ON DELETE actions do not scan the child table.
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_alert_group_id ON alerts_alert (group_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_alert_assigned_team_id ON alerts_alert (assigned_team_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_type_escalation_policy_id ON alerts_type (escalation_policy_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_group_parent_group_id ON alerts_group (parent_group_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_group_owner_team_id ON alerts_group (owner_team_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_group_escalation_policy_id ON alerts_group (escalation_policy_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_alert_event_id ON alerts_delivery (alert_event_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_subscription_id ON alerts_delivery (subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_subscription_team_id ON alerts_subscription (team_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_subscription_channel_id ON alerts_subscription (channel_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_escalation_step_team_id ON alerts_escalation_step (team_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_escalation_step_channel_id ON alerts_escalation_step (channel_id);
|
||||
-- rollback DROP INDEX IF EXISTS ix_alerts_alert_group_id; DROP INDEX IF EXISTS ix_alerts_alert_assigned_team_id; DROP INDEX IF EXISTS ix_alerts_type_escalation_policy_id; DROP INDEX IF EXISTS ix_alerts_group_parent_group_id; DROP INDEX IF EXISTS ix_alerts_group_owner_team_id; DROP INDEX IF EXISTS ix_alerts_group_escalation_policy_id; DROP INDEX IF EXISTS ix_alerts_delivery_alert_event_id; DROP INDEX IF EXISTS ix_alerts_delivery_subscription_id; DROP INDEX IF EXISTS ix_alerts_subscription_team_id; DROP INDEX IF EXISTS ix_alerts_subscription_channel_id; DROP INDEX IF EXISTS ix_alerts_escalation_step_team_id; DROP INDEX IF EXISTS ix_alerts_escalation_step_channel_id;
|
||||
|
||||
-- changeset qbit-alerts:32 dbms:postgresql
|
||||
-- comment: alerts_type.group_id -> alerts_group ON DELETE SET NULL. A deleted group leaves its types ungrouped; AlertTypeSync puts them back in their declared (or the default) group at the next boot. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_type_group_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_type', 'group_id', CAST(c.id AS VARCHAR(100)), CAST(c.group_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:32' FROM alerts_type c WHERE c.group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.group_id);
|
||||
UPDATE alerts_type c SET group_id = NULL WHERE c.group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.group_id);
|
||||
ALTER TABLE alerts_type ADD CONSTRAINT fk_alerts_type_group_id FOREIGN KEY (group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_type DROP CONSTRAINT IF EXISTS fk_alerts_type_group_id;
|
||||
|
||||
-- changeset qbit-alerts:33 dbms:postgresql
|
||||
-- comment: alerts_type.escalation_policy_id -> alerts_escalation_policy ON DELETE SET NULL. A deleted escalation policy leaves the type on its group's policy (or none). Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_type_escalation_policy_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_type', 'escalation_policy_id', CAST(c.id AS VARCHAR(100)), CAST(c.escalation_policy_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:33' FROM alerts_type c WHERE c.escalation_policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.escalation_policy_id);
|
||||
UPDATE alerts_type c SET escalation_policy_id = NULL WHERE c.escalation_policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.escalation_policy_id);
|
||||
ALTER TABLE alerts_type ADD CONSTRAINT fk_alerts_type_escalation_policy_id FOREIGN KEY (escalation_policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_type DROP CONSTRAINT IF EXISTS fk_alerts_type_escalation_policy_id;
|
||||
|
||||
-- changeset qbit-alerts:34 dbms:postgresql
|
||||
-- comment: alerts_group.parent_group_id -> alerts_group ON DELETE SET NULL. A deleted parent group makes its child groups top-level. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_group_parent_group_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_group', 'parent_group_id', CAST(c.id AS VARCHAR(100)), CAST(c.parent_group_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:34' FROM alerts_group c WHERE c.parent_group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.parent_group_id);
|
||||
UPDATE alerts_group c SET parent_group_id = NULL WHERE c.parent_group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.parent_group_id);
|
||||
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_parent_group_id FOREIGN KEY (parent_group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_group DROP CONSTRAINT IF EXISTS fk_alerts_group_parent_group_id;
|
||||
|
||||
-- changeset qbit-alerts:35 dbms:postgresql
|
||||
-- comment: alerts_group.owner_team_id -> alerts_team ON DELETE SET NULL. A deleted team leaves the group without an owner. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_group_owner_team_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_group', 'owner_team_id', CAST(c.id AS VARCHAR(100)), CAST(c.owner_team_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:35' FROM alerts_group c WHERE c.owner_team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.owner_team_id);
|
||||
UPDATE alerts_group c SET owner_team_id = NULL WHERE c.owner_team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.owner_team_id);
|
||||
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_owner_team_id FOREIGN KEY (owner_team_id) REFERENCES alerts_team (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_group DROP CONSTRAINT IF EXISTS fk_alerts_group_owner_team_id;
|
||||
|
||||
-- changeset qbit-alerts:36 dbms:postgresql
|
||||
-- comment: alerts_group.escalation_policy_id -> alerts_escalation_policy ON DELETE SET NULL. A deleted escalation policy leaves the group without one. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_group_escalation_policy_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_group', 'escalation_policy_id', CAST(c.id AS VARCHAR(100)), CAST(c.escalation_policy_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:36' FROM alerts_group c WHERE c.escalation_policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.escalation_policy_id);
|
||||
UPDATE alerts_group c SET escalation_policy_id = NULL WHERE c.escalation_policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.escalation_policy_id);
|
||||
ALTER TABLE alerts_group ADD CONSTRAINT fk_alerts_group_escalation_policy_id FOREIGN KEY (escalation_policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_group DROP CONSTRAINT IF EXISTS fk_alerts_group_escalation_policy_id;
|
||||
|
||||
-- changeset qbit-alerts:37 dbms:postgresql
|
||||
-- comment: alerts_monitor.alert_type_id -> alerts_type ON DELETE RESTRICT. A monitor needs its type; types are never deleted (TABLE_DELETE is off, AlertTypeSync only adds and updates). Orphans are recorded in qqq_fk_quarantine and deleted first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_monitor_alert_type_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_monitor', 'alert_type_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_type_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:37' FROM alerts_monitor c WHERE c.alert_type_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_type p WHERE p.id = c.alert_type_id);
|
||||
DELETE FROM alerts_monitor c WHERE c.alert_type_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_type p WHERE p.id = c.alert_type_id);
|
||||
ALTER TABLE alerts_monitor ADD CONSTRAINT fk_alerts_monitor_alert_type_id FOREIGN KEY (alert_type_id) REFERENCES alerts_type (id) ON DELETE RESTRICT;
|
||||
-- rollback ALTER TABLE alerts_monitor DROP CONSTRAINT IF EXISTS fk_alerts_monitor_alert_type_id;
|
||||
|
||||
-- changeset qbit-alerts:38 dbms:postgresql
|
||||
-- comment: alerts_alert.alert_type_id -> alerts_type ON DELETE RESTRICT. An alert keeps its type; types are never deleted. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_alert_alert_type_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_alert', 'alert_type_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_type_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:38' FROM alerts_alert c WHERE c.alert_type_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_type p WHERE p.id = c.alert_type_id);
|
||||
UPDATE alerts_alert c SET alert_type_id = NULL WHERE c.alert_type_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_type p WHERE p.id = c.alert_type_id);
|
||||
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_alert_type_id FOREIGN KEY (alert_type_id) REFERENCES alerts_type (id) ON DELETE RESTRICT;
|
||||
-- rollback ALTER TABLE alerts_alert DROP CONSTRAINT IF EXISTS fk_alerts_alert_alert_type_id;
|
||||
|
||||
-- changeset qbit-alerts:39 dbms:postgresql
|
||||
-- comment: alerts_alert.monitor_id -> alerts_monitor ON DELETE SET NULL. A deleted monitor leaves its alerts (history) in place, without the monitor. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_alert_monitor_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_alert', 'monitor_id', CAST(c.id AS VARCHAR(100)), CAST(c.monitor_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:39' FROM alerts_alert c WHERE c.monitor_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_monitor p WHERE p.id = c.monitor_id);
|
||||
UPDATE alerts_alert c SET monitor_id = NULL WHERE c.monitor_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_monitor p WHERE p.id = c.monitor_id);
|
||||
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_monitor_id FOREIGN KEY (monitor_id) REFERENCES alerts_monitor (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_alert DROP CONSTRAINT IF EXISTS fk_alerts_alert_monitor_id;
|
||||
|
||||
-- changeset qbit-alerts:40 dbms:postgresql
|
||||
-- comment: alerts_alert.group_id -> alerts_group ON DELETE SET NULL. A deleted group leaves its alerts in place, ungrouped. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_alert_group_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_alert', 'group_id', CAST(c.id AS VARCHAR(100)), CAST(c.group_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:40' FROM alerts_alert c WHERE c.group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.group_id);
|
||||
UPDATE alerts_alert c SET group_id = NULL WHERE c.group_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_group p WHERE p.id = c.group_id);
|
||||
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_group_id FOREIGN KEY (group_id) REFERENCES alerts_group (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_alert DROP CONSTRAINT IF EXISTS fk_alerts_alert_group_id;
|
||||
|
||||
-- changeset qbit-alerts:41 dbms:postgresql
|
||||
-- comment: alerts_alert.assigned_team_id -> alerts_team ON DELETE SET NULL. A deleted team unassigns its alerts. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_alert_assigned_team_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_alert', 'assigned_team_id', CAST(c.id AS VARCHAR(100)), CAST(c.assigned_team_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:41' FROM alerts_alert c WHERE c.assigned_team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.assigned_team_id);
|
||||
UPDATE alerts_alert c SET assigned_team_id = NULL WHERE c.assigned_team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.assigned_team_id);
|
||||
ALTER TABLE alerts_alert ADD CONSTRAINT fk_alerts_alert_assigned_team_id FOREIGN KEY (assigned_team_id) REFERENCES alerts_team (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_alert DROP CONSTRAINT IF EXISTS fk_alerts_alert_assigned_team_id;
|
||||
|
||||
-- changeset qbit-alerts:42 dbms:postgresql
|
||||
-- comment: alerts_record_ref.alert_id -> alerts_alert ON DELETE CASCADE. Record references belong to their alert. Orphans are recorded in qqq_fk_quarantine and deleted first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_record_ref_alert_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_record_ref', 'alert_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:42' FROM alerts_record_ref c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
|
||||
DELETE FROM alerts_record_ref c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
|
||||
ALTER TABLE alerts_record_ref ADD CONSTRAINT fk_alerts_record_ref_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
|
||||
-- rollback ALTER TABLE alerts_record_ref DROP CONSTRAINT IF EXISTS fk_alerts_record_ref_alert_id;
|
||||
|
||||
-- changeset qbit-alerts:43 dbms:postgresql
|
||||
-- comment: alerts_event.alert_id -> alerts_alert ON DELETE CASCADE. The timeline belongs to its alert. Orphans are recorded in qqq_fk_quarantine and deleted first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_event_alert_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_event', 'alert_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:43' FROM alerts_event c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
|
||||
DELETE FROM alerts_event c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
|
||||
ALTER TABLE alerts_event ADD CONSTRAINT fk_alerts_event_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
|
||||
-- rollback ALTER TABLE alerts_event DROP CONSTRAINT IF EXISTS fk_alerts_event_alert_id;
|
||||
|
||||
-- changeset qbit-alerts:44 dbms:postgresql
|
||||
-- comment: alerts_delivery.alert_id -> alerts_alert ON DELETE CASCADE. Delivery log rows of an alert go with the alert. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_delivery_alert_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_delivery', 'alert_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:44' FROM alerts_delivery c WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
|
||||
UPDATE alerts_delivery c SET alert_id = NULL WHERE c.alert_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_alert p WHERE p.id = c.alert_id);
|
||||
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_alert_id FOREIGN KEY (alert_id) REFERENCES alerts_alert (id) ON DELETE CASCADE;
|
||||
-- rollback ALTER TABLE alerts_delivery DROP CONSTRAINT IF EXISTS fk_alerts_delivery_alert_id;
|
||||
|
||||
-- changeset qbit-alerts:45 dbms:postgresql
|
||||
-- comment: alerts_delivery.alert_event_id -> alerts_event ON DELETE SET NULL. The timeline event that caused a delivery (AlertRouter); the log row outlives it. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_delivery_alert_event_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_delivery', 'alert_event_id', CAST(c.id AS VARCHAR(100)), CAST(c.alert_event_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:45' FROM alerts_delivery c WHERE c.alert_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_event p WHERE p.id = c.alert_event_id);
|
||||
UPDATE alerts_delivery c SET alert_event_id = NULL WHERE c.alert_event_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_event p WHERE p.id = c.alert_event_id);
|
||||
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_alert_event_id FOREIGN KEY (alert_event_id) REFERENCES alerts_event (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_delivery DROP CONSTRAINT IF EXISTS fk_alerts_delivery_alert_event_id;
|
||||
|
||||
-- changeset qbit-alerts:46 dbms:postgresql
|
||||
-- comment: alerts_delivery.subscription_id -> alerts_subscription ON DELETE SET NULL. A deleted subscription keeps its delivery history. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_delivery_subscription_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_delivery', 'subscription_id', CAST(c.id AS VARCHAR(100)), CAST(c.subscription_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:46' FROM alerts_delivery c WHERE c.subscription_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_subscription p WHERE p.id = c.subscription_id);
|
||||
UPDATE alerts_delivery c SET subscription_id = NULL WHERE c.subscription_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_subscription p WHERE p.id = c.subscription_id);
|
||||
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_subscription_id FOREIGN KEY (subscription_id) REFERENCES alerts_subscription (id) ON DELETE SET NULL;
|
||||
-- rollback ALTER TABLE alerts_delivery DROP CONSTRAINT IF EXISTS fk_alerts_delivery_subscription_id;
|
||||
|
||||
-- changeset qbit-alerts:47 dbms:postgresql
|
||||
-- comment: alerts_delivery.channel_id -> alerts_channel ON DELETE RESTRICT. A channel with delivery history is disabled, not deleted. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_delivery_channel_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_delivery', 'channel_id', CAST(c.id AS VARCHAR(100)), CAST(c.channel_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:47' FROM alerts_delivery c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
|
||||
UPDATE alerts_delivery c SET channel_id = NULL WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
|
||||
ALTER TABLE alerts_delivery ADD CONSTRAINT fk_alerts_delivery_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
|
||||
-- rollback ALTER TABLE alerts_delivery DROP CONSTRAINT IF EXISTS fk_alerts_delivery_channel_id;
|
||||
|
||||
-- changeset qbit-alerts:48 dbms:postgresql
|
||||
-- comment: alerts_subscription.team_id -> alerts_team ON DELETE RESTRICT. A team subscription needs its team (AlertsSubscriptionCustomizer); remove the subscriptions before the team. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_subscription_team_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_subscription', 'team_id', CAST(c.id AS VARCHAR(100)), CAST(c.team_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:48' FROM alerts_subscription c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
|
||||
UPDATE alerts_subscription c SET team_id = NULL WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
|
||||
ALTER TABLE alerts_subscription ADD CONSTRAINT fk_alerts_subscription_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE RESTRICT;
|
||||
-- rollback ALTER TABLE alerts_subscription DROP CONSTRAINT IF EXISTS fk_alerts_subscription_team_id;
|
||||
|
||||
-- changeset qbit-alerts:49 dbms:postgresql
|
||||
-- comment: alerts_subscription.channel_id -> alerts_channel ON DELETE RESTRICT. A channel subscription needs its channel (AlertsSubscriptionCustomizer); remove the subscriptions before the channel. Orphans are recorded in qqq_fk_quarantine and nulled first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_subscription_channel_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_subscription', 'channel_id', CAST(c.id AS VARCHAR(100)), CAST(c.channel_id AS VARCHAR(100)), 'NULLED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:49' FROM alerts_subscription c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
|
||||
UPDATE alerts_subscription c SET channel_id = NULL WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
|
||||
ALTER TABLE alerts_subscription ADD CONSTRAINT fk_alerts_subscription_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
|
||||
-- rollback ALTER TABLE alerts_subscription DROP CONSTRAINT IF EXISTS fk_alerts_subscription_channel_id;
|
||||
|
||||
-- changeset qbit-alerts:50 dbms:postgresql
|
||||
-- comment: alerts_team_member.team_id -> alerts_team ON DELETE CASCADE. Members belong to their team. Orphans are recorded in qqq_fk_quarantine and deleted first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_team_member_team_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_team_member', 'team_id', CAST(c.id AS VARCHAR(100)), CAST(c.team_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:50' FROM alerts_team_member c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
|
||||
DELETE FROM alerts_team_member c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
|
||||
ALTER TABLE alerts_team_member ADD CONSTRAINT fk_alerts_team_member_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE CASCADE;
|
||||
-- rollback ALTER TABLE alerts_team_member DROP CONSTRAINT IF EXISTS fk_alerts_team_member_team_id;
|
||||
|
||||
-- changeset qbit-alerts:51 dbms:postgresql
|
||||
-- comment: alerts_notification_recipient.notification_id -> alerts_notification ON DELETE CASCADE. Recipient rows belong to their notification (NotificationService.purge deletes them first; this covers any other delete). Orphans are recorded in qqq_fk_quarantine and deleted first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_notification_recipient_notification_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_notification_recipient', 'notification_id', CAST(c.id AS VARCHAR(100)), CAST(c.notification_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:51' FROM alerts_notification_recipient c WHERE c.notification_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_notification p WHERE p.id = c.notification_id);
|
||||
DELETE FROM alerts_notification_recipient c WHERE c.notification_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_notification p WHERE p.id = c.notification_id);
|
||||
ALTER TABLE alerts_notification_recipient ADD CONSTRAINT fk_alerts_notification_recipient_notification_id FOREIGN KEY (notification_id) REFERENCES alerts_notification (id) ON DELETE CASCADE;
|
||||
-- rollback ALTER TABLE alerts_notification_recipient DROP CONSTRAINT IF EXISTS fk_alerts_notification_recipient_notification_id;
|
||||
|
||||
-- changeset qbit-alerts:52 dbms:postgresql
|
||||
-- comment: alerts_escalation_step.policy_id -> alerts_escalation_policy ON DELETE CASCADE. Steps belong to their policy. Orphans are recorded in qqq_fk_quarantine and deleted first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_escalation_step_policy_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_escalation_step', 'policy_id', CAST(c.id AS VARCHAR(100)), CAST(c.policy_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:52' FROM alerts_escalation_step c WHERE c.policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.policy_id);
|
||||
DELETE FROM alerts_escalation_step c WHERE c.policy_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_escalation_policy p WHERE p.id = c.policy_id);
|
||||
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_policy_id FOREIGN KEY (policy_id) REFERENCES alerts_escalation_policy (id) ON DELETE CASCADE;
|
||||
-- rollback ALTER TABLE alerts_escalation_step DROP CONSTRAINT IF EXISTS fk_alerts_escalation_step_policy_id;
|
||||
|
||||
-- changeset qbit-alerts:53 dbms:postgresql
|
||||
-- comment: alerts_escalation_step.team_id -> alerts_team ON DELETE RESTRICT. A team step needs its team (ck_alerts_escalation_step_target forbids a NULL), so an orphan step is removed, not nulled. Orphans are recorded in qqq_fk_quarantine and deleted first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_escalation_step_team_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_escalation_step', 'team_id', CAST(c.id AS VARCHAR(100)), CAST(c.team_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:53' FROM alerts_escalation_step c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
|
||||
DELETE FROM alerts_escalation_step c WHERE c.team_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_team p WHERE p.id = c.team_id);
|
||||
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_team_id FOREIGN KEY (team_id) REFERENCES alerts_team (id) ON DELETE RESTRICT;
|
||||
-- rollback ALTER TABLE alerts_escalation_step DROP CONSTRAINT IF EXISTS fk_alerts_escalation_step_team_id;
|
||||
|
||||
-- changeset qbit-alerts:54 dbms:postgresql
|
||||
-- comment: alerts_escalation_step.channel_id -> alerts_channel ON DELETE RESTRICT. A channel step needs its channel (ck_alerts_escalation_step_target forbids a NULL), so an orphan step is removed, not nulled. Orphans are recorded in qqq_fk_quarantine and deleted first.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_alerts_escalation_step_channel_id'
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) SELECT 'alerts_escalation_step', 'channel_id', CAST(c.id AS VARCHAR(100)), CAST(c.channel_id AS VARCHAR(100)), 'DELETED', CAST(row_to_json(c) AS TEXT), 'qbit-alerts:54' FROM alerts_escalation_step c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
|
||||
DELETE FROM alerts_escalation_step c WHERE c.channel_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM alerts_channel p WHERE p.id = c.channel_id);
|
||||
ALTER TABLE alerts_escalation_step ADD CONSTRAINT fk_alerts_escalation_step_channel_id FOREIGN KEY (channel_id) REFERENCES alerts_channel (id) ON DELETE RESTRICT;
|
||||
-- rollback ALTER TABLE alerts_escalation_step DROP CONSTRAINT IF EXISTS fk_alerts_escalation_step_channel_id;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,727 +0,0 @@
|
||||
components:
|
||||
schemas:
|
||||
AlertsActionV1:
|
||||
description: "An available action: operationId targets an inbox operation with\
|
||||
\ this item id; process actions provide a complete href."
|
||||
properties:
|
||||
href:
|
||||
type: "string"
|
||||
label:
|
||||
type: "string"
|
||||
name:
|
||||
type: "string"
|
||||
operationId:
|
||||
type: "string"
|
||||
processName:
|
||||
type: "string"
|
||||
type: "object"
|
||||
AlertsErrorV1:
|
||||
description: "Deprecated since 1.2.0: errors use BasicErrorResponseV1."
|
||||
properties:
|
||||
error:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
AlertsFollowV1:
|
||||
description: "Capability discovery only. Next obtains the follow URL from its\
|
||||
\ collaboration module."
|
||||
properties:
|
||||
available:
|
||||
description: "True exactly when the collaboration UI API extension is registered."
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
AlertsInboxItemV1:
|
||||
description: "A notification recipient. body is included only in page items\
|
||||
\ and banners; alertId only for ALERT notifications."
|
||||
properties:
|
||||
actions:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsActionV1"
|
||||
type: "array"
|
||||
age:
|
||||
type: "string"
|
||||
alertId:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
body:
|
||||
nullable: true
|
||||
type: "string"
|
||||
category:
|
||||
nullable: true
|
||||
type: "string"
|
||||
deliveredAt:
|
||||
nullable: true
|
||||
type: "string"
|
||||
href:
|
||||
description: "Existing safe link, or null."
|
||||
nullable: true
|
||||
type: "string"
|
||||
id:
|
||||
type: "integer"
|
||||
linkLabel:
|
||||
nullable: true
|
||||
type: "string"
|
||||
notificationId:
|
||||
type: "integer"
|
||||
severity:
|
||||
nullable: true
|
||||
type: "string"
|
||||
source:
|
||||
nullable: true
|
||||
type: "string"
|
||||
sticky:
|
||||
type: "boolean"
|
||||
title:
|
||||
nullable: true
|
||||
type: "string"
|
||||
unread:
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
AlertsInboxPageV1:
|
||||
description: "Filtered notification page."
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsInboxItemV1"
|
||||
type: "array"
|
||||
limit:
|
||||
type: "integer"
|
||||
offset:
|
||||
type: "integer"
|
||||
total:
|
||||
type: "integer"
|
||||
type: "object"
|
||||
AlertsInboxSummaryV1:
|
||||
description: "Bell summary, latest ten items and up to three sticky banners."
|
||||
properties:
|
||||
alertsHref:
|
||||
type: "string"
|
||||
banners:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsInboxItemV1"
|
||||
type: "array"
|
||||
highestUnreadSeverity:
|
||||
nullable: true
|
||||
type: "string"
|
||||
inboxHref:
|
||||
description: "Canonical inbox navigation link."
|
||||
type: "string"
|
||||
items:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsInboxItemV1"
|
||||
type: "array"
|
||||
notificationsHref:
|
||||
description: "Existing notifications page link; retained for compatibility."
|
||||
type: "string"
|
||||
pollSeconds:
|
||||
type: "integer"
|
||||
preferencesSection:
|
||||
description: "Name of the user Preferences section for notification settings\
|
||||
\ (/qqq/v1/preferences/{name}), or null when not registered."
|
||||
nullable: true
|
||||
type: "string"
|
||||
unreadCount:
|
||||
type: "integer"
|
||||
type: "object"
|
||||
AlertsInboxTargetV1:
|
||||
description: "Select id, nonempty ids (first 500), legacy alertId, or all:true\
|
||||
\ in that precedence. All scans are capped at 1000 rows."
|
||||
properties:
|
||||
alertId:
|
||||
type: "integer"
|
||||
all:
|
||||
type: "boolean"
|
||||
id:
|
||||
type: "integer"
|
||||
ids:
|
||||
items:
|
||||
type: "integer"
|
||||
type: "array"
|
||||
type: "object"
|
||||
AlertsMutationResultV1:
|
||||
description: "Number of the caller own recipient rows changed."
|
||||
properties:
|
||||
updated:
|
||||
type: "integer"
|
||||
type: "object"
|
||||
AlertsPanelAlertV1:
|
||||
description: "A visible open alert with permission-aware process actions."
|
||||
properties:
|
||||
actions:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsActionV1"
|
||||
type: "array"
|
||||
age:
|
||||
type: "string"
|
||||
alertId:
|
||||
type: "integer"
|
||||
group:
|
||||
nullable: true
|
||||
type: "string"
|
||||
href:
|
||||
type: "string"
|
||||
raisedAt:
|
||||
nullable: true
|
||||
type: "string"
|
||||
severity:
|
||||
nullable: true
|
||||
type: "string"
|
||||
status:
|
||||
nullable: true
|
||||
type: "string"
|
||||
title:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
AlertsRecordPanelV1:
|
||||
description: "Open alerts, recent resolutions and collaboration capability.\
|
||||
\ watching is deprecated legacy subscription state."
|
||||
properties:
|
||||
canManage:
|
||||
type: "boolean"
|
||||
follow:
|
||||
$ref: "#/components/schemas/AlertsFollowV1"
|
||||
open:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsPanelAlertV1"
|
||||
type: "array"
|
||||
openCount:
|
||||
type: "integer"
|
||||
resolvedLast30Days:
|
||||
type: "integer"
|
||||
viewAllHref:
|
||||
nullable: true
|
||||
type: "string"
|
||||
visible:
|
||||
type: "boolean"
|
||||
watching:
|
||||
description: "Deprecated: existing RECORD subscriptions only; does not represent\
|
||||
\ collaboration follow state."
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
AlertsUnreadTargetV1:
|
||||
description: "Select id or nonempty ids (first 500), in that precedence. Legacy\
|
||||
\ alertId is also accepted for compatibility. all is not supported."
|
||||
properties:
|
||||
alertId:
|
||||
type: "integer"
|
||||
id:
|
||||
type: "integer"
|
||||
ids:
|
||||
items:
|
||||
type: "integer"
|
||||
type: "array"
|
||||
type: "object"
|
||||
BasicErrorResponseV1:
|
||||
description: "The error body of every UI API operation (core and extensions)."
|
||||
properties:
|
||||
error:
|
||||
description: "The error message (the same as message; kept for clients of\
|
||||
\ UI API 1.0 to 1.5)."
|
||||
type: "string"
|
||||
code:
|
||||
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
|
||||
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
|
||||
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
|
||||
\ Extensions may add codes; treat an unknown code by the HTTP status.\
|
||||
\ Since 1.6.0."
|
||||
type: "string"
|
||||
message:
|
||||
description: "A message to show the user. Since 1.6.0."
|
||||
type: "string"
|
||||
fieldErrors:
|
||||
description: "Errors about particular request fields, when known. Since\
|
||||
\ 1.6.0."
|
||||
items:
|
||||
properties:
|
||||
field:
|
||||
description: "The field (or parameter) name."
|
||||
type: "string"
|
||||
message:
|
||||
description: "The error about that field."
|
||||
type: "string"
|
||||
code:
|
||||
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
|
||||
\ PATTERN."
|
||||
type: "string"
|
||||
type: "object"
|
||||
type: "array"
|
||||
traceId:
|
||||
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
|
||||
\ response header); quote it when reporting a problem. Since 1.6.0."
|
||||
type: "string"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
bearerAuth:
|
||||
bearerFormat: "JWT or session UUID"
|
||||
description: "`Authorization: Bearer <token>`, where the token is either\n-\
|
||||
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
|
||||
\ to the app's client,\n or to its native client (`values.nativeClientId`\
|
||||
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
|
||||
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
|
||||
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
|
||||
\ as signing in on the web. When it expires (401), refresh it at the provider\
|
||||
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
|
||||
\ which the server keeps refreshed."
|
||||
scheme: "bearer"
|
||||
type: "http"
|
||||
oauth2:
|
||||
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
|
||||
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
|
||||
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
|
||||
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
|
||||
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
|
||||
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
|
||||
\ token\nas `Authorization: Bearer`."
|
||||
flows:
|
||||
authorizationCode:
|
||||
authorizationUrl: "/authorize"
|
||||
scopes:
|
||||
email: "Email address"
|
||||
offline_access: "A refresh token, to renew the access token without signing\
|
||||
\ in again"
|
||||
openid: "OpenID Connect sign-in"
|
||||
profile: "Name, username and groups (permissions come from the groups)"
|
||||
tokenUrl: "/token"
|
||||
type: "oauth2"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Alerts inbox and record panel"
|
||||
title: "QQQ Middleware API extension: alerts"
|
||||
version: "1.2.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/alerts/inbox/summary:
|
||||
get:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInboxSummary"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxSummaryV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Read the bell summary"
|
||||
tags:
|
||||
- "alerts"
|
||||
/qqq/v1/alerts/inbox:
|
||||
get:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInbox"
|
||||
parameters:
|
||||
- description: "all or unread; other values behave as all."
|
||||
in: "query"
|
||||
name: "status"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Case-insensitive category filter."
|
||||
in: "query"
|
||||
name: "category"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Case-insensitive severity filter."
|
||||
in: "query"
|
||||
name: "severity"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Default 0; negatives clamp to 0; malformed values use the default."
|
||||
in: "query"
|
||||
name: "offset"
|
||||
required: false
|
||||
schema:
|
||||
type: "integer"
|
||||
- description: "Default 25; clamped to 1..100; malformed values use the default."
|
||||
in: "query"
|
||||
name: "limit"
|
||||
required: false
|
||||
schema:
|
||||
type: "integer"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxPageV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Page the notification inbox"
|
||||
tags:
|
||||
- "alerts"
|
||||
/qqq/v1/alerts/inbox/read:
|
||||
post:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInboxRead"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxTargetV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Mark own notifications read"
|
||||
tags:
|
||||
- "alerts"
|
||||
/qqq/v1/alerts/inbox/unread:
|
||||
post:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInboxUnread"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsUnreadTargetV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Mark own notifications unread"
|
||||
tags:
|
||||
- "alerts"
|
||||
/qqq/v1/alerts/inbox/dismiss:
|
||||
post:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInboxDismiss"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxTargetV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Dismiss own notifications"
|
||||
tags:
|
||||
- "alerts"
|
||||
/qqq/v1/alerts/record-panel:
|
||||
get:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsRecordPanel"
|
||||
parameters:
|
||||
- description: "Parent table name."
|
||||
in: "query"
|
||||
name: "tableName"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Parent record key."
|
||||
in: "query"
|
||||
name: "recordId"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsRecordPanelV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Conflict with the current state, e.g. an edit conflict or\
|
||||
\ an invalid transition (code CONFLICT)."
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BasicErrorResponseV1"
|
||||
description: "Too many requests (code RATE_LIMITED); retry later."
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
- bearerAuth:
|
||||
- "N/A"
|
||||
- oauth2:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Read visible alerts for a record"
|
||||
tags:
|
||||
- "alerts"
|
||||
servers:
|
||||
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
|
||||
url: "/"
|
||||
tags:
|
||||
- description: "Operations of the alerts extension."
|
||||
name: "alerts"
|
||||
@@ -1 +0,0 @@
|
||||
version=${project.version}
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user