Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08e729728
|
||
|
|
ee002b2cb3 | ||
|
|
5515c4c2bc
|
||
|
|
ae1f279633
|
||
|
|
8431476609 | ||
|
|
3d56ccc403
|
||
|
|
9e139fe039 | ||
|
|
5348462c7f
|
||
|
|
6460aadb0a | ||
|
|
a65b019ffa
|
||
|
|
8004089b47
|
||
|
|
60b68cf722
|
||
|
|
2692f8b1dc
|
||
|
|
3044f15fbc | ||
|
|
cb3d206e0f
|
||
|
|
110f15d866 | ||
|
|
f50b3c1e18
|
||
|
|
758b68a6a9 | ||
|
|
020d38e810
|
||
|
|
784bf0026c | ||
|
|
65fa558ed5
|
||
|
|
e68bdb9db2 | ||
|
|
1e8f9aac25
|
||
|
|
62dfa5f350
|
||
|
|
81407f88c0
|
||
|
|
4a94a23314
|
||
|
|
cd06fff114 |
No files matched your search
+1
-12
@@ -22,7 +22,7 @@ workflows:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
@@ -58,14 +58,3 @@ workflows:
|
||||
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\/.*/]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
@@ -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 SPI stand-in in `spi/`.
|
||||
**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the core notification integration in `notifications/`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -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.
|
||||
- `spi`: the stand-in for the core producer SPI.
|
||||
- `notifications`: core RC.6 notification delivery, audience resolution and email; all producer types come from `com.kingsrook.qqq.backend.core.alerts`.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
@@ -44,7 +44,7 @@ Packages:
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.1.0` (qqq 4.0.0), with architecture guardrails enforced and strict UI API route tests enabled. The default-active `qqq-snapshot` profile imports released `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Build normally without a version override. Remove that activation when the parent moves to 4.1.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
+19
-3
@@ -4,14 +4,30 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Changed
|
||||
- Switch the optional qbit-email integration to its published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.2`, preserving templates, categories, idempotency keys, linked records and outcome mapping. Narrow the architecture allowance to that API package while retaining all guardrails.
|
||||
- Email goes through qbit-email when it is produced in the instance (optional dependency, `qbit-email.version` 0.1.0-RC.2): alert channel emails (template `alerts_alert`), notification emails (`alerts_notification`) and digests (`alerts_digest`), all NOTIFICATION, one recipient per message, with idempotency keys and the alert or notification as the linked record. `messagingProviderName` stays as the fallback when qbit-email is absent, and is not required when it is present.
|
||||
- Target released QQQ `4.1.0-RC.6`; verify normally with `./mvnw -B verify` (no version override).
|
||||
- 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
|
||||
- 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 a stand-in for core's `WebhookSigner` (QRun-IO/qqq#1109)
|
||||
- a generic webhook, signed with Standard Webhooks headers through core's `WebhookSigner` (QRun-IO/qqq#1109)
|
||||
- PagerDuty (Events API v2: trigger, acknowledge and resolve)
|
||||
- an Alertmanager sink (`/api/v2/alerts`, with the qqq labels and the origin guard)
|
||||
- Prometheus Alertmanager ingest at `POST /qqq/v1/alerts/ingest/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
|
||||
- Prometheus Alertmanager ingest at `POST /qqq/hooks/alerts/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
|
||||
- Tables (`alerts_*`), with the Liquibase changelog `db/liquibase/qbit-alerts/alerts.changelog.sql`:
|
||||
- alerts, their timeline and records
|
||||
- types, groups and monitors
|
||||
@@ -43,5 +59,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`).
|
||||
- A stand-in for the core producer SPI (QRun-IO/qqq#1108), to be removed when that RC ships.
|
||||
- Core alert producer SPI integration (QRun-IO/qqq#1108), plus temporary notification SPI compatibility until RC.6 (QRun-IO/qqq#1125).
|
||||
- Repository scaffold: `AlertsQBitConfig` and `AlertsQBitProducer` (table prefix `alerts_`, permissions `alerts.*`), build, CI and repository standard files.
|
||||
@@ -26,20 +26,61 @@ 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/v1/alerts/ingest/alertmanager`, with notifications off for those types, so infrastructure alerts are triaged in one place.
|
||||
- **Ingests Prometheus Alertmanager** at `POST /qqq/hooks/alerts/alertmanager`, with notifications off for those types, so infrastructure alerts are triaged in one place.
|
||||
- **Owns the Alerts home:** open alerts by severity, my alerts, needs action, by group, a 30-day trend, the noisiest types, delivery health and recently resolved. Other pages get link-only summaries from `AlertSummaryLoader`.
|
||||
|
||||
All data is in standard QQQ tables named `alerts_*`. People change alerts through record actions, never by editing rows.
|
||||
|
||||
## User notifications
|
||||
|
||||
The qbit also carries ordinary notifications for people (design: [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md)):
|
||||
|
||||
- **Admins** with `alerts.notification.admin` write notifications in the Notifications table (title, markdown body, severity, category, link, audience, publish and expire times, a sticky banner flag), then use the record actions Preview audience, Send now, Schedule and Retract. Edits after sending show at once; retracting hides it.
|
||||
- **Audiences** are resolved when sent: all users, roles or groups, holders of a permission, or named users. Each user gets one `alerts_notification_recipient` row, so read, unread and dismissed are per user. Roles need a user directory (`AlertsQBitConfig.withUserDirectory`, an `AlertsUserDirectoryInterface`); without one, "all users" means everyone who has used Alerts.
|
||||
- **Producers** send with `QAlerts.notify(new NotificationSignal(...))` using `com.kingsrook.qqq.backend.core.alerts` types (RC.6). Alerts a user subscribes to arrive in the bell as ALERT notifications.
|
||||
- **Ready-made notices** in `AlertsNotices`: `newSignIn`, `apiKeyCreated`, `apiKeyExpiring`, `permissionChanged` (SECURITY), `shared` (SHARE) and `release` (RELEASE, all users). Core has no sign-in or API key events yet, so hosts call these from their authentication customizer and API key screens.
|
||||
- **Email** is opt-in per user and category (IMMEDIATE, or DAILY_DIGEST at the digest hour in My Alert Settings), through the configured messaging provider; without one it is off. ALERT notifications are not emailed here (alert subscriptions already do), and SECURITY emails carry only the title and link.
|
||||
- **Failed and partial jobs** notify the person who started them (`notifyFailedJobs`, `jobRunLinkPattern`).
|
||||
- **Links** must be app paths with one leading slash, or https URLs; backslashes, whitespace and control characters are refused.
|
||||
- **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.0.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 (built against `4.1.0-RC.4`).
|
||||
- QQQ `4.1.0-RC.6` (released on Maven Central).
|
||||
- An RDBMS backend.
|
||||
- A scheduler, for monitors and email delivery.
|
||||
- A messaging provider, for email.
|
||||
- For email, either [qbit-email](https://github.com/QRun-IO/qbit-email) produced in the same instance, or a messaging provider.
|
||||
- `qqq-middleware-javalin`, for the `/qqq/v1/alerts` routes (optional).
|
||||
|
||||
The build uses `qbit-build-parent:2.1.0` with architecture guardrails and strict UI API route
|
||||
tests enabled. Run `./mvnw -B verify` (`.\mvnw.cmd -B verify` on Windows), without a snapshot
|
||||
override. The default-active `qqq-snapshot` profile retains its historical name but selects
|
||||
the released RC.6 BOM ahead of the parent's QQQ 4.0 BOM.
|
||||
|
||||
## Install
|
||||
|
||||
```java
|
||||
@@ -67,26 +108,32 @@ new AlertsQBitProducer()
|
||||
|
||||
## Raising alerts
|
||||
|
||||
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:
|
||||
Use the producer SPI from `com.kingsrook.qqq.backend.core.alerts`, released in QQQ 4.1.0-RC.5 ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)). `AlertsQBitProducer` registers the qbit's sink through core's `AlertsInstanceMetaData`, so host producers need only core:
|
||||
|
||||
```java
|
||||
AlertsRuntime.get().sink().raise(new AlertSignal("cdn.purge.dead")
|
||||
QAlerts.raise(new AlertSignal("cdn.purge.dead")
|
||||
.withDedupKey("provider:" + providerId)
|
||||
.withTitle("3 CDN purges failed after retries") // external-safe: no personal data
|
||||
.withRecordRefs(AlertRecordRef.of("cdn_purge_request", ids)));
|
||||
```
|
||||
|
||||
Once the RC ships, producers call `QAlerts.raise(...)` from core and need no dependency on this qbit.
|
||||
`QAlerts.resolve(...)` and `QAlerts.heartbeat(...)` reach the same sink. Declare types with core's `QAlertTypeMetaData`. Calls through `QAlerts.raise/resolve` defer to transaction commit when a transaction is present; without this qbit installed, core uses its logging sink.
|
||||
|
||||
User notifications use core `QAlerts.notify(NotificationSignal)` (RC.6, QRun-IO/qqq#1125). Core dispatches through `AlertsSinkAdapter` into the same bounded queue, notification service, audience resolution and email preferences. Calls wait for an explicit or ambient transaction to commit and are dropped on rollback.
|
||||
|
||||
**Breaking Java API migration:** the entire `com.kingsrook.qbits.alerts.spi` package and `AlertsRuntime.sink()` are removed. Replace notification and `AlertLink` imports with `com.kingsrook.qqq.backend.core.alerts`, and replace `AlertsRuntime.get(qInstance).sink().notify(signal)` with `QAlerts.notify(signal)` in the producer's `QContext`. Rebuild callers; qbit-easypost-tracking#16 already moved to core. `getSink()` is for adapter delegation and queue lifecycle management, not a producer API. Notification tables and HTTP contracts are unchanged by this migration.
|
||||
|
||||
## Endpoints (for the Next UI)
|
||||
|
||||
| Method | Path | What |
|
||||
|---|---|---|
|
||||
| GET | `/qqq/v1/alerts/inbox/summary` | Unread count, highest unread severity and the latest 10 items (the bell) |
|
||||
| POST | `/qqq/v1/alerts/inbox/read` | `{"alertId": n}` or `{"all": true}` |
|
||||
| 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/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. |
|
||||
| POST | `/qqq/hooks/alerts/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. |
|
||||
|
||||
## External channels
|
||||
|
||||
@@ -102,12 +149,24 @@ 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 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.
|
||||
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed by core's `WebhookSigner` with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). Both raw UTF-8 and `whsec_` base64 secrets keep the same wire format. Core's `WebhookSecret` requires 16 key bytes; a thin adapter zero-pads legacy shorter keys to 16 bytes, which is HMAC-SHA256-equivalent and preserves their signatures. New secrets should use `WebhookSecret.generate()`.
|
||||
- **PagerDuty** gets one event per alert: `trigger`, `acknowledge` or `resolve`, with `dedup_key` set to the type and key.
|
||||
- **Alertmanager** alerts carry the labels `qqq_type`, `qqq_group`, `qqq_key` and `qqq_origin="qbit-alerts"`. Ingest ignores alerts with that origin, so nothing loops.
|
||||
- **Responses:** 2xx is sent. 408, 425, 429, 5xx and network errors are retried. Any other 4xx fails permanently.
|
||||
- **URLs** must be https, unless the channel's settings say `"allowInsecureHttp": true`, which is meant for local receivers.
|
||||
|
||||
## 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.
|
||||
|
||||
## Known limits (v1)
|
||||
|
||||
- **Claims are best-effort.** Delivery and monitor claims use a lease, not an atomic conditional update. With several scheduler nodes, run the two scheduled processes on one node, or accept at-least-once delivery.
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
<property name="ignoreLines" value="2"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -1,15 +1,11 @@
|
||||
/*
|
||||
* 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/
|
||||
* 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
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* 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,
|
||||
|
||||
+3
-1
@@ -3,10 +3,12 @@
|
||||
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:
|
||||
- **The core SPI is a stand-in** (`spi/`) until QRun-IO/qqq#1108 is released.
|
||||
- **UI API and record following:** the RC.6 alerts extension, collaboration ownership of follow, and machine webhook migration are specified in [UI-API.md](UI-API.md). Existing RECORD watch subscriptions remain readable without migration.
|
||||
- **Alerts and notifications use core's RC.6 producer API**, including sink registration in core instance metadata (QRun-IO/qqq#1108 and #1125). `AlertsSinkAdapter` delegates `raise`, `resolve`, `heartbeat` and `notify` to the runtime queue. No local SPI types remain; storage-specific audience and severity conversion belongs to `NotificationService`.
|
||||
- **Visibility checks 1 to 3 (section 7.2)** are enforced by record security locks on session keys (`alerts_permission`, `alerts_readable_table`), not by a pre-query customizer. QQQ has no pre-query customizer, and locks also apply to counts, exports and the API.
|
||||
- **Heartbeats** are stored in an extra table, `alerts_heartbeat`.
|
||||
- **Claims** are leases, not atomic conditional updates (see README.md, Known limits).
|
||||
- **Email threading headers** wait for core messaging support.
|
||||
- **The delivery log is `alerts_delivery`** (the design calls it `alerts_notification`), and **`alerts_inbox_item` is retired**: user notifications took those roles (NOTIFICATIONS.md section 2).
|
||||
|
||||
User notifications (announcements, job failures, shares, security notices, releases and marketing), the recipient model and the Next UI notification center are designed in [NOTIFICATIONS.md](NOTIFICATIONS.md), with the owner decisions of 2026-10-13.
|
||||
+30
-8
@@ -138,8 +138,9 @@ notification admins. The home stays the only Alerts dashboard.
|
||||
|
||||
## 6. Producer SPI
|
||||
|
||||
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:
|
||||
The notification API from [QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)
|
||||
ships in 4.1.0-RC.6. This branch targets that release and delivers core `QAlerts.notify`
|
||||
calls through the installed qbit sink:
|
||||
|
||||
```java
|
||||
QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
@@ -151,13 +152,26 @@ QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
```
|
||||
|
||||
- `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.
|
||||
- `AlertSinkInterface` has a default method, `notify(NotificationSignal)`, that logs; existing sinks keep
|
||||
working. `LoggingAlertSink` logs at INFO (WARN for ERROR/CRITICAL). The qbit's sink queues the signal like an alert signal.
|
||||
- The guarantees match `raise`: it never throws or blocks, it waits for the commit when a transaction is
|
||||
open, and bad input is counted.
|
||||
- `NotificationAudience`: `allUsers()`, `roles(...)`, `permission(p)`, `users(...)`, `user(id)`.
|
||||
- Until that RC ships, the qbit carries the same classes in its `spi` stand-in package, and hosts call
|
||||
`AlertsRuntime.get(qInstance).sink().notify(...)`.
|
||||
- All producer types, including `AlertLink`, are from `com.kingsrook.qqq.backend.core.alerts`.
|
||||
The qbit implements core's `notify` override in `AlertsSinkAdapter` and `QbitAlertsSink`;
|
||||
signals use the existing bounded queue and `NotificationService` for audience resolution,
|
||||
inbox recipients, scheduling and category/email preferences. Alert subscription channels
|
||||
keep their existing behavior. Quiet-hour scheduling for alerts remains unimplemented (see README).
|
||||
- **Breaking migration:** the local `spi` package and `AlertsRuntime.sink()` are removed.
|
||||
Change imports to core, call `QAlerts.notify(signal)` in the producer's `QContext`, and
|
||||
rebuild. qbit-easypost-tracking#16 uses core. The runtime's `getSink()` supports adapter
|
||||
delegation and queue lifecycle operations; producers use `QAlerts`.
|
||||
- Stored audience types and comma/newline-separated values remain readable. The service
|
||||
retains trimming, duplicate removal and null storage for empty audiences, so existing
|
||||
notification dedup keys and rows need no migration. Core types do not own database parsing.
|
||||
- Verification covers core producer calls through the asynchronous queue to the strict HTTP
|
||||
inbox, all audience factories, commit/rollback, category mutes, immediate email and digests.
|
||||
The UI fragment and all six HTTP operations are unchanged by this notification migration.
|
||||
|
||||
### 6.1 Automatic sources
|
||||
|
||||
@@ -188,7 +202,7 @@ Gating is unchanged from #85: no bell and zero requests unless `metaData.apps.al
|
||||
is hidden. QQQ has no SSE today.
|
||||
- **Popover** (lazy chunk): newest first, a severity icon, the title, the source, a relative time, and a
|
||||
link. You can mark one read or unread, or dismiss it, and the footer has "Mark all read" and "View all".
|
||||
- **Page** `/notifications` (lazy route): filters (All or Unread, category, severity), paging, a markdown
|
||||
- **Page** `/app/notifications` (lazy route): filters (All or Unread, category, severity), paging, a markdown
|
||||
body, and the same actions.
|
||||
- **Banner:** sticky, undismissed, unexpired items show above the page content until dismissed.
|
||||
- **Budget:** total JS 1100 KB. The badge adds well under 1 KB to first load; the popover and the page are
|
||||
@@ -231,7 +245,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 | Producer SPI (core PR plus the stand-in), job failures |
|
||||
| c | Core RC.6 producer SPI and sink integration, job failures |
|
||||
| d | Security notices, shares, release and marketing producers |
|
||||
| e | Email: immediate and daily digest |
|
||||
|
||||
@@ -245,3 +259,11 @@ 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).
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Alerts UI API and webhook migration
|
||||
|
||||
The qbit targets QQQ **4.1.0-RC.6** (pending release). `AlertsQBitProducer` registers the
|
||||
`alerts` `MiddlewareSpecExtension`, version `1.0.0`, with the established `/alerts` prefix.
|
||||
`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 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.
|
||||
|
||||
## Next handoff: additive payload fields
|
||||
|
||||
- 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. The committed BOM version is RC.6; while it is unreleased, verify with
|
||||
an already installed local core build:
|
||||
|
||||
```sh
|
||||
./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
|
||||
```
|
||||
|
||||
Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test`
|
||||
(and the same local version override). Normal tests compare the fragment without rewriting it.
|
||||
Checkstyle uses `checkstyle/config.xml` and the repository's existing short Apache headers.
|
||||
@@ -22,12 +22,16 @@
|
||||
<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>
|
||||
<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.0.0</version>
|
||||
<version>2.1.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -80,6 +84,15 @@
|
||||
<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>
|
||||
@@ -107,6 +120,32 @@
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<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>
|
||||
@@ -158,14 +197,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.4 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.6 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.4</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -21,20 +21,22 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeam;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
@@ -51,7 +53,9 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
** see; comment; subscribe themselves; their own settings and inbox.
|
||||
** - alerts.manage: acknowledge, snooze, resolve, reopen, assign, mute.
|
||||
** - alerts.admin: monitors, types, groups, teams, channels, every
|
||||
** subscription, the notification log, retries.
|
||||
** subscription, the delivery log, retries.
|
||||
** - alerts.notification.admin: write, send, schedule, retract and delete
|
||||
** user notifications (docs/NOTIFICATIONS.md).
|
||||
** Each table also has QQQ's standard per-table permissions
|
||||
** (alerts_alert.read, ...); getViewerPermissionNames,
|
||||
** getManagerPermissionNames and getAdminPermissionNames list what to grant
|
||||
@@ -66,8 +70,12 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
public static final String PERMISSION_MANAGE = "alerts.manage";
|
||||
public static final String PERMISSION_ADMIN = "alerts.admin";
|
||||
|
||||
public static final String PERMISSION_NOTIFICATION_ADMIN = "alerts.notification.admin";
|
||||
|
||||
public static final String APP_NAME = "alerts";
|
||||
|
||||
private Boolean legacyAlertmanagerPath = false;
|
||||
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private String messagingProviderName;
|
||||
@@ -92,6 +100,12 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
private Boolean registerRoutes = true;
|
||||
private Boolean synchronousSink = false;
|
||||
|
||||
private QCodeReference userDirectory;
|
||||
private Integer maxAudienceSize = 50_000;
|
||||
private Integer notificationRetentionDays = 180;
|
||||
private Boolean notifyFailedJobs = true;
|
||||
private String jobRunLinkPattern;
|
||||
|
||||
private transient AlertsRuntime runtime;
|
||||
|
||||
|
||||
@@ -114,7 +128,8 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
{
|
||||
assertCondition(qInstance.getMessagingProvider(messagingProviderName) != null, "Alerts QBit messaging provider not found: " + messagingProviderName, errors);
|
||||
}
|
||||
for(Integer value : new Integer[] {reopenWindowMinutes, flapThreshold, flapWindowMinutes, maxOpenPerMonitor, monitorTimeoutSeconds, evaluateEverySeconds, deliverEverySeconds})
|
||||
for(Integer value : new Integer[] {reopenWindowMinutes, flapThreshold, flapWindowMinutes, maxOpenPerMonitor, monitorTimeoutSeconds, evaluateEverySeconds, deliverEverySeconds,
|
||||
maxAudienceSize, notificationRetentionDays})
|
||||
{
|
||||
assertCondition(value != null && value > 0, "Alerts QBit timing and limit settings must be positive", errors);
|
||||
}
|
||||
@@ -140,7 +155,8 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
{
|
||||
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,
|
||||
AlertsNotification.TABLE_NAME, AlertsEvent.TABLE_NAME, AlertsRecordRef.TABLE_NAME, AlertsInboxItem.TABLE_NAME, AlertsHeartbeat.TABLE_NAME));
|
||||
AlertsDelivery.TABLE_NAME, AlertsEvent.TABLE_NAME, AlertsRecordRef.TABLE_NAME, AlertsHeartbeat.TABLE_NAME, AlertsNotification.TABLE_NAME,
|
||||
AlertsNotificationRecipient.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME));
|
||||
}
|
||||
|
||||
|
||||
@@ -160,13 +176,15 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
{
|
||||
names.add(table + ".read");
|
||||
}
|
||||
for(String table : List.of(AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsInboxItem.TABLE_NAME))
|
||||
for(String table : List.of(AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME))
|
||||
{
|
||||
names.add(table + ".read");
|
||||
names.add(table + ".insert");
|
||||
names.add(table + ".edit");
|
||||
names.add(table + ".delete");
|
||||
}
|
||||
names.add(AlertsNotificationRecipient.TABLE_NAME + ".read");
|
||||
names.add(AlertsNotificationRecipient.TABLE_NAME + ".edit");
|
||||
return (names);
|
||||
}
|
||||
|
||||
@@ -192,7 +210,7 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
*******************************************************************************/
|
||||
public List<String> getAdminPermissionNames()
|
||||
{
|
||||
List<String> names = new ArrayList<>(List.of(PERMISSION_VIEW, PERMISSION_MANAGE, PERMISSION_ADMIN));
|
||||
List<String> names = new ArrayList<>(List.of(PERMISSION_VIEW, PERMISSION_MANAGE, PERMISSION_ADMIN, PERMISSION_NOTIFICATION_ADMIN));
|
||||
for(String table : allTableNames())
|
||||
{
|
||||
for(String suffix : List.of("read", "insert", "edit", "delete"))
|
||||
@@ -205,6 +223,24 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a notification author needs on top of a viewer's permissions:
|
||||
** alerts.notification.admin and full access to the notifications table
|
||||
** (they read every recipient row through the alerts_user all-access key).
|
||||
*******************************************************************************/
|
||||
public List<String> getNotificationAdminPermissionNames()
|
||||
{
|
||||
List<String> names = new ArrayList<>(getViewerPermissionNames());
|
||||
names.add(PERMISSION_NOTIFICATION_ADMIN);
|
||||
for(String suffix : List.of("read", "insert", "edit", "delete"))
|
||||
{
|
||||
names.add(AlertsNotification.TABLE_NAME + "." + suffix);
|
||||
}
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
@@ -576,7 +612,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/v1/alerts/ingest/alertmanager.
|
||||
** bearer token Alertmanager sends to POST /qqq/hooks/alerts/alertmanager.
|
||||
** Null (the default) turns ingest off.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withAlertmanagerIngestTokenSecretRef(String alertmanagerIngestTokenSecretRef)
|
||||
@@ -605,4 +641,180 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userDirectory
|
||||
*******************************************************************************/
|
||||
public QCodeReference getUserDirectory()
|
||||
{
|
||||
return (this.userDirectory);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userDirectory
|
||||
*******************************************************************************/
|
||||
public void setUserDirectory(QCodeReference userDirectory)
|
||||
{
|
||||
this.userDirectory = userDirectory;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userDirectory: the host's user directory (AlertsUserDirectoryInterface) for notification audiences; default: everyone who has used Alerts
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withUserDirectory(QCodeReference userDirectory)
|
||||
{
|
||||
this.userDirectory = userDirectory;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxAudienceSize
|
||||
*******************************************************************************/
|
||||
public Integer getMaxAudienceSize()
|
||||
{
|
||||
return (this.maxAudienceSize);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxAudienceSize
|
||||
*******************************************************************************/
|
||||
public void setMaxAudienceSize(Integer maxAudienceSize)
|
||||
{
|
||||
this.maxAudienceSize = maxAudienceSize;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxAudienceSize: the most users one notification reaches (default 50,000)
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withMaxAudienceSize(Integer maxAudienceSize)
|
||||
{
|
||||
this.maxAudienceSize = maxAudienceSize;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notificationRetentionDays
|
||||
*******************************************************************************/
|
||||
public Integer getNotificationRetentionDays()
|
||||
{
|
||||
return (this.notificationRetentionDays);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notificationRetentionDays
|
||||
*******************************************************************************/
|
||||
public void setNotificationRetentionDays(Integer notificationRetentionDays)
|
||||
{
|
||||
this.notificationRetentionDays = notificationRetentionDays;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notificationRetentionDays: how long sent and retracted notifications are kept (default 180)
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withNotificationRetentionDays(Integer notificationRetentionDays)
|
||||
{
|
||||
this.notificationRetentionDays = notificationRetentionDays;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notifyFailedJobs
|
||||
*******************************************************************************/
|
||||
public Boolean getNotifyFailedJobs()
|
||||
{
|
||||
return (this.notifyFailedJobs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notifyFailedJobs
|
||||
*******************************************************************************/
|
||||
public void setNotifyFailedJobs(Boolean notifyFailedJobs)
|
||||
{
|
||||
this.notifyFailedJobs = notifyFailedJobs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notifyFailedJobs: notify a user when a process they started fails or completes with errors (default true)
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withNotifyFailedJobs(Boolean notifyFailedJobs)
|
||||
{
|
||||
this.notifyFailedJobs = notifyFailedJobs;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for jobRunLinkPattern
|
||||
*******************************************************************************/
|
||||
public String getJobRunLinkPattern()
|
||||
{
|
||||
return (this.jobRunLinkPattern);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for jobRunLinkPattern
|
||||
*******************************************************************************/
|
||||
public void setJobRunLinkPattern(String jobRunLinkPattern)
|
||||
{
|
||||
this.jobRunLinkPattern = jobRunLinkPattern;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for jobRunLinkPattern: where a job notification links, with {processName} and {processUUID} (default: the process's table)
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withJobRunLinkPattern(String jobRunLinkPattern)
|
||||
{
|
||||
this.jobRunLinkPattern = jobRunLinkPattern;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether to 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);
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,11 @@ 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.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.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -197,7 +197,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
AlertsRuntime previous = qBitConfig.getRuntime();
|
||||
if(previous != null)
|
||||
{
|
||||
previous.sink().stop();
|
||||
previous.getSink().stop();
|
||||
}
|
||||
AlertsRuntime runtime = new AlertsRuntime(qBitConfig, qInstance);
|
||||
qBitConfig.setRuntime(runtime);
|
||||
@@ -212,6 +212,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.kingsrook.qbits.alerts.engine.AlertTypeSync;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsEngine;
|
||||
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
|
||||
import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -53,6 +54,7 @@ public class AlertsRuntime
|
||||
private final DeliveryWorker deliveryWorker;
|
||||
private final MonitorEvaluator evaluator;
|
||||
private final QbitAlertsSink sink;
|
||||
private final NotificationService notifications;
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +86,7 @@ public class AlertsRuntime
|
||||
this.deliveryWorker = new DeliveryWorker(this);
|
||||
this.evaluator = new MonitorEvaluator(this);
|
||||
this.sink = new QbitAlertsSink(this);
|
||||
this.notifications = new NotificationService(this);
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +225,16 @@ public class AlertsRuntime
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notifications (user notifications: send, retract, the bell)
|
||||
*******************************************************************************/
|
||||
public NotificationService getNotifications()
|
||||
{
|
||||
return (this.notifications);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for router
|
||||
*******************************************************************************/
|
||||
@@ -263,10 +276,10 @@ public class AlertsRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sink producers send signals to (until the core SPI ships, hosts call
|
||||
** it directly: AlertsRuntime.get().sink().raise(...)).
|
||||
** The runtime sink for adapter delegation and queue lifecycle management.
|
||||
** Producers use core QAlerts for both alerts and notifications.
|
||||
*******************************************************************************/
|
||||
public QbitAlertsSink sink()
|
||||
public QbitAlertsSink getSink()
|
||||
{
|
||||
return (this.sink);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
/*******************************************************************************
|
||||
** The Alerts record-security keys (design section 7).
|
||||
**
|
||||
** Three security key types guard the qbit's tables:
|
||||
** Four security key types guard the qbit's tables:
|
||||
** - alerts_user: the session's user id. Personal rows (subscriptions,
|
||||
** settings, inbox items) are locked to it. alerts.admin holders get its
|
||||
** all-access key.
|
||||
@@ -48,7 +48,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
** type sets a visibility permission is only readable with it.
|
||||
** - alerts_readable_table: every table the session can read. An alert about
|
||||
** a subject table is only readable by users who can read that table.
|
||||
** The last two follow the logged-in user, so counts, lists, the bell and
|
||||
** - alerts_recipient: the session's user id, for notification recipient
|
||||
** rows. Notification authors (alerts.notification.admin) and alerts.admin
|
||||
** holders get its all-access key.
|
||||
** alerts_permission and alerts_readable_table follow the logged-in user, so counts, lists, the bell and
|
||||
** the home are all filtered in the query. (The system user, which the
|
||||
** engine runs as, holds every all-access key.)
|
||||
**
|
||||
@@ -66,6 +69,8 @@ public final class AlertsSecurity
|
||||
public static final String KEY_PERMISSION_ALL = "alerts_permission_all_access";
|
||||
public static final String KEY_READABLE_TABLE = "alerts_readable_table";
|
||||
public static final String KEY_READABLE_TABLE_ALL = "alerts_readable_table_all_access";
|
||||
public static final String KEY_RECIPIENT = "alerts_recipient";
|
||||
public static final String KEY_RECIPIENT_ALL = "alerts_recipient_all_access";
|
||||
|
||||
private static final String ALERT_TABLE = "alerts_alert";
|
||||
|
||||
@@ -88,7 +93,8 @@ public final class AlertsSecurity
|
||||
return (List.of(
|
||||
new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS),
|
||||
new QSecurityKeyType().withName(KEY_PERMISSION).withAllAccessKeyName(KEY_PERMISSION_ALL),
|
||||
new QSecurityKeyType().withName(KEY_READABLE_TABLE).withAllAccessKeyName(KEY_READABLE_TABLE_ALL)));
|
||||
new QSecurityKeyType().withName(KEY_READABLE_TABLE).withAllAccessKeyName(KEY_READABLE_TABLE_ALL),
|
||||
new QSecurityKeyType().withName(KEY_RECIPIENT).withAllAccessKeyName(KEY_RECIPIENT_ALL)));
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +112,20 @@ public final class AlertsSecurity
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient lock: a notification recipient row is its user's (notification
|
||||
** authors and alerts admins hold the all-access key).
|
||||
*******************************************************************************/
|
||||
public static RecordSecurityLock recipientLock(String fieldName)
|
||||
{
|
||||
return (new RecordSecurityLock()
|
||||
.withSecurityKeyType(KEY_RECIPIENT)
|
||||
.withFieldName(fieldName)
|
||||
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Visibility lock on an alert (joinFromAlert null) or on a child of an
|
||||
** alert (through that join): the session must hold the alert's
|
||||
@@ -154,7 +174,7 @@ public final class AlertsSecurity
|
||||
Map<String, List<Serializable>> keys = session.getSecurityKeyValues();
|
||||
if(keys != null)
|
||||
{
|
||||
for(String key : List.of(KEY_USER, KEY_USER_ALL_ACCESS, KEY_PERMISSION, KEY_READABLE_TABLE))
|
||||
for(String key : List.of(KEY_USER, KEY_USER_ALL_ACCESS, KEY_PERMISSION, KEY_READABLE_TABLE, KEY_RECIPIENT, KEY_RECIPIENT_ALL))
|
||||
{
|
||||
keys.remove(key);
|
||||
}
|
||||
@@ -169,6 +189,11 @@ public final class AlertsSecurity
|
||||
if(StringUtils.hasContent(userId))
|
||||
{
|
||||
session.withSecurityKeyValue(KEY_USER, userId);
|
||||
session.withSecurityKeyValue(KEY_RECIPIENT, userId);
|
||||
}
|
||||
if(session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN) || session.hasPermission(AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN))
|
||||
{
|
||||
session.withSecurityKeyValue(KEY_RECIPIENT_ALL, true);
|
||||
}
|
||||
if(session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN))
|
||||
{
|
||||
@@ -257,6 +282,7 @@ public final class AlertsSecurity
|
||||
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
|
||||
session.withSecurityKeyValue(KEY_PERMISSION_ALL, true);
|
||||
session.withSecurityKeyValue(KEY_READABLE_TABLE_ALL, true);
|
||||
session.withSecurityKeyValue(KEY_RECIPIENT_ALL, true);
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,12 @@ import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsPreferences;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
@@ -34,41 +37,53 @@ import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.home.AlertsHome;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
|
||||
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.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bell and record-panel endpoints' logic (design sections 6.3, 6.4),
|
||||
** The bell, notifications page and record-panel endpoints' logic (design
|
||||
** sections 6.3, 6.4; docs/NOTIFICATIONS.md 8.1),
|
||||
** independent of the HTTP layer. Everything is read and written in the
|
||||
** caller's session (record locks: their own inbox, alerts they can see).
|
||||
** caller's session (record locks: their own recipient rows, alerts they can
|
||||
** see); notification content is read for the rows they hold.
|
||||
*******************************************************************************/
|
||||
public class AlertsApi
|
||||
{
|
||||
static final int INBOX_ITEMS = 10;
|
||||
static final int MAX_BANNERS = 3;
|
||||
static final int SCAN_LIMIT = 1000;
|
||||
static final int MAX_BATCH = 500;
|
||||
static final int DEFAULT_PAGE = 25;
|
||||
static final int MAX_PAGE = 100;
|
||||
static final int POLL_SECONDS = 60;
|
||||
|
||||
public static final String NOTIFICATIONS_HREF = "/app/notifications";
|
||||
static final int PANEL_ROWS = 5;
|
||||
static final int RESOLVED_LOOKBACK = 30;
|
||||
|
||||
@@ -104,52 +119,34 @@ public class AlertsApi
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bell: unread count, highest unread severity, the latest items.
|
||||
** The bell (docs/NOTIFICATIONS.md 8.1): the unread count, the highest
|
||||
** unread severity, the latest items, and sticky banners.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> inboxSummary() throws QException
|
||||
{
|
||||
String userId = requireViewer();
|
||||
Instant now = runtime.now();
|
||||
|
||||
List<QRecord> items = userId == null ? List.of() : AlertsDb.query(AlertsInboxItem.TABLE_NAME, AlertsDb.eq("userId", userId)
|
||||
.withCriteria("archivedAt", QCriteriaOperator.IS_BLANK)
|
||||
.withOrderBy(new QFilterOrderBy("deliveredAt", false))
|
||||
.withLimit(200));
|
||||
|
||||
Map<Integer, QRecord> alerts = new HashMap<>();
|
||||
List<Integer> ids = items.stream().map(item -> item.getValueInteger("alertId")).toList();
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids)))
|
||||
{
|
||||
alerts.put(alert.getValueInteger("id"), alert);
|
||||
}
|
||||
}
|
||||
Map<Integer, String> groups = groupLabels();
|
||||
String userId = requireViewer();
|
||||
Instant now = runtime.now();
|
||||
List<Entry> entries = entries(userId, now);
|
||||
|
||||
int unread = 0;
|
||||
AlertSeverity highest = null;
|
||||
NotificationSeverity highest = null;
|
||||
List<Map<String, Object>> latest = new ArrayList<>();
|
||||
for(QRecord item : items)
|
||||
List<Map<String, Object>> banners = new ArrayList<>();
|
||||
for(Entry entry : entries)
|
||||
{
|
||||
QRecord alert = alerts.get(item.getValueInteger("alertId"));
|
||||
if(alert == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean isUnread = item.getValue("readAt") == null;
|
||||
if(isUnread)
|
||||
if(entry.unread())
|
||||
{
|
||||
unread++;
|
||||
AlertSeverity severity = AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING);
|
||||
NotificationSeverity severity = NotificationService.severityOf(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
highest = highest == null || severity.compareTo(highest) > 0 ? severity : highest;
|
||||
}
|
||||
if(latest.size() < INBOX_ITEMS)
|
||||
{
|
||||
Map<String, Object> row = alertRow(alert, groups, now);
|
||||
row.put("unread", isUnread);
|
||||
row.put("deliveredAt", item.getValueInstant("deliveredAt") == null ? null : item.getValueInstant("deliveredAt").toString());
|
||||
latest.add(row);
|
||||
latest.add(item(entry, now, false));
|
||||
}
|
||||
if(Boolean.TRUE.equals(entry.notification().getValueBoolean("sticky")) && banners.size() < MAX_BANNERS)
|
||||
{
|
||||
banners.add(item(entry, now, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,49 +154,379 @@ public class AlertsApi
|
||||
result.put("unreadCount", unread);
|
||||
result.put("highestUnreadSeverity", highest == null ? null : highest.name());
|
||||
result.put("items", latest);
|
||||
result.put("banners", banners);
|
||||
result.put("notificationsHref", NOTIFICATIONS_HREF);
|
||||
result.put("inboxHref", NOTIFICATIONS_HREF);
|
||||
result.put("alertsHref", "/app/" + AlertsQBitConfig.APP_NAME);
|
||||
result.put("pollSeconds", 60);
|
||||
result.put("pollSeconds", POLL_SECONDS);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark one alert's inbox item read (alertId), or all of the user's.
|
||||
** The notifications page: the user's notifications (not dismissed),
|
||||
** filtered by status (all or unread), category and severity, a page at a
|
||||
** time, with bodies.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> inbox(Map<String, String> params) throws QException
|
||||
{
|
||||
String userId = requireViewer();
|
||||
Instant now = runtime.now();
|
||||
boolean onlyNew = "unread".equalsIgnoreCase(params.get("status"));
|
||||
String category = StringUtils.hasContent(params.get("category")) ? params.get("category").toUpperCase(Locale.ROOT) : null;
|
||||
String severity = StringUtils.hasContent(params.get("severity")) ? params.get("severity").toUpperCase(Locale.ROOT) : null;
|
||||
int offset = Math.max(0, parseInt(params.get("offset"), 0));
|
||||
int limit = Math.min(MAX_PAGE, Math.max(1, parseInt(params.get("limit"), DEFAULT_PAGE)));
|
||||
|
||||
List<Entry> matching = new ArrayList<>();
|
||||
for(Entry entry : entries(userId, now))
|
||||
{
|
||||
if((!onlyNew || entry.unread())
|
||||
&& (category == null || category.equals(entry.notification().getValueString("category")))
|
||||
&& (severity == null || severity.equals(entry.notification().getValueString("severity"))))
|
||||
{
|
||||
matching.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
for(Entry entry : matching.subList(Math.min(offset, matching.size()), Math.min(offset + limit, matching.size())))
|
||||
{
|
||||
items.add(item(entry, now, true));
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("items", items);
|
||||
result.put("total", matching.size());
|
||||
result.put("offset", offset);
|
||||
result.put("limit", limit);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark read: {"id": n}, {"ids": [..]}, {"all": true}, or (0.1 clients)
|
||||
** {"alertId": n}.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> markRead(Map<String, Object> body) throws QException
|
||||
{
|
||||
String userId = requireViewer();
|
||||
Instant now = runtime.now();
|
||||
return (updateOwn(userId, target(userId, body, true), "readAt", now, record -> record.getValue("readAt") == null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark unread: {"id": n} or {"ids": [..]}.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> markUnread(Map<String, Object> body) throws QException
|
||||
{
|
||||
String userId = requireViewer();
|
||||
return (updateOwn(userId, target(userId, body, false), "readAt", null, record -> record.getValue("readAt") != null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Dismiss (hide from the bell and the page; also marks read): {"id": n},
|
||||
** {"ids": [..]} or {"all": true}.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> dismiss(Map<String, Object> body) throws QException
|
||||
{
|
||||
String userId = requireViewer();
|
||||
Instant now = runtime.now();
|
||||
return (updateOwn(userId, target(userId, body, true), "dismissedAt", now, record -> record.getValue("dismissedAt") == null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The open alerts in a user's bell (ALERT notifications not dismissed),
|
||||
** for the Alerts home's "My alerts".
|
||||
*******************************************************************************/
|
||||
public static List<Integer> myAlertIds(String userId, int limit) throws QException
|
||||
{
|
||||
List<Integer> notificationIds = new ArrayList<>();
|
||||
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
|
||||
.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK).withLimit(limit)))
|
||||
{
|
||||
notificationIds.add(recipient.getValueInteger("notificationId"));
|
||||
}
|
||||
List<Integer> alertIds = new ArrayList<>();
|
||||
if(notificationIds.isEmpty())
|
||||
{
|
||||
return (alertIds);
|
||||
}
|
||||
for(QRecord notification : AlertsRuntime.get().asSystem(() -> AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("id", QCriteriaOperator.IN, notificationIds)
|
||||
.withCriteria("category", QCriteriaOperator.EQUALS, NotificationCategory.ALERT.name())
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, NotificationService.STATUS_SENT))))
|
||||
{
|
||||
Integer alertId = parseInteger(notification.getValueString("sourceRecordId"));
|
||||
if(alertId != null)
|
||||
{
|
||||
alertIds.add(alertId);
|
||||
}
|
||||
}
|
||||
return (alertIds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One recipient row with its (live) notification.
|
||||
*******************************************************************************/
|
||||
record Entry(QRecord recipient, QRecord notification)
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
boolean unread()
|
||||
{
|
||||
return (recipient.getValue("readAt") == null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user's bell: their recipient rows (the owner lock applies) that are
|
||||
** not dismissed, joined with notifications that are SENT, published and
|
||||
** not expired, newest first. ALERT notifications are kept only while the
|
||||
** user can still see the alert.
|
||||
*******************************************************************************/
|
||||
private List<Entry> entries(String userId, Instant now) throws QException
|
||||
{
|
||||
if(userId == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
|
||||
.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK)
|
||||
.withOrderBy(new QFilterOrderBy("deliveredAt", false))
|
||||
.withOrderBy(new QFilterOrderBy("id", false))
|
||||
.withLimit(SCAN_LIMIT));
|
||||
if(recipients.isEmpty())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
|
||||
List<Integer> ids = recipients.stream().map(recipient -> recipient.getValueInteger("notificationId")).distinct().toList();
|
||||
Map<Integer, QRecord> notifications = new HashMap<>();
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
for(QRecord notification : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("id", QCriteriaOperator.IN, ids)
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, NotificationService.STATUS_SENT)))
|
||||
{
|
||||
Instant publishAt = notification.getValueInstant("publishAt");
|
||||
Instant expireAt = notification.getValueInstant("expireAt");
|
||||
if((publishAt == null || !publishAt.isAfter(now)) && (expireAt == null || expireAt.isAfter(now)))
|
||||
{
|
||||
notifications.put(notification.getValueInteger("id"), notification);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
|
||||
List<Integer> alertIds = new ArrayList<>();
|
||||
for(QRecord notification : notifications.values())
|
||||
{
|
||||
if(NotificationCategory.ALERT.name().equals(notification.getValueString("category")) && parseInteger(notification.getValueString("sourceRecordId")) != null)
|
||||
{
|
||||
alertIds.add(parseInteger(notification.getValueString("sourceRecordId")));
|
||||
}
|
||||
}
|
||||
Set<String> visibleAlerts = new HashSet<>();
|
||||
if(!alertIds.isEmpty())
|
||||
{
|
||||
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, alertIds)))
|
||||
{
|
||||
visibleAlerts.add(String.valueOf(alert.getValue("id")));
|
||||
}
|
||||
}
|
||||
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
for(QRecord recipient : recipients)
|
||||
{
|
||||
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
|
||||
if(notification != null && (!NotificationCategory.ALERT.name().equals(notification.getValueString("category")) || visibleAlerts.contains(notification.getValueString("sourceRecordId"))))
|
||||
{
|
||||
entries.add(new Entry(recipient, notification));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bell or page item.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> item(Entry entry, Instant now, boolean withBody)
|
||||
{
|
||||
QRecord notification = entry.notification();
|
||||
Instant deliveredAt = entry.recipient().getValueInstant("deliveredAt");
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", entry.recipient().getValueInteger("id"));
|
||||
row.put("notificationId", notification.getValueInteger("id"));
|
||||
row.put("title", notification.getValueString("title"));
|
||||
if(withBody)
|
||||
{
|
||||
row.put("body", notification.getValueString("body"));
|
||||
}
|
||||
row.put("severity", notification.getValueString("severity"));
|
||||
row.put("category", notification.getValueString("category"));
|
||||
row.put("source", notification.getValueString("sourceName"));
|
||||
row.put("href", NotificationService.safeLink(notification.getValueString("linkUrl")));
|
||||
row.put("linkLabel", notification.getValueString("linkLabel"));
|
||||
row.put("deliveredAt", deliveredAt == null ? null : deliveredAt.toString());
|
||||
row.put("age", AlertMessageRenderer.age(deliveredAt, now));
|
||||
row.put("unread", entry.unread());
|
||||
row.put("sticky", Boolean.TRUE.equals(notification.getValueBoolean("sticky")));
|
||||
if(NotificationCategory.ALERT.name().equals(notification.getValueString("category")))
|
||||
{
|
||||
row.put("alertId", parseInteger(notification.getValueString("sourceRecordId")));
|
||||
}
|
||||
List<Map<String, Object>> actions = new ArrayList<>();
|
||||
actions.add(Map.of("name", entry.unread() ? "read" : "unread", "label", entry.unread() ? "Mark read" : "Mark unread",
|
||||
"operationId", entry.unread() ? InboxReadSpec.OPERATION_ID : InboxUnreadSpec.OPERATION_ID));
|
||||
actions.add(Map.of("name", "dismiss", "label", "Dismiss", "operationId", InboxDismissSpec.OPERATION_ID));
|
||||
row.put("actions", actions);
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Which of the user's recipient rows a request names (null: all of them).
|
||||
*******************************************************************************/
|
||||
private List<Integer> target(String userId, Map<String, Object> body, boolean allowAll) throws QException
|
||||
{
|
||||
if(body != null && body.get("id") != null)
|
||||
{
|
||||
return (List.of(toInteger(body.get("id"))));
|
||||
}
|
||||
if(body != null && body.get("ids") instanceof List<?> list && !list.isEmpty())
|
||||
{
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
for(Object value : list.subList(0, Math.min(list.size(), MAX_BATCH)))
|
||||
{
|
||||
ids.add(toInteger(value));
|
||||
}
|
||||
return (ids);
|
||||
}
|
||||
if(body != null && body.get("alertId") != null)
|
||||
{
|
||||
Integer alertId = toInteger(body.get("alertId"));
|
||||
QRecord notification = runtime.asSystem(() -> AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", "alert:" + alertId)));
|
||||
if(notification == null || userId == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
return (AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
|
||||
.withCriteria("notificationId", QCriteriaOperator.EQUALS, notification.getValue("id"))).stream().map(recipient -> recipient.getValueInteger("id")).toList());
|
||||
}
|
||||
if(allowAll && body != null && Boolean.TRUE.equals(body.get("all")))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
throw (new QBadRequestException(allowAll ? "Send id, ids, or all: true." : "Send id or ids."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set one field on the user's own recipient rows (the named ones, or all
|
||||
** not dismissed), where the row still needs it. Dismissing also marks
|
||||
** read. Recounts the notifications' readers.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> updateOwn(String userId, List<Integer> ids, String field, Instant value, java.util.function.Predicate<QRecord> needs) throws QException
|
||||
{
|
||||
if(userId == null || (ids != null && ids.isEmpty()))
|
||||
{
|
||||
return (Map.of("updated", 0));
|
||||
}
|
||||
QQueryFilter filter = AlertsDb.eq("userId", userId).withCriteria("readAt", QCriteriaOperator.IS_BLANK);
|
||||
Object alertId = body == null ? null : body.get("alertId");
|
||||
if(alertId != null)
|
||||
QQueryFilter filter = AlertsDb.eq("userId", userId).withLimit(SCAN_LIMIT);
|
||||
if(ids != null)
|
||||
{
|
||||
filter.withCriteria("alertId", QCriteriaOperator.EQUALS, Integer.valueOf(String.valueOf(alertId)));
|
||||
filter.withCriteria("id", QCriteriaOperator.IN, ids);
|
||||
}
|
||||
else if(body == null || !Boolean.TRUE.equals(body.get("all")))
|
||||
else
|
||||
{
|
||||
throw (new QBadRequestException("Send alertId, or all: true."));
|
||||
filter.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK);
|
||||
}
|
||||
|
||||
Instant now = runtime.now();
|
||||
List<QRecord> updates = new ArrayList<>();
|
||||
for(QRecord item : AlertsDb.query(AlertsInboxItem.TABLE_NAME, filter.withLimit(500)))
|
||||
List<QRecord> updates = new ArrayList<>();
|
||||
Set<Integer> affected = new HashSet<>();
|
||||
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, filter))
|
||||
{
|
||||
updates.add(new QRecord().withValue("id", item.getValue("id")).withValue("readAt", now));
|
||||
if(needs.test(recipient))
|
||||
{
|
||||
QRecord update = new QRecord().withValue("id", recipient.getValue("id")).withValue(field, value);
|
||||
if("dismissedAt".equals(field) && recipient.getValue("readAt") == null)
|
||||
{
|
||||
update.setValue("readAt", value);
|
||||
}
|
||||
updates.add(update);
|
||||
affected.add(recipient.getValueInteger("notificationId"));
|
||||
}
|
||||
}
|
||||
if(!updates.isEmpty())
|
||||
{
|
||||
UpdateAction.executeForRecords(new UpdateInput(AlertsInboxItem.TABLE_NAME).withRecords(updates));
|
||||
UpdateAction.executeForRecords(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(updates));
|
||||
runtime.getNotifications().recountRead(affected);
|
||||
}
|
||||
return (Map.of("updated", updates.size()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer toInteger(Object value) throws QBadRequestException
|
||||
{
|
||||
Integer parsed = value == null ? null : parseInteger(String.valueOf(value));
|
||||
if(parsed == null)
|
||||
{
|
||||
throw (new QBadRequestException("Ids must be numbers."));
|
||||
}
|
||||
return (parsed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer parseInteger(String value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (value == null ? null : Integer.valueOf(value.trim().replaceAll("\\.0+$", "")));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static int parseInt(String value, int fallback)
|
||||
{
|
||||
Integer parsed = parseInteger(value);
|
||||
return (parsed == null ? fallback : parsed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Open alerts" panel for a record: visible open alerts that reference
|
||||
** it (at most 5), how many were resolved in 30 days, and links. visible is
|
||||
@@ -259,46 +586,13 @@ 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.
|
||||
*******************************************************************************/
|
||||
@@ -331,11 +625,32 @@ 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).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.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", "AlertsErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "AlertsErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "AlertsErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", "AlertsErrorV1")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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,27 +30,20 @@ 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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Javalin routes under /qqq/v1/alerts for the Next UI bell and record panel:
|
||||
** - GET /inbox/summary unread count, highest unread severity, latest 10
|
||||
** - POST /inbox/read {"alertId": n} 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.
|
||||
** Machine webhook routes. The UI routes are registered through AlertsUiApi.
|
||||
*******************************************************************************/
|
||||
public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsRouteProvider.class);
|
||||
|
||||
public static final String WEBHOOK_PATH = "/qqq/hooks/alerts/alertmanager";
|
||||
public static final String LEGACY_SUNSET = "Tue, 13 Apr 2027 00:00:00 GMT";
|
||||
public static final String BASE_PATH = "/qqq/v1/alerts";
|
||||
|
||||
static final int MAX_INGEST_BYTES = 1024 * 1024;
|
||||
@@ -92,11 +85,17 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.get(BASE_PATH + "/inbox/summary", context -> respond(context, false, (api, body) -> api.inboxSummary()));
|
||||
config.routes.post(BASE_PATH + "/inbox/read", context -> respond(context, true, AlertsApi::markRead));
|
||||
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);
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,31 +129,6 @@ 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 JSON body (an object), or 400.
|
||||
*******************************************************************************/
|
||||
@@ -183,15 +157,4 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One endpoint's work.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface Handler
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
Object handle(AlertsApi api, Map<String, Object> body) throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.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).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 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("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("Framework error response.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alerts UI API, retaining the established /alerts namespace.
|
||||
*******************************************************************************/
|
||||
public final class AlertsUiApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AlertsUiApi()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register once, alongside the optional Javalin webhook provider.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The independently versioned, discoverable UI contract.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
|
||||
.withSemanticVersion("1.0.0").withDescription("Alerts inbox and record panel")
|
||||
.withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class)
|
||||
.withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class)
|
||||
.withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Dismiss own notifications.
|
||||
*******************************************************************************/
|
||||
public class InboxDismissSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInboxDismiss";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/dismiss")
|
||||
.withShortSummary("Dismiss own notifications").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsMutationResultV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient selectors; precedence is id, ids, alertId, then all.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().dismiss(input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark own notifications read.
|
||||
*******************************************************************************/
|
||||
public class InboxReadSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInboxRead";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/read")
|
||||
.withShortSummary("Mark own notifications read").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsMutationResultV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient selectors; precedence is id, ids, alertId, then all.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().markRead(input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page the notification inbox.
|
||||
*******************************************************************************/
|
||||
public class InboxSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInbox";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox")
|
||||
.withShortSummary("Page the notification inbox").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsInboxPageV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing query parameters and paging semantics.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (List.of(
|
||||
new Parameter().withName("status").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("all or unread; other values behave as all."),
|
||||
new Parameter().withName("category").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive category filter."),
|
||||
new Parameter().withName("severity").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive severity filter."),
|
||||
new Parameter().withName("offset").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 0; negatives clamp to 0; malformed values use the default."),
|
||||
new Parameter().withName("limit").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 25; clamped to 1..100; malformed values use the default.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().inbox(input.getQueryParams()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the bell summary.
|
||||
*******************************************************************************/
|
||||
public class InboxSummarySpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInboxSummary";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox/summary")
|
||||
.withShortSummary("Read the bell summary").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsInboxSummaryV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish shared components once; all six extension specs register together.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (AlertsSchemas.components());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().inboxSummary());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark own notifications unread.
|
||||
*******************************************************************************/
|
||||
public class InboxUnreadSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInboxUnread";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/unread")
|
||||
.withShortSummary("Mark own notifications unread").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsMutationResultV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient selectors; precedence is id, ids, alertId, then all.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("AlertsUnreadTargetV1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().markUnread(input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read visible alerts for a record.
|
||||
*******************************************************************************/
|
||||
public class RecordPanelSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsRecordPanel";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/record-panel")
|
||||
.withShortSummary("Read visible alerts for a record").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsRecordPanelV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing query parameters and paging semantics.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (List.of(
|
||||
new Parameter().withName("tableName").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent table name."),
|
||||
new Parameter().withName("recordId").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent record key.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().recordPanel(input.getQueryParam("tableName"), input.getQueryParam("recordId")));
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.customizers;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rules for the notification tables, in the core actions (so the admin, the
|
||||
** API and the bell all follow them):
|
||||
** - Notifications: the author is the session user; new ones start as
|
||||
** DRAFT (sending is a record action); status and the send bookkeeping
|
||||
** can't be edited; links must be app paths or https; an audience needs
|
||||
** its values. Deleting one deletes its recipient rows.
|
||||
** - Recipients: people change only read and dismissed on their own rows
|
||||
** (the owner lock); the qbit inserts and deletes them.
|
||||
** - Notification settings: the user id is the session's; SECURITY can't be
|
||||
** muted in-app.
|
||||
** The system user (the qbit itself) is not restricted.
|
||||
*******************************************************************************/
|
||||
public class AlertsNotificationCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
private static final Set<String> NOTIFICATION_SYSTEM_FIELDS = Set.of("status", "authorUserId", "sentAt", "retractedAt", "recipientCount", "readCount");
|
||||
private static final Set<String> RECIPIENT_EDITABLE_FIELDS = Set.of("id", "readAt", "dismissedAt");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stamp the user and author before QQQ's required-field and security-lock
|
||||
** checks, so they check the values this customizer sets.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput insertInput, boolean isPreview)
|
||||
{
|
||||
return (AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
if(AlertsSecurity.isSystemUser())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
String tableName = insertInput.getTableName();
|
||||
if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
|
||||
{
|
||||
return (AlertsWriteGuardCustomizer.deny(tableName, Capability.TABLE_INSERT, records));
|
||||
}
|
||||
String userId = AlertsSecurity.userId(QContext.getQSession());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(AlertsNotification.TABLE_NAME.equals(tableName))
|
||||
{
|
||||
record.setValue("authorUserId", userId);
|
||||
record.setValue("status", NotificationService.STATUS_DRAFT);
|
||||
for(String field : List.of("sentAt", "retractedAt", "recipientCount", "readCount"))
|
||||
{
|
||||
record.removeValue(field);
|
||||
}
|
||||
if(!StringUtils.hasContent(record.getValueString("sourceName")))
|
||||
{
|
||||
record.setValue("sourceName", "admin");
|
||||
}
|
||||
checkNotification(record);
|
||||
}
|
||||
else if(AlertsNotificationPreference.TABLE_NAME.equals(tableName))
|
||||
{
|
||||
if(!isAlertsAdmin() || record.getValue("userId") == null)
|
||||
{
|
||||
record.setValue("userId", userId);
|
||||
}
|
||||
checkPreference(record);
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(AlertsSecurity.isSystemUser())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
String tableName = updateInput.getTableName();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(AlertsNotification.TABLE_NAME.equals(tableName))
|
||||
{
|
||||
for(String field : NOTIFICATION_SYSTEM_FIELDS)
|
||||
{
|
||||
record.removeValue(field);
|
||||
}
|
||||
checkNotification(record);
|
||||
}
|
||||
else if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
|
||||
{
|
||||
for(String field : new ArrayList<>(record.getValues().keySet()))
|
||||
{
|
||||
if(!RECIPIENT_EDITABLE_FIELDS.contains(field))
|
||||
{
|
||||
record.removeValue(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(AlertsNotificationPreference.TABLE_NAME.equals(tableName))
|
||||
{
|
||||
record.removeValue("userId");
|
||||
QRecord merged = new QRecord(record);
|
||||
oldRecordList.flatMap(old -> old.stream().filter(o -> o.getValue("id") != null && o.getValue("id").equals(record.getValue("id"))).findFirst())
|
||||
.ifPresent(old -> old.getValues().forEach((field, value) -> merged.getValues().putIfAbsent(field, value)));
|
||||
checkPreference(merged);
|
||||
merged.getErrors().forEach(record::addError);
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** People never delete recipient rows; deleting a notification deletes its
|
||||
** recipients first (as the qbit).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
String tableName = deleteInput.getTableName();
|
||||
if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
|
||||
{
|
||||
return (AlertsSecurity.isSystemUser() ? records : AlertsWriteGuardCustomizer.deny(tableName, Capability.TABLE_DELETE, records));
|
||||
}
|
||||
if(AlertsNotification.TABLE_NAME.equals(tableName) && !isPreview)
|
||||
{
|
||||
List<Serializable> ids = records.stream().map(record -> (Serializable) record.getValueInteger("id")).filter(id -> id != null).toList();
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
AlertsRuntime.get().asSystem(() -> new DeleteAction().execute(new DeleteInput(AlertsNotificationRecipient.TABLE_NAME)
|
||||
.withQueryFilter(new QQueryFilter(new QFilterCriteria("notificationId", QCriteriaOperator.IN, ids)))));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification's link and audience (the fields present on the record).
|
||||
*******************************************************************************/
|
||||
static void checkNotification(QRecord record)
|
||||
{
|
||||
if(record.getValues().containsKey("linkUrl") && StringUtils.hasContent(record.getValueString("linkUrl")) && NotificationService.safeLink(record.getValueString("linkUrl")) == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("The link must be an app path (starting with /) or an https URL."));
|
||||
}
|
||||
if(record.getValues().containsKey("audienceType"))
|
||||
{
|
||||
NotificationAudience audience = NotificationService.audienceOf(record);
|
||||
if(audience == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Choose who the notification is for."));
|
||||
}
|
||||
else if(audience.getType() != NotificationAudience.Type.ALL_USERS && audience.getValues().isEmpty())
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("List the " + switch(audience.getType())
|
||||
{
|
||||
case ROLES -> "roles or groups";
|
||||
case PERMISSION -> "permission";
|
||||
default -> "user ids";
|
||||
} + " in Audience Members."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SECURITY notices can't be muted in-app.
|
||||
*******************************************************************************/
|
||||
static void checkPreference(QRecord record)
|
||||
{
|
||||
if(NotificationCategory.SECURITY.name().equals(record.getValueString("category")) && Boolean.TRUE.equals(record.getValueBoolean("inAppMuted")))
|
||||
{
|
||||
record.addError(new PermissionDeniedMessage("Security notices can't be muted in-app; you can still choose how they are emailed."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isAlertsAdmin()
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
return (session != null && session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN));
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,9 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.home.HomeHtml;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
@@ -69,7 +69,7 @@ public final class AlertMessageRenderer
|
||||
/*******************************************************************************
|
||||
** One message for one or more alerts.
|
||||
*******************************************************************************/
|
||||
public static AlertMessage render(List<Item> items, NotificationKind kind, String publicBaseUrl, Instant now)
|
||||
public static AlertMessage render(List<Item> items, DeliveryKind kind, String publicBaseUrl, Instant now)
|
||||
{
|
||||
AlertSeverity highest = AlertSeverity.INFO;
|
||||
List<Integer> alertIds = new ArrayList<>();
|
||||
@@ -84,26 +84,26 @@ public final class AlertMessageRenderer
|
||||
}
|
||||
|
||||
String base = StringUtils.hasContent(publicBaseUrl) ? publicBaseUrl.replaceAll("/+$", "") : "";
|
||||
String prefix = kind == NotificationKind.TEST ? "TEST: " : "";
|
||||
String prefix = kind == DeliveryKind.TEST ? "TEST: " : "";
|
||||
String subject;
|
||||
String link;
|
||||
if(items.size() == 1)
|
||||
{
|
||||
QRecord alert = items.get(0).alert();
|
||||
String state = kind == NotificationKind.RESOLVED ? "RESOLVED" : AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING).name();
|
||||
String state = kind == DeliveryKind.RESOLVED ? "RESOLVED" : AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING).name();
|
||||
subject = prefix + "[" + state + "] " + AlertRedactor.redact(alert.getValueString("title"));
|
||||
link = base + "/app/" + AlertsAlert.TABLE_NAME + "/" + alert.getValue("id");
|
||||
}
|
||||
else
|
||||
{
|
||||
String group = commonGroup(items);
|
||||
subject = prefix + items.size() + (kind == NotificationKind.RESOLVED ? " alerts resolved" : " new alerts") + (group == null ? "" : " in " + group);
|
||||
subject = prefix + items.size() + (kind == DeliveryKind.RESOLVED ? " alerts resolved" : " new alerts") + (group == null ? "" : " in " + group);
|
||||
link = base + "/app/" + AlertsAlert.TABLE_NAME;
|
||||
}
|
||||
|
||||
StringBuilder text = new StringBuilder();
|
||||
StringBuilder html = new StringBuilder("<div style=\"font-family:sans-serif;font-size:14px;line-height:1.5\">");
|
||||
if(kind == NotificationKind.TEST)
|
||||
if(kind == DeliveryKind.TEST)
|
||||
{
|
||||
text.append("This is a test notification from Alerts.\n\n");
|
||||
html.append("<p><strong>This is a test notification from Alerts.</strong></p>");
|
||||
@@ -156,7 +156,7 @@ public final class AlertMessageRenderer
|
||||
Integer id = alert.getValueInteger("id");
|
||||
entries.add(new AlertMessage.Entry(id, alert.getValueString("alertTypeName"), safe(item.typeLabel()), alert.getValueString("dedupKey"),
|
||||
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING).name(),
|
||||
kind == NotificationKind.RESOLVED ? "RESOLVED" : (alert.getValueString("status") == null ? "OPEN" : alert.getValueString("status")),
|
||||
kind == DeliveryKind.RESOLVED ? "RESOLVED" : (alert.getValueString("status") == null ? "OPEN" : alert.getValueString("status")),
|
||||
safe(alert.getValueString("title")), standard ? AlertRedactor.redact(alert.getValueString("summary")) : null, item.groupLabel(),
|
||||
standard ? alert.getValueInteger("itemCount") : null, id == null ? base + "/app/" + AlertsAlert.TABLE_NAME : base + "/app/" + AlertsAlert.TABLE_NAME + "/" + id,
|
||||
alert.getValueInstant("firstRaisedAt") == null ? null : alert.getValueInstant("firstRaisedAt").toString(),
|
||||
|
||||
@@ -29,18 +29,18 @@ import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationKind;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationStatus;
|
||||
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.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -100,8 +100,8 @@ public class DeliveryWorker
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
List<QRecord> due = AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.PENDING.name(), NotificationStatus.RETRYING.name()))
|
||||
List<QRecord> due = AlertsDb.query(AlertsDelivery.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.RETRYING.name()))
|
||||
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
|
||||
.withOrderBy(new QFilterOrderBy("id"))
|
||||
.withLimit(BATCH_LIMIT));
|
||||
@@ -110,11 +110,11 @@ public class DeliveryWorker
|
||||
for(QRecord row : due)
|
||||
{
|
||||
Instant claimedUntil = row.getValueInstant("claimedUntil");
|
||||
if(claimedUntil != null && claimedUntil.isAfter(now) && NotificationStatus.SENDING.name().equals(row.getValueString("status")))
|
||||
if(claimedUntil != null && claimedUntil.isAfter(now) && DeliveryStatus.SENDING.name().equals(row.getValueString("status")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("status", NotificationStatus.SENDING.name())
|
||||
AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("status", DeliveryStatus.SENDING.name())
|
||||
.withValue("claimedUntil", now.plus(Duration.ofMinutes(2))));
|
||||
groups.computeIfAbsent(row.getValue("channelId") + "|" + row.getValueString("recipientUserId") + "|" + row.getValueString("kind"), key -> new ArrayList<>()).add(row);
|
||||
}
|
||||
@@ -140,10 +140,10 @@ public class DeliveryWorker
|
||||
int count = 0;
|
||||
for(Serializable id : notificationIds)
|
||||
{
|
||||
QRecord row = AlertsDb.byId(AlertsNotification.TABLE_NAME, id);
|
||||
if(row != null && List.of(NotificationStatus.FAILED.name(), NotificationStatus.DEAD.name()).contains(row.getValueString("status")))
|
||||
QRecord row = AlertsDb.byId(AlertsDelivery.TABLE_NAME, id);
|
||||
if(row != null && List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()).contains(row.getValueString("status")))
|
||||
{
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", NotificationStatus.PENDING.name())
|
||||
AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", DeliveryStatus.PENDING.name())
|
||||
.withValue("nextAttemptAt", runtime.now()).withValue("attempts", 0).withValue("claimedUntil", null).withValue("lastError", null));
|
||||
count++;
|
||||
}
|
||||
@@ -171,17 +171,17 @@ public class DeliveryWorker
|
||||
String channelType = channel.getValueString("channelType");
|
||||
boolean personal = ChannelType.IN_APP.name().equals(channelType) || ChannelType.EMAIL.name().equals(channelType);
|
||||
Instant now = runtime.now();
|
||||
QRecord row = AlertsDb.insert(AlertsNotification.TABLE_NAME, new QRecord()
|
||||
QRecord row = AlertsDb.insert(AlertsDelivery.TABLE_NAME, new QRecord()
|
||||
.withValue("channelId", channelId)
|
||||
.withValue("recipientUserId", personal ? actorUserId : null)
|
||||
.withValue("recipientLabel", personal ? actorUserId : channel.getValueString("label"))
|
||||
.withValue("kind", NotificationKind.TEST.name())
|
||||
.withValue("status", NotificationStatus.SENDING.name())
|
||||
.withValue("kind", DeliveryKind.TEST.name())
|
||||
.withValue("status", DeliveryStatus.SENDING.name())
|
||||
.withValue("attempts", 0)
|
||||
.withValue("nextAttemptAt", now)
|
||||
.withValue("claimedUntil", now.plus(Duration.ofMinutes(2))));
|
||||
sendGroup(List.of(row), now);
|
||||
return (AlertsDb.byId(AlertsNotification.TABLE_NAME, row.getValue("id")));
|
||||
return (AlertsDb.byId(AlertsDelivery.TABLE_NAME, row.getValue("id")));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ public class DeliveryWorker
|
||||
return (rows.size());
|
||||
}
|
||||
|
||||
NotificationKind kind = NotificationKind.valueOf(first.getValueString("kind"));
|
||||
DeliveryKind kind = DeliveryKind.valueOf(first.getValueString("kind"));
|
||||
List<AlertMessageRenderer.Item> items = new ArrayList<>();
|
||||
Map<Integer, QRecord> types = new HashMap<>();
|
||||
Map<Integer, QRecord> groups = new HashMap<>();
|
||||
@@ -275,22 +275,22 @@ public class DeliveryWorker
|
||||
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("attempts", attempts).withValue("claimedUntil", null);
|
||||
switch(result.outcome())
|
||||
{
|
||||
case SENT -> update.withValue("status", NotificationStatus.SENT.name()).withValue("sentAt", now).withValue("providerMessageId", result.providerMessageId()).withValue("lastError", null);
|
||||
case FAILED -> update.withValue("status", NotificationStatus.FAILED.name()).withValue("lastError", truncate(result.error()));
|
||||
case SENT -> update.withValue("status", DeliveryStatus.SENT.name()).withValue("sentAt", now).withValue("providerMessageId", result.providerMessageId()).withValue("lastError", null);
|
||||
case FAILED -> update.withValue("status", DeliveryStatus.FAILED.name()).withValue("lastError", truncate(result.error()));
|
||||
case RETRY ->
|
||||
{
|
||||
if(attempts > BACKOFF.size())
|
||||
{
|
||||
update.withValue("status", NotificationStatus.DEAD.name()).withValue("lastError", truncate(result.error()));
|
||||
update.withValue("status", DeliveryStatus.DEAD.name()).withValue("lastError", truncate(result.error()));
|
||||
}
|
||||
else
|
||||
{
|
||||
update.withValue("status", NotificationStatus.RETRYING.name()).withValue("nextAttemptAt", now.plus(BACKOFF.get(attempts - 1))).withValue("lastError", truncate(result.error()));
|
||||
update.withValue("status", DeliveryStatus.RETRYING.name()).withValue("nextAttemptAt", now.plus(BACKOFF.get(attempts - 1))).withValue("lastError", truncate(result.error()));
|
||||
}
|
||||
}
|
||||
default -> throw (new IllegalStateException("Unexpected outcome " + result.outcome()));
|
||||
}
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, update);
|
||||
AlertsDb.update(AlertsDelivery.TABLE_NAME, update);
|
||||
}
|
||||
|
||||
|
||||
@@ -327,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.qbits.alerts.spi.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
|
||||
.withRecordRef(com.kingsrook.qqq.backend.core.alerts.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stand-in alert a TEST message describes.
|
||||
** The sample alert a TEST message describes.
|
||||
*******************************************************************************/
|
||||
private static QRecord testAlert(Instant now)
|
||||
{
|
||||
|
||||
@@ -18,10 +18,15 @@ 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;
|
||||
@@ -40,8 +45,10 @@ 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. Without a messaging provider the channel fails permanently and
|
||||
** says why.
|
||||
** the emails. When qbit-email is produced in the instance, each recipient gets
|
||||
** their own message through it (template alerts_alert, NOTIFICATION); the
|
||||
** messaging provider is then not needed. Without either, the channel fails
|
||||
** permanently and says why.
|
||||
*******************************************************************************/
|
||||
public class EmailChannel implements AlertChannelInterface
|
||||
{
|
||||
@@ -79,13 +86,16 @@ public class EmailChannel implements AlertChannelInterface
|
||||
public List<String> validate(QRecord channel, AlertSecretResolver secrets)
|
||||
{
|
||||
List<String> problems = new ArrayList<>();
|
||||
if(!StringUtils.hasContent(config.getMessagingProviderName()))
|
||||
if(!AlertsEmail.isActive())
|
||||
{
|
||||
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(!StringUtils.hasContent(config.getMessagingProviderName()))
|
||||
{
|
||||
problems.add("Email is not configured: set messagingProviderName in the Alerts QBit config.");
|
||||
}
|
||||
if(!StringUtils.hasContent(config.getFromAddress()))
|
||||
{
|
||||
problems.add("Email has no From address: set fromAddress in the Alerts QBit config.");
|
||||
}
|
||||
}
|
||||
if(ChannelType.EMAIL_LIST.name().equals(type) && listAddresses(channel).isEmpty())
|
||||
{
|
||||
@@ -114,6 +124,11 @@ public class EmailChannel implements AlertChannelInterface
|
||||
return (DeliveryResult.failed("No email address."));
|
||||
}
|
||||
|
||||
if(AlertsEmail.isActive())
|
||||
{
|
||||
return (deliverThroughEmailQBit(message, addresses));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MultiParty to = new MultiParty();
|
||||
@@ -138,6 +153,75 @@ 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 = 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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 stand-in entry).
|
||||
** The message's entries (a TEST message gets one sample entry).
|
||||
*******************************************************************************/
|
||||
static List<AlertMessage.Entry> entries(AlertMessage message)
|
||||
{
|
||||
|
||||
+25
-55
@@ -17,36 +17,25 @@
|
||||
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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSigner;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
** Compatibility adapter for the qbit's existing webhook secret strings.
|
||||
** Core signs every request. Core requires at least 16 key bytes, whereas the
|
||||
** previous signer accepted shorter keys. HMAC-SHA256 zero-pads keys to its
|
||||
** 64-byte block size, so padding a short legacy key to 16 bytes preserves
|
||||
** its signature exactly. New secrets should use WebhookSecret.generate().
|
||||
*******************************************************************************/
|
||||
public final class StandardWebhookSigner
|
||||
{
|
||||
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.
|
||||
*******************************************************************************/
|
||||
@@ -57,55 +46,36 @@ public final class StandardWebhookSigner
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The three signature headers.
|
||||
** The three core Standard Webhooks headers, preserving legacy short keys.
|
||||
*******************************************************************************/
|
||||
public static Map<String, String> headers(String messageId, Instant timestamp, byte[] body, String 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);
|
||||
return (WebhookSigner.headers(messageId, timestamp, body, List.of(parseSecret(secret))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** base64 HMAC-SHA256 over id.timestamp.body.
|
||||
** Preserve raw UTF-8 and whsec_ base64 secrets, including legacy short keys.
|
||||
*******************************************************************************/
|
||||
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)
|
||||
private static WebhookSecret parseSecret(String secret)
|
||||
{
|
||||
if(secret == null || secret.isBlank())
|
||||
{
|
||||
throw (new IllegalArgumentException("A webhook signing secret is required"));
|
||||
}
|
||||
String trimmed = secret.trim();
|
||||
return (trimmed.startsWith(PREFIX) ? Base64.getDecoder().decode(trimmed.substring(PREFIX.length())) : trimmed.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] key = trimmed.startsWith(WebhookSecret.PREFIX)
|
||||
? Base64.getDecoder().decode(trimmed.substring(WebhookSecret.PREFIX.length()))
|
||||
: trimmed.getBytes(StandardCharsets.UTF_8);
|
||||
if(key.length == 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("A webhook signing secret is required"));
|
||||
}
|
||||
if(key.length < WebhookSecret.MINIMUM_KEY_BYTES)
|
||||
{
|
||||
return (WebhookSecret.parse(WebhookSecret.PREFIX + Base64.getEncoder().encodeToString(Arrays.copyOf(key, WebhookSecret.MINIMUM_KEY_BYTES))));
|
||||
}
|
||||
return (WebhookSecret.parse(trimmed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.email;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The guard for sending through qbit-email. qbit-email is an optional
|
||||
** dependency: this class (which names no qbit-email class) answers whether the
|
||||
** email qbit is on the classpath AND produced in the current instance. Only
|
||||
** then does the rest of the qbit call {@link AlertsEmailSender}; otherwise
|
||||
** email goes through core SendMessageAction and messagingProviderName.
|
||||
*******************************************************************************/
|
||||
public final class AlertsEmail
|
||||
{
|
||||
private static final String EMAIL_API_CLASS = "com.kingsrook.qbits.email.api.EmailApi";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AlertsEmail()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether email goes through qbit-email in the current instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isActive()
|
||||
{
|
||||
return (isActive(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether email goes through qbit-email in an instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isActive(QInstance qInstance)
|
||||
{
|
||||
if(qInstance == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
Class.forName(EMAIL_API_CLASS);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (AlertsEmailSender.isInstalled(qInstance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* 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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
@@ -30,15 +30,14 @@ import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsEngine.AlertTarget;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
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.MuteEffect;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationKind;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SuppressedReason;
|
||||
@@ -46,14 +45,21 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
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.spi.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
|
||||
@@ -71,10 +77,10 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** 4. apply, in order: the type's notify switch, mutes, flapping, the user's
|
||||
** pause, channel enabled / minimum severity / address / rate limit, and a
|
||||
** 10-minute duplicate check;
|
||||
** 5. write an alerts_notification row per destination (SUPPRESSED rows keep
|
||||
** 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
|
||||
** inbox item per in-app recipient.
|
||||
** In-app deliveries are complete when the inbox item is written; other
|
||||
** ALERT notification recipient row per in-app recipient.
|
||||
** In-app deliveries are complete when the recipient row is written; other
|
||||
** channels are left PENDING for the delivery worker.
|
||||
*******************************************************************************/
|
||||
public class AlertRouter
|
||||
@@ -109,13 +115,13 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** The notification kind for a timeline event (null: not notifiable).
|
||||
*******************************************************************************/
|
||||
public static NotificationKind kindFor(EventType eventType)
|
||||
public static DeliveryKind kindFor(EventType eventType)
|
||||
{
|
||||
return switch(eventType)
|
||||
{
|
||||
case RAISED, REOPENED, SEVERITY_CHANGED, FLAPPING_STARTED -> NotificationKind.RAISED;
|
||||
case RESOLVED -> NotificationKind.RESOLVED;
|
||||
case ESCALATED -> NotificationKind.ESCALATED;
|
||||
case RAISED, REOPENED, SEVERITY_CHANGED, FLAPPING_STARTED -> DeliveryKind.RAISED;
|
||||
case RESOLVED -> DeliveryKind.RESOLVED;
|
||||
case ESCALATED -> DeliveryKind.ESCALATED;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
@@ -128,7 +134,7 @@ public class AlertRouter
|
||||
*******************************************************************************/
|
||||
public void route(QRecord alert, EventType eventType, Integer eventId)
|
||||
{
|
||||
NotificationKind kind = kindFor(eventType);
|
||||
DeliveryKind kind = kindFor(eventType);
|
||||
if(alert == null || kind == null)
|
||||
{
|
||||
return;
|
||||
@@ -231,23 +237,24 @@ public class AlertRouter
|
||||
|
||||
if(reason != null)
|
||||
{
|
||||
notification.setValue("status", NotificationStatus.SUPPRESSED.name());
|
||||
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
|
||||
notification.setValue("suppressedReason", reason.name());
|
||||
AlertsDb.insert(AlertsNotification.TABLE_NAME, notification);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
}
|
||||
else if(inApp)
|
||||
{
|
||||
notification.setValue("status", NotificationStatus.SENT.name());
|
||||
notification.setValue("status", DeliveryStatus.SENT.name());
|
||||
notification.setValue("sentAt", now);
|
||||
QRecord inserted = AlertsDb.insert(AlertsNotification.TABLE_NAME, notification);
|
||||
deliverToInbox(destination.userId(), alert.getValueInteger("id"), inserted.getValueInteger("id"), 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", NotificationStatus.PENDING.name());
|
||||
notification.setValue("status", DeliveryStatus.PENDING.name());
|
||||
notification.setValue("nextAttemptAt", now);
|
||||
AlertsDb.insert(AlertsNotification.TABLE_NAME, notification);
|
||||
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
@@ -303,9 +310,9 @@ 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), and subscribed channels.
|
||||
** chosen channels), subscribed channels, and readable record followers.
|
||||
*******************************************************************************/
|
||||
List<Destination> destinations(AlertTarget target, AlertSeverity severity, NotificationKind kind) 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<>();
|
||||
@@ -353,6 +360,24 @@ public class AlertRouter
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String recordKey : 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new ArrayList<>(destinations.values()));
|
||||
}
|
||||
|
||||
@@ -365,7 +390,7 @@ public class AlertRouter
|
||||
private List<QRecord> userChannels(QRecord subscription, QRecord preference) throws QException
|
||||
{
|
||||
List<QRecord> channels = new ArrayList<>();
|
||||
String overrides = subscription.getValueString("channelOverrideIds");
|
||||
String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds");
|
||||
if(StringUtils.hasContent(overrides))
|
||||
{
|
||||
for(String id : overrides.split("[,\\s]+"))
|
||||
@@ -463,7 +488,7 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** Whether a subscription's events list includes this kind (empty: all).
|
||||
*******************************************************************************/
|
||||
private static boolean includesKind(String events, NotificationKind kind)
|
||||
private static boolean includesKind(String events, DeliveryKind kind)
|
||||
{
|
||||
if(!StringUtils.hasContent(events))
|
||||
{
|
||||
@@ -486,7 +511,7 @@ public class AlertRouter
|
||||
** with a host whose authentication module builds sessions with permissions
|
||||
** for a user id, the alert is read through that session's record locks.
|
||||
** Otherwise (empty) the user's permissions are unknown, and the caller
|
||||
** allows in-app (the inbox is filtered by the same locks when it is read)
|
||||
** allows in-app (the bell re-checks the alert under the reader's session)
|
||||
** but sends externally only alerts with no visibility permission and no
|
||||
** subject table.
|
||||
*******************************************************************************/
|
||||
@@ -516,6 +541,39 @@ public class AlertRouter
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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, from the instance's authentication module (null
|
||||
** when it can't make one).
|
||||
@@ -553,8 +611,8 @@ public class AlertRouter
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int lastHour = AlertsDb.count(AlertsNotification.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.PENDING.name(), NotificationStatus.SENDING.name(), NotificationStatus.SENT.name(), NotificationStatus.RETRYING.name()))
|
||||
int lastHour = AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.SENDING.name(), DeliveryStatus.SENT.name(), DeliveryStatus.RETRYING.name()))
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN, now.minus(1, ChronoUnit.HOURS)));
|
||||
return (lastHour >= limit);
|
||||
}
|
||||
@@ -564,37 +622,18 @@ public class AlertRouter
|
||||
/*******************************************************************************
|
||||
** The same alert, channel, recipient and kind within 10 minutes.
|
||||
*******************************************************************************/
|
||||
private boolean isDuplicate(QRecord alert, Destination destination, NotificationKind kind, 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(NotificationStatus.PENDING.name(), NotificationStatus.SENDING.name(), NotificationStatus.SENT.name(), NotificationStatus.RETRYING.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(destination.userId() != null)
|
||||
{
|
||||
filter.withCriteria("recipientUserId", QCriteriaOperator.EQUALS, destination.userId());
|
||||
}
|
||||
return (AlertsDb.count(AlertsNotification.TABLE_NAME, filter) > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Put (or bring back to unread) the alert in the user's bell.
|
||||
*******************************************************************************/
|
||||
void deliverToInbox(String userId, Integer alertId, Integer notificationId, Instant now) throws QException
|
||||
{
|
||||
QRecord existing = AlertsDb.first(AlertsInboxItem.TABLE_NAME, AlertsDb.eq("userId", userId).withCriteria("alertId", QCriteriaOperator.EQUALS, alertId));
|
||||
if(existing == null)
|
||||
{
|
||||
AlertsDb.insert(AlertsInboxItem.TABLE_NAME, new QRecord().withValue("userId", userId).withValue("alertId", alertId).withValue("notificationId", notificationId).withValue("deliveredAt", now));
|
||||
}
|
||||
else
|
||||
{
|
||||
AlertsDb.update(AlertsInboxItem.TABLE_NAME, new QRecord().withValue("id", existing.getValue("id")).withValue("notificationId", notificationId)
|
||||
.withValue("deliveredAt", now).withValue("readAt", null).withValue("archivedAt", null));
|
||||
}
|
||||
return (AlertsDb.count(AlertsDelivery.TABLE_NAME, filter) > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.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.alerts.AlertDefaultMonitor;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
|
||||
@@ -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.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.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
@@ -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.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.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
|
||||
@@ -18,29 +18,50 @@ package com.kingsrook.qbits.alerts.engine;
|
||||
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Feeds PROCESS_FAILED and process HEARTBEAT monitors from QQQ's process
|
||||
** lifecycle events (no ESB needed): a failed run raises, a completed run
|
||||
** resolves and beats the process's heartbeat key. Only processes some
|
||||
** monitor watches are handled (the watched names are cached for a minute),
|
||||
** so other process runs cost one set lookup. Never affects the process.
|
||||
** Feeds alerts and notifications from QQQ's process lifecycle events (no ESB
|
||||
** needed). Never affects the process.
|
||||
** - PROCESS_FAILED and process HEARTBEAT monitors: a failed run raises, a
|
||||
** completed run resolves and beats the process's heartbeat key. Only
|
||||
** processes some monitor watches are handled (the watched names are
|
||||
** cached for a minute).
|
||||
** - Job notifications (docs/NOTIFICATIONS.md 6.1): when a run started by a
|
||||
** person fails, or completes with error summary lines (a partial run),
|
||||
** that person gets a JOB notification linked to the run. System runs and
|
||||
** the qbit's own processes are skipped. Off with notifyFailedJobs(false).
|
||||
*******************************************************************************/
|
||||
public class AlertsProcessListener implements ProcessLifecycleListenerInterface
|
||||
{
|
||||
@@ -64,6 +85,16 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (notifiesJobs(runtime, processName) || isWatched(runtime, processName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a monitor watches this process.
|
||||
*******************************************************************************/
|
||||
private static boolean isWatched(AlertsRuntime runtime, String processName)
|
||||
{
|
||||
for(String pattern : watched(runtime))
|
||||
{
|
||||
if(processName.equals(pattern) || processName.matches(pattern.replace(".", "\\.").replace("*", ".*")))
|
||||
@@ -102,8 +133,17 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
|
||||
}
|
||||
try
|
||||
{
|
||||
runtime.getEngine().heartbeat(MonitorEvaluator.processHeartbeatKey(input.getProcessName()), runtime.now());
|
||||
runtime.getEvaluator().processCompleted(input.getProcessName());
|
||||
if(isWatched(runtime, input.getProcessName()))
|
||||
{
|
||||
runtime.getEngine().heartbeat(MonitorEvaluator.processHeartbeatKey(input.getProcessName()), runtime.now());
|
||||
runtime.getEvaluator().processCompleted(input.getProcessName());
|
||||
}
|
||||
int errors = errorCount(output);
|
||||
if(errors > 0 && notifiesJobs(runtime, input.getProcessName()))
|
||||
{
|
||||
notifyStarter(runtime, input, NotificationSeverity.WARNING, "%s finished with errors",
|
||||
(errors == 1 ? "1 record" : errors + " records") + " had errors. Open the run's results for the details.");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -126,9 +166,17 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
|
||||
}
|
||||
try
|
||||
{
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(input.getProcessName());
|
||||
runtime.getEvaluator().processFailed(input.getProcessName(), process == null ? null : process.getLabel(),
|
||||
exception == null ? null : exception.getClass().getSimpleName() + ": " + exception.getMessage());
|
||||
if(isWatched(runtime, input.getProcessName()))
|
||||
{
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(input.getProcessName());
|
||||
runtime.getEvaluator().processFailed(input.getProcessName(), process == null ? null : process.getLabel(),
|
||||
exception == null ? null : exception.getClass().getSimpleName() + ": " + exception.getMessage());
|
||||
}
|
||||
if(notifiesJobs(runtime, input.getProcessName()))
|
||||
{
|
||||
String message = exception == null || exception.getMessage() == null ? "It stopped with an error." : exception.getMessage();
|
||||
notifyStarter(runtime, input, NotificationSeverity.ERROR, "%s failed", StringUtils.safeTruncate(message, 1000));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -138,6 +186,92 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether runs of this process notify the person who started them.
|
||||
*******************************************************************************/
|
||||
private static boolean notifiesJobs(AlertsRuntime runtime, String processName)
|
||||
{
|
||||
return (Boolean.TRUE.equals(runtime.getConfig().getNotifyFailedJobs()) && !processName.startsWith(AlertsQBitConfig.PERMISSION_BASE_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How many records a completed run reported as errors (its ERROR summary
|
||||
** lines), or 1 when the output carries an exception.
|
||||
*******************************************************************************/
|
||||
public static int errorCount(RunProcessOutput output)
|
||||
{
|
||||
if(output == null)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
if(output.getException().isPresent())
|
||||
{
|
||||
return (1);
|
||||
}
|
||||
int errors = 0;
|
||||
Object summary = output.getValues() == null ? null : output.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
if(summary instanceof List<?> lines)
|
||||
{
|
||||
for(Object line : lines)
|
||||
{
|
||||
if(line instanceof ProcessSummaryLineInterface summaryLine && summaryLine.getStatus() == Status.ERROR)
|
||||
{
|
||||
Integer count = line instanceof ProcessSummaryLine counted ? counted.getCount() : null;
|
||||
errors += count == null || count < 1 ? 1 : count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send a JOB notification to the person who started the run (nothing for
|
||||
** system runs or sessions without a user).
|
||||
*******************************************************************************/
|
||||
private static void notifyStarter(AlertsRuntime runtime, RunProcessInput input, NotificationSeverity severity, String titleFormat, String body)
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
String userId = session == null || session instanceof QSystemUserSession ? null : AlertsSecurity.userId(session);
|
||||
if(userId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(input.getProcessName());
|
||||
String label = process == null || process.getLabel() == null ? input.getProcessName() : process.getLabel();
|
||||
String tableName = process == null ? null : process.getTableName();
|
||||
QAlerts.notify(new NotificationSignal(String.format(titleFormat, label))
|
||||
.withBody(body)
|
||||
.withSeverity(severity)
|
||||
.withCategory(NotificationCategory.JOB)
|
||||
.withSourceName("processes")
|
||||
.withSourceKey("process:" + input.getProcessUUID())
|
||||
.withSourceTableName(tableName)
|
||||
.withLink(jobLink(runtime, input.getProcessName(), input.getProcessUUID(), tableName))
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a job notification links: the configured run link pattern, else
|
||||
** the process's table, else nowhere.
|
||||
*******************************************************************************/
|
||||
public static AlertLink jobLink(AlertsRuntime runtime, String processName, String processUUID, String tableName)
|
||||
{
|
||||
String pattern = runtime.getConfig().getJobRunLinkPattern();
|
||||
if(StringUtils.hasContent(pattern))
|
||||
{
|
||||
return (AlertLink.toPath(pattern.replace("{processName}", processName).replace("{processUUID}", processUUID == null ? "" : processUUID), "Open the run"));
|
||||
}
|
||||
return (tableName == null ? null : AlertLink.toPath("/app/" + tableName, "Open " + tableName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Forget the cached names (after monitors change; tests).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -19,9 +19,10 @@ package com.kingsrook.qbits.alerts.engine;
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertResolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
|
||||
|
||||
@@ -43,7 +44,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.sink().raise(signal);
|
||||
runtime.getSink().raise(signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.sink().resolve(resolution);
|
||||
runtime.getSink().resolve(resolution);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +74,22 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.sink().heartbeat(heartbeatKey, at);
|
||||
runtime.getSink().heartbeat(heartbeatKey, at);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core notification producers share the runtime's bounded delivery queue.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(NotificationSignal signal)
|
||||
{
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.getSink().notify(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -28,10 +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.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.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.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
@@ -132,6 +133,26 @@ public class QbitAlertsSink implements AlertSinkInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue a notification (it is written and sent by the notification
|
||||
** service on the drain thread).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(NotificationSignal signal)
|
||||
{
|
||||
if(signal == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(signal.getOccurredAt() == null)
|
||||
{
|
||||
signal.setOccurredAt(runtime.now());
|
||||
}
|
||||
submit(() -> runtime.getNotifications().notify(signal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process everything queued now, on this thread (tests, shutdown).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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.qbits.alerts.spi.AlertSeverity;
|
||||
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.tables.query.QCriteriaOperator;
|
||||
|
||||
@@ -34,24 +34,27 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.api.AlertsApi;
|
||||
import com.kingsrook.qbits.alerts.delivery.AlertMessageRenderer;
|
||||
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.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationStatus;
|
||||
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.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
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;
|
||||
@@ -84,6 +87,7 @@ public final class AlertsHome
|
||||
public static final String KIND_TREND = "trend";
|
||||
public static final String KIND_NOISY = "noisy";
|
||||
public static final String KIND_DELIVERY = "delivery";
|
||||
public static final String KIND_NOTIFICATIONS = "notifications";
|
||||
public static final String KIND_RESOLVED = "resolved";
|
||||
|
||||
static final int ROWS = 10;
|
||||
@@ -124,6 +128,7 @@ public final class AlertsHome
|
||||
case KIND_TREND -> trend(now);
|
||||
case KIND_NOISY -> noisy(now);
|
||||
case KIND_DELIVERY -> delivery(now);
|
||||
case KIND_NOTIFICATIONS -> notifications(now);
|
||||
case KIND_RESOLVED -> resolved(now);
|
||||
default -> new RawHTML(null, HomeHtml.emptyState("Unknown Alerts widget."));
|
||||
};
|
||||
@@ -160,7 +165,7 @@ public final class AlertsHome
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** My alerts: assigned to me, or in my inbox; unacknowledged first.
|
||||
** My alerts: assigned to me, or in my bell (not dismissed); unacknowledged first.
|
||||
*******************************************************************************/
|
||||
static QWidgetData mine(Instant now) throws QException
|
||||
{
|
||||
@@ -176,13 +181,9 @@ public final class AlertsHome
|
||||
{
|
||||
mine.put(alert.getValueInteger("id"), alert);
|
||||
}
|
||||
if(canRead(AlertsInboxItem.TABLE_NAME))
|
||||
if(canRead(AlertsNotificationRecipient.TABLE_NAME))
|
||||
{
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
for(QRecord item : AlertsDb.query(AlertsInboxItem.TABLE_NAME, AlertsDb.eq("userId", userId).withCriteria("archivedAt", QCriteriaOperator.IS_BLANK).withLimit(SCAN_LIMIT)))
|
||||
{
|
||||
ids.add(item.getValueInteger("alertId"));
|
||||
}
|
||||
List<Integer> ids = AlertsApi.myAlertIds(userId, SCAN_LIMIT);
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids).withCriteria("status", QCriteriaOperator.IN, Status.openNames())))
|
||||
@@ -376,13 +377,35 @@ public final class AlertsHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notifications, link-only (the Notifications table is the owner):
|
||||
** scheduled, sent this week, drafts.
|
||||
*******************************************************************************/
|
||||
static QWidgetData notifications(Instant now) throws QException
|
||||
{
|
||||
if(!canRead(AlertsNotification.TABLE_NAME))
|
||||
{
|
||||
return (new RawHTML(null, HomeHtml.emptyState("Notifications are shown to notification authors.")));
|
||||
}
|
||||
QQueryFilter scheduled = AlertsDb.eq("status", NotificationService.STATUS_SCHEDULED);
|
||||
QQueryFilter drafts = AlertsDb.eq("status", NotificationService.STATUS_DRAFT);
|
||||
QQueryFilter sent = AlertsDb.eq("status", NotificationService.STATUS_SENT).withCriteria("category", QCriteriaOperator.NOT_EQUALS, "ALERT")
|
||||
.withCriteria("sentAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS));
|
||||
String line = HomeHtml.link(listUrl(AlertsNotification.TABLE_NAME, scheduled), HomeHtml.plural(AlertsDb.count(AlertsNotification.TABLE_NAME, scheduled), "scheduled notification"))
|
||||
+ " · " + HomeHtml.link(listUrl(AlertsNotification.TABLE_NAME, sent), HomeHtml.number(AlertsDb.count(AlertsNotification.TABLE_NAME, sent)) + " sent this week")
|
||||
+ " · " + HomeHtml.link(listUrl(AlertsNotification.TABLE_NAME, drafts), HomeHtml.plural(AlertsDb.count(AlertsNotification.TABLE_NAME, drafts), "draft"));
|
||||
return (new RawHTML(null, "<p style=\"margin:0\">" + line + "</p>"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivery health (alerts admins): unhealthy channels and failed or dead
|
||||
** deliveries in 24 hours; one line when all is well.
|
||||
*******************************************************************************/
|
||||
static QWidgetData delivery(Instant now) throws QException
|
||||
{
|
||||
if(!canRead(AlertsChannel.TABLE_NAME) || !canRead(AlertsNotification.TABLE_NAME))
|
||||
if(!canRead(AlertsChannel.TABLE_NAME) || !canRead(AlertsDelivery.TABLE_NAME))
|
||||
{
|
||||
return (new RawHTML(null, HomeHtml.emptyState("Delivery health is shown to alerts admins.")));
|
||||
}
|
||||
@@ -391,8 +414,8 @@ public final class AlertsHome
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord channel : channels)
|
||||
{
|
||||
int failed = AlertsDb.count(AlertsNotification.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.FAILED.name(), NotificationStatus.DEAD.name()))
|
||||
int failed = AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()))
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since));
|
||||
String health = channel.getValueString("healthStatus");
|
||||
if(failed == 0 && (health == null || HealthStatus.HEALTHY.name().equals(health)))
|
||||
@@ -402,8 +425,8 @@ public final class AlertsHome
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("channel", HomeHtml.link("/app/" + AlertsChannel.TABLE_NAME + "/" + channel.getValue("id"), channel.getValueString("label")));
|
||||
row.put("health", HomeHtml.badge(AlertsPossibleValues.label(health == null ? "HEALTHY" : health), HealthStatus.UNHEALTHY.name().equals(health) ? HomeHtml.BAD_COLOR : HomeHtml.WARN_COLOR));
|
||||
row.put("failed", HomeHtml.link(listUrl(AlertsNotification.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.FAILED.name(), NotificationStatus.DEAD.name()))), HomeHtml.number(failed)));
|
||||
row.put("failed", HomeHtml.link(listUrl(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()))), HomeHtml.number(failed)));
|
||||
row.put("error", HomeHtml.escape(StringUtils.hasContent(channel.getValueString("lastError")) ? StringUtils.safeTruncate(channel.getValueString("lastError"), 120, "…") : ""));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ import com.kingsrook.qbits.alerts.AlertsPermissionChecker;
|
||||
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.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeam;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
@@ -44,7 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
/*******************************************************************************
|
||||
** The top-level Alerts app (design section 6.1). Its home is the Alerts
|
||||
** dashboard; its nav lists the main tables in the design's order. The
|
||||
** timeline, alert records, team members and inbox items are not in the nav:
|
||||
** timeline, alert records, team members and notification recipients are not in the nav:
|
||||
** they are child lists and relations on their parents, and stay standard
|
||||
** tables reachable by URL and the API. Tables a user can't read are hidden
|
||||
** by permission. No processes in the nav.
|
||||
@@ -52,8 +54,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
public class AlertsAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, AlertsQBitConfig>
|
||||
{
|
||||
/** the main tables, in nav order. */
|
||||
public static final List<String> MAIN_TABLES = List.of(AlertsAlert.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME,
|
||||
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsNotification.TABLE_NAME);
|
||||
public static final List<String> MAIN_TABLES = List.of(AlertsAlert.TABLE_NAME, AlertsNotification.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME,
|
||||
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsDelivery.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -46,6 +46,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
|
||||
public static final String NOISY = "alertsHomeNoisy";
|
||||
public static final String DELIVERY = "alertsHomeDeliveryHealth";
|
||||
public static final String RESOLVED = "alertsHomeRecentlyResolved";
|
||||
public static final String NOTICES = "alertsHomeNotifications";
|
||||
|
||||
|
||||
|
||||
@@ -54,7 +55,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
|
||||
*******************************************************************************/
|
||||
public static List<String> homeWidgetNames()
|
||||
{
|
||||
return (List.of(SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, RESOLVED));
|
||||
return (List.of(SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED));
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +77,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
|
||||
output.add(widget(TREND, "Opened and Resolved, 30 Days", WidgetType.LINE_CHART.getType(), AlertsHome.KIND_TREND, AlertsQBitConfig.PERMISSION_VIEW));
|
||||
output.add(widget(NOISY, "Noisiest Alert Types, 7 Days", table, AlertsHome.KIND_NOISY, AlertsQBitConfig.PERMISSION_VIEW));
|
||||
output.add(widget(DELIVERY, "Delivery Health", html, AlertsHome.KIND_DELIVERY, AlertsQBitConfig.PERMISSION_ADMIN));
|
||||
output.add(widget(NOTICES, "Notifications", html, AlertsHome.KIND_NOTIFICATIONS, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN));
|
||||
output.add(widget(RESOLVED, "Recently Resolved", table, AlertsHome.KIND_RESOLVED, AlertsQBitConfig.PERMISSION_VIEW));
|
||||
return (output);
|
||||
}
|
||||
|
||||
@@ -23,17 +23,20 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.customizers.AlertsNotificationCustomizer;
|
||||
import com.kingsrook.qbits.alerts.customizers.AlertsSubscriptionCustomizer;
|
||||
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.AlertsEvent;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
@@ -41,8 +44,12 @@ 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.qbits.alerts.notifications.NotificationService;
|
||||
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;
|
||||
@@ -76,27 +83,27 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
** audit, API) secured by QQQ mechanisms rather than by hiding it:
|
||||
** - Permissions: QQQ's per-table read / insert / edit / delete permissions
|
||||
** (AlertsQBitConfig lists them per role).
|
||||
** - Record security locks (AlertsSecurity): an alert, its timeline, records
|
||||
** and inbox items are visible only when the session holds the type's
|
||||
** - Record security locks (AlertsSecurity): an alert, its timeline and records
|
||||
** are visible only when the session holds the type's
|
||||
** visibility permission (if any) and can read its subject table (if any).
|
||||
** Subscriptions, settings and inbox items are the user's own (admins hold
|
||||
** Subscriptions, settings and notification recipient rows are the user's own (admins hold
|
||||
** the all-access key).
|
||||
** - Tables the qbit writes (alerts, timeline, records, notifications, inbox
|
||||
** items, heartbeats) have no insert or delete capability; people change
|
||||
** - Tables the qbit writes (alerts, timeline, records, the delivery log,
|
||||
** heartbeats) have no insert or delete capability; people change
|
||||
** alerts through record actions, never by editing rows.
|
||||
*******************************************************************************/
|
||||
public final class AlertsTableMetaDataHelper
|
||||
{
|
||||
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_NOTIFICATION = "alerts_alert_join_alerts_notification";
|
||||
public static final String JOIN_ALERT_INBOX_ITEM = "alerts_alert_join_alerts_inbox_item";
|
||||
public static final String JOIN_ALERT_DELIVERY = "alerts_alert_join_alerts_delivery";
|
||||
public static final String JOIN_TYPE_ALERT = "alerts_type_join_alerts_alert";
|
||||
public static final String JOIN_TYPE_MONITOR = "alerts_type_join_alerts_monitor";
|
||||
public static final String JOIN_GROUP_TYPE = "alerts_group_join_alerts_type";
|
||||
public static final String JOIN_MONITOR_ALERT = "alerts_monitor_join_alerts_alert";
|
||||
public static final String JOIN_TEAM_MEMBER = "alerts_team_join_alerts_team_member";
|
||||
public static final String JOIN_CHANNEL_NOTIFICATION = "alerts_channel_join_alerts_notification";
|
||||
public static final String JOIN_NOTIFICATION_RECIPIENT = "alerts_notification_join_alerts_notification_recipient";
|
||||
public static final String JOIN_CHANNEL_DELIVERY = "alerts_channel_join_alerts_delivery";
|
||||
|
||||
|
||||
|
||||
@@ -125,7 +132,7 @@ public final class AlertsTableMetaDataHelper
|
||||
"snoozedUntil", "muted", "resolvedAt", "resolvedByUserId", "resolution", "resolutionNote")))
|
||||
.withSection(widgetSection(JOIN_ALERT_EVENT, "Timeline", "history"))
|
||||
.withSection(widgetSection(JOIN_ALERT_RECORD_REF, "Records", "link"))
|
||||
.withSection(widgetSection(JOIN_ALERT_NOTIFICATION, "Notifications", "send"))
|
||||
.withSection(widgetSection(JOIN_ALERT_DELIVERY, "Notifications", "send"))
|
||||
.withSection(new QFieldSection("occurrence", "Occurrences", new QIcon("repeat"), Tier.T3, List.of("firstRaisedAt", "lastRaisedAt", "occurrenceCount", "reopenCount", "flapping",
|
||||
"lastNotifiedAt", "monitorId", "dedupKey", "alertTypeName", "visibilityPermission", "openDedupKey", "escalationLevel", "links", "attributes", "createDate", "modifyDate")));
|
||||
return (readOnly(table));
|
||||
@@ -334,7 +341,7 @@ public final class AlertsTableMetaDataHelper
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "channelType", "enabled", "healthStatus")))
|
||||
.withSection(new QFieldSection("settings", "Settings", new QIcon("tune"), Tier.T2, List.of("settings", "secretRef", "detailLevel", "minSeverity", "rateLimitPerHour")))
|
||||
.withSection(new QFieldSection("health", "Health", new QIcon("monitor_heart"), Tier.T2, List.of("lastSuccessAt", "lastFailureAt", "consecutiveFailures", "lastError")))
|
||||
.withSection(widgetSection(JOIN_CHANNEL_NOTIFICATION, "Notifications", "send"))
|
||||
.withSection(widgetSection(JOIN_CHANNEL_DELIVERY, "Notifications", "send"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.getField("settings").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json")));
|
||||
table.getField("enabled").setDefaultValue(true);
|
||||
@@ -368,11 +375,11 @@ public final class AlertsTableMetaDataHelper
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** alerts_notification
|
||||
** alerts_delivery
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotificationTable(AlertsQBitConfig config) throws QException
|
||||
public static QTableMetaData buildDeliveryTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, AlertsNotification.TABLE_NAME, "Notification Log", "send", AlertsNotification.class)
|
||||
QTableMetaData table = base(config, AlertsDelivery.TABLE_NAME, "Delivery Log", "send", AlertsDelivery.class)
|
||||
.withRecordLabelFormat("%s %s")
|
||||
.withRecordLabelFields("kind", "recipientLabel")
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "alertId", "kind", "status", "channelId", "recipientLabel")))
|
||||
@@ -384,19 +391,92 @@ public final class AlertsTableMetaDataHelper
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** alerts_inbox_item
|
||||
** alerts_notification (user notifications, docs/NOTIFICATIONS.md 3.1)
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildInboxItemTable(AlertsQBitConfig config) throws QException
|
||||
public static QTableMetaData buildNotificationTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, AlertsInboxItem.TABLE_NAME, "My Alerts Inbox", "inbox", AlertsInboxItem.class)
|
||||
QTableMetaData table = text(base(config, AlertsNotification.TABLE_NAME, "Notifications", "campaign", AlertsNotification.class), "body", "audienceValue")
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("alertId")
|
||||
.withRecordLabelFields("title")
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "title", "severity", "category", "status")))
|
||||
.withSection(new QFieldSection("message", "Message", new QIcon("article"), Tier.T2, List.of("body", "linkUrl", "linkLabel", "sticky")))
|
||||
.withSection(new QFieldSection("audience", "Audience", new QIcon("groups"), Tier.T2, List.of("audienceType", "audienceValue", "recipientCount", "readCount")))
|
||||
.withSection(new QFieldSection("schedule", "Schedule", new QIcon("schedule"), Tier.T2, List.of("publishAt", "expireAt", "sentAt", "retractedAt")))
|
||||
.withSection(widgetSection(JOIN_NOTIFICATION_RECIPIENT, "Recipients", "person"))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("authorUserId", "sourceName", "sourceKey", "sourceTableName", "sourceRecordId",
|
||||
"createDate", "modifyDate")));
|
||||
table.getField("severity").setDefaultValue(NotificationSeverity.INFO.name());
|
||||
table.getField("category").setDefaultValue(NotificationCategory.SYSTEM.name());
|
||||
table.getField("audienceType").setDefaultValue(NotificationAudience.Type.ALL_USERS.name());
|
||||
table.getField("status").setDefaultValue(NotificationService.STATUS_DRAFT);
|
||||
table.getField("sticky").setDefaultValue(false);
|
||||
table.getField("body").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("markdown")));
|
||||
return (notificationCustomizer(table, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** alerts_notification_recipient (one notification for one user). Users
|
||||
** see and edit (read, dismissed) only their own rows; the qbit inserts
|
||||
** and deletes them.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotificationRecipientTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, AlertsNotificationRecipient.TABLE_NAME, "Notification Recipients", "mark_email_read", AlertsNotificationRecipient.class)
|
||||
.withRecordLabelFormat("%s %s")
|
||||
.withRecordLabelFields("notificationId", "userId")
|
||||
.withUniqueKey(new UniqueKey("notificationId", "userId"))
|
||||
.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("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_DELETE);
|
||||
return (notificationCustomizer(table, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** alerts_notification_preference (a user's settings for one category)
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData buildNotificationPreferenceTable(AlertsQBitConfig config) throws QException
|
||||
{
|
||||
QTableMetaData table = base(config, AlertsNotificationPreference.TABLE_NAME, "My Notification Settings", "tune", AlertsNotificationPreference.class)
|
||||
.withRecordLabelFormat("%s %s")
|
||||
.withRecordLabelFields("userId", "category")
|
||||
.withUniqueKey(new UniqueKey("userId", "category"))
|
||||
.withRecordSecurityLock(AlertsSecurity.ownerLock("userId"))
|
||||
.withRecordSecurityLock(AlertsSecurity.visibilityLock(JOIN_ALERT_INBOX_ITEM))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "alertId", "deliveredAt", "readAt", "archivedAt")))
|
||||
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("userId", "notificationId", "createDate", "modifyDate")));
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT);
|
||||
return (guard(table));
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "emailMode", "emailMinSeverity")))
|
||||
.withSection(new QFieldSection("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());
|
||||
return (notificationCustomizer(table, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The notification tables' customizer (insert, update and, optionally,
|
||||
** delete).
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData notificationCustomizer(QTableMetaData table, boolean onDelete)
|
||||
{
|
||||
QCodeReference reference = new QCodeReference(AlertsNotificationCustomizer.class);
|
||||
List<TableCustomizers> roles = new ArrayList<>(List.of(TableCustomizers.PRE_INSERT_RECORD, TableCustomizers.PRE_UPDATE_RECORD));
|
||||
if(onDelete)
|
||||
{
|
||||
roles.add(TableCustomizers.PRE_DELETE_RECORD);
|
||||
}
|
||||
for(TableCustomizers role : roles)
|
||||
{
|
||||
table.withCustomizer(role, reference);
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
@@ -424,7 +504,8 @@ public final class AlertsTableMetaDataHelper
|
||||
{
|
||||
return (List.of(buildAlertTable(config), buildMonitorTable(config), buildTypeTable(config), buildGroupTable(config), buildSubscriptionTable(config),
|
||||
buildUserPreferenceTable(config), buildTeamTable(config), buildTeamMemberTable(config), buildChannelTable(config), buildMuteTable(config),
|
||||
buildNotificationTable(config), buildEventTable(config), buildRecordRefTable(config), buildInboxItemTable(config), buildHeartbeatTable(config)));
|
||||
buildDeliveryTable(config), buildEventTable(config), buildRecordRefTable(config), buildHeartbeatTable(config), buildNotificationTable(config),
|
||||
buildNotificationRecipientTable(config), buildNotificationPreferenceTable(config)));
|
||||
}
|
||||
|
||||
|
||||
@@ -436,7 +517,7 @@ public final class AlertsTableMetaDataHelper
|
||||
{
|
||||
List<QPossibleValueSource> sources = new ArrayList<>(AlertsPossibleValues.buildEnumSources());
|
||||
for(String table : List.of(AlertsAlert.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME,
|
||||
AlertsChannel.TABLE_NAME, AlertsSubscription.TABLE_NAME))
|
||||
AlertsChannel.TABLE_NAME, AlertsSubscription.TABLE_NAME, AlertsNotification.TABLE_NAME))
|
||||
{
|
||||
sources.add(QPossibleValueSource.newForTable(table));
|
||||
}
|
||||
@@ -453,14 +534,14 @@ public final class AlertsTableMetaDataHelper
|
||||
List<QJoinMetaData> joins = new ArrayList<>();
|
||||
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_NOTIFICATION, AlertsAlert.TABLE_NAME, AlertsNotification.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_INBOX_ITEM, AlertsAlert.TABLE_NAME, AlertsInboxItem.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_ALERT_DELIVERY, AlertsAlert.TABLE_NAME, AlertsDelivery.TABLE_NAME, "alertId"));
|
||||
joins.add(oneToMany(JOIN_TYPE_ALERT, AlertsType.TABLE_NAME, AlertsAlert.TABLE_NAME, "alertTypeId"));
|
||||
joins.add(oneToMany(JOIN_TYPE_MONITOR, AlertsType.TABLE_NAME, AlertsMonitor.TABLE_NAME, "alertTypeId"));
|
||||
joins.add(oneToMany(JOIN_GROUP_TYPE, AlertsGroup.TABLE_NAME, AlertsType.TABLE_NAME, "groupId"));
|
||||
joins.add(oneToMany(JOIN_MONITOR_ALERT, AlertsMonitor.TABLE_NAME, AlertsAlert.TABLE_NAME, "monitorId"));
|
||||
joins.add(oneToMany(JOIN_TEAM_MEMBER, AlertsTeam.TABLE_NAME, AlertsTeamMember.TABLE_NAME, "teamId"));
|
||||
joins.add(oneToMany(JOIN_CHANNEL_NOTIFICATION, AlertsChannel.TABLE_NAME, AlertsNotification.TABLE_NAME, "channelId"));
|
||||
joins.add(oneToMany(JOIN_CHANNEL_DELIVERY, AlertsChannel.TABLE_NAME, AlertsDelivery.TABLE_NAME, "channelId"));
|
||||
joins.add(oneToMany(JOIN_NOTIFICATION_RECIPIENT, AlertsNotification.TABLE_NAME, AlertsNotificationRecipient.TABLE_NAME, "notificationId"));
|
||||
return (joins);
|
||||
}
|
||||
|
||||
@@ -481,14 +562,15 @@ public final class AlertsTableMetaDataHelper
|
||||
List<QWidgetMetaDataInterface> widgets = new ArrayList<>();
|
||||
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_NOTIFICATION), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
|
||||
widgets.add(childList(joins.get(JOIN_ALERT_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
|
||||
widgets.add(childList(joins.get(JOIN_TYPE_ALERT), "Alerts", 10, new QFilterOrderBy("lastRaisedAt", false), List.of("title", "severity", "status", "lastRaisedAt")));
|
||||
widgets.add(childList(joins.get(JOIN_TYPE_MONITOR), "Monitors", 10, new QFilterOrderBy("id", true), List.of("name", "kind", "enabled", "lastStatus")));
|
||||
widgets.add(childList(joins.get(JOIN_GROUP_TYPE), "Alert Types", 50, new QFilterOrderBy("label", true), List.of("label", "defaultSeverity", "enabled", "openCount")));
|
||||
widgets.add(childList(joins.get(JOIN_MONITOR_ALERT), "Alerts", 10, new QFilterOrderBy("lastRaisedAt", false), List.of("title", "severity", "status", "lastRaisedAt")));
|
||||
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_NOTIFICATION), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
|
||||
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")));
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
/*
|
||||
* 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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One delivery attempt: who, which channel, status, retries and error.
|
||||
** Suppressed deliveries are logged too, with their reason. Written by the
|
||||
** qbit; read-only.
|
||||
**
|
||||
** Generated from the qbit-alerts schema (one definition for the entity,
|
||||
** the Liquibase changelog and the test DDL).
|
||||
*******************************************************************************/
|
||||
public class AlertsDelivery extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_delivery";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = "alerts_alert", label = "Alert")
|
||||
private Integer alertId;
|
||||
|
||||
@QField(isEditable = false, label = "Alert Event")
|
||||
private Integer alertEventId;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = "alerts_subscription", label = "Subscription")
|
||||
private Integer subscriptionId;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = "alerts_channel", label = "Channel")
|
||||
private Integer channelId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Recipient")
|
||||
private String recipientUserId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Sent To")
|
||||
private String recipientLabel;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_delivery_kind")
|
||||
private String kind;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_delivery_status")
|
||||
private String status;
|
||||
|
||||
@QField(isEditable = false, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_suppressed_reason")
|
||||
private String suppressedReason;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer attempts;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant nextAttemptAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant claimedUntil;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant sentAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String providerMessageId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 2000, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String lastError;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery(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 AlertsDelivery withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertId
|
||||
*******************************************************************************/
|
||||
public Integer getAlertId()
|
||||
{
|
||||
return (this.alertId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for alertId
|
||||
*******************************************************************************/
|
||||
public void setAlertId(Integer alertId)
|
||||
{
|
||||
this.alertId = alertId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertId
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withAlertId(Integer alertId)
|
||||
{
|
||||
this.alertId = alertId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertEventId
|
||||
*******************************************************************************/
|
||||
public Integer getAlertEventId()
|
||||
{
|
||||
return (this.alertEventId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for alertEventId
|
||||
*******************************************************************************/
|
||||
public void setAlertEventId(Integer alertEventId)
|
||||
{
|
||||
this.alertEventId = alertEventId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertEventId
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withAlertEventId(Integer alertEventId)
|
||||
{
|
||||
this.alertEventId = alertEventId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subscriptionId
|
||||
*******************************************************************************/
|
||||
public Integer getSubscriptionId()
|
||||
{
|
||||
return (this.subscriptionId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for subscriptionId
|
||||
*******************************************************************************/
|
||||
public void setSubscriptionId(Integer subscriptionId)
|
||||
{
|
||||
this.subscriptionId = subscriptionId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subscriptionId
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withSubscriptionId(Integer subscriptionId)
|
||||
{
|
||||
this.subscriptionId = subscriptionId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for channelId
|
||||
*******************************************************************************/
|
||||
public Integer getChannelId()
|
||||
{
|
||||
return (this.channelId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for channelId
|
||||
*******************************************************************************/
|
||||
public void setChannelId(Integer channelId)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for channelId
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withChannelId(Integer channelId)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recipientUserId
|
||||
*******************************************************************************/
|
||||
public String getRecipientUserId()
|
||||
{
|
||||
return (this.recipientUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recipientUserId
|
||||
*******************************************************************************/
|
||||
public void setRecipientUserId(String recipientUserId)
|
||||
{
|
||||
this.recipientUserId = recipientUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recipientUserId
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withRecipientUserId(String recipientUserId)
|
||||
{
|
||||
this.recipientUserId = recipientUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recipientLabel
|
||||
*******************************************************************************/
|
||||
public String getRecipientLabel()
|
||||
{
|
||||
return (this.recipientLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recipientLabel
|
||||
*******************************************************************************/
|
||||
public void setRecipientLabel(String recipientLabel)
|
||||
{
|
||||
this.recipientLabel = recipientLabel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recipientLabel
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withRecipientLabel(String recipientLabel)
|
||||
{
|
||||
this.recipientLabel = recipientLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kind
|
||||
*******************************************************************************/
|
||||
public String getKind()
|
||||
{
|
||||
return (this.kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for kind
|
||||
*******************************************************************************/
|
||||
public void setKind(String kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for kind
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withKind(String kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (this.status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for suppressedReason
|
||||
*******************************************************************************/
|
||||
public String getSuppressedReason()
|
||||
{
|
||||
return (this.suppressedReason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for suppressedReason
|
||||
*******************************************************************************/
|
||||
public void setSuppressedReason(String suppressedReason)
|
||||
{
|
||||
this.suppressedReason = suppressedReason;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for suppressedReason
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withSuppressedReason(String suppressedReason)
|
||||
{
|
||||
this.suppressedReason = suppressedReason;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for attempts
|
||||
*******************************************************************************/
|
||||
public Integer getAttempts()
|
||||
{
|
||||
return (this.attempts);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for attempts
|
||||
*******************************************************************************/
|
||||
public void setAttempts(Integer attempts)
|
||||
{
|
||||
this.attempts = attempts;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for attempts
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withAttempts(Integer attempts)
|
||||
{
|
||||
this.attempts = attempts;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for nextAttemptAt
|
||||
*******************************************************************************/
|
||||
public Instant getNextAttemptAt()
|
||||
{
|
||||
return (this.nextAttemptAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for nextAttemptAt
|
||||
*******************************************************************************/
|
||||
public void setNextAttemptAt(Instant nextAttemptAt)
|
||||
{
|
||||
this.nextAttemptAt = nextAttemptAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for nextAttemptAt
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withNextAttemptAt(Instant nextAttemptAt)
|
||||
{
|
||||
this.nextAttemptAt = nextAttemptAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for claimedUntil
|
||||
*******************************************************************************/
|
||||
public Instant getClaimedUntil()
|
||||
{
|
||||
return (this.claimedUntil);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for claimedUntil
|
||||
*******************************************************************************/
|
||||
public void setClaimedUntil(Instant claimedUntil)
|
||||
{
|
||||
this.claimedUntil = claimedUntil;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for claimedUntil
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withClaimedUntil(Instant claimedUntil)
|
||||
{
|
||||
this.claimedUntil = claimedUntil;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sentAt
|
||||
*******************************************************************************/
|
||||
public Instant getSentAt()
|
||||
{
|
||||
return (this.sentAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sentAt
|
||||
*******************************************************************************/
|
||||
public void setSentAt(Instant sentAt)
|
||||
{
|
||||
this.sentAt = sentAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sentAt
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withSentAt(Instant sentAt)
|
||||
{
|
||||
this.sentAt = sentAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerMessageId
|
||||
*******************************************************************************/
|
||||
public String getProviderMessageId()
|
||||
{
|
||||
return (this.providerMessageId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for providerMessageId
|
||||
*******************************************************************************/
|
||||
public void setProviderMessageId(String providerMessageId)
|
||||
{
|
||||
this.providerMessageId = providerMessageId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerMessageId
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withProviderMessageId(String providerMessageId)
|
||||
{
|
||||
this.providerMessageId = providerMessageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastError
|
||||
*******************************************************************************/
|
||||
public String getLastError()
|
||||
{
|
||||
return (this.lastError);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastError
|
||||
*******************************************************************************/
|
||||
public void setLastError(String lastError)
|
||||
{
|
||||
this.lastError = lastError;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastError
|
||||
*******************************************************************************/
|
||||
public AlertsDelivery withLastError(String lastError)
|
||||
{
|
||||
this.lastError = lastError;
|
||||
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 AlertsDelivery 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 AlertsDelivery withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One delivery attempt: who, which channel, status, retries and error.
|
||||
** Suppressed deliveries are logged too, with their reason. Written by the
|
||||
** qbit; read-only.
|
||||
** A message for people: an announcement an admin writes, or one a producer emits (a failed job, a share, a security notice, a release). One recipient row per user it reaches.
|
||||
**
|
||||
** Generated from the qbit-alerts schema (one definition for the entity,
|
||||
** the Liquibase changelog and the test DDL).
|
||||
@@ -39,50 +37,68 @@ public class AlertsNotification extends QRecordEntity
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = "alerts_alert", label = "Alert")
|
||||
private Integer alertId;
|
||||
@QField(isRequired = true, maxLength = 200, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String title;
|
||||
|
||||
@QField(isEditable = false, label = "Alert Event")
|
||||
private Integer alertEventId;
|
||||
@QField(label = "Message")
|
||||
private String body;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = "alerts_subscription", label = "Subscription")
|
||||
private Integer subscriptionId;
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_notification_severity")
|
||||
private String severity;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = "alerts_channel", label = "Channel")
|
||||
private Integer channelId;
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_notification_category")
|
||||
private String category;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Recipient")
|
||||
private String recipientUserId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Sent To")
|
||||
private String recipientLabel;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_notification_kind")
|
||||
private String kind;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_notification_status")
|
||||
@QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_notification_status")
|
||||
private String status;
|
||||
|
||||
@QField(isEditable = false, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_suppressed_reason")
|
||||
private String suppressedReason;
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Source")
|
||||
private String sourceName;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer attempts;
|
||||
@QField(maxLength = 200, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String sourceKey;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant nextAttemptAt;
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Source Table")
|
||||
private String sourceTableName;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant claimedUntil;
|
||||
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Source Record")
|
||||
private String sourceRecordId;
|
||||
|
||||
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR, label = "Link")
|
||||
private String linkUrl;
|
||||
|
||||
@QField(maxLength = 200, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String linkLabel;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_audience_type", label = "Audience")
|
||||
private String audienceType;
|
||||
|
||||
@QField(label = "Audience Members")
|
||||
private String audienceValue;
|
||||
|
||||
@QField()
|
||||
private Instant publishAt;
|
||||
|
||||
@QField()
|
||||
private Instant expireAt;
|
||||
|
||||
@QField(label = "Sticky Banner")
|
||||
private Boolean sticky;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Author")
|
||||
private String authorUserId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant sentAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String providerMessageId;
|
||||
@QField(isEditable = false)
|
||||
private Instant retractedAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 2000, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String lastError;
|
||||
@QField(isEditable = false, label = "Recipients")
|
||||
private Integer recipientCount;
|
||||
|
||||
@QField(isEditable = false, label = "Read")
|
||||
private Integer readCount;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
@@ -100,7 +116,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
@@ -110,7 +125,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
@@ -120,7 +134,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
@@ -130,7 +143,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
@@ -141,224 +153,118 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertId
|
||||
** Getter for title
|
||||
*******************************************************************************/
|
||||
public Integer getAlertId()
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.alertId);
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for alertId
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setAlertId(Integer alertId)
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.alertId = alertId;
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertId
|
||||
** Fluent setter for title
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withAlertId(Integer alertId)
|
||||
public AlertsNotification withTitle(String title)
|
||||
{
|
||||
this.alertId = alertId;
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertEventId
|
||||
** Getter for body
|
||||
*******************************************************************************/
|
||||
public Integer getAlertEventId()
|
||||
public String getBody()
|
||||
{
|
||||
return (this.alertEventId);
|
||||
return (this.body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for alertEventId
|
||||
** Setter for body
|
||||
*******************************************************************************/
|
||||
public void setAlertEventId(Integer alertEventId)
|
||||
public void setBody(String body)
|
||||
{
|
||||
this.alertEventId = alertEventId;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertEventId
|
||||
** Fluent setter for body
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withAlertEventId(Integer alertEventId)
|
||||
public AlertsNotification withBody(String body)
|
||||
{
|
||||
this.alertEventId = alertEventId;
|
||||
this.body = body;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subscriptionId
|
||||
** Getter for severity
|
||||
*******************************************************************************/
|
||||
public Integer getSubscriptionId()
|
||||
public String getSeverity()
|
||||
{
|
||||
return (this.subscriptionId);
|
||||
return (this.severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for subscriptionId
|
||||
** Setter for severity
|
||||
*******************************************************************************/
|
||||
public void setSubscriptionId(Integer subscriptionId)
|
||||
public void setSeverity(String severity)
|
||||
{
|
||||
this.subscriptionId = subscriptionId;
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subscriptionId
|
||||
** Fluent setter for severity
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withSubscriptionId(Integer subscriptionId)
|
||||
public AlertsNotification withSeverity(String severity)
|
||||
{
|
||||
this.subscriptionId = subscriptionId;
|
||||
this.severity = severity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for channelId
|
||||
** Getter for category
|
||||
*******************************************************************************/
|
||||
public Integer getChannelId()
|
||||
public String getCategory()
|
||||
{
|
||||
return (this.channelId);
|
||||
return (this.category);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for channelId
|
||||
** Setter for category
|
||||
*******************************************************************************/
|
||||
public void setChannelId(Integer channelId)
|
||||
public void setCategory(String category)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for channelId
|
||||
** Fluent setter for category
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withChannelId(Integer channelId)
|
||||
public AlertsNotification withCategory(String category)
|
||||
{
|
||||
this.channelId = channelId;
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recipientUserId
|
||||
*******************************************************************************/
|
||||
public String getRecipientUserId()
|
||||
{
|
||||
return (this.recipientUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recipientUserId
|
||||
*******************************************************************************/
|
||||
public void setRecipientUserId(String recipientUserId)
|
||||
{
|
||||
this.recipientUserId = recipientUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recipientUserId
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withRecipientUserId(String recipientUserId)
|
||||
{
|
||||
this.recipientUserId = recipientUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recipientLabel
|
||||
*******************************************************************************/
|
||||
public String getRecipientLabel()
|
||||
{
|
||||
return (this.recipientLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recipientLabel
|
||||
*******************************************************************************/
|
||||
public void setRecipientLabel(String recipientLabel)
|
||||
{
|
||||
this.recipientLabel = recipientLabel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for recipientLabel
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withRecipientLabel(String recipientLabel)
|
||||
{
|
||||
this.recipientLabel = recipientLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kind
|
||||
*******************************************************************************/
|
||||
public String getKind()
|
||||
{
|
||||
return (this.kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for kind
|
||||
*******************************************************************************/
|
||||
public void setKind(String kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for kind
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withKind(String kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
@@ -368,7 +274,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
@@ -378,7 +283,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status
|
||||
*******************************************************************************/
|
||||
@@ -389,130 +293,341 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for suppressedReason
|
||||
** Getter for sourceName
|
||||
*******************************************************************************/
|
||||
public String getSuppressedReason()
|
||||
public String getSourceName()
|
||||
{
|
||||
return (this.suppressedReason);
|
||||
return (this.sourceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for suppressedReason
|
||||
** Setter for sourceName
|
||||
*******************************************************************************/
|
||||
public void setSuppressedReason(String suppressedReason)
|
||||
public void setSourceName(String sourceName)
|
||||
{
|
||||
this.suppressedReason = suppressedReason;
|
||||
this.sourceName = sourceName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for suppressedReason
|
||||
** Fluent setter for sourceName
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withSuppressedReason(String suppressedReason)
|
||||
public AlertsNotification withSourceName(String sourceName)
|
||||
{
|
||||
this.suppressedReason = suppressedReason;
|
||||
this.sourceName = sourceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for attempts
|
||||
** Getter for sourceKey
|
||||
*******************************************************************************/
|
||||
public Integer getAttempts()
|
||||
public String getSourceKey()
|
||||
{
|
||||
return (this.attempts);
|
||||
return (this.sourceKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for attempts
|
||||
** Setter for sourceKey
|
||||
*******************************************************************************/
|
||||
public void setAttempts(Integer attempts)
|
||||
public void setSourceKey(String sourceKey)
|
||||
{
|
||||
this.attempts = attempts;
|
||||
this.sourceKey = sourceKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for attempts
|
||||
** Fluent setter for sourceKey
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withAttempts(Integer attempts)
|
||||
public AlertsNotification withSourceKey(String sourceKey)
|
||||
{
|
||||
this.attempts = attempts;
|
||||
this.sourceKey = sourceKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for nextAttemptAt
|
||||
** Getter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public Instant getNextAttemptAt()
|
||||
public String getSourceTableName()
|
||||
{
|
||||
return (this.nextAttemptAt);
|
||||
return (this.sourceTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for nextAttemptAt
|
||||
** Setter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public void setNextAttemptAt(Instant nextAttemptAt)
|
||||
public void setSourceTableName(String sourceTableName)
|
||||
{
|
||||
this.nextAttemptAt = nextAttemptAt;
|
||||
this.sourceTableName = sourceTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for nextAttemptAt
|
||||
** Fluent setter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withNextAttemptAt(Instant nextAttemptAt)
|
||||
public AlertsNotification withSourceTableName(String sourceTableName)
|
||||
{
|
||||
this.nextAttemptAt = nextAttemptAt;
|
||||
this.sourceTableName = sourceTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for claimedUntil
|
||||
** Getter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public Instant getClaimedUntil()
|
||||
public String getSourceRecordId()
|
||||
{
|
||||
return (this.claimedUntil);
|
||||
return (this.sourceRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for claimedUntil
|
||||
** Setter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public void setClaimedUntil(Instant claimedUntil)
|
||||
public void setSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.claimedUntil = claimedUntil;
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for claimedUntil
|
||||
** Fluent setter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withClaimedUntil(Instant claimedUntil)
|
||||
public AlertsNotification withSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.claimedUntil = claimedUntil;
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for linkUrl
|
||||
*******************************************************************************/
|
||||
public String getLinkUrl()
|
||||
{
|
||||
return (this.linkUrl);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for linkUrl
|
||||
*******************************************************************************/
|
||||
public void setLinkUrl(String linkUrl)
|
||||
{
|
||||
this.linkUrl = linkUrl;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for linkUrl
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withLinkUrl(String linkUrl)
|
||||
{
|
||||
this.linkUrl = linkUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for linkLabel
|
||||
*******************************************************************************/
|
||||
public String getLinkLabel()
|
||||
{
|
||||
return (this.linkLabel);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for linkLabel
|
||||
*******************************************************************************/
|
||||
public void setLinkLabel(String linkLabel)
|
||||
{
|
||||
this.linkLabel = linkLabel;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for linkLabel
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withLinkLabel(String linkLabel)
|
||||
{
|
||||
this.linkLabel = linkLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for audienceType
|
||||
*******************************************************************************/
|
||||
public String getAudienceType()
|
||||
{
|
||||
return (this.audienceType);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for audienceType
|
||||
*******************************************************************************/
|
||||
public void setAudienceType(String audienceType)
|
||||
{
|
||||
this.audienceType = audienceType;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for audienceType
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withAudienceType(String audienceType)
|
||||
{
|
||||
this.audienceType = audienceType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for audienceValue
|
||||
*******************************************************************************/
|
||||
public String getAudienceValue()
|
||||
{
|
||||
return (this.audienceValue);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for audienceValue
|
||||
*******************************************************************************/
|
||||
public void setAudienceValue(String audienceValue)
|
||||
{
|
||||
this.audienceValue = audienceValue;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for audienceValue
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withAudienceValue(String audienceValue)
|
||||
{
|
||||
this.audienceValue = audienceValue;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publishAt
|
||||
*******************************************************************************/
|
||||
public Instant getPublishAt()
|
||||
{
|
||||
return (this.publishAt);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for publishAt
|
||||
*******************************************************************************/
|
||||
public void setPublishAt(Instant publishAt)
|
||||
{
|
||||
this.publishAt = publishAt;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publishAt
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withPublishAt(Instant publishAt)
|
||||
{
|
||||
this.publishAt = publishAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for expireAt
|
||||
*******************************************************************************/
|
||||
public Instant getExpireAt()
|
||||
{
|
||||
return (this.expireAt);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for expireAt
|
||||
*******************************************************************************/
|
||||
public void setExpireAt(Instant expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for expireAt
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withExpireAt(Instant expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sticky
|
||||
*******************************************************************************/
|
||||
public Boolean getSticky()
|
||||
{
|
||||
return (this.sticky);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sticky
|
||||
*******************************************************************************/
|
||||
public void setSticky(Boolean sticky)
|
||||
{
|
||||
this.sticky = sticky;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sticky
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withSticky(Boolean sticky)
|
||||
{
|
||||
this.sticky = sticky;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for authorUserId
|
||||
*******************************************************************************/
|
||||
public String getAuthorUserId()
|
||||
{
|
||||
return (this.authorUserId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for authorUserId
|
||||
*******************************************************************************/
|
||||
public void setAuthorUserId(String authorUserId)
|
||||
{
|
||||
this.authorUserId = authorUserId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for authorUserId
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withAuthorUserId(String authorUserId)
|
||||
{
|
||||
this.authorUserId = authorUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sentAt
|
||||
@@ -523,7 +638,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sentAt
|
||||
*******************************************************************************/
|
||||
@@ -533,7 +647,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sentAt
|
||||
*******************************************************************************/
|
||||
@@ -544,68 +657,89 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for providerMessageId
|
||||
** Getter for retractedAt
|
||||
*******************************************************************************/
|
||||
public String getProviderMessageId()
|
||||
public Instant getRetractedAt()
|
||||
{
|
||||
return (this.providerMessageId);
|
||||
return (this.retractedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for providerMessageId
|
||||
** Setter for retractedAt
|
||||
*******************************************************************************/
|
||||
public void setProviderMessageId(String providerMessageId)
|
||||
public void setRetractedAt(Instant retractedAt)
|
||||
{
|
||||
this.providerMessageId = providerMessageId;
|
||||
this.retractedAt = retractedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for providerMessageId
|
||||
** Fluent setter for retractedAt
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withProviderMessageId(String providerMessageId)
|
||||
public AlertsNotification withRetractedAt(Instant retractedAt)
|
||||
{
|
||||
this.providerMessageId = providerMessageId;
|
||||
this.retractedAt = retractedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastError
|
||||
** Getter for recipientCount
|
||||
*******************************************************************************/
|
||||
public String getLastError()
|
||||
public Integer getRecipientCount()
|
||||
{
|
||||
return (this.lastError);
|
||||
return (this.recipientCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastError
|
||||
** Setter for recipientCount
|
||||
*******************************************************************************/
|
||||
public void setLastError(String lastError)
|
||||
public void setRecipientCount(Integer recipientCount)
|
||||
{
|
||||
this.lastError = lastError;
|
||||
this.recipientCount = recipientCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastError
|
||||
** Fluent setter for recipientCount
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withLastError(String lastError)
|
||||
public AlertsNotification withRecipientCount(Integer recipientCount)
|
||||
{
|
||||
this.lastError = lastError;
|
||||
this.recipientCount = recipientCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for readCount
|
||||
*******************************************************************************/
|
||||
public Integer getReadCount()
|
||||
{
|
||||
return (this.readCount);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for readCount
|
||||
*******************************************************************************/
|
||||
public void setReadCount(Integer readCount)
|
||||
{
|
||||
this.readCount = readCount;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for readCount
|
||||
*******************************************************************************/
|
||||
public AlertsNotification withReadCount(Integer readCount)
|
||||
{
|
||||
this.readCount = readCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
@@ -616,7 +750,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
@@ -626,7 +759,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
@@ -637,7 +769,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
@@ -647,7 +778,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
@@ -657,7 +787,6 @@ public class AlertsNotification extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* 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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's settings for one notification category: muted in-app (never for security), and email off, immediate or in a daily digest, from a minimum severity.
|
||||
**
|
||||
** Generated from the qbit-alerts schema (one definition for the entity,
|
||||
** the Liquibase changelog and the test DDL).
|
||||
*******************************************************************************/
|
||||
public class AlertsNotificationPreference extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_notification_preference";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "User")
|
||||
private String userId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_notification_category")
|
||||
private String category;
|
||||
|
||||
@QField(label = "Muted In-App")
|
||||
private Boolean inAppMuted;
|
||||
|
||||
@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(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference(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 AlertsNotificationPreference withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for category
|
||||
*******************************************************************************/
|
||||
public String getCategory()
|
||||
{
|
||||
return (this.category);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for category
|
||||
*******************************************************************************/
|
||||
public void setCategory(String category)
|
||||
{
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for category
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withCategory(String category)
|
||||
{
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for inAppMuted
|
||||
*******************************************************************************/
|
||||
public Boolean getInAppMuted()
|
||||
{
|
||||
return (this.inAppMuted);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for inAppMuted
|
||||
*******************************************************************************/
|
||||
public void setInAppMuted(Boolean inAppMuted)
|
||||
{
|
||||
this.inAppMuted = inAppMuted;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for inAppMuted
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withInAppMuted(Boolean inAppMuted)
|
||||
{
|
||||
this.inAppMuted = inAppMuted;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for emailMode
|
||||
*******************************************************************************/
|
||||
public String getEmailMode()
|
||||
{
|
||||
return (this.emailMode);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for emailMode
|
||||
*******************************************************************************/
|
||||
public void setEmailMode(String emailMode)
|
||||
{
|
||||
this.emailMode = emailMode;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for emailMode
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withEmailMode(String emailMode)
|
||||
{
|
||||
this.emailMode = emailMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for emailMinSeverity
|
||||
*******************************************************************************/
|
||||
public String getEmailMinSeverity()
|
||||
{
|
||||
return (this.emailMinSeverity);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for emailMinSeverity
|
||||
*******************************************************************************/
|
||||
public void setEmailMinSeverity(String emailMinSeverity)
|
||||
{
|
||||
this.emailMinSeverity = emailMinSeverity;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for emailMinSeverity
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationPreference withEmailMinSeverity(String emailMinSeverity)
|
||||
{
|
||||
this.emailMinSeverity = emailMinSeverity;
|
||||
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 AlertsNotificationPreference 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 AlertsNotificationPreference withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+112
-109
@@ -25,27 +25,24 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One alert in one user's bell.
|
||||
** One notification for one user: when it reached them, and whether they read it, dismissed it, or were emailed.
|
||||
**
|
||||
** Generated from the qbit-alerts schema (one definition for the entity,
|
||||
** the Liquibase changelog and the test DDL).
|
||||
*******************************************************************************/
|
||||
public class AlertsInboxItem extends QRecordEntity
|
||||
public class AlertsNotificationRecipient extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "alerts_inbox_item";
|
||||
public static final String TABLE_NAME = "alerts_notification_recipient";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, possibleValueSourceName = "alerts_notification", label = "Notification")
|
||||
private Integer notificationId;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "User")
|
||||
private String userId;
|
||||
|
||||
@QField(isRequired = true, isEditable = false, possibleValueSourceName = "alerts_alert", label = "Alert")
|
||||
private Integer alertId;
|
||||
|
||||
@QField(isEditable = false, label = "Notification")
|
||||
private Integer notificationId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant deliveredAt;
|
||||
|
||||
@@ -53,7 +50,13 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
private Instant readAt;
|
||||
|
||||
@QField()
|
||||
private Instant archivedAt;
|
||||
private Instant dismissedAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant emailedAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_email_status")
|
||||
private String emailStatus;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
@@ -66,22 +69,20 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem()
|
||||
public AlertsNotificationRecipient()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem(QRecord record)
|
||||
public AlertsNotificationRecipient(QRecord record)
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
@@ -91,7 +92,6 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
@@ -101,80 +101,16 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withId(Integer id)
|
||||
public AlertsNotificationRecipient withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertId
|
||||
*******************************************************************************/
|
||||
public Integer getAlertId()
|
||||
{
|
||||
return (this.alertId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for alertId
|
||||
*******************************************************************************/
|
||||
public void setAlertId(Integer alertId)
|
||||
{
|
||||
this.alertId = alertId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertId
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withAlertId(Integer alertId)
|
||||
{
|
||||
this.alertId = alertId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notificationId
|
||||
*******************************************************************************/
|
||||
@@ -184,7 +120,6 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notificationId
|
||||
*******************************************************************************/
|
||||
@@ -194,17 +129,43 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notificationId
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withNotificationId(Integer notificationId)
|
||||
public AlertsNotificationRecipient withNotificationId(Integer notificationId)
|
||||
{
|
||||
this.notificationId = notificationId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationRecipient withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for deliveredAt
|
||||
@@ -215,7 +176,6 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for deliveredAt
|
||||
*******************************************************************************/
|
||||
@@ -225,18 +185,16 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for deliveredAt
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withDeliveredAt(Instant deliveredAt)
|
||||
public AlertsNotificationRecipient withDeliveredAt(Instant deliveredAt)
|
||||
{
|
||||
this.deliveredAt = deliveredAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for readAt
|
||||
*******************************************************************************/
|
||||
@@ -246,7 +204,6 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for readAt
|
||||
*******************************************************************************/
|
||||
@@ -256,48 +213,99 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for readAt
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withReadAt(Instant readAt)
|
||||
public AlertsNotificationRecipient withReadAt(Instant readAt)
|
||||
{
|
||||
this.readAt = readAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for archivedAt
|
||||
** Getter for dismissedAt
|
||||
*******************************************************************************/
|
||||
public Instant getArchivedAt()
|
||||
public Instant getDismissedAt()
|
||||
{
|
||||
return (this.archivedAt);
|
||||
return (this.dismissedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for archivedAt
|
||||
** Setter for dismissedAt
|
||||
*******************************************************************************/
|
||||
public void setArchivedAt(Instant archivedAt)
|
||||
public void setDismissedAt(Instant dismissedAt)
|
||||
{
|
||||
this.archivedAt = archivedAt;
|
||||
this.dismissedAt = dismissedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for archivedAt
|
||||
** Fluent setter for dismissedAt
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withArchivedAt(Instant archivedAt)
|
||||
public AlertsNotificationRecipient withDismissedAt(Instant dismissedAt)
|
||||
{
|
||||
this.archivedAt = archivedAt;
|
||||
this.dismissedAt = dismissedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for emailedAt
|
||||
*******************************************************************************/
|
||||
public Instant getEmailedAt()
|
||||
{
|
||||
return (this.emailedAt);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for emailedAt
|
||||
*******************************************************************************/
|
||||
public void setEmailedAt(Instant emailedAt)
|
||||
{
|
||||
this.emailedAt = emailedAt;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for emailedAt
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationRecipient withEmailedAt(Instant emailedAt)
|
||||
{
|
||||
this.emailedAt = emailedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for emailStatus
|
||||
*******************************************************************************/
|
||||
public String getEmailStatus()
|
||||
{
|
||||
return (this.emailStatus);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for emailStatus
|
||||
*******************************************************************************/
|
||||
public void setEmailStatus(String emailStatus)
|
||||
{
|
||||
this.emailStatus = emailStatus;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for emailStatus
|
||||
*******************************************************************************/
|
||||
public AlertsNotificationRecipient withEmailStatus(String emailStatus)
|
||||
{
|
||||
this.emailStatus = emailStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
@@ -308,7 +316,6 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
@@ -318,18 +325,16 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withCreateDate(Instant createDate)
|
||||
public AlertsNotificationRecipient withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
@@ -339,7 +344,6 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
@@ -349,11 +353,10 @@ public class AlertsInboxItem extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AlertsInboxItem withModifyDate(Instant modifyDate)
|
||||
public AlertsNotificationRecipient withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
@@ -20,10 +20,13 @@ package com.kingsrook.qbits.alerts.model;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
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.ResolveMode;
|
||||
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.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;
|
||||
@@ -51,9 +54,15 @@ public final class AlertsPossibleValues
|
||||
public static final String CHANNEL_TYPE = "alerts_channel_type";
|
||||
public static final String HEALTH_STATUS = "alerts_health_status";
|
||||
public static final String MUTE_EFFECT = "alerts_mute_effect";
|
||||
public static final String NOTIFICATION_KIND = "alerts_notification_kind";
|
||||
public static final String NOTIFICATION_STATUS = "alerts_notification_status";
|
||||
public static final String DELIVERY_KIND = "alerts_delivery_kind";
|
||||
public static final String DELIVERY_STATUS = "alerts_delivery_status";
|
||||
public static final String SUPPRESSED_REASON = "alerts_suppressed_reason";
|
||||
public static final String NOTIFICATION_SEVERITY = "alerts_notification_severity";
|
||||
public static final String NOTIFICATION_CATEGORY = "alerts_notification_category";
|
||||
public static final String NOTIFICATION_STATUS = "alerts_notification_status";
|
||||
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";
|
||||
|
||||
|
||||
|
||||
@@ -192,7 +201,7 @@ public final class AlertsPossibleValues
|
||||
/*******************************************************************************
|
||||
** Why a notification was sent.
|
||||
*******************************************************************************/
|
||||
public enum NotificationKind
|
||||
public enum DeliveryKind
|
||||
{
|
||||
RAISED, ESCALATED, REMINDER, RESOLVED, DIGEST, TEST
|
||||
}
|
||||
@@ -202,7 +211,7 @@ public final class AlertsPossibleValues
|
||||
/*******************************************************************************
|
||||
** A notification's delivery state.
|
||||
*******************************************************************************/
|
||||
public enum NotificationStatus
|
||||
public enum DeliveryStatus
|
||||
{
|
||||
PENDING, SENDING, SENT, RETRYING, FAILED, DEAD, SUPPRESSED
|
||||
}
|
||||
@@ -251,9 +260,45 @@ public final class AlertsPossibleValues
|
||||
enumSource(CHANNEL_TYPE, ChannelType.values()),
|
||||
enumSource(HEALTH_STATUS, HealthStatus.values()),
|
||||
enumSource(MUTE_EFFECT, MuteEffect.values()),
|
||||
enumSource(NOTIFICATION_KIND, NotificationKind.values()),
|
||||
enumSource(DELIVERY_KIND, DeliveryKind.values()),
|
||||
enumSource(DELIVERY_STATUS, DeliveryStatus.values()),
|
||||
enumSource(SUPPRESSED_REASON, SuppressedReason.values()),
|
||||
enumSource(NOTIFICATION_SEVERITY, NotificationSeverity.values()),
|
||||
enumSource(NOTIFICATION_CATEGORY, NotificationCategory.values()),
|
||||
enumSource(NOTIFICATION_STATUS, NotificationStatus.values()),
|
||||
enumSource(SUPPRESSED_REASON, SuppressedReason.values())));
|
||||
enumSource(AUDIENCE_TYPE, NotificationAudience.Type.values()),
|
||||
enumSource(EMAIL_STATUS, EmailStatus.values()),
|
||||
enumSource(EMAIL_MODE, EmailMode.values())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user notification's state.
|
||||
*******************************************************************************/
|
||||
public enum NotificationStatus
|
||||
{
|
||||
DRAFT, SCHEDULED, SENT, RETRACTED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether (and how) a recipient was emailed.
|
||||
*******************************************************************************/
|
||||
public enum EmailStatus
|
||||
{
|
||||
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How a user wants a category by email.
|
||||
*******************************************************************************/
|
||||
public enum EmailMode
|
||||
{
|
||||
OFF, IMMEDIATE, DAILY_DIGEST
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolves a notification audience to user ids at send time
|
||||
** (docs/NOTIFICATIONS.md section 4): ALL_USERS and ROLES from the user
|
||||
** directory, PERMISSION by building each directory user's session, USERS as
|
||||
** given. The result is capped at maxAudienceSize. Call it as the system user.
|
||||
*******************************************************************************/
|
||||
public class AlertsAudienceResolver
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsAudienceResolver.class);
|
||||
|
||||
private final AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The users an audience reached: ids in order, labels where known, and why
|
||||
** some could not be resolved.
|
||||
*******************************************************************************/
|
||||
public record Resolved(List<String> userIds, Map<String, String> labels, List<String> warnings)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertsAudienceResolver(AlertsRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve an audience to users.
|
||||
*******************************************************************************/
|
||||
public Resolved resolve(NotificationAudience audience) throws QException
|
||||
{
|
||||
Map<String, String> users = new LinkedHashMap<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
if(audience == null)
|
||||
{
|
||||
warnings.add("No audience is set.");
|
||||
return (new Resolved(List.of(), Map.of(), warnings));
|
||||
}
|
||||
|
||||
switch(audience.getType())
|
||||
{
|
||||
case USERS ->
|
||||
{
|
||||
Map<String, String> labels = labelsIfKnown();
|
||||
for(String userId : audience.getValues())
|
||||
{
|
||||
users.put(userId, labels.getOrDefault(userId, userId));
|
||||
}
|
||||
if(audience.getValues().isEmpty())
|
||||
{
|
||||
warnings.add("Name at least one user id.");
|
||||
}
|
||||
}
|
||||
case ALL_USERS ->
|
||||
{
|
||||
for(AlertsUserDirectoryInterface.DirectoryUser user : directory().listUsers())
|
||||
{
|
||||
users.put(user.userId(), label(user));
|
||||
}
|
||||
}
|
||||
case ROLES ->
|
||||
{
|
||||
Set<String> wanted = audience.getValues().stream().map(role -> role.toLowerCase(Locale.ROOT)).collect(Collectors.toSet());
|
||||
boolean anyRoles = false;
|
||||
for(AlertsUserDirectoryInterface.DirectoryUser user : directory().listUsers())
|
||||
{
|
||||
Set<String> roles = user.roles() == null ? Set.of() : user.roles();
|
||||
anyRoles |= !roles.isEmpty();
|
||||
if(roles.stream().anyMatch(role -> wanted.contains(role.toLowerCase(Locale.ROOT))))
|
||||
{
|
||||
users.put(user.userId(), label(user));
|
||||
}
|
||||
}
|
||||
if(!anyRoles)
|
||||
{
|
||||
warnings.add("The user directory lists no roles, so a roles audience reaches nobody. Configure a user directory (AlertsQBitConfig.withUserDirectory).");
|
||||
}
|
||||
}
|
||||
case PERMISSION ->
|
||||
{
|
||||
String permission = audience.getValues().isEmpty() ? null : audience.getValues().get(0);
|
||||
boolean anySession = false;
|
||||
for(AlertsUserDirectoryInterface.DirectoryUser user : permission == null ? List.<AlertsUserDirectoryInterface.DirectoryUser>of() : directory().listUsers())
|
||||
{
|
||||
QSession session = automatedSession(user.userId());
|
||||
anySession |= session != null;
|
||||
if(session != null && session.hasPermission(permission))
|
||||
{
|
||||
users.put(user.userId(), label(user));
|
||||
}
|
||||
}
|
||||
if(permission == null)
|
||||
{
|
||||
warnings.add("Name the permission.");
|
||||
}
|
||||
else if(!anySession)
|
||||
{
|
||||
warnings.add("The authentication module could not build sessions for users, so their permissions are unknown and a permission audience reaches nobody.");
|
||||
}
|
||||
}
|
||||
default -> warnings.add("Unknown audience.");
|
||||
}
|
||||
|
||||
List<String> ids = new ArrayList<>(users.keySet());
|
||||
int max = runtime.getConfig().getMaxAudienceSize();
|
||||
if(ids.size() > max)
|
||||
{
|
||||
warnings.add("The audience has " + ids.size() + " users; only the first " + max + " (maxAudienceSize) are reached.");
|
||||
ids = new ArrayList<>(ids.subList(0, max));
|
||||
}
|
||||
Map<String, String> labels = new LinkedHashMap<>();
|
||||
for(String id : ids)
|
||||
{
|
||||
labels.put(id, users.get(id));
|
||||
}
|
||||
return (new Resolved(ids, labels, warnings));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured directory, or the known-users default.
|
||||
*******************************************************************************/
|
||||
public AlertsUserDirectoryInterface directory()
|
||||
{
|
||||
QCodeReference reference = runtime.getConfig().getUserDirectory();
|
||||
if(reference != null)
|
||||
{
|
||||
AlertsUserDirectoryInterface directory = QCodeLoader.getAdHoc(AlertsUserDirectoryInterface.class, reference);
|
||||
if(directory != null)
|
||||
{
|
||||
return (directory);
|
||||
}
|
||||
}
|
||||
return (new KnownUsersDirectory());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Labels for the directory's users (empty when it can't be read).
|
||||
*******************************************************************************/
|
||||
private Map<String, String> labelsIfKnown()
|
||||
{
|
||||
Map<String, String> labels = new LinkedHashMap<>();
|
||||
try
|
||||
{
|
||||
for(AlertsUserDirectoryInterface.DirectoryUser user : directory().listUsers())
|
||||
{
|
||||
labels.put(user.userId(), label(user));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Could not read the user directory for labels", e);
|
||||
}
|
||||
return (labels);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String label(AlertsUserDirectoryInterface.DirectoryUser user)
|
||||
{
|
||||
return (user.label() == null || user.label().isBlank() ? user.userId() : user.label());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A session for a user from the instance's authentication module (null
|
||||
** when it can't make one).
|
||||
*******************************************************************************/
|
||||
private QSession automatedSession(String userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
QInstance qInstance = runtime.getQInstance();
|
||||
if(qInstance.getAuthentication() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
|
||||
return (module.createAutomatedSessionForUser(qInstance, userId));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.debug("Could not build a session for a user", e, logPair("userId", userId));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.notifications;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
**
|
||||
** 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 DateTimeFormatter DATE = DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Helpers only.
|
||||
*******************************************************************************/
|
||||
private AlertsNotices()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A sign-in from a new device or place (where: e.g. "Firefox on Windows,
|
||||
** near Columbus, OH").
|
||||
*******************************************************************************/
|
||||
public static void newSignIn(String userId, String where)
|
||||
{
|
||||
send(new NotificationSignal("New sign-in to your account")
|
||||
.withBody("A sign-in from " + where + ". If this wasn't you, change your password now.")
|
||||
.withSeverity(NotificationSeverity.WARNING), userId, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An API key was created for the user.
|
||||
*******************************************************************************/
|
||||
public static void apiKeyCreated(String userId, String keyLabel)
|
||||
{
|
||||
send(new NotificationSignal("API key created: " + keyLabel)
|
||||
.withBody("A new API key was created on your account. If you didn't create it, revoke it now.")
|
||||
.withSeverity(NotificationSeverity.INFO), userId, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An API key expires soon (one notification per key, refreshed on repeat).
|
||||
*******************************************************************************/
|
||||
public static void apiKeyExpiring(String userId, String keyLabel, Instant expiresAt)
|
||||
{
|
||||
send(new NotificationSignal("API key expires " + DATE.format(expiresAt) + ": " + keyLabel)
|
||||
.withBody("Create a new key and update the clients that use this one before it expires.")
|
||||
.withSeverity(NotificationSeverity.WARNING), userId, "apikey-expiring:" + keyLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user's permissions or roles changed (what: e.g. "You were added to
|
||||
** the Editors role").
|
||||
*******************************************************************************/
|
||||
public static void permissionChanged(String userId, String what)
|
||||
{
|
||||
send(new NotificationSignal("Your access changed")
|
||||
.withBody(what + ". If you didn't expect this, contact an administrator.")
|
||||
.withSeverity(NotificationSeverity.INFO), userId, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Something was shared with or assigned to a user (a notebook, a record
|
||||
** lock, an approval waiting for them).
|
||||
*******************************************************************************/
|
||||
public static void shared(String userId, String title, AlertLink link, String sourceName)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A release announcement to every user ("Version 2.4 is out").
|
||||
*******************************************************************************/
|
||||
public static void release(String version, String notesMarkdown, AlertLink link)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS)
|
||||
.withCategory(NotificationCategory.RELEASE).withSourceName("release").withSourceKey("release:" + version).withLink(link)
|
||||
.withAudience(NotificationAudience.allUsers()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void send(NotificationSignal signal, String userId, String sourceKey)
|
||||
{
|
||||
if(userId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QAlerts.notify(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The host's list of people, for notification audiences (docs/NOTIFICATIONS.md
|
||||
** section 4). Set it with AlertsQBitConfig.withUserDirectory. Without one,
|
||||
** the directory is everyone who has used Alerts (KnownUsersDirectory).
|
||||
*******************************************************************************/
|
||||
public interface AlertsUserDirectoryInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Every active user, with their roles or groups (for ROLES audiences).
|
||||
*******************************************************************************/
|
||||
List<DirectoryUser> listUsers() throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One person: their QQQ user id (the session's user id reference), a
|
||||
** display label, an email address (may be null), and their roles or groups.
|
||||
*******************************************************************************/
|
||||
record DirectoryUser(String userId, String label, String emailAddress, Set<String> roles)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.notifications;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default user directory: everyone who has used Alerts, that is, who has
|
||||
** a row in My Alert Settings (created the first time a user opens the bell,
|
||||
** the Alerts home or a record panel). It knows no roles. Called as the
|
||||
** system user.
|
||||
*******************************************************************************/
|
||||
public class KnownUsersDirectory implements AlertsUserDirectoryInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<DirectoryUser> listUsers() throws QException
|
||||
{
|
||||
List<DirectoryUser> users = new ArrayList<>();
|
||||
for(QRecord preference : AlertsDb.query(AlertsUserPreference.TABLE_NAME, new QQueryFilter()))
|
||||
{
|
||||
String userId = preference.getValueString("userId");
|
||||
if(userId != null)
|
||||
{
|
||||
users.add(new DirectoryUser(userId, preference.getValueString("userLabel"), preference.getValueString("emailAddress"), Set.of()));
|
||||
}
|
||||
}
|
||||
return (users);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
/*
|
||||
* 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.io.Serializable;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.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.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.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.MultiParty;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Party;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailContentRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.email.EmailPartyRole;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Email for user notifications (docs/NOTIFICATIONS.md section 7), opt-in per
|
||||
** user and category in My Notification Settings:
|
||||
** - 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.
|
||||
** 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
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
public class NotificationEmailer
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(NotificationEmailer.class);
|
||||
|
||||
static final int SCAN = 500;
|
||||
|
||||
private final AlertsRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What one run did.
|
||||
*******************************************************************************/
|
||||
public record Result(int sent, int digests, int skipped, int failed)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public NotificationEmailer(AlertsRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether email can be sent at all.
|
||||
*******************************************************************************/
|
||||
public boolean isConfigured()
|
||||
{
|
||||
if(AlertsEmail.isActive())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
AlertsQBitConfig config = runtime.getConfig();
|
||||
return (StringUtils.hasContent(config.getMessagingProviderName()) && StringUtils.hasContent(config.getFromAddress()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send what is due: immediate emails, and digests for users at their hour.
|
||||
*******************************************************************************/
|
||||
public Result run() throws QException
|
||||
{
|
||||
if(!isConfigured())
|
||||
{
|
||||
return (new Result(0, 0, 0, 0));
|
||||
}
|
||||
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("emailMode", 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));
|
||||
}
|
||||
|
||||
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<>();
|
||||
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"))
|
||||
|| !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))
|
||||
{
|
||||
mark(recipient, EmailStatus.SKIPPED, null);
|
||||
skipped++;
|
||||
}
|
||||
else if(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});
|
||||
}
|
||||
}
|
||||
|
||||
int digestCount = 0;
|
||||
for(Map.Entry<String, List<QRecord[]>> digest : digests.entrySet())
|
||||
{
|
||||
QRecord setting = settings.get(digest.getKey());
|
||||
if(!isDigestHour(setting, now))
|
||||
{
|
||||
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);
|
||||
for(QRecord[] pair : digest.getValue())
|
||||
{
|
||||
mark(pair[0], status == EmailStatus.SENT ? EmailStatus.DIGESTED : status, status == EmailStatus.SENT ? now : null);
|
||||
}
|
||||
digestCount += status == EmailStatus.SENT ? 1 : 0;
|
||||
failed += status == EmailStatus.FAILED ? 1 : 0;
|
||||
}
|
||||
return (new Result(sent, digestCount, skipped, failed));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether it is the user's digest hour now (their time zone, default UTC).
|
||||
*******************************************************************************/
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(setting != null && StringUtils.hasContent(setting.getValueString("timeZone")))
|
||||
{
|
||||
return (ZoneId.of(setting.getValueString("timeZone")));
|
||||
}
|
||||
}
|
||||
catch(DateTimeException e)
|
||||
{
|
||||
return (ZoneOffset.UTC);
|
||||
}
|
||||
return (ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a severity is at least the minimum (default WARNING).
|
||||
*******************************************************************************/
|
||||
static boolean atLeast(String severity, String minimum)
|
||||
{
|
||||
return (NotificationService.severityOf(severity, NotificationSeverity.INFO).compareTo(NotificationService.severityOf(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.
|
||||
*******************************************************************************/
|
||||
private boolean send(String address, String subject, List<QRecord> items)
|
||||
{
|
||||
if(!StringUtils.hasContent(address))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
StringBuilder text = new StringBuilder();
|
||||
StringBuilder html = new StringBuilder("<div style=\"font-family:sans-serif\">");
|
||||
for(QRecord item : items)
|
||||
{
|
||||
String title = item.getValueString("title");
|
||||
String body = NotificationCategory.SECURITY.name().equals(item.getValueString("category")) ? null : item.getValueString("body");
|
||||
String link = absolute(NotificationService.safeLink(item.getValueString("linkUrl")));
|
||||
text.append(title).append("\n");
|
||||
html.append("<h3 style=\"margin:16px 0 4px\">").append(escape(title)).append("</h3>");
|
||||
if(StringUtils.hasContent(body))
|
||||
{
|
||||
text.append(body).append("\n");
|
||||
html.append("<p style=\"white-space:pre-wrap;margin:0\">").append(escape(body)).append("</p>");
|
||||
}
|
||||
if(link != null)
|
||||
{
|
||||
text.append(link).append("\n");
|
||||
html.append("<p style=\"margin:4px 0\"><a href=\"").append(escape(link)).append("\">")
|
||||
.append(escape(StringUtils.hasContent(item.getValueString("linkLabel")) ? item.getValueString("linkLabel") : "Open")).append("</a></p>");
|
||||
}
|
||||
text.append("\n");
|
||||
}
|
||||
String settings = absolute("/app/" + AlertsNotificationPreference.TABLE_NAME);
|
||||
text.append("Change these emails in My Notification Settings: ").append(settings).append("\n");
|
||||
html.append("<p style=\"color:#666;font-size:12px;margin-top:24px\">Change these emails in <a href=\"").append(escape(settings)).append("\">My Notification Settings</a>.</p></div>");
|
||||
try
|
||||
{
|
||||
new SendMessageAction().execute(new SendMessageInput()
|
||||
.withMessagingProviderName(runtime.getConfig().getMessagingProviderName())
|
||||
.withFrom(new Party().withAddress(runtime.getConfig().getFromAddress()).withRole(EmailPartyRole.FROM))
|
||||
.withTo(new MultiParty().withParty(new Party().withAddress(address).withRole(EmailPartyRole.TO)))
|
||||
.withSubject(subject)
|
||||
.withContent(new Content().withContentRole(EmailContentRole.TEXT).withBody(text.toString()))
|
||||
.withContent(new Content().withContentRole(EmailContentRole.HTML).withBody(html.toString())));
|
||||
return (true);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not email a notification", e, logPair("subject", subject));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link made absolute with the public base URL (relative app paths).
|
||||
*******************************************************************************/
|
||||
private String absolute(String link)
|
||||
{
|
||||
String base = runtime.getConfig().getPublicBaseUrl();
|
||||
if(link == null || !link.startsWith("/") || !StringUtils.hasContent(base))
|
||||
{
|
||||
return (link);
|
||||
}
|
||||
return (base.replaceAll("/+$", "") + link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String escape(String value)
|
||||
{
|
||||
return (value == null ? "" : value.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The live (SENT, published, not expired) notifications for the rows.
|
||||
*******************************************************************************/
|
||||
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();
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return (live);
|
||||
}
|
||||
for(QRecord notification : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids)
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, NotificationService.STATUS_SENT)))
|
||||
{
|
||||
Instant expireAt = notification.getValueInstant("expireAt");
|
||||
if(expireAt == null || expireAt.isAfter(now))
|
||||
{
|
||||
live.put(notification.getValueInteger("id"), notification);
|
||||
}
|
||||
}
|
||||
return (live);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** My Alert Settings rows (address, time zone, digest hour) by user.
|
||||
*******************************************************************************/
|
||||
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))))
|
||||
{
|
||||
settings.put(setting.getValueString("userId"), setting);
|
||||
}
|
||||
return (settings);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String address(QRecord setting)
|
||||
{
|
||||
return (setting == null ? null : setting.getValueString("emailAddress"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void mark(QRecord recipient, EmailStatus status, Instant at) throws QException
|
||||
{
|
||||
QRecord update = new QRecord().withValue("id", recipient.getValue("id")).withValue("emailStatus", status.name());
|
||||
if(at != null)
|
||||
{
|
||||
update.setValue("emailedAt", at);
|
||||
}
|
||||
AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, update);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
/*
|
||||
* 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.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.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.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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User notifications (docs/NOTIFICATIONS.md): writes notifications from
|
||||
** producer signals and alerts, resolves audiences into one recipient row per
|
||||
** user, retracts, sends scheduled ones when they are due, and purges old
|
||||
** ones. Everything runs as the system user; what each person sees is
|
||||
** filtered by their own recipient rows (owner record lock).
|
||||
*******************************************************************************/
|
||||
public class NotificationService
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(NotificationService.class);
|
||||
|
||||
public static final String STATUS_DRAFT = "DRAFT";
|
||||
public static final String STATUS_SCHEDULED = "SCHEDULED";
|
||||
public static final String STATUS_SENT = "SENT";
|
||||
public static final String STATUS_RETRACTED = "RETRACTED";
|
||||
|
||||
static final int BATCH = 500;
|
||||
|
||||
private final AlertsRuntime runtime;
|
||||
private final AlertsAudienceResolver resolver;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a send did.
|
||||
*******************************************************************************/
|
||||
public record SendResult(int added, int total, List<String> warnings)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public NotificationService(AlertsRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.resolver = new AlertsAudienceResolver(runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A producer's notification: written, then sent now (or scheduled for its
|
||||
** publishAt). A signal whose sourceKey and audience match a live
|
||||
** notification updates it and brings it back to unread for its readers.
|
||||
** Returns the notification id.
|
||||
*******************************************************************************/
|
||||
public Integer notify(NotificationSignal signal) throws QException
|
||||
{
|
||||
if(signal == null || !StringUtils.hasContent(signal.getTitle()) || signal.getAudience() == null)
|
||||
{
|
||||
throw (new QUserFacingException("A notification needs a title and an audience."));
|
||||
}
|
||||
Instant now = runtime.now();
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
QRecord values = fromSignal(signal);
|
||||
QRecord existing = null;
|
||||
if(StringUtils.hasContent(signal.getSourceKey()))
|
||||
{
|
||||
existing = AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", signal.getSourceKey())
|
||||
.withCriteria("audienceType", QCriteriaOperator.EQUALS, signal.getAudience().getType().name())
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(STATUS_SENT, STATUS_SCHEDULED))
|
||||
.withCriteria(audienceValueCriteria(signal.getAudience())));
|
||||
}
|
||||
|
||||
boolean scheduled = signal.getPublishAt() != null && signal.getPublishAt().isAfter(now);
|
||||
Integer id;
|
||||
if(existing != null)
|
||||
{
|
||||
id = existing.getValueInteger("id");
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, values.withValue("id", id));
|
||||
if(STATUS_SENT.equals(existing.getValueString("status")))
|
||||
{
|
||||
markAllUnread(id, now);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
values.setValue("status", scheduled ? STATUS_SCHEDULED : STATUS_DRAFT);
|
||||
id = AlertsDb.insert(AlertsNotification.TABLE_NAME, values).getValueInteger("id");
|
||||
}
|
||||
if(!scheduled)
|
||||
{
|
||||
send(id);
|
||||
}
|
||||
return (id);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send (or re-send) a notification: resolve its audience and add a
|
||||
** recipient row for each user who doesn't have one (users who muted the
|
||||
** category in-app are skipped, except for SECURITY). Marks it SENT.
|
||||
*******************************************************************************/
|
||||
public SendResult send(Integer notificationId) throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
QRecord notification = AlertsDb.byId(AlertsNotification.TABLE_NAME, notificationId);
|
||||
if(notification == null)
|
||||
{
|
||||
throw (new QUserFacingException("The notification was not found."));
|
||||
}
|
||||
if(STATUS_RETRACTED.equals(notification.getValueString("status")))
|
||||
{
|
||||
throw (new QUserFacingException("A retracted notification can't be sent again; copy it instead."));
|
||||
}
|
||||
|
||||
AlertsAudienceResolver.Resolved resolved = resolver.resolve(audienceOf(notification));
|
||||
Set<String> existing = recipientUserIds(notificationId);
|
||||
Set<String> muted = mutedUsers(notification.getValueString("category"));
|
||||
|
||||
List<QRecord> inserts = new ArrayList<>();
|
||||
for(String userId : resolved.userIds())
|
||||
{
|
||||
if(!existing.contains(userId) && !muted.contains(userId))
|
||||
{
|
||||
inserts.add(newRecipient(notificationId, userId, now));
|
||||
}
|
||||
}
|
||||
insertRecipients(inserts);
|
||||
|
||||
QRecord update = new QRecord().withValue("id", notificationId)
|
||||
.withValue("status", STATUS_SENT)
|
||||
.withValue("recipientCount", existing.size() + inserts.size());
|
||||
if(notification.getValue("sentAt") == null)
|
||||
{
|
||||
update.setValue("sentAt", now);
|
||||
}
|
||||
if(notification.getValue("publishAt") == null)
|
||||
{
|
||||
update.setValue("publishAt", now);
|
||||
}
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, update);
|
||||
LOG.info("Sent a notification", logPair("notificationId", notificationId), logPair("added", inserts.size()), logPair("total", existing.size() + inserts.size()));
|
||||
return (new SendResult(inserts.size(), existing.size() + inserts.size(), resolved.warnings()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Who a notification would reach now (Preview audience).
|
||||
*******************************************************************************/
|
||||
public AlertsAudienceResolver.Resolved preview(QRecord notification) throws QException
|
||||
{
|
||||
return (runtime.asSystem(() -> resolver.resolve(audienceOf(notification))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Schedule a draft for its publishAt (which must be in the future).
|
||||
*******************************************************************************/
|
||||
public void schedule(Integer notificationId) throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
QRecord notification = AlertsDb.byId(AlertsNotification.TABLE_NAME, notificationId);
|
||||
Instant publishAt = notification == null ? null : notification.getValueInstant("publishAt");
|
||||
if(publishAt == null || !publishAt.isAfter(now))
|
||||
{
|
||||
throw (new QUserFacingException("Set Publish At to a future time first."));
|
||||
}
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", notificationId).withValue("status", STATUS_SCHEDULED));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retract a notification: it disappears from every bell. Recipient rows
|
||||
** are kept (for the record).
|
||||
*******************************************************************************/
|
||||
public void retract(Integer notificationId) throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
runtime.asSystem(() -> AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", notificationId)
|
||||
.withValue("status", STATUS_RETRACTED).withValue("retractedAt", now)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send every SCHEDULED notification whose publishAt has passed. Returns
|
||||
** how many were sent.
|
||||
*******************************************************************************/
|
||||
public int sendDue() throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
List<QRecord> due = runtime.asSystem(() -> AlertsDb.query(AlertsNotification.TABLE_NAME, AlertsDb.eq("status", STATUS_SCHEDULED)
|
||||
.withCriteria("publishAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withLimit(50)));
|
||||
int sent = 0;
|
||||
for(QRecord notification : due)
|
||||
{
|
||||
try
|
||||
{
|
||||
send(notification.getValueInteger("id"));
|
||||
sent++;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not send a scheduled notification", e, logPair("notificationId", notification.getValue("id")));
|
||||
}
|
||||
}
|
||||
return (sent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete notifications (and their recipients) a week past their expireAt,
|
||||
** and SENT or RETRACTED ones older than notificationRetentionDays. At most
|
||||
** 500 per call. Returns how many were deleted.
|
||||
*******************************************************************************/
|
||||
public int purge() throws QException
|
||||
{
|
||||
Instant now = runtime.now();
|
||||
Instant oldest = now.minus(runtime.getConfig().getNotificationRetentionDays(), ChronoUnit.DAYS);
|
||||
return (runtime.asSystem(() ->
|
||||
{
|
||||
Set<Serializable> ids = new HashSet<>();
|
||||
for(QRecord row : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("expireAt", QCriteriaOperator.LESS_THAN, now.minus(7, ChronoUnit.DAYS)).withLimit(BATCH)))
|
||||
{
|
||||
ids.add(row.getValueInteger("id"));
|
||||
}
|
||||
for(QRecord row : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of(STATUS_SENT, STATUS_RETRACTED))
|
||||
.withCriteria("createDate", QCriteriaOperator.LESS_THAN, oldest).withLimit(BATCH)))
|
||||
{
|
||||
ids.add(row.getValueInteger("id"));
|
||||
}
|
||||
deleteWithRecipients(new ArrayList<>(ids));
|
||||
return (ids.size());
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete notifications and their recipient rows (as the system user).
|
||||
*******************************************************************************/
|
||||
public void deleteWithRecipients(List<Serializable> notificationIds) throws QException
|
||||
{
|
||||
if(CollectionUtils.nullSafeIsEmpty(notificationIds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
new DeleteAction().execute(new DeleteInput(AlertsNotificationRecipient.TABLE_NAME)
|
||||
.withQueryFilter(new QQueryFilter(new QFilterCriteria("notificationId", QCriteriaOperator.IN, notificationIds))));
|
||||
new DeleteAction().execute(new DeleteInput(AlertsNotification.TABLE_NAME).withPrimaryKeys(notificationIds));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Put an alert in a user's bell (the in-app channel): one ALERT
|
||||
** notification per alert (sourceKey alert:id), kept in step with the
|
||||
** alert's title and severity, and a recipient row for the user that comes
|
||||
** back to unread on every delivery. Call as the system user.
|
||||
*******************************************************************************/
|
||||
public void deliverAlert(String userId, QRecord alert, Instant now) throws QException
|
||||
{
|
||||
Integer alertId = alert.getValueInteger("id");
|
||||
String sourceKey = "alert:" + alertId;
|
||||
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("category", NotificationCategory.ALERT.name())
|
||||
.withValue("sourceName", StringUtils.hasContent(alert.getValueString("sourceName")) ? alert.getValueString("sourceName") : "alerts")
|
||||
.withValue("sourceKey", sourceKey)
|
||||
.withValue("sourceTableName", AlertsAlert.TABLE_NAME)
|
||||
.withValue("sourceRecordId", String.valueOf(alertId))
|
||||
.withValue("linkUrl", "/app/" + AlertsAlert.TABLE_NAME + "/" + alertId)
|
||||
.withValue("linkLabel", "Open alert");
|
||||
|
||||
QRecord notification = AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", sourceKey)
|
||||
.withCriteria("category", QCriteriaOperator.EQUALS, NotificationCategory.ALERT.name()));
|
||||
Integer notificationId;
|
||||
if(notification == null)
|
||||
{
|
||||
notificationId = AlertsDb.insert(AlertsNotification.TABLE_NAME, values
|
||||
.withValue("audienceType", NotificationAudience.Type.USERS.name())
|
||||
.withValue("status", STATUS_SENT)
|
||||
.withValue("publishAt", now)
|
||||
.withValue("sentAt", now)
|
||||
.withValue("recipientCount", 0)).getValueInteger("id");
|
||||
}
|
||||
else
|
||||
{
|
||||
notificationId = notification.getValueInteger("id");
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, values.withValue("id", notificationId).withValue("status", STATUS_SENT));
|
||||
}
|
||||
|
||||
QRecord recipient = AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId)
|
||||
.withCriteria("userId", QCriteriaOperator.EQUALS, userId));
|
||||
if(recipient == null)
|
||||
{
|
||||
AlertsDb.insert(AlertsNotificationRecipient.TABLE_NAME, newRecipient(notificationId, userId, now));
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", notificationId)
|
||||
.withValue("recipientCount", AlertsDb.count(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId))));
|
||||
}
|
||||
else
|
||||
{
|
||||
AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, new QRecord().withValue("id", recipient.getValue("id"))
|
||||
.withValue("deliveredAt", now).withValue("readAt", null).withValue("dismissedAt", null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recount a notification's readers (after people mark it read or unread).
|
||||
*******************************************************************************/
|
||||
public void recountRead(Set<Integer> notificationIds) throws QException
|
||||
{
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
for(Integer id : notificationIds)
|
||||
{
|
||||
int read = AlertsDb.count(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", id).withCriteria("readAt", QCriteriaOperator.IS_NOT_BLANK));
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", id).withValue("readCount", read));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification's audience, from its stored fields (null when unknown).
|
||||
*******************************************************************************/
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link as a URL the UI can follow: a record or table path, an app path,
|
||||
** or an https URL (anything else is dropped).
|
||||
*******************************************************************************/
|
||||
public static String linkUrl(AlertLink link)
|
||||
{
|
||||
if(link == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(link.getTableName() != null && link.getRecordId() != null)
|
||||
{
|
||||
return ("/app/" + link.getTableName() + "/" + link.getRecordId());
|
||||
}
|
||||
if(link.getTableName() != null)
|
||||
{
|
||||
return ("/app/" + link.getTableName());
|
||||
}
|
||||
return (safeLink(link.getPath() != null ? link.getPath() : link.getUrl()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The link if it is a relative app path (one leading slash) or an https URL
|
||||
** with a host, else null. Backslashes, whitespace and control characters
|
||||
** are refused anywhere (browsers treat "/\evil.com" as "//evil.com").
|
||||
*******************************************************************************/
|
||||
public static String safeLink(String url)
|
||||
{
|
||||
if(!StringUtils.hasContent(url))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String trimmed = url.trim();
|
||||
for(char c : trimmed.toCharArray())
|
||||
{
|
||||
if(c == '\\' || Character.isWhitespace(c) || Character.isISOControl(c))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
if(trimmed.startsWith("/"))
|
||||
{
|
||||
return (trimmed.length() > 1 && trimmed.charAt(1) == '/' ? null : trimmed);
|
||||
}
|
||||
try
|
||||
{
|
||||
java.net.URI uri = new java.net.URI(trimmed);
|
||||
return ("https".equalsIgnoreCase(uri.getScheme()) && StringUtils.hasContent(uri.getHost()) ? trimmed : null);
|
||||
}
|
||||
catch(java.net.URISyntaxException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The notification fields from a signal.
|
||||
*******************************************************************************/
|
||||
private QRecord fromSignal(NotificationSignal signal)
|
||||
{
|
||||
return (new QRecord()
|
||||
.withValue("title", StringUtils.safeTruncate(signal.getTitle(), 200))
|
||||
.withValue("body", signal.getBody())
|
||||
.withValue("severity", (signal.getSeverity() == null ? NotificationSeverity.INFO : signal.getSeverity()).name())
|
||||
.withValue("category", (signal.getCategory() == null ? NotificationCategory.SYSTEM : signal.getCategory()).name())
|
||||
.withValue("sourceName", StringUtils.safeTruncate(signal.getSourceName(), 100))
|
||||
.withValue("sourceKey", StringUtils.safeTruncate(signal.getSourceKey(), 200))
|
||||
.withValue("sourceTableName", signal.getSourceTableName())
|
||||
.withValue("sourceRecordId", signal.getSourceRecordId())
|
||||
.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("publishAt", signal.getPublishAt())
|
||||
.withValue("expireAt", signal.getExpireAt())
|
||||
.withValue("sticky", Boolean.TRUE.equals(signal.getSticky())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Match the stored audience value (null when there are no values).
|
||||
*******************************************************************************/
|
||||
private static QFilterCriteria audienceValueCriteria(NotificationAudience audience)
|
||||
{
|
||||
String value = audienceValue(audience);
|
||||
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"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The users who already have this notification.
|
||||
*******************************************************************************/
|
||||
private Set<String> recipientUserIds(Integer notificationId) throws QException
|
||||
{
|
||||
Set<String> userIds = new HashSet<>();
|
||||
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId)))
|
||||
{
|
||||
userIds.add(recipient.getValueString("userId"));
|
||||
}
|
||||
return (userIds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users who muted a category in-app (never for SECURITY).
|
||||
*******************************************************************************/
|
||||
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).withCriteria("inAppMuted", QCriteriaOperator.EQUALS, true)))
|
||||
{
|
||||
userIds.add(preference.getValueString("userId"));
|
||||
}
|
||||
return (userIds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert recipient rows in batches.
|
||||
*******************************************************************************/
|
||||
private void insertRecipients(List<QRecord> recipients) throws QException
|
||||
{
|
||||
for(int start = 0; start < recipients.size(); start += BATCH)
|
||||
{
|
||||
List<QRecord> batch = recipients.subList(start, Math.min(recipients.size(), start + BATCH));
|
||||
for(QRecord inserted : new InsertAction().execute(new InsertInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(new ArrayList<>(batch))).getRecords())
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(inserted.getErrors()))
|
||||
{
|
||||
LOG.warn("Could not add a notification recipient", logPair("error", inserted.getErrors().get(0).getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bring a notification back to unread (and undismissed) for everyone who
|
||||
** has it (a producer repeated it).
|
||||
*******************************************************************************/
|
||||
private void markAllUnread(Integer notificationId, Instant now) throws QException
|
||||
{
|
||||
List<QRecord> updates = new ArrayList<>();
|
||||
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId)))
|
||||
{
|
||||
updates.add(new QRecord().withValue("id", recipient.getValue("id")).withValue("deliveredAt", now).withValue("readAt", null).withValue("dismissedAt", null));
|
||||
}
|
||||
for(int start = 0; start < updates.size(); start += BATCH)
|
||||
{
|
||||
new UpdateAction().execute(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(new ArrayList<>(updates.subList(start, Math.min(updates.size(), start + BATCH)))));
|
||||
}
|
||||
if(!updates.isEmpty())
|
||||
{
|
||||
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", notificationId).withValue("readCount", 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import java.util.List;
|
||||
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.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
@@ -56,7 +57,11 @@ public enum AlertsAction
|
||||
ENABLE_MONITOR("alertsEnableMonitor", "Enable monitor", "toggle_on", AlertsMonitor.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, null),
|
||||
DISABLE_MONITOR("alertsDisableMonitor", "Disable monitor", "toggle_off", AlertsMonitor.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, null),
|
||||
SEND_TEST("alertsSendTestNotification", "Send test notification", "send", AlertsChannel.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, 1),
|
||||
RETRY_DELIVERY("alertsRetryDelivery", "Retry delivery", "replay", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, null);
|
||||
RETRY_DELIVERY("alertsRetryDelivery", "Retry delivery", "replay", AlertsDelivery.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, null),
|
||||
PREVIEW_AUDIENCE("alertsPreviewAudience", "Preview audience", "groups", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN, 1),
|
||||
SEND_NOTIFICATION("alertsSendNotification", "Send now", "send", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN, null),
|
||||
SCHEDULE_NOTIFICATION("alertsScheduleNotification", "Schedule", "schedule_send", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN, null),
|
||||
RETRACT_NOTIFICATION("alertsRetractNotification", "Retract", "unpublished", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN, null);
|
||||
|
||||
public static final String FIELD_NOTE = "alertsNote";
|
||||
public static final String FIELD_SNOOZE_HOURS = "alertsSnoozeHours";
|
||||
|
||||
@@ -29,14 +29,16 @@ import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.notifications.AlertsAudienceResolver;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -66,6 +68,8 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
*******************************************************************************/
|
||||
public final class AlertsActionSteps
|
||||
{
|
||||
static final int AUDIENCE_SAMPLE = 10;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Steps only.
|
||||
@@ -165,7 +169,11 @@ public final class AlertsActionSteps
|
||||
*******************************************************************************/
|
||||
private void review(AlertsAction action, QRecord record) throws QException
|
||||
{
|
||||
if(action == AlertsAction.RUN_CHECK)
|
||||
if(action == AlertsAction.PREVIEW_AUDIENCE || action == AlertsAction.SEND_NOTIFICATION)
|
||||
{
|
||||
extra.addAll(audienceLines(runtime().getNotifications().preview(record), action == AlertsAction.PREVIEW_AUDIENCE));
|
||||
}
|
||||
else if(action == AlertsAction.RUN_CHECK)
|
||||
{
|
||||
MonitorEvaluator.Plan plan = runtime().getEvaluator().preview(record.getValueInteger("id"));
|
||||
extra.addAll(planLines(plan, false));
|
||||
@@ -311,7 +319,7 @@ public final class AlertsActionSteps
|
||||
{
|
||||
QRecord notification = runtime.getDeliveryWorker().sendTest(id, actor);
|
||||
String status = notification.getValueString("status");
|
||||
if(!NotificationStatus.SENT.name().equals(status))
|
||||
if(!DeliveryStatus.SENT.name().equals(status))
|
||||
{
|
||||
extra.add(new ProcessSummaryLine(Status.ERROR, null, "The test did not send: " + Objects.toString(notification.getValueString("lastError"), status)));
|
||||
yield (false);
|
||||
@@ -320,6 +328,28 @@ public final class AlertsActionSteps
|
||||
}
|
||||
case RETRY_DELIVERY -> runtime.getDeliveryWorker().retry(List.of(id)) > 0;
|
||||
case CREATE_MONITOR -> createMonitor(record);
|
||||
case PREVIEW_AUDIENCE ->
|
||||
{
|
||||
extra.addAll(audienceLines(runtime.getNotifications().preview(record), true));
|
||||
yield (true);
|
||||
}
|
||||
case SEND_NOTIFICATION ->
|
||||
{
|
||||
NotificationService.SendResult result = runtime.getNotifications().send(id);
|
||||
extra.add(new ProcessSummaryLine(Status.INFO, null, "\"" + record.getValueString("title") + "\" reached " + count(result.added(), "new recipient")
|
||||
+ " (" + count(result.total(), "recipient") + " in all)."));
|
||||
yield (true);
|
||||
}
|
||||
case SCHEDULE_NOTIFICATION ->
|
||||
{
|
||||
runtime.getNotifications().schedule(id);
|
||||
yield (true);
|
||||
}
|
||||
case RETRACT_NOTIFICATION ->
|
||||
{
|
||||
runtime.getNotifications().retract(id);
|
||||
yield (true);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -339,7 +369,10 @@ public final class AlertsActionSteps
|
||||
case REOPEN -> AlertsPossibleValues.Status.RESOLVED.name().equals(status) ? null : "Only resolved alerts can be reopened.";
|
||||
case ENABLE_MONITOR -> Boolean.TRUE.equals(record.getValueBoolean("enabled")) ? "Already enabled." : null;
|
||||
case DISABLE_MONITOR -> Boolean.FALSE.equals(record.getValueBoolean("enabled")) ? "Already disabled." : null;
|
||||
case RETRY_DELIVERY -> List.of(NotificationStatus.FAILED.name(), NotificationStatus.DEAD.name()).contains(status) ? null : "Only failed or dead notifications can be retried.";
|
||||
case RETRY_DELIVERY -> List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()).contains(status) ? null : "Only failed or dead notifications can be retried.";
|
||||
case PREVIEW_AUDIENCE, SEND_NOTIFICATION -> NotificationService.STATUS_RETRACTED.equals(status) ? "Retracted notifications can't be sent." : null;
|
||||
case SCHEDULE_NOTIFICATION -> NotificationService.STATUS_DRAFT.equals(status) ? null : "Only drafts can be scheduled.";
|
||||
case RETRACT_NOTIFICATION -> NotificationService.STATUS_SENT.equals(status) || NotificationService.STATUS_SCHEDULED.equals(status) ? null : "Only sent or scheduled notifications can be retracted.";
|
||||
case SUBSCRIBE_TYPE -> alreadySubscribed(ScopeType.ALERT_TYPE, record.getValueString("name"));
|
||||
case SUBSCRIBE_GROUP -> alreadySubscribed(ScopeType.GROUP, record.getValueString("name"));
|
||||
case SUBSCRIBE_MONITOR -> alreadySubscribed(ScopeType.MONITOR, String.valueOf(record.getValue("id")));
|
||||
@@ -472,6 +505,28 @@ public final class AlertsActionSteps
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review lines for an audience: who it reaches, a sample, and warnings.
|
||||
*******************************************************************************/
|
||||
static List<ProcessSummaryLine> audienceLines(AlertsAudienceResolver.Resolved resolved, boolean preview)
|
||||
{
|
||||
List<ProcessSummaryLine> lines = new ArrayList<>();
|
||||
int size = resolved.userIds().size();
|
||||
lines.add(new ProcessSummaryLine(size == 0 ? Status.WARNING : Status.INFO, null, (preview ? "Would reach " : "Will reach up to ") + count(size, "user") + "."));
|
||||
if(size > 0)
|
||||
{
|
||||
List<String> sample = resolved.userIds().stream().limit(AUDIENCE_SAMPLE).map(userId -> resolved.labels().getOrDefault(userId, userId)).toList();
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "For example: " + String.join(", ", sample) + (size > sample.size() ? ", and " + (size - sample.size()) + " more." : ".")));
|
||||
}
|
||||
for(String warning : resolved.warnings())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.WARNING, null, warning));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The action this process run is.
|
||||
*******************************************************************************/
|
||||
@@ -509,6 +564,7 @@ public final class AlertsActionSteps
|
||||
case "alerts_group" -> "group";
|
||||
case "alerts_monitor" -> "monitor";
|
||||
case "alerts_channel" -> "channel";
|
||||
case "alerts_delivery" -> "delivery";
|
||||
case "alerts_notification" -> "notification";
|
||||
default -> "record";
|
||||
};
|
||||
@@ -537,6 +593,10 @@ public final class AlertsActionSteps
|
||||
case SEND_TEST -> "tested";
|
||||
case RETRY_DELIVERY -> "queued again";
|
||||
case CREATE_MONITOR -> "given a new (disabled) monitor";
|
||||
case PREVIEW_AUDIENCE -> "previewed";
|
||||
case SEND_NOTIFICATION -> "sent";
|
||||
case SCHEDULE_NOTIFICATION -> "scheduled";
|
||||
case RETRACT_NOTIFICATION -> "retracted";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.alerts.processes;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationEmailer;
|
||||
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;
|
||||
@@ -72,7 +73,10 @@ public final class AlertsScheduledSteps
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AlertsRuntime runtime = AlertsRuntime.get();
|
||||
runtime.sink().flush();
|
||||
runtime.getSink().flush();
|
||||
runtime.getNotifications().sendDue();
|
||||
runtime.getNotifications().purge();
|
||||
new NotificationEmailer(runtime).run();
|
||||
output.addValue(OUTPUT_COUNT, runtime.getDeliveryWorker().deliverDue());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,219 +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.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);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +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.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);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +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.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
|
||||
}
|
||||
@@ -1,223 +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.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);
|
||||
}
|
||||
}
|
||||
@@ -1,150 +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.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);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +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.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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,489 +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.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);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +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.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);
|
||||
|
||||
}
|
||||
@@ -1,228 +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.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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +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.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);
|
||||
}
|
||||
}
|
||||
@@ -1,410 +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.spi;
|
||||
|
||||
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 QAlertTypeMetaData implements TopLevelMetaDataInterface, SourceQBitAware
|
||||
{
|
||||
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;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find a declared alert type by name, or null.
|
||||
*******************************************************************************/
|
||||
public static QAlertTypeMetaData of(QInstance qInstance, String name)
|
||||
{
|
||||
AlertsInstanceMetaData alertsInstanceMetaData = AlertsInstanceMetaData.of(qInstance);
|
||||
return (alertsInstanceMetaData == null ? null : alertsInstanceMetaData.getAlertType(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Adds this type to the instance's AlertsInstanceMetaData (creating it).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void addSelfToInstance(QInstance qInstance)
|
||||
{
|
||||
AlertsInstanceMetaData.ofOrWithNew(qInstance).withAlertType(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The source shown to people: sourceName if set, otherwise the qbit that
|
||||
** produced this meta-data.
|
||||
*******************************************************************************/
|
||||
public String getEffectiveSourceName()
|
||||
{
|
||||
return (sourceName != null ? sourceName : sourceQBitName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceName
|
||||
*******************************************************************************/
|
||||
public String getSourceName()
|
||||
{
|
||||
return (this.sourceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceName
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withSourceName(String sourceName)
|
||||
{
|
||||
this.sourceName = sourceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getSourceQBitName()
|
||||
{
|
||||
return (this.sourceQBitName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
this.sourceQBitName = sourceQBitName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QAlertTypeMetaData withSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
this.sourceQBitName = sourceQBitName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for groupName
|
||||
*******************************************************************************/
|
||||
public String getGroupName()
|
||||
{
|
||||
return (this.groupName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for groupName (the default group; created if missing)
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withGroupName(String groupName)
|
||||
{
|
||||
this.groupName = groupName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultSeverity
|
||||
*******************************************************************************/
|
||||
public AlertSeverity getDefaultSeverity()
|
||||
{
|
||||
return (this.defaultSeverity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultSeverity
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withDefaultSeverity(AlertSeverity defaultSeverity)
|
||||
{
|
||||
this.defaultSeverity = defaultSeverity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for howToFix
|
||||
*******************************************************************************/
|
||||
public String getHowToFix()
|
||||
{
|
||||
return (this.howToFix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for howToFix
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withHowToFix(String howToFix)
|
||||
{
|
||||
this.howToFix = howToFix;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subjectTableName
|
||||
*******************************************************************************/
|
||||
public String getSubjectTableName()
|
||||
{
|
||||
return (this.subjectTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subjectTableName (drives visibility and the record panel)
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withSubjectTableName(String subjectTableName)
|
||||
{
|
||||
this.subjectTableName = subjectTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for visibilityPermission
|
||||
*******************************************************************************/
|
||||
public String getVisibilityPermission()
|
||||
{
|
||||
return (this.visibilityPermission);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for visibilityPermission (an extra gate on who may see it)
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withVisibilityPermission(String visibilityPermission)
|
||||
{
|
||||
this.visibilityPermission = visibilityPermission;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resolveMode
|
||||
*******************************************************************************/
|
||||
public ResolveMode getResolveMode()
|
||||
{
|
||||
return (this.resolveMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for resolveMode
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withResolveMode(ResolveMode resolveMode)
|
||||
{
|
||||
this.resolveMode = resolveMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve automatically after this long with no raise (sets the mode to
|
||||
** QUIET_PERIOD).
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withQuietPeriod(Duration quietPeriod)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +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.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
|
||||
}
|
||||
@@ -1,31 +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.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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;
|
||||
@@ -6,8 +6,8 @@
|
||||
-- - include: { file: db/liquibase/qbit-alerts/alerts.changelog.sql }
|
||||
--
|
||||
-- Rules for this file: the host runs it, the qbit never runs DDL; additive only
|
||||
-- (nothing is ever dropped or rewritten); every statement is idempotent
|
||||
-- (IF NOT EXISTS). Column names are the snake_case form of the field names.
|
||||
-- (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.
|
||||
|
||||
-- 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.
|
||||
@@ -314,7 +314,7 @@ CREATE TABLE IF NOT EXISTS alerts_notification (
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_status_next_attempt_at ON alerts_notification (status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_status_next_attempt_at ON alerts_notification (status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_alert_id ON alerts_notification (alert_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_channel_id_status ON alerts_notification (channel_id, status);
|
||||
|
||||
@@ -347,3 +347,83 @@ CREATE TABLE IF NOT EXISTS alerts_heartbeat (
|
||||
CONSTRAINT pk_alerts_heartbeat PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_heartbeat UNIQUE (heartbeat_key)
|
||||
);
|
||||
|
||||
-- changeset qbit-alerts:16
|
||||
-- comment: Pre-GA rename (docs/NOTIFICATIONS.md section 2): the delivery log becomes alerts_delivery, so alerts_notification can hold user notifications. Runs only on a schema that still has the 0.1.0-RC.1 delivery log.
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_notification' AND LOWER(column_name) = 'alert_event_id'
|
||||
ALTER TABLE alerts_notification RENAME TO alerts_delivery;
|
||||
ALTER TABLE alerts_delivery RENAME CONSTRAINT pk_alerts_notification TO pk_alerts_delivery;
|
||||
ALTER INDEX ix_alerts_notification_alert_id RENAME TO ix_alerts_delivery_alert_id;
|
||||
ALTER INDEX ix_alerts_notification_channel_id_status RENAME TO ix_alerts_delivery_channel_id_status;
|
||||
|
||||
-- changeset qbit-alerts:17
|
||||
-- comment: Pre-GA retirement (docs/NOTIFICATIONS.md section 2): alert bell items are now notification recipients. alerts_inbox_item held only bell read state.
|
||||
DROP TABLE IF EXISTS alerts_inbox_item;
|
||||
|
||||
-- changeset qbit-alerts:18
|
||||
-- comment: A message for people: an announcement an admin writes, or one a producer emits (a failed job, a share, a security notice, a release). One recipient row per user it reaches.
|
||||
CREATE TABLE IF NOT EXISTS alerts_notification (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT,
|
||||
severity VARCHAR(16) NOT NULL,
|
||||
category VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(16),
|
||||
source_name VARCHAR(100),
|
||||
source_key VARCHAR(200),
|
||||
source_table_name VARCHAR(100),
|
||||
source_record_id VARCHAR(100),
|
||||
link_url VARCHAR(1000),
|
||||
link_label VARCHAR(200),
|
||||
audience_type VARCHAR(16) NOT NULL,
|
||||
audience_value TEXT,
|
||||
publish_at TIMESTAMP,
|
||||
expire_at TIMESTAMP,
|
||||
sticky BOOLEAN,
|
||||
author_user_id VARCHAR(100),
|
||||
sent_at TIMESTAMP,
|
||||
retracted_at TIMESTAMP,
|
||||
recipient_count INTEGER,
|
||||
read_count INTEGER,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_status_publish_at ON alerts_notification (status, publish_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_source_key ON alerts_notification (source_key);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_expire_at ON alerts_notification (expire_at);
|
||||
|
||||
-- changeset qbit-alerts:19
|
||||
-- comment: One notification for one user: when it reached them, and whether they read it, dismissed it, or were emailed.
|
||||
CREATE TABLE IF NOT EXISTS alerts_notification_recipient (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notification_id INTEGER NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
delivered_at TIMESTAMP,
|
||||
read_at TIMESTAMP,
|
||||
dismissed_at TIMESTAMP,
|
||||
emailed_at TIMESTAMP,
|
||||
email_status VARCHAR(16),
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification_recipient PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_notification_recipient UNIQUE (notification_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_user_id_read_at ON alerts_notification_recipient (user_id, read_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_email_status ON alerts_notification_recipient (email_status);
|
||||
|
||||
-- changeset qbit-alerts:20
|
||||
-- comment: 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.
|
||||
CREATE TABLE IF NOT EXISTS alerts_notification_preference (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
category VARCHAR(16) NOT NULL,
|
||||
in_app_muted BOOLEAN,
|
||||
email_mode VARCHAR(16),
|
||||
email_min_severity VARCHAR(16),
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category)
|
||||
);
|
||||
@@ -0,0 +1,518 @@
|
||||
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: "Framework error response."
|
||||
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"
|
||||
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"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Alerts inbox and record panel"
|
||||
title: "QQQ Middleware API extension: alerts"
|
||||
version: "1.0.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/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxSummaryV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Read the bell summary"
|
||||
/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/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxPageV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Page the notification inbox"
|
||||
/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/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Mark own notifications read"
|
||||
/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/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Mark own notifications unread"
|
||||
/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/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Dismiss own notifications"
|
||||
/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/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsRecordPanelV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Read visible alerts for a record"
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qbits.alerts.api.AlertsUiApi;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The committed extension fragment describes exactly the six UI operations.
|
||||
*******************************************************************************/
|
||||
class AlertsApiContractTest extends BaseAlertsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Regenerate only by explicit build property, then check the deterministic fragment.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
{
|
||||
var extension = AlertsUiApi.extension();
|
||||
var version = new MiddlewareVersionV1();
|
||||
String generated = UiApiContract.generateExtensionYaml(version, extension);
|
||||
Path path = Path.of("src/main/resources/openapi/qqq-v1/alerts.yaml");
|
||||
if(Boolean.getBoolean("alerts.openapi.update"))
|
||||
{
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, generated);
|
||||
}
|
||||
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
|
||||
"mvnw.cmd -Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts"));
|
||||
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder(
|
||||
"/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read",
|
||||
"/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel");
|
||||
assertThat(extension.getSpecs()).hasSize(6);
|
||||
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "actions", "follow");
|
||||
assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production registers one extension; the existing route opt-out still works.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws Exception
|
||||
{
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).containsOnlyKeys("alerts");
|
||||
AlertsUiApi.register(qInstance);
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).hasSize(1);
|
||||
assertThat(AlertsUiApi.extension().getEffectivePathPrefix()).isEqualTo("/alerts");
|
||||
QInstance disabled = new QInstance();
|
||||
disabled.addBackend(defineH2Backend());
|
||||
new AlertsQBitProducer().withQBitConfig(new AlertsQBitConfig().withBackendName(BACKEND_NAME).withRegisterRoutes(false)).produce(disabled);
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(disabled)).isNull();
|
||||
assertThat(new AlertsQBitConfig().getLegacyAlertmanagerPath()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.api.AlertsRouteProvider;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
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.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.UiApiRouteAuditor;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real HTTP coverage of the extension, permission boundaries and machine webhook.
|
||||
*******************************************************************************/
|
||||
class AlertsApiHttpTest extends BaseAlertsTest
|
||||
{
|
||||
private static final String TOKEN_PROPERTY = "alerts.test.http.token";
|
||||
private static final String TOKEN = "test-webhook-token";
|
||||
private QApplicationJavalinServer server;
|
||||
private int port;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Give the HTTP mock session the permissions of its test cookie identity.
|
||||
*******************************************************************************/
|
||||
public static class HttpSessionCustomizer extends TestSessionCustomizer
|
||||
{
|
||||
/***************************************************************************
|
||||
** Test identities are carried in sessionUUID cookies.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
QSession granted = sessionFor(session.getUuid());
|
||||
session.setUser(granted.getUser());
|
||||
session.setPermissions(granted.getPermissions());
|
||||
AlertsSecurity.applyToSession(instance, session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shut down even after a failed assertion or startup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stopServer()
|
||||
{
|
||||
if(server != null)
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
System.clearProperty(TOKEN_PROPERTY);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every application server runs strict; the legacy alias is tested separately.
|
||||
*******************************************************************************/
|
||||
private void start() throws Exception
|
||||
{
|
||||
qInstance.getAuthentication().setCustomizer(new QCodeReference(HttpSessionCustomizer.class));
|
||||
server = new QApplicationJavalinServer(new AbstractQQQApplication()
|
||||
{
|
||||
/***************************************************************************
|
||||
** Use the test database and produced metadata.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (qInstance);
|
||||
}
|
||||
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
|
||||
.withMiddlewareVersionList(List.of(new MiddlewareVersionV1()));
|
||||
server.start();
|
||||
port = server.getBoundPort();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send through Javalin with an explicit user and optional bearer authorization.
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> request(String method, String path, String body, String user, String authorization) throws Exception
|
||||
{
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
|
||||
.header("Cookie", "sessionUUID=" + user);
|
||||
if(authorization != null)
|
||||
{
|
||||
request.header("Authorization", authorization);
|
||||
}
|
||||
if(body != null)
|
||||
{
|
||||
request.header("Content-Type", "application/json");
|
||||
}
|
||||
request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
|
||||
try(HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
return (client.send(request.build(), HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse success bodies while reporting any unexpected framework failure.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> json(HttpResponse<String> response) throws Exception
|
||||
{
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
return (JsonUtils.toObject(response.body(), Map.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A core producer reaches the strict HTTP inbox through the background sink.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void coreNotificationReachesInbox() throws Exception
|
||||
{
|
||||
config.withSynchronousSink(false);
|
||||
start();
|
||||
long failures = QAlerts.getProducerFailureCount();
|
||||
QAlerts.notify(new NotificationSignal("Shipment delivered").withBody("Your shipment arrived.")
|
||||
.withCategory(NotificationCategory.JOB).withSourceName("easypost-tracking")
|
||||
.withLink(AlertLink.toRecord(WIDGET, 5, "Shipment"))
|
||||
.withAudience(NotificationAudience.user("viewer-1")));
|
||||
runtime.getSink().stop();
|
||||
|
||||
String base = "/qqq/v1/alerts/inbox";
|
||||
Map<String, Object> summary = json(request("GET", base + "/summary", null, "viewer-1", null));
|
||||
assertThat(summary).containsEntry("unreadCount", 1).containsEntry("highestUnreadSeverity", "INFO");
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
|
||||
assertThat(items).singleElement().satisfies(item -> assertThat(item)
|
||||
.containsEntry("title", "Shipment delivered").containsEntry("category", "JOB")
|
||||
.containsEntry("source", "easypost-tracking").containsEntry("href", "/app/" + WIDGET + "/5"));
|
||||
Map<String, Object> page = json(request("GET", base, null, "viewer-1", null));
|
||||
assertThat((List<Map<String, Object>>) page.get("items")).singleElement()
|
||||
.satisfies(item -> assertThat(item).containsEntry("body", "Your shipment arrived."));
|
||||
assertThat(json(request("GET", base + "/summary", null, "viewer-2", null))).containsEntry("unreadCount", 0);
|
||||
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing paths, selectors, paging, null fields and additive client affordances.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void strictUiRoutes() throws Exception
|
||||
{
|
||||
new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).inboxSummary();
|
||||
Integer widget = insertWidgets("BROKEN").get(0);
|
||||
var alert = runtime.getEngine().raise(new AlertSignal("test.http.problem").withDedupKey("http")
|
||||
.withTitle("Needs attention").withSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR).withRecordRef(AlertRecordRef.of(WIDGET, widget)));
|
||||
start();
|
||||
String base = "/qqq/v1/alerts";
|
||||
Map<String, Object> summary = json(request("GET", base + "/inbox/summary", null, ADMIN, null));
|
||||
assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications");
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
|
||||
assertThat(items).hasSize(1);
|
||||
Map<String, Object> item = items.get(0);
|
||||
assertThat(item).containsKeys("href", "actions", "alertId");
|
||||
assertThat((List<Map<String, Object>>) item.get("actions")).extracting(a -> a.get("operationId")).containsExactly("alertsInboxRead", "alertsInboxDismiss");
|
||||
String target = JsonUtils.toJson(Map.of("id", item.get("id")));
|
||||
assertThat(json(request("POST", base + "/inbox/read", target, ADMIN, null))).containsEntry("updated", 1);
|
||||
assertThat(json(request("POST", base + "/inbox/unread", target, ADMIN, null))).containsEntry("updated", 1);
|
||||
assertThat(json(request("GET", base + "/inbox?status=unread&offset=bad&limit=1000", null, ADMIN, null)))
|
||||
.containsEntry("total", 1).containsEntry("offset", 0).containsEntry("limit", 100);
|
||||
Map<String, Object> panel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, ADMIN, null));
|
||||
assertThat(panel).containsEntry("follow", Map.of("available", false)).containsEntry("watching", false);
|
||||
Map<String, Object> row = ((List<Map<String, Object>>) panel.get("open")).get(0);
|
||||
assertThat((List<Map<String, Object>>) row.get("actions")).extracting(a -> a.get("processName")).containsExactly("alertsAcknowledge", "alertsSnooze");
|
||||
assertThat(((List<Map<String, Object>>) row.get("actions")).get(0).get("href"))
|
||||
.isEqualTo("/app/alertsAcknowledge?recordsParam=recordIds&recordIds=" + alert.getValue("id") + "&tableName=alerts_alert");
|
||||
Map<String, Object> viewerPanel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, VIEWER, null));
|
||||
assertThat(((List<Map<String, Object>>) viewerPanel.get("open")).get(0).get("actions")).isEqualTo(List.of());
|
||||
assertThat(json(request("POST", base + "/inbox/dismiss", target, ADMIN, null))).containsEntry("updated", 1);
|
||||
assertThat(json(request("GET", base + "/inbox/summary", null, ADMIN, null))).containsEntry("highestUnreadSeverity", null);
|
||||
assertThat(request("POST", base + "/record-panel/watch", "{}", ADMIN, null).statusCode()).isEqualTo(404);
|
||||
HttpResponse<String> invalidJson = request("POST", base + "/inbox/read", "not json", ADMIN, null);
|
||||
assertThat(invalidJson.statusCode()).isEqualTo(400);
|
||||
assertThat(invalidJson.body()).contains("The request body is not valid JSON.");
|
||||
assertThat(request("POST", base + "/inbox/read", null, ADMIN, null).statusCode()).isEqualTo(400);
|
||||
assertThat(request("GET", base + "/inbox/summary", null, "nobody", null).statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", base + "/record-panel?tableName=widget&recordId=1", null, OUTSIDER, null).statusCode()).isEqualTo(403);
|
||||
String openapi = request("GET", "/qqq/v1/openapi.yaml", null, ADMIN, null).body();
|
||||
assertThat(openapi).contains("alertsInboxSummary", "alertsRecordPanel").doesNotContain("/record-panel/watch", "/ingest/alertmanager");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical webhook uses the same secret and body validation outside v1.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalWebhook() throws Exception
|
||||
{
|
||||
System.setProperty(TOKEN_PROPERTY, TOKEN);
|
||||
config.withAlertmanagerIngestTokenSecretRef("${prop." + TOKEN_PROPERTY + "}");
|
||||
start();
|
||||
assertThat(request("POST", AlertsRouteProvider.BASE_PATH + "/ingest/alertmanager", "{}", ADMIN, "Bearer " + TOKEN).statusCode()).isEqualTo(404);
|
||||
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{}", "nobody", null).statusCode()).isEqualTo(401);
|
||||
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{}", "nobody", "Bearer wrong").statusCode()).isEqualTo(401);
|
||||
HttpResponse<String> response = request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{\"alerts\":[]}", "nobody", "Bearer " + TOKEN);
|
||||
assertThat(json(response)).containsEntry("raised", 0).containsEntry("resolved", 0).containsEntry("ignored", 0);
|
||||
assertThat(response.headers().firstValue("Deprecation")).isEmpty();
|
||||
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "[]", "nobody", "Bearer " + TOKEN).statusCode()).isEqualTo(400);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test the migration-only raw provider and prove strict mode refuses its alias.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void legacyWebhook() throws Exception
|
||||
{
|
||||
System.setProperty(TOKEN_PROPERTY, TOKEN);
|
||||
config.withAlertmanagerIngestTokenSecretRef("${prop." + TOKEN_PROPERTY + "}").withLegacyAlertmanagerPath(true);
|
||||
AlertsRouteProvider provider = new AlertsRouteProvider();
|
||||
provider.setQInstance(qInstance);
|
||||
Javalin app = Javalin.create(provider::acceptJavalinConfig).start(0);
|
||||
try
|
||||
{
|
||||
port = app.port();
|
||||
String alias = AlertsRouteProvider.BASE_PATH + "/ingest/alertmanager";
|
||||
for(String path : List.of(alias, AlertsRouteProvider.WEBHOOK_PATH))
|
||||
{
|
||||
assertThat(request("POST", path, "{}", "nobody", "Bearer wrong").statusCode()).isEqualTo(401);
|
||||
HttpResponse<String> response = request("POST", path, "{\"alerts\":[]}", "nobody", "Bearer " + TOKEN);
|
||||
assertThat(json(response)).containsEntry("raised", 0);
|
||||
if(path.equals(alias))
|
||||
{
|
||||
assertThat(response.headers().firstValue("Deprecation")).contains("true");
|
||||
assertThat(response.headers().firstValue("Sunset")).contains(AlertsRouteProvider.LEGACY_SUNSET);
|
||||
}
|
||||
}
|
||||
assertThatThrownBy(() -> UiApiRouteAuditor.audit(app, List.of(new MiddlewareVersionV1()), true))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining(alias);
|
||||
}
|
||||
finally
|
||||
{
|
||||
app.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import com.kingsrook.qbits.alerts.metadata.AlertsAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -171,7 +171,13 @@ class AlertsQBitProducerTest extends BaseAlertsTest
|
||||
}
|
||||
for(String tableName : AlertsQBitConfig.allTableNames())
|
||||
{
|
||||
int start = changelog.indexOf("CREATE TABLE IF NOT EXISTS " + tableName + " (");
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// alerts_delivery was created as alerts_notification (changeset 13) and //
|
||||
// renamed (16); the user-notification table is the later alerts_notification //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
String created = "alerts_delivery".equals(tableName) ? "alerts_notification" : tableName;
|
||||
int start = "alerts_notification".equals(tableName) ? changelog.lastIndexOf("CREATE TABLE IF NOT EXISTS alerts_notification (")
|
||||
: changelog.indexOf("CREATE TABLE IF NOT EXISTS " + created + " (");
|
||||
String body = changelog.substring(start, changelog.indexOf(");", start));
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
@@ -193,7 +199,7 @@ class AlertsQBitProducerTest extends BaseAlertsTest
|
||||
assertThat(available).contains(AlertsQBitConfig.PERMISSION_VIEW, AlertsQBitConfig.PERMISSION_MANAGE, AlertsQBitConfig.PERMISSION_ADMIN, "alerts_alert.read");
|
||||
assertThat(config.getManagerPermissionNames()).containsAll(config.getViewerPermissionNames()).contains(AlertsQBitConfig.PERMISSION_MANAGE);
|
||||
assertThat(config.getAdminPermissionNames()).containsAll(config.getManagerPermissionNames());
|
||||
assertThat(config.getViewerPermissionNames()).doesNotContain(AlertsQBitConfig.PERMISSION_ADMIN, "alerts_channel.read", "alerts_notification.read");
|
||||
assertThat(config.getViewerPermissionNames()).doesNotContain(AlertsQBitConfig.PERMISSION_ADMIN, "alerts_channel.read", "alerts_delivery.read");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -361,7 +361,7 @@ public class BaseAlertsTest
|
||||
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession automatedSessionForUser, java.io.Serializable userId)
|
||||
{
|
||||
String id = String.valueOf(userId);
|
||||
if(id.startsWith("admin") || id.startsWith("manager") || id.startsWith("viewer") || id.startsWith("outsider"))
|
||||
if(id.startsWith("admin") || id.startsWith("author") || id.startsWith("manager") || id.startsWith("viewer") || id.startsWith("outsider"))
|
||||
{
|
||||
automatedSessionForUser.setPermissions(sessionFor(id).getPermissions());
|
||||
}
|
||||
@@ -385,6 +385,10 @@ public class BaseAlertsTest
|
||||
{
|
||||
session.getPermissions().addAll(defaults.getAdminPermissionNames());
|
||||
}
|
||||
else if(userId.startsWith("author"))
|
||||
{
|
||||
session.getPermissions().addAll(defaults.getNotificationAdminPermissionNames());
|
||||
}
|
||||
else if(userId.startsWith("manager"))
|
||||
{
|
||||
session.getPermissions().addAll(defaults.getManagerPermissionNames());
|
||||
|
||||
@@ -28,12 +28,12 @@ import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
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.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
@@ -185,7 +185,7 @@ class HomeAndApiTest extends BaseAlertsTest
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
|
||||
assertThat(items).hasSize(2);
|
||||
assertThat(items.get(0)).containsKeys("title", "href", "group", "age", "unread");
|
||||
assertThat(items.get(0)).containsKeys("id", "title", "href", "source", "category", "age", "unread", "alertId");
|
||||
|
||||
api.markRead(Map.of("alertId", items.get(0).get("alertId")));
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(1);
|
||||
@@ -201,7 +201,7 @@ class HomeAndApiTest extends BaseAlertsTest
|
||||
|
||||
/*******************************************************************************
|
||||
** The record panel: hidden with nothing to show; open alerts and recent
|
||||
** resolutions otherwise; "Watch alerts for this record".
|
||||
** resolutions otherwise; legacy watch rows and collaboration capability.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecordPanel() throws Exception
|
||||
@@ -225,13 +225,18 @@ class HomeAndApiTest extends BaseAlertsTest
|
||||
assertThat(other.get("visible")).isEqualTo(true);
|
||||
assertThat(other.get("openCount")).isEqualTo(0);
|
||||
|
||||
assertThat(api.watchRecord(Map.of("tableName", WIDGET, "recordId", widgets.get(0))).get("created")).isEqualTo(true);
|
||||
assertThat(api.watchRecord(Map.of("tableName", WIDGET, "recordId", widgets.get(0))).get("created")).isEqualTo(false);
|
||||
assertThat(panel.get("follow")).isEqualTo(Map.of("available", false));
|
||||
com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance)
|
||||
.withExtension(new com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0"));
|
||||
assertThat(api.recordPanel(WIDGET, String.valueOf(widgets.get(0))).get("follow")).isEqualTo(Map.of("available", true));
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
|
||||
.withValue("subscriberType", "USER").withValue("userId", ADMIN).withValue("scopeType", "RECORD")
|
||||
.withValue("scopeValue", WIDGET).withValue("scopeRecordId", String.valueOf(widgets.get(0)))
|
||||
.withValue("enabled", true)));
|
||||
assertThat(api.recordPanel(WIDGET, String.valueOf(widgets.get(0))).get("watching")).isEqualTo(true);
|
||||
assertThat(all(AlertsSubscription.TABLE_NAME)).anyMatch(s -> "RECORD".equals(s.getValueString("scopeType")));
|
||||
|
||||
assertThatThrownBy(() -> api.recordPanel("nope", "1")).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> api.watchRecord(Map.of())).isInstanceOf(QBadRequestException.class);
|
||||
loginAs(OUTSIDER);
|
||||
assertThatThrownBy(() -> api.recordPanel(WIDGET, "1")).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
@@ -24,24 +24,24 @@ import java.util.Map;
|
||||
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.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
@@ -312,9 +312,9 @@ class SecurityAndActionsTest extends BaseAlertsTest
|
||||
QRecord email = runtime.asSystem(() -> AlertsDb.first(AlertsChannel.TABLE_NAME, AlertsDb.eq("name", "email")));
|
||||
List<String> test = run(AlertsAction.SEND_TEST, List.of(email.getValueInteger("id")), Map.of());
|
||||
assertThat(test).anyMatch(line -> line.startsWith("ERROR"));
|
||||
QRecord failed = all(AlertsNotification.TABLE_NAME).stream().filter(n -> "TEST".equals(n.getValueString("kind"))).findFirst().orElseThrow();
|
||||
QRecord failed = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> "TEST".equals(n.getValueString("kind"))).findFirst().orElseThrow();
|
||||
assertThat(failed.getValueString("status")).isEqualTo("FAILED");
|
||||
assertThat(run(AlertsAction.RETRY_DELIVERY, List.of(failed.getValueInteger("id")), Map.of())).anyMatch(line -> line.startsWith("OK: 1"));
|
||||
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsNotification.TABLE_NAME, failed.getValue("id"))).getValueString("status")).isEqualTo("PENDING");
|
||||
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsDelivery.TABLE_NAME, failed.getValue("id"))).getValueString("status")).isEqualTo("PENDING");
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,8 @@ import com.kingsrook.qbits.alerts.delivery.AlertMessage;
|
||||
import com.kingsrook.qbits.alerts.delivery.AlertSecretResolver;
|
||||
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertCheckInterface;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
|
||||
@@ -26,10 +26,11 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels;
|
||||
import com.kingsrook.qbits.alerts.delivery.channels.StandardWebhookSigner;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookVerifier;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -172,8 +173,8 @@ class ExternalChannelsTest
|
||||
String timestamp = request.headers().get("Webhook-timestamp").get(0);
|
||||
String signature = request.headers().get("Webhook-signature").get(0);
|
||||
assertThat(id).startsWith("msg_");
|
||||
String expected = StandardWebhookSigner.sign(id, Long.parseLong(timestamp), request.body().getBytes(StandardCharsets.UTF_8), "whsec_c2VjcmV0LWtleS1mb3ItdGVzdHM=");
|
||||
assertThat(signature).isEqualTo("v1," + expected);
|
||||
new WebhookVerifier(List.of(WebhookSecret.parse("whsec_c2VjcmV0LWtleS1mb3ItdGVzdHM=")))
|
||||
.verify(Map.of("webhook-id", id, "webhook-timestamp", timestamp, "webhook-signature", signature), request.body().getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(request.body()).contains("\"type\":\"qqq.alert.resolved\"").contains("\"dedupKey\":\"provider:cf\"");
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
@@ -236,9 +237,9 @@ class ExternalChannelsTest
|
||||
List<?> resolvedBody = JsonUtils.toObject(received.get(1).body(), List.class);
|
||||
assertThat(((Map<?, ?>) resolvedBody.get(0)).get("endsAt")).isEqualTo("2026-10-12T11:00:00Z");
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// a test message is one stand-in alert, already over //
|
||||
//////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////
|
||||
// a test message is one sample alert, already over //
|
||||
////////////////////////////////////////////////////
|
||||
alertmanager.deliver(new AlertMessage("TEST: hello", "t", "h", "INFO", "TEST", List.of(), "https://x/app/alerts_alert"), channel, null, secrets);
|
||||
assertThat(received.get(2).body()).contains("alerts.test");
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertRouter;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationKind;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -86,21 +86,21 @@ class RenderingTest
|
||||
.withValue("summary", "The nightly job stopped.").withValue("details", "password=hunter2 in the log <script>x</script>").withValue("itemCount", 3)
|
||||
.withValue("firstRaisedAt", now.minusSeconds(3 * 3600));
|
||||
|
||||
AlertMessage minimal = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.MINIMAL)), NotificationKind.RAISED, "https://x.example/", now);
|
||||
AlertMessage minimal = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.MINIMAL)), DeliveryKind.RAISED, "https://x.example/", now);
|
||||
assertThat(minimal.subject()).isEqualTo("[ERROR] Backups failed for [redacted]");
|
||||
assertThat(minimal.text()).contains("3 h").doesNotContain("nightly job").doesNotContain("hunter2");
|
||||
assertThat(minimal.link()).isEqualTo("https://x.example/app/alerts_alert/7");
|
||||
|
||||
AlertMessage standard = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.STANDARD)), NotificationKind.RAISED, null, now);
|
||||
AlertMessage standard = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.STANDARD)), DeliveryKind.RAISED, null, now);
|
||||
assertThat(standard.text()).contains("nightly job").contains("3 records").doesNotContain("hunter2");
|
||||
|
||||
AlertMessage full = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.FULL)), NotificationKind.RESOLVED, null, now);
|
||||
AlertMessage full = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.FULL)), DeliveryKind.RESOLVED, null, now);
|
||||
assertThat(full.subject()).startsWith("[RESOLVED]");
|
||||
assertThat(full.text()).contains("[redacted] in the log").doesNotContain("hunter2");
|
||||
assertThat(full.html()).contains("<").doesNotContain("<script");
|
||||
|
||||
AlertMessage test = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", null, DetailLevel.MINIMAL),
|
||||
new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.MINIMAL)), NotificationKind.TEST, null, now);
|
||||
new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.MINIMAL)), DeliveryKind.TEST, null, now);
|
||||
assertThat(test.subject()).isEqualTo("TEST: 2 new alerts");
|
||||
assertThat(DetailLevel.lowerOf(DetailLevel.FULL, DetailLevel.MINIMAL)).isEqualTo(DetailLevel.MINIMAL);
|
||||
assertThat(AlertMessageRenderer.age(null, now)).isEqualTo("now");
|
||||
@@ -160,6 +160,6 @@ class RenderingTest
|
||||
assertThat(AlertsPossibleValues.label("IDP_GROUP")).isEqualTo("IdP Group");
|
||||
assertThat(AlertsPossibleValues.label("IN_APP")).isEqualTo("In App");
|
||||
assertThat(AlertsPossibleValues.label(null)).isNull();
|
||||
assertThat(AlertsPossibleValues.buildEnumSources()).hasSize(19);
|
||||
assertThat(AlertsPossibleValues.buildEnumSources()).hasSize(25);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.alerts.delivery.channels.StandardWebhookSigner;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed wire vectors generated independently with Python's hmac/hashlib.
|
||||
** Cover both secret encodings, short legacy keys and a key over one block.
|
||||
*******************************************************************************/
|
||||
class StandardWebhookSignerTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Raw and whsec_ secrets produce the exact same legacy header values.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@MethodSource("wireVectors")
|
||||
void testLegacyWireFormat(String key, String expectedSignature)
|
||||
{
|
||||
Instant timestamp = Instant.ofEpochSecond(1791806400L);
|
||||
byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8);
|
||||
Map<String, String> expected = Map.of("webhook-id", "msg_test", "webhook-timestamp", "1791806400", "webhook-signature", expectedSignature);
|
||||
assertThat(StandardWebhookSigner.headers("msg_test", timestamp, body, " " + key + " ")).isEqualTo(expected);
|
||||
String encoded = "whsec_" + Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(StandardWebhookSigner.headers("msg_test", timestamp, body, encoded)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Independent expected signatures, including the 15/16-byte core boundary.
|
||||
*******************************************************************************/
|
||||
private static Stream<Arguments> wireVectors()
|
||||
{
|
||||
return (Stream.of(
|
||||
Arguments.of("short", "v1,Jz84jUiA/gQwIt0Td1DzUA/OE5soKzC5H9C5e94lliM="),
|
||||
Arguments.of("sixteen-byte-key", "v1,qep/FxanCVlGy2Zohku2Bgd3ufM4KNRcj5qT8t7blNA="),
|
||||
Arguments.of("sixteen-byte-key!", "v1,tbs9jXA3r4N06ZLpuOe6V4RasiRwYwbM2+2ZxRu8lNU="),
|
||||
Arguments.of("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "v1,K6s1XISWGs8vzyUo3Ecush1eoxy/d5CiND+gzS70I8U=")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty or malformed keys still fail before any request is sent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testInvalidSecrets()
|
||||
{
|
||||
for(String secret : new String[] { null, "", " ", "whsec_", "whsec_!" })
|
||||
{
|
||||
assertThatThrownBy(() -> StandardWebhookSigner.headers("msg_test", Instant.EPOCH, null, secret)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,15 +39,20 @@ import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
|
||||
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.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.ResolveMode;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
|
||||
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.alerts.AlertsInstanceMetaData;
|
||||
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.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
@@ -169,6 +174,55 @@ public class AlertsDemoServer extends AbstractQQQApplication
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User notifications for the bell, the page and the admin screens: an
|
||||
** announcement, a sticky security notice, a failed job, a share, a
|
||||
** marketing note, a draft and a scheduled one.
|
||||
*******************************************************************************/
|
||||
static void seedNotifications(AlertsRuntime runtime, BaseAlertsTest.MutableClock clock) throws QException
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
AlertsDb.insert("alerts_user_preference", new QRecord().withValue("userId", "sam").withValue("userLabel", "Sam Rivera").withValue("inAppEnabled", true)
|
||||
.withValue("emailEnabled", false).withValue("digestHour", 8));
|
||||
return (null);
|
||||
});
|
||||
NotificationService service = runtime.getNotifications();
|
||||
clock.set(now.minus(Duration.ofDays(3)));
|
||||
service.notify(new NotificationSignal("Version 4.1 is out").withBody("**What's new:** a notification center in the top bar, record pickers with thumbnails, "
|
||||
+ "and dark mode for the SQL Workbench. " + "\n\n" + "See the release notes for everything else.")
|
||||
.withSeverity(NotificationSeverity.SUCCESS).withCategory(NotificationCategory.RELEASE).withSourceName("admin")
|
||||
.withLink(AlertLink.toUrl("https://github.com/QRun-IO/qqq/releases", "Release notes")).withAudience(NotificationAudience.allUsers()));
|
||||
clock.set(now.minus(Duration.ofDays(1)));
|
||||
service.notify(new NotificationSignal("Spring content review starts Monday").withBody("Editors: please review your top 10 posts' excerpts and images before the campaign.")
|
||||
.withCategory(NotificationCategory.MARKETING).withSourceName("qbit-marketing").withAudience(NotificationAudience.allUsers()));
|
||||
clock.set(now.minus(Duration.ofHours(5)));
|
||||
service.notify(new NotificationSignal("Sam shared the notebook \"Q3 traffic\" with you").withCategory(NotificationCategory.SHARE).withSourceName("qbit-sql-workbench")
|
||||
.withLink(AlertLink.toPath("/app/blog_post", "Open notebook")).withAudience(NotificationAudience.user(ADMIN)));
|
||||
clock.set(now.minus(Duration.ofMinutes(40)));
|
||||
service.notify(new NotificationSignal("Blog post import finished with errors").withBody("3 rows had errors: the publish date was not a date. The other 117 rows were imported.")
|
||||
.withSeverity(NotificationSeverity.WARNING).withCategory(NotificationCategory.JOB).withSourceName("processes").withSourceKey("process:demo-import")
|
||||
.withLink(AlertLink.toPath("/app/blog_post", "Open Blog Posts")).withAudience(NotificationAudience.user(ADMIN)));
|
||||
clock.set(now.minus(Duration.ofMinutes(8)));
|
||||
service.notify(new NotificationSignal("New sign-in to your account").withBody("A sign-in from Firefox on Windows near Columbus, OH. If this wasn't you, change your password now.")
|
||||
.withSeverity(NotificationSeverity.CRITICAL).withCategory(NotificationCategory.SECURITY).withSourceName("security").withSticky(true)
|
||||
.withAudience(NotificationAudience.user(ADMIN)));
|
||||
clock.set(now);
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
AlertsDb.insert("alerts_notification", new QRecord().withValue("title", "Planned maintenance Saturday 06:00 UTC").withValue("body", "The admin is read-only for about 15 minutes.")
|
||||
.withValue("severity", "WARNING").withValue("category", "SYSTEM").withValue("status", "DRAFT").withValue("audienceType", "ALL_USERS").withValue("authorUserId", ADMIN)
|
||||
.withValue("sourceName", "admin").withValue("sticky", false));
|
||||
AlertsDb.insert("alerts_notification", new QRecord().withValue("title", "Holiday hours").withValue("body", "Support answers within one business day over the holidays.")
|
||||
.withValue("severity", "INFO").withValue("category", "SYSTEM").withValue("status", "SCHEDULED").withValue("audienceType", "ROLES").withValue("audienceValue", "staff")
|
||||
.withValue("publishAt", now.plus(Duration.ofDays(10))).withValue("authorUserId", ADMIN).withValue("sourceName", "admin").withValue("sticky", false));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the tables and produce 30 days of history through the engine.
|
||||
*******************************************************************************/
|
||||
@@ -253,29 +307,29 @@ public class AlertsDemoServer extends AbstractQQQApplication
|
||||
.withValue("finishedAt", clock.instant())).getValueInteger("id"));
|
||||
if(day == 11 || day == 23)
|
||||
{
|
||||
runtime.sink().raise(new AlertSignal("backups.job.failed").withDedupKey("nightly-db").withTitle("Nightly database backup failed")
|
||||
runtime.getSink().raise(new AlertSignal("backups.job.failed").withDedupKey("nightly-db").withTitle("Nightly database backup failed")
|
||||
.withSummary("The dump step exited with an error.").withDetails("pg_dump: error: connection to server lost")
|
||||
.withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(AlertLink.toRecord("backup_run", runId, "Open the run")));
|
||||
.withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(com.kingsrook.qqq.backend.core.alerts.AlertLink.toRecord("backup_run", runId, "Open the run")));
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime.sink().resolve(new com.kingsrook.qbits.alerts.spi.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded"));
|
||||
runtime.sink().heartbeat("backups.nightly-db", clock.instant());
|
||||
runtime.getSink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded"));
|
||||
runtime.getSink().heartbeat("backups.nightly-db", clock.instant());
|
||||
}
|
||||
}
|
||||
if(day % 4 == 1 || day == 27)
|
||||
{
|
||||
runtime.sink().raise(new AlertSignal("cdn.purge.dead").withDedupKey("provider:cloudfront").withTitle((2 + day % 3) + " CDN purges failed after retries")
|
||||
runtime.getSink().raise(new AlertSignal("cdn.purge.dead").withDedupKey("provider:cloudfront").withTitle((2 + day % 3) + " CDN purges failed after retries")
|
||||
.withSummary("Content will correct itself when the edge TTL expires.").withCount(2 + day % 3));
|
||||
if(day != 27)
|
||||
{
|
||||
clock.set(clock.instant().plus(Duration.ofHours(3)));
|
||||
runtime.sink().resolve(new com.kingsrook.qbits.alerts.spi.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded"));
|
||||
runtime.getSink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded"));
|
||||
}
|
||||
}
|
||||
if(day == 25)
|
||||
{
|
||||
runtime.sink().raise(new AlertSignal("storage.quota.near").withDedupKey("bucket:media").withTitle("Media bucket is at 84% of its quota").withCount(84));
|
||||
runtime.getSink().raise(new AlertSignal("storage.quota.near").withDedupKey("bucket:media").withTitle("Media bucket is at 84% of its quota").withCount(84));
|
||||
}
|
||||
if(day % 7 == 0 || day == 29)
|
||||
{
|
||||
@@ -292,6 +346,7 @@ public class AlertsDemoServer extends AbstractQQQApplication
|
||||
runtime.getEngine().comment(acked.getValueInteger("id"), ADMIN, "Asked the author for an excerpt.");
|
||||
}
|
||||
runtime.getDeliveryWorker().deliverDue();
|
||||
seedNotifications(runtime, clock);
|
||||
runtime.setClock(Clock.systemUTC());
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* 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.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.AlertsPreferences;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
import com.kingsrook.qbits.alerts.delivery.AlertMessage;
|
||||
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
|
||||
import com.kingsrook.qbits.alerts.delivery.EmailChannel;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationEmailer;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailQBitProducer;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiMessage;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Email through qbit-email: with the email qbit produced on the same H2
|
||||
** instance, alert channel emails, notification emails and digests become
|
||||
** email_message rows (one recipient each, the right template, category,
|
||||
** idempotency key and linked record); a repeat is a duplicate; no provider
|
||||
** is a refusal; an unsubscribed address is skipped.
|
||||
*******************************************************************************/
|
||||
class EmailQBitSendingTest extends BaseAlertsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The email qbit in the same instance, and the alerts config without a
|
||||
** messaging provider (qbit-email makes it unnecessary). G1 permits the
|
||||
** producer and configuration imports in test setup.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeInstance(QInstance qInstance)
|
||||
{
|
||||
try
|
||||
{
|
||||
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName(BACKEND_NAME).withPublicBaseUrl("https://admin.example.com")).produce(qInstance);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The email tables, next to the alerts ones (a clean database each test).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void primeDatabase() throws Exception
|
||||
{
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("DROP ALL OBJECTS");
|
||||
}
|
||||
super.primeDatabase();
|
||||
String script;
|
||||
try(InputStream in = EmailQBitSendingTest.class.getClassLoader().getResourceAsStream("email-h2.sql"))
|
||||
{
|
||||
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : script.split(";"))
|
||||
{
|
||||
if(!sql.isBlank())
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An enabled default provider (messages are queued, not sent, in these tests).
|
||||
*******************************************************************************/
|
||||
private void addProvider() throws Exception
|
||||
{
|
||||
runtime.asSystem(() -> AlertsDb.insert("email_provider", new QRecord().withValue("name", "relay").withValue("label", "Relay").withValue("kind", "SMTP")
|
||||
.withValue("host", "127.0.0.1").withValue("port", 2525).withValue("security", "NONE").withValue("fromAddress", "sender@example.com")
|
||||
.withValue("enabled", true).withValue("isDefault", true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find messages through the API, oldest first. Read the fixture rows with
|
||||
** core QQQ for content assertions: the API deliberately omits bodies,
|
||||
** recipients and sender details from its metadata projection.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> messages() throws QException
|
||||
{
|
||||
List<QRecord> messages = new ArrayList<>();
|
||||
for(EmailApiMessage message : EmailApi.findMessagesByIdempotencyKeyPrefix("alerts:", 100))
|
||||
{
|
||||
messages.add(runtime.asSystem(() -> AlertsDb.byId("email_message", message.id())));
|
||||
}
|
||||
return (messages);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The name of a message's template.
|
||||
*******************************************************************************/
|
||||
private String templateName(QRecord message) throws QException
|
||||
{
|
||||
return (EmailApi.getMessage(message.getValueInteger("id")).orElseThrow().templateName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's email settings and one category preference (as the
|
||||
** notification email tests do).
|
||||
*******************************************************************************/
|
||||
private void emailSettings(String userId, String category, String mode) throws QException
|
||||
{
|
||||
AlertsPreferences.ensure(runtime, loginAs(userId));
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", settings.getValue("id")).withValue("emailAddress", userId + "@example.com")
|
||||
.withValue("timeZone", "UTC").withValue("digestHour", 12));
|
||||
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", userId).withValue("category", category)
|
||||
.withValue("emailMode", mode).withValue("emailMinSeverity", "INFO"));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An alert message for alert 5.
|
||||
*******************************************************************************/
|
||||
private static AlertMessage alertMessage()
|
||||
{
|
||||
return (new AlertMessage("[ERROR] Export failed", "text", "<p>html</p>", "ERROR", "RAISED", List.of(5), "https://admin.example.com/app/alerts_alert/5",
|
||||
List.of(new AlertMessage.Entry(5, "jobs.export", "Jobs", "k", "ERROR", "OPEN", "Export failed", "3 records failed", "Nightly", 3,
|
||||
"https://admin.example.com/app/alerts_alert/5", "2026-10-12T12:00:00Z", null))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qbit is detected, and the email channel needs no messaging provider.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testActiveWithoutMessagingProvider() throws Exception
|
||||
{
|
||||
assertThat(AlertsEmail.isActive()).isTrue();
|
||||
assertThat(AlertsEmail.isActive(null)).isFalse();
|
||||
assertThat(EmailApi.isInstalled(qInstance)).isTrue();
|
||||
assertThat(new EmailChannel(new AlertsQBitConfig(), ChannelType.EMAIL).validate(null, null)).isEmpty();
|
||||
assertThat(new NotificationEmailer(runtime).isConfigured()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An alert email: one message, the alerts_alert template, NOTIFICATION, the
|
||||
** recipient, an idempotency key without the address, the alert as the linked
|
||||
** record, and the sender. Sending it again does not queue a second one.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAlertChannelEmail() throws Exception
|
||||
{
|
||||
addProvider();
|
||||
EmailChannel channel = new EmailChannel(config, ChannelType.EMAIL);
|
||||
DeliveryResult delivered = channel.deliver(alertMessage(), null, "ops@example.com", null);
|
||||
assertThat(delivered.outcome()).as("Delivery outcome: %s", delivered.error()).isEqualTo(DeliveryResult.Outcome.SENT);
|
||||
|
||||
List<QRecord> messages = messages();
|
||||
assertThat(messages).hasSize(1);
|
||||
QRecord message = messages.get(0);
|
||||
assertThat(templateName(message)).isEqualTo("alerts_alert");
|
||||
assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION");
|
||||
assertThat(message.getValueString("toAddresses")).isEqualTo("ops@example.com");
|
||||
assertThat(message.getValueString("subject")).isEqualTo("[ERROR] Export failed");
|
||||
assertThat(message.getValueString("idempotencyKey")).startsWith("alerts:alert:5:").doesNotContain("ops@example.com").contains(":RAISED:");
|
||||
assertThat(message.getValueString("linkedTableName")).isEqualTo("alerts_alert");
|
||||
assertThat(message.getValueString("linkedRecordId")).isEqualTo("5");
|
||||
assertThat(message.getValueString("senderQbit")).isEqualTo("qbit-alerts");
|
||||
assertThat(message.getValueString("senderProcess")).isEqualTo("alertsDeliverNotifications");
|
||||
assertThat(message.getValueString("htmlBody")).contains("Export failed").contains("3 records").contains("https://admin.example.com/app/alerts_alert/5");
|
||||
|
||||
assertThat(channel.deliver(alertMessage(), null, "ops@example.com", null).outcome()).isEqualTo(DeliveryResult.Outcome.SENT);
|
||||
assertThat(messages()).hasSize(1);
|
||||
|
||||
clock.advance(java.time.Duration.ofMinutes(10));
|
||||
assertThat(channel.deliver(alertMessage(), null, "ops@example.com", null).outcome()).isEqualTo(DeliveryResult.Outcome.SENT);
|
||||
assertThat(messages()).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A list channel sends one message per address.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEmailListIsOneMessagePerAddress() throws Exception
|
||||
{
|
||||
addProvider();
|
||||
QRecord list = new QRecord().withValue("settings", "{\"addresses\": [\"ops@example.com\", \"dev@example.com\"]}");
|
||||
assertThat(new EmailChannel(config, ChannelType.EMAIL_LIST).deliver(alertMessage(), list, null, null).outcome()).isEqualTo(DeliveryResult.Outcome.SENT);
|
||||
|
||||
List<QRecord> messages = messages();
|
||||
assertThat(messages).hasSize(2);
|
||||
assertThat(messages).extracting(m -> m.getValueString("toAddresses")).containsExactlyInAnyOrder("ops@example.com", "dev@example.com");
|
||||
assertThat(messages).extracting(m -> m.getValueString("idempotencyKey")).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No enabled provider: qbit-email refuses, and the channel fails (not retried)
|
||||
** saying so.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRefusedWithoutProvider() throws Exception
|
||||
{
|
||||
DeliveryResult result = new EmailChannel(config, ChannelType.EMAIL).deliver(alertMessage(), null, "ops@example.com", null);
|
||||
assertThat(result.outcome()).isEqualTo(DeliveryResult.Outcome.FAILED);
|
||||
assertThat(result.error()).startsWith("Email was refused");
|
||||
assertThat(messages()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unsubscribed address is suppressed: the channel says so, nothing is sent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testSuppressedAddress() throws Exception
|
||||
{
|
||||
addProvider();
|
||||
runtime.asSystem(() -> AlertsDb.insert("email_suppression", new QRecord().withValue("address", "ops@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE")));
|
||||
DeliveryResult result = new EmailChannel(config, ChannelType.EMAIL).deliver(alertMessage(), null, "ops@example.com", null);
|
||||
assertThat(result.outcome()).isEqualTo(DeliveryResult.Outcome.FAILED);
|
||||
assertThat(result.error()).contains("suppressed");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An immediate notification email: one message, alerts_notification, the
|
||||
** notification row linked, one recipient; running again sends nothing more.
|
||||
** SECURITY notices carry no body.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testImmediateNotification() throws Exception
|
||||
{
|
||||
addProvider();
|
||||
emailSettings("viewer-1", "JOB", "IMMEDIATE");
|
||||
emailSettings("viewer-2", "SECURITY", "IMMEDIATE");
|
||||
loginAs(ADMIN);
|
||||
QAlerts.notify(new NotificationSignal("Export failed").withBody("Disk full").withSeverity(NotificationSeverity.WARNING)
|
||||
.withCategory(NotificationCategory.JOB).withLink(AlertLink.toPath("/app/widget", "Widgets")).withAudience(NotificationAudience.users("viewer-1")));
|
||||
QAlerts.notify(new NotificationSignal("New sign-in").withBody("Secret detail").withSeverity(NotificationSeverity.WARNING)
|
||||
.withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.users("viewer-2")));
|
||||
|
||||
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
|
||||
assertThat(result.sent()).isEqualTo(2);
|
||||
|
||||
List<QRecord> messages = messages();
|
||||
assertThat(messages).hasSize(2);
|
||||
QRecord job = messages.stream().filter(m -> "viewer-1@example.com".equals(m.getValueString("toAddresses"))).findFirst().orElseThrow();
|
||||
assertThat(templateName(job)).isEqualTo("alerts_notification");
|
||||
assertThat(job.getValueString("category")).isEqualTo("NOTIFICATION");
|
||||
assertThat(job.getValueString("subject")).isEqualTo("Export failed");
|
||||
assertThat(job.getValueString("idempotencyKey")).startsWith("alerts:notification:").doesNotContain("viewer-1@example.com");
|
||||
assertThat(job.getValueString("linkedTableName")).isEqualTo("alerts_notification");
|
||||
assertThat(job.getValueString("linkedRecordId")).isNotBlank();
|
||||
assertThat(job.getValueString("senderQbit")).isEqualTo("qbit-alerts");
|
||||
assertThat(job.getValueString("senderProcess")).isEqualTo("alertsDeliverNotifications");
|
||||
assertThat(job.getValueString("htmlBody")).contains("Disk full").contains("https://admin.example.com/app/widget").contains("My Notification Settings");
|
||||
QRecord security = messages.stream().filter(m -> "viewer-2@example.com".equals(m.getValueString("toAddresses"))).findFirst().orElseThrow();
|
||||
assertThat(security.getValueString("htmlBody")).doesNotContain("Secret detail");
|
||||
|
||||
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(0);
|
||||
assertThat(messages()).hasSize(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification with no enabled provider fails (the row says FAILED).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotificationRefused() throws Exception
|
||||
{
|
||||
emailSettings("viewer-1", "JOB", "IMMEDIATE");
|
||||
loginAs(ADMIN);
|
||||
QAlerts.notify(new NotificationSignal("Export failed").withSeverity(NotificationSeverity.WARNING).withCategory(NotificationCategory.JOB)
|
||||
.withAudience(NotificationAudience.users("viewer-1")));
|
||||
NotificationEmailer.Result refused = new NotificationEmailer(runtime).run();
|
||||
assertThat(refused.failed()).isEqualTo(1);
|
||||
assertThat(refused.sent()).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification to an address that has been suppressed is skipped.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotificationSuppressed() throws Exception
|
||||
{
|
||||
addProvider();
|
||||
emailSettings("viewer-1", "JOB", "IMMEDIATE");
|
||||
loginAs(ADMIN);
|
||||
QAlerts.notify(new NotificationSignal("Export failed").withSeverity(NotificationSeverity.WARNING).withCategory(NotificationCategory.JOB)
|
||||
.withAudience(NotificationAudience.users("viewer-1")));
|
||||
runtime.asSystem(() -> AlertsDb.insert("email_suppression", new QRecord().withValue("address", "viewer-1@example.com").withValue("scope", "ALL").withValue("reason", "HARD_BOUNCE")));
|
||||
NotificationEmailer.Result suppressed = new NotificationEmailer(runtime).run();
|
||||
assertThat(suppressed.skipped()).isEqualTo(1);
|
||||
assertThat(suppressed.sent()).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The daily digest: one message to the user, alerts_digest, keyed by user and
|
||||
** window start, listing the unread items.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDigest() throws Exception
|
||||
{
|
||||
addProvider();
|
||||
clock.set(Instant.parse("2026-10-12T12:05:00Z"));
|
||||
emailSettings("viewer-1", "RELEASE", "DAILY_DIGEST");
|
||||
loginAs(ADMIN);
|
||||
QAlerts.notify(new NotificationSignal("Version 2.5 is out").withBody("Dark mode").withCategory(NotificationCategory.RELEASE)
|
||||
.withAudience(NotificationAudience.user("viewer-1")));
|
||||
QAlerts.notify(new NotificationSignal("Version 2.6 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(1);
|
||||
List<QRecord> messages = messages();
|
||||
assertThat(messages).hasSize(1);
|
||||
QRecord message = messages.get(0);
|
||||
assertThat(templateName(message)).isEqualTo("alerts_digest");
|
||||
assertThat(message.getValueString("category")).isEqualTo("NOTIFICATION");
|
||||
assertThat(message.getValueString("toAddresses")).isEqualTo("viewer-1@example.com");
|
||||
assertThat(message.getValueString("subject")).isEqualTo("2 new notifications");
|
||||
assertThat(message.getValueString("idempotencyKey")).isEqualTo("alerts:digest:viewer-1:2026-10-12T12:00:00Z");
|
||||
assertThat(message.getValueString("htmlBody")).contains("Version 2.5 is out").contains("Dark mode").contains("Version 2.6 is out");
|
||||
assertThat(message.getValueString("linkedTableName")).isEqualTo("alerts_notification");
|
||||
assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient hashes are stable, case-insensitive and hide the address.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHash()
|
||||
{
|
||||
assertThat(AlertsEmailSender.hash("Ops@Example.com ")).isEqualTo(AlertsEmailSender.hash("ops@example.com")).hasSize(16).doesNotContain("ops");
|
||||
assertThat(AlertsEmailSender.hash("a@example.com")).isNotEqualTo(AlertsEmailSender.hash("b@example.com"));
|
||||
}
|
||||
}
|
||||
@@ -35,13 +35,13 @@ 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.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
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.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
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.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
@@ -340,11 +340,11 @@ class AlertsEngineTest extends BaseAlertsTest
|
||||
@Test
|
||||
void testSink() throws Exception
|
||||
{
|
||||
runtime.sink().raise(signal("k1"));
|
||||
runtime.sink().heartbeat("sink.beat", null);
|
||||
runtime.sink().resolve(new AlertResolution(TYPE, "k1", "gone"));
|
||||
runtime.sink().raise(null);
|
||||
runtime.sink().resolve(null);
|
||||
runtime.getSink().raise(signal("k1"));
|
||||
runtime.getSink().heartbeat("sink.beat", null);
|
||||
runtime.getSink().resolve(new AlertResolution(TYPE, "k1", "gone"));
|
||||
runtime.getSink().raise(null);
|
||||
runtime.getSink().resolve(null);
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
assertThat(alerts).hasSize(1);
|
||||
assertThat(alerts.get(0).getValueString("status")).isEqualTo("RESOLVED");
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.engine;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface;
|
||||
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.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core followers enter the normal routing pipeline without a collaboration qbit.
|
||||
*******************************************************************************/
|
||||
class FollowerFanoutTest extends BaseAlertsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Keep provider state isolated between tests; absence is the default.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void resetProvider()
|
||||
{
|
||||
Provider.followers = List.of();
|
||||
Provider.references.clear();
|
||||
Provider.failure = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the optional core SPI in instance metadata, with FOLLOW enabled.
|
||||
*******************************************************************************/
|
||||
private void followers(String... userIds)
|
||||
{
|
||||
Provider.followers = java.util.Arrays.stream(userIds).map(id -> new RecordFollower().withUserId(id).withNotifyOnChange(true)).toList();
|
||||
RecordFeaturesMetaData.ofOrWithNew(qInstance).withEnabled(RecordFeature.FOLLOW, true)
|
||||
.withFollowersProvider(new QCodeReference(Provider.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A normal subscriber must retain their destination when also following.
|
||||
*******************************************************************************/
|
||||
private void subscribe(String userId) throws QException
|
||||
{
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
|
||||
.withValue("subscriberType", "USER").withValue("userId", userId)
|
||||
.withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("enabled", true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use a real source row, without a subject-table visibility lock on the alert.
|
||||
*******************************************************************************/
|
||||
private void raise(Integer... widgetIds) throws QException
|
||||
{
|
||||
AlertSignal signal = new AlertSignal("test.followers").withTitle("Widget needs attention").withSeverity(AlertSeverity.ERROR)
|
||||
.withDedupKey(java.util.Arrays.toString(widgetIds));
|
||||
for(Integer widgetId : widgetIds)
|
||||
{
|
||||
signal.withRecordRef(AlertRecordRef.of(WIDGET, widgetId));
|
||||
}
|
||||
runtime.getEngine().raise(signal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Followers from multiple refs and an existing subscription receive only once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void followersJoinSubscribersOnce() throws Exception
|
||||
{
|
||||
List<Integer> widgets = insertWidgets("BROKEN", "BROKEN");
|
||||
followers(VIEWER, "viewer-2", VIEWER);
|
||||
subscribe(VIEWER);
|
||||
raise(widgets.toArray(Integer[]::new));
|
||||
|
||||
assertThat(Provider.references).containsExactly(new RecordReference(WIDGET, widgets.get(0).toString()), new RecordReference(WIDGET, widgets.get(1).toString()));
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactlyInAnyOrder(VIEWER, "viewer-2");
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2).allSatisfy(row -> assertThat(row.getValueString("status")).isEqualTo("SENT"));
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", VIEWER))).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValue("subscriptionId")).isNotNull());
|
||||
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo(ADMIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table access and a known recipient session are required, even for the inbox.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void inaccessibleAndUnknownFollowersAreExcluded() throws Exception
|
||||
{
|
||||
followers(VIEWER, OUTSIDER, "unknown-user", "");
|
||||
raise(insertWidgets("BROKEN").get(0));
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).extracting(row -> row.getValueString("recipientUserId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table permission alone is insufficient when the source has record locks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void sourceRecordLocksAreEnforced() throws Exception
|
||||
{
|
||||
Integer widget = insertWidgets("BROKEN").get(0);
|
||||
runtime.asSystem(() -> AlertsDb.update(WIDGET, new QRecord().withValue("id", widget).withValue("name", VIEWER)));
|
||||
qInstance.getTable(WIDGET).withRecordSecurityLock(AlertsSecurity.ownerLock("name"));
|
||||
followers(VIEWER, "viewer-2");
|
||||
raise(widget);
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No provider preserves subscription behavior, even with a source record.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noProviderPreservesSubscribers() throws Exception
|
||||
{
|
||||
subscribe(VIEWER);
|
||||
raise(insertWidgets("BROKEN").get(0));
|
||||
assertThat(Provider.references).isEmpty();
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recordless alerts never ask for followers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void noRecordDoesNotFanOut() throws Exception
|
||||
{
|
||||
followers(VIEWER);
|
||||
raise();
|
||||
assertThat(Provider.references).isEmpty();
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A disabled core FOLLOW feature leaves existing recipients intact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void disabledFollowPreservesSubscribers() throws Exception
|
||||
{
|
||||
followers("viewer-2");
|
||||
subscribe(VIEWER);
|
||||
Integer widget = insertWidgets("BROKEN").get(0);
|
||||
RecordFeaturesMetaData.ofOrWithNew(qInstance.getTable(WIDGET)).withEnabled(RecordFeature.FOLLOW, false);
|
||||
raise(widget);
|
||||
assertThat(Provider.references).isEmpty();
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core isolates provider failures so normal subscribers still receive the alert.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerFailurePreservesSubscribers() throws Exception
|
||||
{
|
||||
followers("viewer-2");
|
||||
subscribe(VIEWER);
|
||||
Provider.failure = true;
|
||||
raise(insertWidgets("BROKEN").get(0));
|
||||
assertThat(Provider.references).hasSize(1);
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Followers use ordinary in-app/email preferences and vacation suppression.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void followerPreferencesAndPauseApply() throws Exception
|
||||
{
|
||||
followers(VIEWER, "viewer-2", "viewer-3");
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", VIEWER).withValue("inAppEnabled", false).withValue("emailEnabled", false));
|
||||
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "viewer-2").withValue("inAppEnabled", true)
|
||||
.withValue("emailEnabled", true).withValue("emailAddress", "follower@example.com").withValue("pausedUntil", clock.instant().plus(Duration.ofDays(1))));
|
||||
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "viewer-3").withValue("inAppEnabled", false)
|
||||
.withValue("emailEnabled", true).withValue("emailAddress", "other@example.com"));
|
||||
return (null);
|
||||
});
|
||||
raise(insertWidgets("BROKEN").get(0));
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly("viewer-2");
|
||||
assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(3);
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("suppressedReason", "USER_PAUSED"))).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueString("recipientUserId")).isEqualTo("viewer-2"));
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", "viewer-3"))).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueString("status")).isEqualTo("PENDING"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A core-only provider; the event preference decision belongs to the provider.
|
||||
*******************************************************************************/
|
||||
public static class Provider implements RecordFollowersProviderInterface
|
||||
{
|
||||
static List<RecordFollower> followers = List.of();
|
||||
static final List<RecordReference> references = new ArrayList<>();
|
||||
static boolean failure;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Assert ALERT dispatch and record lookup context at the SPI boundary.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<RecordFollower> getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException
|
||||
{
|
||||
assertThat(instance).isSameAs(QContext.getQInstance());
|
||||
assertThat(event).isEqualTo(FollowEvent.ALERT);
|
||||
references.add(reference);
|
||||
if(failure)
|
||||
{
|
||||
throw (new QException("Test provider unavailable"));
|
||||
}
|
||||
return (followers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,13 @@ import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
|
||||
@@ -28,19 +28,20 @@ import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
|
||||
import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
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.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
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.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -128,7 +129,7 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
|
||||
*******************************************************************************/
|
||||
private List<QRecord> notifications() throws QException
|
||||
{
|
||||
return (all(AlertsNotification.TABLE_NAME));
|
||||
return (all(AlertsDelivery.TABLE_NAME));
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +156,8 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
|
||||
assertThat(sent).hasSize(1);
|
||||
assertThat(sent.get(0).getValueString("status")).isEqualTo("SENT");
|
||||
assertThat(sent.get(0).getValueString("recipientLabel")).isEqualTo("Name of admin-1 (in app)");
|
||||
assertThat(all(AlertsInboxItem.TABLE_NAME)).hasSize(1);
|
||||
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).hasSize(1);
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement().matches(n -> "ALERT".equals(n.getValueString("category")));
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// a viewer gets settings but no bootstrap subscription //
|
||||
@@ -312,7 +314,7 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
|
||||
List<QRecord> rows = notifications();
|
||||
assertThat(rows).anyMatch(r -> "viewer-2".equals(r.getValueString("recipientUserId")) && "SENT".equals(r.getValueString("status")));
|
||||
assertThat(rows).anyMatch(r -> "outsider-2".equals(r.getValueString("recipientUserId")) && "NOT_VISIBLE".equals(r.getValueString("suppressedReason")));
|
||||
assertThat(where(AlertsInboxItem.TABLE_NAME, AlertsDb.eq("userId", "outsider-2"))).isEmpty();
|
||||
assertThat(where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "outsider-2"))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -350,7 +352,7 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
|
||||
clock.advance(wait);
|
||||
runtime.getDeliveryWorker().deliverDue();
|
||||
}
|
||||
QRecord dead = runtime.asSystem(() -> AlertsDb.byId(AlertsNotification.TABLE_NAME, retrying.getValue("id")));
|
||||
QRecord dead = runtime.asSystem(() -> AlertsDb.byId(AlertsDelivery.TABLE_NAME, retrying.getValue("id")));
|
||||
assertThat(dead.getValueString("status")).isEqualTo("DEAD");
|
||||
QRecord unhealthy = runtime.asSystem(() -> AlertsDb.byId(AlertsChannel.TABLE_NAME, channel.getValue("id")));
|
||||
assertThat(unhealthy.getValueString("healthStatus")).isEqualTo("UNHEALTHY");
|
||||
@@ -358,7 +360,7 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
|
||||
QRecord failing = runtime.asSystem(() -> AlertsDb.first(AlertsAlert.TABLE_NAME, AlertsDb.eq("alertTypeName", DeliveryWorker.CHANNEL_FAILING_TYPE)));
|
||||
assertThat(failing).isNotNull();
|
||||
assertThat(failing.getValueString("status")).isEqualTo("OPEN");
|
||||
assertThat(where(AlertsNotification.TABLE_NAME, AlertsDb.eq("alertId", failing.getValue("id")))).allMatch(r -> r.getValue("recipientUserId") != null);
|
||||
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("alertId", failing.getValue("id")))).allMatch(r -> r.getValue("recipientUserId") != null);
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// retry, and a success heals the channel //
|
||||
|
||||
@@ -24,9 +24,13 @@ import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsScheduledSteps;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertResolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -41,6 +45,38 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class SinkAndScheduleTest extends BaseAlertsTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A host using only core reaches the qbit through instance metadata and
|
||||
** the loadable adapter, including the asynchronous queue and persistence.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreProducerReachesInstalledSink() throws Exception
|
||||
{
|
||||
AlertsInstanceMetaData metadata = AlertsInstanceMetaData.of(qInstance);
|
||||
assertThat(metadata.getSink().getName()).isEqualTo(AlertsSinkAdapter.class.getName());
|
||||
assertThat(qInstance.getSupplementalMetaData("alertsQBitStandIn")).isNull();
|
||||
metadata.withAlertType(new QAlertTypeMetaData().withName("host.backup.failed").withDefaultSeverity(AlertSeverity.ERROR));
|
||||
long failures = QAlerts.getProducerFailureCount();
|
||||
|
||||
QAlerts.raise(new AlertSignal("host.backup.failed").withDedupKey("nightly").withTitle("Nightly backup failed"));
|
||||
QAlerts.heartbeat("host.backup.nightly");
|
||||
runtime.getSink().stop();
|
||||
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
assertThat(alerts).hasSize(1);
|
||||
assertThat(alerts.get(0).getValueString("title")).isEqualTo("Nightly backup failed");
|
||||
assertThat(alerts.get(0).getValueString("severity")).isEqualTo("ERROR");
|
||||
assertThat(alerts.get(0).getValueString("status")).isEqualTo("OPEN");
|
||||
assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1);
|
||||
|
||||
QAlerts.resolve("host.backup.failed", "nightly", "Retry succeeded");
|
||||
runtime.getSink().stop();
|
||||
assertThat(all(AlertsAlert.TABLE_NAME).get(0).getValueString("status")).isEqualTo("RESOLVED");
|
||||
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process signals on the background queue in these tests.
|
||||
*******************************************************************************/
|
||||
@@ -66,9 +102,9 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
{
|
||||
String type = "test.stop.thing" + i;
|
||||
adapter.raise(new AlertSignal(type).withTitle("Stop " + i));
|
||||
runtime.sink().stop();
|
||||
runtime.getSink().stop();
|
||||
adapter.resolve(new AlertResolution(type, null, "done"));
|
||||
runtime.sink().stop();
|
||||
runtime.getSink().stop();
|
||||
}
|
||||
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
@@ -87,7 +123,7 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
AlertsSinkAdapter adapter = new AlertsSinkAdapter();
|
||||
adapter.raise(new AlertSignal("test.queue.thing").withTitle("Queued"));
|
||||
adapter.heartbeat("queue.beat", null);
|
||||
runtime.sink().flush();
|
||||
runtime.getSink().flush();
|
||||
for(int i = 0; i < 50 && all(AlertsAlert.TABLE_NAME).isEmpty(); i++)
|
||||
{
|
||||
Thread.sleep(50);
|
||||
@@ -95,7 +131,7 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
assertThat(all(AlertsAlert.TABLE_NAME)).hasSize(1);
|
||||
|
||||
adapter.resolve(new AlertResolution("test.queue.thing", null, "done"));
|
||||
runtime.sink().stop();
|
||||
runtime.getSink().stop();
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
assertThat(alerts.get(0).getValueString("status")).isEqualTo("RESOLVED");
|
||||
assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1);
|
||||
@@ -111,10 +147,10 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
{
|
||||
for(int i = 0; i < 1005; i++)
|
||||
{
|
||||
runtime.sink().raise(new AlertSignal("test.flood.thing").withDedupKey("same"));
|
||||
runtime.getSink().raise(new AlertSignal("test.flood.thing").withDedupKey("same"));
|
||||
}
|
||||
assertThat(runtime.sink().getDroppedCount()).isEqualTo(5);
|
||||
runtime.sink().stop();
|
||||
assertThat(runtime.getSink().getDroppedCount()).isEqualTo(5);
|
||||
runtime.getSink().stop();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class EntitiesTest
|
||||
{
|
||||
static final List<Class<? extends QRecordEntity>> ENTITIES = List.of(AlertsAlert.class, AlertsChannel.class, AlertsEvent.class, AlertsGroup.class, AlertsHeartbeat.class,
|
||||
AlertsInboxItem.class, AlertsMonitor.class, AlertsMute.class, AlertsNotification.class, AlertsRecordRef.class, AlertsSubscription.class, AlertsTeam.class,
|
||||
AlertsMonitor.class, AlertsNotification.class, AlertsNotificationRecipient.class, AlertsNotificationPreference.class, AlertsMute.class, AlertsDelivery.class, AlertsRecordRef.class, AlertsSubscription.class, AlertsTeam.class,
|
||||
AlertsTeamMember.class, AlertsType.class, AlertsUserPreference.class);
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* 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.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
import com.kingsrook.qbits.alerts.TestSteps;
|
||||
import com.kingsrook.qbits.alerts.api.AlertsApi;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsProcessListener;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Job notifications: a person's failed or partial process runs reach their
|
||||
** bell (docs/NOTIFICATIONS.md 6.1).
|
||||
*******************************************************************************/
|
||||
class JobNotificationsTest extends BaseAlertsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void resetFlaky()
|
||||
{
|
||||
TestSteps.fail = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the test process as the current session.
|
||||
*******************************************************************************/
|
||||
private RunProcessOutput runNightly() throws Exception
|
||||
{
|
||||
return (new RunProcessAction().execute(new RunProcessInput().withProcessName("nightlyThing").withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A failed run notifies the person who started it, once per run.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testFailedRunNotifiesStarter() throws Exception
|
||||
{
|
||||
TestSteps.fail = true;
|
||||
loginAs("viewer-1");
|
||||
try
|
||||
{
|
||||
runNightly();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// the run's own failure is expected //
|
||||
////////////////////////////////////////
|
||||
}
|
||||
|
||||
List<QRecord> notifications = runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME));
|
||||
assertThat(notifications).singleElement().satisfies(notification ->
|
||||
{
|
||||
assertThat(notification.getValueString("title")).isEqualTo("Nightly thing failed");
|
||||
assertThat(notification.getValueString("body")).contains("disk full");
|
||||
assertThat(notification.getValueString("category")).isEqualTo("JOB");
|
||||
assertThat(notification.getValueString("severity")).isEqualTo("ERROR");
|
||||
assertThat(notification.getValueString("sourceKey")).startsWith("process:");
|
||||
assertThat(notification.getValueString("audienceValue")).isEqualTo("viewer-1");
|
||||
});
|
||||
|
||||
Map<String, Object> summary = new AlertsApi(runtime).inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(1);
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// system runs and a successful run notify nobody //
|
||||
//////////////////////////////////////////////////////////////
|
||||
QContext.init(qInstance, AlertsSecurity.systemSession());
|
||||
try
|
||||
{
|
||||
runNightly();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// the run's own failure is expected //
|
||||
////////////////////////////////////////
|
||||
}
|
||||
TestSteps.fail = false;
|
||||
loginAs("viewer-1");
|
||||
runNightly();
|
||||
assertThat(runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME))).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turned off, nothing is sent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testOff() throws Exception
|
||||
{
|
||||
config.setNotifyFailedJobs(false);
|
||||
TestSteps.fail = true;
|
||||
loginAs("viewer-1");
|
||||
try
|
||||
{
|
||||
runNightly();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// the run's own failure is expected //
|
||||
////////////////////////////////////////
|
||||
}
|
||||
assertThat(runtime.asSystem(() -> AlertsDb.count(AlertsNotification.TABLE_NAME, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()))).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Partial runs: ERROR summary lines count; links follow the pattern.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPartialRunsAndLinks()
|
||||
{
|
||||
RunProcessOutput output = new RunProcessOutput();
|
||||
assertThat(AlertsProcessListener.errorCount(output)).isEqualTo(0);
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
lines.add(new ProcessSummaryLine(Status.OK, 10, "rows were loaded"));
|
||||
lines.add(new ProcessSummaryLine(Status.ERROR, 3, "rows had bad dates"));
|
||||
output.addValue(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY, lines);
|
||||
assertThat(AlertsProcessListener.errorCount(output)).isEqualTo(3);
|
||||
assertThat(AlertsProcessListener.errorCount(null)).isEqualTo(0);
|
||||
|
||||
assertThat(AlertsProcessListener.jobLink(runtime, "p", "u-1", "widget").getPath()).isEqualTo("/app/widget");
|
||||
assertThat(AlertsProcessListener.jobLink(runtime, "p", "u-1", null)).isNull();
|
||||
config.setJobRunLinkPattern("/runs/{processName}/{processUUID}");
|
||||
assertThat(AlertsProcessListener.jobLink(runtime, "p", "u-1", null).getPath()).isEqualTo("/runs/p/u-1");
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.notifications;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import com.kingsrook.qbits.alerts.AlertsPreferences;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notification email (immediate and daily digest) and the ready-made notices.
|
||||
*******************************************************************************/
|
||||
class NotificationEmailAndNoticesTest extends BaseAlertsTest
|
||||
{
|
||||
static final List<SendMessageInput> SENT = new CopyOnWriteArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Captures messages instead of sending them.
|
||||
*******************************************************************************/
|
||||
public static class CapturingProvider implements MessagingProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("alerts-capture");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SendMessageOutput sendMessage(SendMessageInput sendMessageInput)
|
||||
{
|
||||
SENT.add(sendMessageInput);
|
||||
return (new SendMessageOutput());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(AlertsQBitConfig config)
|
||||
{
|
||||
QMessagingProviderDispatcher.registerMessagingProvider(new CapturingProvider());
|
||||
qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("capture").withType("alerts-capture"));
|
||||
SENT.clear();
|
||||
config.withMessagingProviderName("capture").withFromAddress("alerts@example.com");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's email settings: address, time zone and digest hour, and one
|
||||
** category preference.
|
||||
*******************************************************************************/
|
||||
private void emailSettings(String userId, String category, String mode, String minSeverity) throws QException
|
||||
{
|
||||
AlertsPreferences.ensure(runtime, loginAs(userId));
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
QRecord settings = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
|
||||
AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", settings.getValue("id")).withValue("emailAddress", userId + "@example.com")
|
||||
.withValue("timeZone", "UTC").withValue("digestHour", 12));
|
||||
AlertsDb.insert(AlertsNotificationPreference.TABLE_NAME, new QRecord().withValue("userId", userId).withValue("category", category)
|
||||
.withValue("emailMode", mode).withValue("emailMinSeverity", minSeverity));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private String emailStatus(String userId) throws QException
|
||||
{
|
||||
return (runtime.asSystem(() -> AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId))).getValueString("emailStatus"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String text(SendMessageInput input)
|
||||
{
|
||||
return (input.getContentList().stream().map(Content::getBody).reduce("", String::concat));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immediate email at or above the minimum severity; below it is skipped;
|
||||
** users without email settings are left alone.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testImmediate() throws Exception
|
||||
{
|
||||
emailSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING");
|
||||
emailSettings("viewer-2", "JOB", "IMMEDIATE", "ERROR");
|
||||
QAlerts.notify(new NotificationSignal("Export failed").withBody("Disk <full>").withSeverity(NotificationSeverity.WARNING)
|
||||
.withCategory(NotificationCategory.JOB).withLink(AlertLink.toPath("/app/widget", "Widgets")).withAudience(NotificationAudience.users("viewer-1", "viewer-2", "manager-1")));
|
||||
|
||||
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
|
||||
assertThat(result.sent()).isEqualTo(1);
|
||||
assertThat(result.skipped()).isEqualTo(1);
|
||||
assertThat(SENT).singleElement().satisfies(message ->
|
||||
{
|
||||
assertThat(message.getSubject()).isEqualTo("Export failed");
|
||||
assertThat(text(message)).contains("Disk <full>").contains("https://admin.example.com/app/widget").contains("My Notification Settings");
|
||||
});
|
||||
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
|
||||
assertThat(emailStatus("viewer-2")).isEqualTo("SKIPPED");
|
||||
assertThat(emailStatus("manager-1")).isEqualTo("NONE");
|
||||
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The daily digest waits for the user's hour, then lists the unread ones.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDigest() throws Exception
|
||||
{
|
||||
clock.set(Instant.parse("2026-10-12T09:00:00Z"));
|
||||
emailSettings("viewer-1", "RELEASE", "DAILY_DIGEST", "INFO");
|
||||
AlertsNotices.release("2.4", "Dark mode everywhere.", null);
|
||||
QAlerts.notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0);
|
||||
clock.set(Instant.parse("2026-10-12T12:05:00Z"));
|
||||
assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(1);
|
||||
assertThat(SENT).singleElement().satisfies(message ->
|
||||
{
|
||||
assertThat(message.getSubject()).isEqualTo("2 new notifications");
|
||||
assertThat(text(message)).contains("Version 2.4 is out").contains("Dark mode everywhere.").contains("Version 2.5 is out");
|
||||
});
|
||||
assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0);
|
||||
assertThat(NotificationEmailer.isDigestHour(new QRecord().withValue("timeZone", "Not/AZone").withValue("digestHour", 12), clock.instant())).isTrue();
|
||||
assertThat(NotificationEmailer.atLeast("CRITICAL", null)).isTrue();
|
||||
assertThat(NotificationEmailer.atLeast("INFO", null)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Security notices: no body in email; the notices helpers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNotices() throws Exception
|
||||
{
|
||||
emailSettings("viewer-1", "SECURITY", "IMMEDIATE", "INFO");
|
||||
loginAs("viewer-1");
|
||||
AlertsNotices.newSignIn("viewer-1", "Firefox on Windows");
|
||||
AlertsNotices.apiKeyCreated("viewer-1", "ci-bot");
|
||||
AlertsNotices.apiKeyExpiring("viewer-1", "ci-bot", Instant.parse("2026-11-01T00:00:00Z"));
|
||||
AlertsNotices.apiKeyExpiring("viewer-1", "ci-bot", Instant.parse("2026-11-01T00:00:00Z"));
|
||||
AlertsNotices.permissionChanged("viewer-1", "You were added to the Editors role");
|
||||
AlertsNotices.shared("viewer-1", "Sam shared a notebook with you", AlertLink.toPath("/app/widget", "Open"), "qbit-sql-workbench");
|
||||
|
||||
List<QRecord> notifications = runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME));
|
||||
assertThat(notifications).hasSize(5);
|
||||
assertThat(notifications).filteredOn(n -> "SECURITY".equals(n.getValueString("category"))).hasSize(4);
|
||||
assertThat(notifications).anyMatch(n -> n.getValueString("title").equals("API key expires Nov 1, 2026: ci-bot"));
|
||||
|
||||
new NotificationEmailer(runtime).run();
|
||||
assertThat(SENT).hasSize(4);
|
||||
assertThat(SENT).noneMatch(message -> text(message).contains("change your password"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
/*
|
||||
* 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.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsPreferences;
|
||||
import com.kingsrook.qbits.alerts.BaseAlertsTest;
|
||||
import com.kingsrook.qbits.alerts.api.AlertsApi;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User notifications (docs/NOTIFICATIONS.md): authoring, audiences,
|
||||
** recipients, the bell and page API, actions, producers, retention.
|
||||
*******************************************************************************/
|
||||
class NotificationsTest extends BaseAlertsTest
|
||||
{
|
||||
static final String AUTHOR = "author-1";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A directory with roles, for ROLES audiences.
|
||||
*******************************************************************************/
|
||||
public static class TestDirectory implements AlertsUserDirectoryInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<DirectoryUser> listUsers()
|
||||
{
|
||||
return (List.of(
|
||||
new DirectoryUser("viewer-1", "Vera Viewer", "vera@example.com", Set.of("staff")),
|
||||
new DirectoryUser("viewer-2", "Victor Viewer", null, Set.of("staff", "editors")),
|
||||
new DirectoryUser("admin-1", "Ada Admin", null, Set.of("admins")),
|
||||
new DirectoryUser("outsider-1", "Otto Outsider", null, Set.of())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users who have opened the bell (the default directory).
|
||||
*******************************************************************************/
|
||||
private void knownUsers(String... userIds) throws QException
|
||||
{
|
||||
for(String userId : userIds)
|
||||
{
|
||||
AlertsPreferences.ensure(runtime, loginAs(userId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a notification as the current user; returns the record.
|
||||
*******************************************************************************/
|
||||
private QRecord author(QRecord notification) throws QException
|
||||
{
|
||||
return (new InsertAction().execute(new InsertInput(AlertsNotification.TABLE_NAME).withRecord(notification)).getRecords().get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> items(Map<String, Object> result)
|
||||
{
|
||||
return ((List<Map<String, Object>>) result.get("items"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run an action as the current user; the Result lines.
|
||||
*******************************************************************************/
|
||||
private List<String> run(AlertsAction action, List<Serializable> ids) throws QException
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput()
|
||||
.withProcessName(action.getProcessName())
|
||||
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)
|
||||
.withCallback(new QProcessCallback()
|
||||
{
|
||||
@Override
|
||||
public QQueryFilter getQueryFilter()
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Map<String, Serializable> getFieldValues(List<QFieldMetaData> fields)
|
||||
{
|
||||
return (new HashMap<>());
|
||||
}
|
||||
});
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
if(output.getException().isPresent())
|
||||
{
|
||||
throw (new QException(output.getException().get()));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<ProcessSummaryLineInterface> lines = (List<ProcessSummaryLineInterface>) output.getValue(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
List<String> text = new ArrayList<>();
|
||||
for(ProcessSummaryLineInterface line : lines == null ? List.<ProcessSummaryLineInterface>of() : lines)
|
||||
{
|
||||
text.add(line.getStatus() + ": " + (line instanceof ProcessSummaryLine summary ? summary.getCount() + " " + summary.getMessage() : String.valueOf(line)));
|
||||
}
|
||||
return (text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An author writes, previews, sends, edits and retracts an announcement;
|
||||
** viewers read, unread and dismiss it in the bell.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAuthorSendReadRetract() throws Exception
|
||||
{
|
||||
knownUsers("viewer-1", "viewer-2");
|
||||
loginAs(AUTHOR);
|
||||
QRecord draft = author(new QRecord().withValue("title", "Version 2.0 is out").withValue("body", "**New:** dark mode.")
|
||||
.withValue("severity", "SUCCESS").withValue("category", "RELEASE").withValue("audienceType", "ALL_USERS").withValue("linkUrl", "/app/releases"));
|
||||
assertThat(draft.getErrors()).isEmpty();
|
||||
Integer id = draft.getValueInteger("id");
|
||||
QRecord stored = AlertsDb.byId(AlertsNotification.TABLE_NAME, id);
|
||||
assertThat(stored.getValueString("status")).isEqualTo("DRAFT");
|
||||
assertThat(stored.getValueString("authorUserId")).isEqualTo(AUTHOR);
|
||||
assertThat(stored.getValueString("sourceName")).isEqualTo("admin");
|
||||
|
||||
List<String> preview = run(AlertsAction.PREVIEW_AUDIENCE, List.of(id));
|
||||
assertThat(String.join("\n", preview)).contains("Would reach 2 users").contains("Name of viewer-1");
|
||||
assertThat(runtime.asSystem(() -> all(AlertsNotificationRecipient.TABLE_NAME))).isEmpty();
|
||||
|
||||
List<String> sent = run(AlertsAction.SEND_NOTIFICATION, List.of(id));
|
||||
assertThat(String.join("\n", sent)).contains("notification was sent").contains("reached 2 new recipients");
|
||||
assertThat(AlertsDb.byId(AlertsNotification.TABLE_NAME, id).getValueString("status")).isEqualTo("SENT");
|
||||
assertThat(AlertsDb.byId(AlertsNotification.TABLE_NAME, id).getValueInteger("recipientCount")).isEqualTo(2);
|
||||
|
||||
loginAs("viewer-1");
|
||||
AlertsApi api = new AlertsApi(runtime);
|
||||
Map<String, Object> summary = api.inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(1);
|
||||
assertThat(summary.get("highestUnreadSeverity")).isEqualTo("SUCCESS");
|
||||
Map<String, Object> item = items(summary).get(0);
|
||||
assertThat(item).containsEntry("title", "Version 2.0 is out").containsEntry("category", "RELEASE").containsEntry("href", "/app/releases").containsEntry("unread", true);
|
||||
assertThat(item).doesNotContainKey("body");
|
||||
Integer mine = (Integer) item.get("id");
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// another user's row is invisible to me, and can't be changed //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
Integer theirs = runtime.asSystem(() -> AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "viewer-2"))).getValueInteger("id");
|
||||
assertThat(AlertsDb.byId(AlertsNotificationRecipient.TABLE_NAME, theirs)).isNull();
|
||||
assertThat(api.markRead(Map.of("id", theirs)).get("updated")).isEqualTo(0);
|
||||
|
||||
assertThat(api.markRead(Map.of("id", mine)).get("updated")).isEqualTo(1);
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
|
||||
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsNotification.TABLE_NAME, id)).getValueInteger("readCount")).isEqualTo(1);
|
||||
assertThat(api.markUnread(Map.of("ids", List.of(mine))).get("updated")).isEqualTo(1);
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(1);
|
||||
assertThatThrownBy(() -> api.markUnread(Map.of("all", true))).hasMessageContaining("Send id or ids");
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the page has the body; editing after send changes what I see //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
assertThat(items(api.inbox(Map.of())).get(0).get("body")).isEqualTo("**New:** dark mode.");
|
||||
loginAs(AUTHOR);
|
||||
new UpdateAction().execute(new UpdateInput(AlertsNotification.TABLE_NAME).withRecord(new QRecord().withValue("id", id).withValue("title", "Version 2.0.1 is out")
|
||||
.withValue("status", "DRAFT").withValue("recipientCount", 99)));
|
||||
assertThat(AlertsDb.byId(AlertsNotification.TABLE_NAME, id).getValueString("status")).isEqualTo("SENT");
|
||||
loginAs("viewer-1");
|
||||
assertThat(items(api.inboxSummary()).get(0).get("title")).isEqualTo("Version 2.0.1 is out");
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// a user can change only read and dismissed on their row //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
QRecord moved = new UpdateAction().execute(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecord(new QRecord().withValue("id", mine)
|
||||
.withValue("userId", "viewer-2"))).getRecords().get(0);
|
||||
assertThat(moved.getErrors()).isNotEmpty();
|
||||
new UpdateAction().execute(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecord(new QRecord().withValue("id", mine)
|
||||
.withValue("emailStatus", "SENT").withValue("readAt", Instant.now())));
|
||||
QRecord row = AlertsDb.byId(AlertsNotificationRecipient.TABLE_NAME, mine);
|
||||
assertThat(row.getValueString("userId")).isEqualTo("viewer-1");
|
||||
assertThat(row.getValueString("emailStatus")).isEqualTo("NONE");
|
||||
assertThat(row.getValue("readAt")).isNotNull();
|
||||
assertThat(new DeleteAction().execute(new DeleteInput(AlertsNotificationRecipient.TABLE_NAME).withPrimaryKeys(List.of(mine))).getDeletedRecordCount()).isEqualTo(0);
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// retracting hides it everywhere //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
loginAs(AUTHOR);
|
||||
assertThat(String.join("\n", run(AlertsAction.RETRACT_NOTIFICATION, List.of(id)))).contains("notification was retracted");
|
||||
assertThat(String.join("\n", run(AlertsAction.SEND_NOTIFICATION, List.of(id)))).contains("can't be sent");
|
||||
loginAs("viewer-2");
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
|
||||
assertThat(items(api.inbox(Map.of()))).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Dismiss, all, the alertId form, and page filters and paging.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDismissAndPage() throws Exception
|
||||
{
|
||||
for(int i = 0; i < 4; i++)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Job " + i + " failed").withSeverity(i % 2 == 0 ? NotificationSeverity.ERROR : NotificationSeverity.WARNING)
|
||||
.withCategory(NotificationCategory.JOB).withAudience(NotificationAudience.user("viewer-1")));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
}
|
||||
QAlerts.notify(new NotificationSignal("Sign-in from a new device").withCategory(NotificationCategory.SECURITY)
|
||||
.withSeverity(NotificationSeverity.CRITICAL).withSticky(true).withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
loginAs("viewer-1");
|
||||
AlertsApi api = new AlertsApi(runtime);
|
||||
Map<String, Object> summary = api.inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(5);
|
||||
assertThat(summary.get("highestUnreadSeverity")).isEqualTo("CRITICAL");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> banners = (List<Map<String, Object>>) summary.get("banners");
|
||||
assertThat(banners).singleElement().satisfies(banner -> assertThat(banner).containsEntry("title", "Sign-in from a new device").containsKey("body"));
|
||||
assertThat(items(summary).get(0).get("title")).isEqualTo("Sign-in from a new device");
|
||||
|
||||
assertThat(api.inbox(Map.of("category", "job")).get("total")).isEqualTo(4);
|
||||
assertThat(api.inbox(Map.of("severity", "ERROR")).get("total")).isEqualTo(2);
|
||||
Map<String, Object> page = api.inbox(Map.of("offset", "1", "limit", "2"));
|
||||
assertThat(page).containsEntry("total", 5).containsEntry("offset", 1).containsEntry("limit", 2);
|
||||
assertThat(items(page)).extracting(i -> i.get("title")).containsExactly("Job 3 failed", "Job 2 failed");
|
||||
|
||||
assertThat(api.dismiss(Map.of("id", banners.get(0).get("id"))).get("updated")).isEqualTo(1);
|
||||
summary = api.inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(4);
|
||||
assertThat((List<?>) summary.get("banners")).isEmpty();
|
||||
assertThat(api.markRead(Map.of("all", true)).get("updated")).isEqualTo(4);
|
||||
assertThat(api.inbox(Map.of("status", "unread")).get("total")).isEqualTo(0);
|
||||
assertThat(api.dismiss(Map.of("all", true)).get("updated")).isEqualTo(4);
|
||||
assertThat(api.inbox(Map.of()).get("total")).isEqualTo(0);
|
||||
assertThatThrownBy(() -> api.dismiss(Map.of())).hasMessageContaining("Send id");
|
||||
assertThatThrownBy(() -> api.dismiss(Map.of("id", "x"))).hasMessageContaining("numbers");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producers: a repeated sourceKey updates one notification and brings it
|
||||
** back to unread; links are made safe; bad signals are refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testProducerDedupAndLinks() throws Exception
|
||||
{
|
||||
NotificationSignal signal = new NotificationSignal("Export failed").withSourceKey("process:abc").withCategory(NotificationCategory.JOB)
|
||||
.withLink(AlertLink.toRecord("widget", 5, "Widget")).withAudience(NotificationAudience.user("viewer-1"));
|
||||
Integer first = runtime.getNotifications().notify(signal);
|
||||
loginAs("viewer-1");
|
||||
AlertsApi api = new AlertsApi(runtime);
|
||||
api.markRead(Map.of("all", true));
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
|
||||
|
||||
QAlerts.notify(signal.withTitle("Export failed again").withAudience(NotificationAudience.users(" viewer-1 ", "viewer-1")));
|
||||
assertThat(runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME))).singleElement().satisfies(notification ->
|
||||
{
|
||||
assertThat(notification.getValueInteger("id")).isEqualTo(first);
|
||||
assertThat(notification.getValueString("audienceValue")).isEqualTo("viewer-1");
|
||||
});
|
||||
Map<String, Object> summary = api.inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(1);
|
||||
assertThat(items(summary).get(0)).containsEntry("title", "Export failed again").containsEntry("href", "/app/widget/5");
|
||||
|
||||
assertThat(NotificationService.linkUrl(AlertLink.toUrl("javascript:alert(1)", "x"))).isNull();
|
||||
assertThat(NotificationService.linkUrl(AlertLink.toUrl("https://example.com/x", "x"))).isEqualTo("https://example.com/x");
|
||||
assertThat(NotificationService.linkUrl(AlertLink.toPath("/app/alerts", "x"))).isEqualTo("/app/alerts");
|
||||
assertThat(NotificationService.safeLink("//evil.example.com")).isNull();
|
||||
for(String bad : List.of("/\\evil.com", "\\\\evil.com", "javascript:alert(1)", "data:text/html,x", "http://x.example.com", "https:///nohost", "/a b", "/a\tb", "https://"))
|
||||
{
|
||||
assertThat(NotificationService.safeLink(bad)).as(bad).isNull();
|
||||
}
|
||||
assertThat(NotificationService.safeLink(" https://example.com/x ")).isEqualTo("https://example.com/x");
|
||||
assertThat(NotificationService.safeLink("/")).isEqualTo("/");
|
||||
assertThatThrownBy(() -> runtime.getNotifications().notify(new NotificationSignal("No audience"))).hasMessageContaining("audience");
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// through the sink (the producer SPI path) //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
QAlerts.notify(new NotificationSignal("Shared with you: Q3 notebook").withCategory(NotificationCategory.SHARE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core-only notification producers deliver to the selected user's bell.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreNotificationDelivery() throws Exception
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Shipment delivered")
|
||||
.withSeverity(NotificationSeverity.INFO).withCategory(NotificationCategory.JOB)
|
||||
.withLink(AlertLink.toRecord("widget", 5, "Shipment"))
|
||||
.withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
loginAs("viewer-1");
|
||||
Map<String, Object> summary = new AlertsApi(runtime).inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(1);
|
||||
assertThat(items(summary).get(0)).containsEntry("title", "Shipment delivered").containsEntry("href", "/app/widget/5");
|
||||
loginAs("viewer-2");
|
||||
assertThat(new AlertsApi(runtime).inboxSummary().get("unreadCount")).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every core audience factory resolves through the persisted notification.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreAudiences() throws Exception
|
||||
{
|
||||
config.setUserDirectory(new QCodeReference(TestDirectory.class));
|
||||
Map<NotificationAudience, List<String>> audiences = Map.of(
|
||||
NotificationAudience.user("viewer-1"), List.of("viewer-1"),
|
||||
NotificationAudience.users(" viewer-1 ", "viewer-2", "viewer-1"), List.of("viewer-1", "viewer-2"),
|
||||
NotificationAudience.roles(" staff ", "editors"), List.of("viewer-1", "viewer-2"),
|
||||
NotificationAudience.permission(" alerts.admin "), List.of("admin-1"),
|
||||
NotificationAudience.allUsers(), List.of("viewer-1", "viewer-2", "admin-1", "outsider-1"));
|
||||
for(var entry : audiences.entrySet())
|
||||
{
|
||||
String key = entry.getKey().toString();
|
||||
QAlerts.notify(new NotificationSignal("Audience test").withSourceKey(key).withAudience(entry.getKey()));
|
||||
QRecord notification = runtime.asSystem(() -> AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", key)));
|
||||
assertThat(notification).isNotNull();
|
||||
assertThat(notification.getValueString("category")).isEqualTo("SYSTEM");
|
||||
assertThat(runtime.asSystem(() -> where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notification.getValue("id")))))
|
||||
.extracting(record -> record.getValueString("userId")).containsExactlyInAnyOrderElementsOf(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core commit deferral and rollback apply before the qbit creates recipients.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreNotificationTransactions() throws Exception
|
||||
{
|
||||
QBackendTransaction committed = new QBackendTransaction();
|
||||
QAlerts.notify(new NotificationSignal("Committed").withAudience(NotificationAudience.user("viewer-1")), committed);
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).isEmpty();
|
||||
committed.commit();
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement()
|
||||
.satisfies(record -> assertThat(record.getValueString("title")).isEqualTo("Committed"));
|
||||
|
||||
QBackendTransaction rolledBack = new QBackendTransaction();
|
||||
QContext.setTransaction(rolledBack);
|
||||
try
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Rolled back").withAudience(NotificationAudience.user("viewer-1")));
|
||||
rolledBack.rollback();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setTransaction(null);
|
||||
}
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).hasSize(1);
|
||||
loginAs("viewer-1");
|
||||
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("unreadCount", 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audiences: roles from a directory, permission holders, warnings.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testAudiences() throws Exception
|
||||
{
|
||||
knownUsers("viewer-1");
|
||||
NotificationService service = runtime.getNotifications();
|
||||
QRecord roles = new QRecord().withValue("audienceType", "ROLES").withValue("audienceValue", "editors");
|
||||
assertThat(service.preview(roles).userIds()).isEmpty();
|
||||
assertThat(service.preview(roles).warnings()).anyMatch(w -> w.contains("no roles"));
|
||||
|
||||
config.setUserDirectory(new QCodeReference(TestDirectory.class));
|
||||
assertThat(service.preview(roles).userIds()).containsExactly("viewer-2");
|
||||
assertThat(service.preview(new QRecord().withValue("audienceType", "ROLES").withValue("audienceValue", "STAFF, admins")).userIds())
|
||||
.containsExactlyInAnyOrder("viewer-1", "viewer-2", "admin-1");
|
||||
assertThat(service.preview(new QRecord().withValue("audienceType", "PERMISSION").withValue("audienceValue", "alerts.admin")).userIds()).containsExactly("admin-1");
|
||||
assertThat(service.preview(new QRecord().withValue("audienceType", "USERS").withValue("audienceValue", "viewer-1\nnew-person")).labels())
|
||||
.containsEntry("viewer-1", "Vera Viewer").containsEntry("new-person", "new-person");
|
||||
assertThat(service.preview(new QRecord().withValue("audienceType", "nonsense")).warnings()).isNotEmpty();
|
||||
|
||||
config.setMaxAudienceSize(2);
|
||||
AlertsAudienceResolver.Resolved capped = service.preview(new QRecord().withValue("audienceType", "ALL_USERS"));
|
||||
assertThat(capped.userIds()).hasSize(2);
|
||||
assertThat(capped.warnings()).anyMatch(w -> w.contains("maxAudienceSize"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validation on the tables: links, audiences, security mutes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testValidation() throws Exception
|
||||
{
|
||||
loginAs(AUTHOR);
|
||||
assertThat(author(new QRecord().withValue("title", "x").withValue("severity", "INFO").withValue("category", "SYSTEM").withValue("audienceType", "ALL_USERS")
|
||||
.withValue("linkUrl", "javascript:alert(1)")).getErrors()).anyMatch(e -> e.getMessage().contains("app path"));
|
||||
assertThat(author(new QRecord().withValue("title", "x").withValue("severity", "INFO").withValue("category", "SYSTEM").withValue("audienceType", "USERS"))
|
||||
.getErrors()).anyMatch(e -> e.getMessage().contains("user ids"));
|
||||
|
||||
loginAs("viewer-1");
|
||||
QRecord muted = new InsertAction().execute(new InsertInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("category", "SECURITY").withValue("inAppMuted", true))).getRecords().get(0);
|
||||
assertThat(muted.getErrors()).anyMatch(e -> e.getMessage().contains("can't be muted"));
|
||||
QRecord security = new InsertAction().execute(new InsertInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("category", "SECURITY").withValue("emailMode", "IMMEDIATE"))).getRecords().get(0);
|
||||
assertThat(security.getErrors()).isEmpty();
|
||||
assertThat(AlertsDb.byId(AlertsNotificationPreference.TABLE_NAME, security.getValue("id")).getValueString("userId")).isEqualTo("viewer-1");
|
||||
QRecord update = new UpdateAction().execute(new UpdateInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("id", security.getValue("id")).withValue("inAppMuted", true))).getRecords().get(0);
|
||||
assertThat(update.getErrors()).anyMatch(e -> e.getMessage().contains("can't be muted"));
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// a muted category skips the user; security is never skipped //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
new InsertAction().execute(new InsertInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord().withValue("category", "MARKETING").withValue("inAppMuted", true)));
|
||||
QAlerts.notify(new NotificationSignal("Big sale").withCategory(NotificationCategory.MARKETING).withAudience(NotificationAudience.users("viewer-1", "viewer-2")));
|
||||
QAlerts.notify(new NotificationSignal("Password changed").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user("viewer-1")));
|
||||
assertThat(new AlertsApi(runtime).inboxSummary().get("unreadCount")).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduling, expiry, purge, and deleting with recipients.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testScheduleExpireAndPurge() throws Exception
|
||||
{
|
||||
knownUsers("viewer-1");
|
||||
loginAs(AUTHOR);
|
||||
QRecord draft = author(new QRecord().withValue("title", "Maintenance tonight").withValue("severity", "WARNING").withValue("category", "SYSTEM")
|
||||
.withValue("audienceType", "USERS").withValue("audienceValue", "viewer-1"));
|
||||
Integer id = draft.getValueInteger("id");
|
||||
assertThat(String.join("\n", run(AlertsAction.SCHEDULE_NOTIFICATION, List.of(id)))).contains("future");
|
||||
|
||||
new UpdateAction().execute(new UpdateInput(AlertsNotification.TABLE_NAME).withRecord(new QRecord().withValue("id", id)
|
||||
.withValue("publishAt", clock.instant().plus(Duration.ofHours(1))).withValue("expireAt", clock.instant().plus(Duration.ofHours(3)))));
|
||||
assertThat(String.join("\n", run(AlertsAction.SCHEDULE_NOTIFICATION, List.of(id)))).contains("notification was scheduled");
|
||||
assertThat(runtime.getNotifications().sendDue()).isEqualTo(0);
|
||||
clock.advance(Duration.ofMinutes(61));
|
||||
assertThat(runtime.getNotifications().sendDue()).isEqualTo(1);
|
||||
|
||||
loginAs("viewer-1");
|
||||
AlertsApi api = new AlertsApi(runtime);
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(1);
|
||||
clock.advance(Duration.ofHours(3));
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
|
||||
|
||||
assertThat(runtime.getNotifications().purge()).isEqualTo(0);
|
||||
clock.advance(Duration.ofDays(8));
|
||||
assertThat(runtime.getNotifications().purge()).isEqualTo(1);
|
||||
assertThat(runtime.asSystem(() -> all(AlertsNotificationRecipient.TABLE_NAME))).isEmpty();
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// deleting a notification deletes its recipients //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
Integer other = runtime.getNotifications().notify(new NotificationSignal("Hello").withAudience(NotificationAudience.user("viewer-1")));
|
||||
loginAs(AUTHOR);
|
||||
new DeleteAction().execute(new DeleteInput(AlertsNotification.TABLE_NAME).withPrimaryKeys(List.of(other)));
|
||||
assertThat(runtime.asSystem(() -> all(AlertsNotificationRecipient.TABLE_NAME))).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
DROP TABLE IF EXISTS alerts_heartbeat;
|
||||
DROP TABLE IF EXISTS alerts_inbox_item;
|
||||
DROP TABLE IF EXISTS alerts_notification_preference;
|
||||
DROP TABLE IF EXISTS alerts_notification_recipient;
|
||||
DROP TABLE IF EXISTS alerts_notification;
|
||||
DROP TABLE IF EXISTS alerts_delivery;
|
||||
DROP TABLE IF EXISTS alerts_mute;
|
||||
DROP TABLE IF EXISTS alerts_channel;
|
||||
DROP TABLE IF EXISTS alerts_team_member;
|
||||
@@ -274,7 +277,7 @@ CREATE TABLE IF NOT EXISTS alerts_mute (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_mute_starts_at_ends_at ON alerts_mute (starts_at, ends_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts_notification (
|
||||
CREATE TABLE IF NOT EXISTS alerts_delivery (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
alert_id INTEGER,
|
||||
alert_event_id INTEGER,
|
||||
@@ -293,26 +296,11 @@ CREATE TABLE IF NOT EXISTS alerts_notification (
|
||||
last_error VARCHAR(2000),
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification PRIMARY KEY (id)
|
||||
CONSTRAINT pk_alerts_delivery PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_status_next_attempt_at ON alerts_notification (status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_alert_id ON alerts_notification (alert_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_channel_id_status ON alerts_notification (channel_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts_inbox_item (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
alert_id INTEGER NOT NULL,
|
||||
notification_id INTEGER,
|
||||
delivered_at TIMESTAMP,
|
||||
read_at TIMESTAMP,
|
||||
archived_at TIMESTAMP,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_inbox_item PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_inbox_item_user_id_read_at ON alerts_inbox_item (user_id, read_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_inbox_item_alert_id ON alerts_inbox_item (alert_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_status_next_attempt_at ON alerts_delivery (status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_alert_id ON alerts_delivery (alert_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_channel_id_status ON alerts_delivery (channel_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts_heartbeat (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
@@ -324,3 +312,64 @@ CREATE TABLE IF NOT EXISTS alerts_heartbeat (
|
||||
CONSTRAINT pk_alerts_heartbeat PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_heartbeat UNIQUE (heartbeat_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts_notification (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT,
|
||||
severity VARCHAR(16) NOT NULL,
|
||||
category VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(16),
|
||||
source_name VARCHAR(100),
|
||||
source_key VARCHAR(200),
|
||||
source_table_name VARCHAR(100),
|
||||
source_record_id VARCHAR(100),
|
||||
link_url VARCHAR(1000),
|
||||
link_label VARCHAR(200),
|
||||
audience_type VARCHAR(16) NOT NULL,
|
||||
audience_value TEXT,
|
||||
publish_at TIMESTAMP,
|
||||
expire_at TIMESTAMP,
|
||||
sticky BOOLEAN,
|
||||
author_user_id VARCHAR(100),
|
||||
sent_at TIMESTAMP,
|
||||
retracted_at TIMESTAMP,
|
||||
recipient_count INTEGER,
|
||||
read_count INTEGER,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_status_publish_at ON alerts_notification (status, publish_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_source_key ON alerts_notification (source_key);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_expire_at ON alerts_notification (expire_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts_notification_recipient (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
notification_id INTEGER NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
delivered_at TIMESTAMP,
|
||||
read_at TIMESTAMP,
|
||||
dismissed_at TIMESTAMP,
|
||||
emailed_at TIMESTAMP,
|
||||
email_status VARCHAR(16),
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification_recipient PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_notification_recipient UNIQUE (notification_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_user_id_read_at ON alerts_notification_recipient (user_id, read_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_email_status ON alerts_notification_recipient (email_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts_notification_preference (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
category VARCHAR(16) NOT NULL,
|
||||
in_app_muted BOOLEAN,
|
||||
email_mode VARCHAR(16),
|
||||
email_min_severity VARCHAR(16),
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id),
|
||||
CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category)
|
||||
);
|
||||
@@ -0,0 +1,241 @@
|
||||
-- Phases (a), (b) and (c). Apply before producing the qbit. Host security columns must be added by the host.
|
||||
|
||||
CREATE TABLE email_provider (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
kind VARCHAR(500),
|
||||
enabled BOOLEAN,
|
||||
is_default BOOLEAN,
|
||||
from_address VARCHAR(500),
|
||||
from_name VARCHAR(500),
|
||||
reply_to VARCHAR(500),
|
||||
sending_domain VARCHAR(500),
|
||||
host VARCHAR(500),
|
||||
port INTEGER,
|
||||
security VARCHAR(500),
|
||||
region VARCHAR(500),
|
||||
username_env_name VARCHAR(500),
|
||||
secret_env_name VARCHAR(500),
|
||||
webhook_secret_env_name VARCHAR(500),
|
||||
sns_topic_arn VARCHAR(500),
|
||||
configuration_set VARCHAR(500),
|
||||
message_stream VARCHAR(500),
|
||||
max_per_minute INTEGER,
|
||||
max_per_day INTEGER,
|
||||
track_opens BOOLEAN,
|
||||
track_clicks BOOLEAN,
|
||||
phi_allowed BOOLEAN,
|
||||
health_status VARCHAR(500),
|
||||
last_success_date TIMESTAMP,
|
||||
last_failure_date TIMESTAMP,
|
||||
last_failure_message VARCHAR(500),
|
||||
dispatch_lock INTEGER,
|
||||
default_slot INTEGER,
|
||||
UNIQUE (name),
|
||||
UNIQUE (default_slot)
|
||||
);
|
||||
|
||||
CREATE TABLE email_provider_sender_address (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
provider_id INTEGER NOT NULL,
|
||||
address VARCHAR(500) NOT NULL,
|
||||
display_name VARCHAR(500),
|
||||
is_default BOOLEAN,
|
||||
enabled BOOLEAN,
|
||||
note TEXT,
|
||||
UNIQUE (provider_id, address)
|
||||
);
|
||||
|
||||
CREATE TABLE email_message (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
status VARCHAR(500),
|
||||
to_addresses TEXT,
|
||||
cc_addresses TEXT,
|
||||
bcc_addresses TEXT,
|
||||
from_address VARCHAR(500),
|
||||
from_address_id INTEGER,
|
||||
reply_to VARCHAR(500),
|
||||
subject TEXT,
|
||||
template_id INTEGER,
|
||||
template_version_id INTEGER,
|
||||
brand_id INTEGER,
|
||||
category VARCHAR(500),
|
||||
locale VARCHAR(500),
|
||||
variables_snapshot TEXT,
|
||||
html_body TEXT,
|
||||
text_body TEXT,
|
||||
body_sha256 VARCHAR(500),
|
||||
phi_approved BOOLEAN,
|
||||
unsubscribe_url TEXT,
|
||||
idempotency_key VARCHAR(500),
|
||||
rfc_message_id VARCHAR(500),
|
||||
provider_id INTEGER,
|
||||
provider_message_id VARCHAR(500),
|
||||
attempt_count INTEGER,
|
||||
next_attempt_date TIMESTAMP,
|
||||
lease_expires_date TIMESTAMP,
|
||||
last_error VARCHAR(500),
|
||||
finished_date TIMESTAMP,
|
||||
sent_date TIMESTAMP,
|
||||
delivered_date TIMESTAMP,
|
||||
bounced_date TIMESTAMP,
|
||||
complained_date TIMESTAMP,
|
||||
dead_letter_date TIMESTAMP,
|
||||
linked_table_name VARCHAR(500),
|
||||
linked_record_id VARCHAR(500),
|
||||
sender_qbit VARCHAR(500),
|
||||
sender_process VARCHAR(500),
|
||||
suppressed_addresses TEXT,
|
||||
send_after TIMESTAMP,
|
||||
UNIQUE (idempotency_key),
|
||||
UNIQUE (rfc_message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE email_message_attempt (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
message_id INTEGER NOT NULL,
|
||||
attempt_number INTEGER NOT NULL,
|
||||
outcome VARCHAR(500),
|
||||
provider_id INTEGER,
|
||||
claimed_by VARCHAR(500),
|
||||
start_date TIMESTAMP,
|
||||
end_date TIMESTAMP,
|
||||
lease_expires_date TIMESTAMP,
|
||||
duration_millis INTEGER,
|
||||
error VARCHAR(500),
|
||||
UNIQUE (message_id, attempt_number)
|
||||
);
|
||||
|
||||
CREATE TABLE email_template (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
category VARCHAR(500),
|
||||
kind VARCHAR(500),
|
||||
brand_id INTEGER,
|
||||
active_version_id INTEGER,
|
||||
approved_version_id INTEGER,
|
||||
version_lock INTEGER,
|
||||
phi_approved BOOLEAN,
|
||||
phi_approved_by VARCHAR(500),
|
||||
phi_approved_date TIMESTAMP,
|
||||
edit_permission_name VARCHAR(500),
|
||||
owner_qbit VARCHAR(500),
|
||||
description TEXT,
|
||||
UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE email_template_version (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
template_id INTEGER,
|
||||
version_number INTEGER,
|
||||
status VARCHAR(500),
|
||||
locale VARCHAR(500),
|
||||
subject TEXT,
|
||||
preheader TEXT,
|
||||
body_source TEXT,
|
||||
text_source TEXT,
|
||||
compiled_html TEXT,
|
||||
variables_schema TEXT,
|
||||
sample_variables TEXT,
|
||||
created_by VARCHAR(500),
|
||||
activated_date TIMESTAMP,
|
||||
UNIQUE (template_id, version_number, locale)
|
||||
);
|
||||
|
||||
CREATE TABLE email_brand (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(500),
|
||||
label VARCHAR(500),
|
||||
logo_url VARCHAR(500),
|
||||
logo_dark_url VARCHAR(500),
|
||||
logo_alt VARCHAR(500),
|
||||
primary_color VARCHAR(500),
|
||||
text_color VARCHAR(500),
|
||||
background_color VARCHAR(500),
|
||||
dark_background_color VARCHAR(500),
|
||||
dark_text_color VARCHAR(500),
|
||||
dark_primary_color VARCHAR(500),
|
||||
dark_button_text_color VARCHAR(500),
|
||||
button_text_color VARCHAR(500),
|
||||
footer_markdown TEXT,
|
||||
postal_address TEXT,
|
||||
social_links TEXT,
|
||||
site_url VARCHAR(500),
|
||||
is_default BOOLEAN,
|
||||
app_name VARCHAR(500),
|
||||
UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE email_suppression (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
address VARCHAR(500),
|
||||
scope VARCHAR(500),
|
||||
reason VARCHAR(500),
|
||||
source_message_id INTEGER,
|
||||
source_event_id INTEGER,
|
||||
note TEXT,
|
||||
created_date TIMESTAMP,
|
||||
expires_date TIMESTAMP,
|
||||
UNIQUE (address, scope)
|
||||
);
|
||||
|
||||
CREATE INDEX email_message_due ON email_message (status, next_attempt_date);
|
||||
CREATE INDEX email_attempt_provider_start ON email_message_attempt (provider_id, start_date);
|
||||
CREATE INDEX email_attempt_provider_end ON email_message_attempt (provider_id, end_date, id);
|
||||
|
||||
CREATE TABLE email_webhook_inbox (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
provider_id INTEGER NOT NULL,
|
||||
kind VARCHAR(500) NOT NULL,
|
||||
external_event_id VARCHAR(500) NOT NULL,
|
||||
nonce VARCHAR(500),
|
||||
status VARCHAR(500) NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_date TIMESTAMP,
|
||||
last_error VARCHAR(500),
|
||||
payload_json TEXT,
|
||||
payload_sha256 VARCHAR(500),
|
||||
received_date TIMESTAMP,
|
||||
processed_date TIMESTAMP,
|
||||
UNIQUE (provider_id, external_event_id),
|
||||
UNIQUE (provider_id, nonce)
|
||||
);
|
||||
|
||||
CREATE TABLE email_event (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
message_id INTEGER NOT NULL,
|
||||
provider_id INTEGER,
|
||||
event_type VARCHAR(500) NOT NULL,
|
||||
bounce_type VARCHAR(500),
|
||||
recipient VARCHAR(500),
|
||||
occurred_date TIMESTAMP,
|
||||
detail VARCHAR(500),
|
||||
url TEXT,
|
||||
event_key VARCHAR(500) NOT NULL UNIQUE
|
||||
);
|
||||
CREATE INDEX email_inbox_due ON email_webhook_inbox (status, next_attempt_date);
|
||||
CREATE INDEX email_event_message ON email_event (message_id);
|
||||
CREATE INDEX email_event_type_date ON email_event (event_type, occurred_date);
|
||||
CREATE INDEX email_message_provider_id ON email_message (provider_id, provider_message_id);
|
||||
Reference in new issue
Block a user