Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08e729728
|
||
|
|
ee002b2cb3 | ||
|
|
5515c4c2bc
|
||
|
|
ae1f279633
|
||
|
|
8431476609 | ||
|
|
3d56ccc403
|
||
|
|
9e139fe039 | ||
|
|
5348462c7f
|
||
|
|
6460aadb0a | ||
|
|
8004089b47
|
||
|
|
60b68cf722
|
No files matched your search
@@ -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 notification compatibility types 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`: notification types and their AlertLink dependency retained for binary compatibility until core RC.6; alerts use core RC.5.
|
||||
- `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.5` 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
|
||||
|
||||
+8
-3
@@ -5,14 +5,19 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Changed
|
||||
- Target QQQ `4.1.0-RC.6` for the UI extension API; local verification uses a command-line version override only.
|
||||
- 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. Follower fan-out waits for core #1164 (#16, F12).
|
||||
- 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).
|
||||
- Removed alert SPI copies. Retained `spi.NotificationSignal`, `NotificationAudience`, `NotificationCategory`, `NotificationSeverity`, `AlertLink`, and the binary-compatible `AlertsRuntime.sink().notify(...)` entry point for qbit-easypost-tracking 0.1.x until QRun-IO/qqq#1125 ships in RC.6.
|
||||
- **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
|
||||
|
||||
@@ -37,7 +37,7 @@ The qbit also carries ordinary notifications for people (design: [docs/NOTIFICAT
|
||||
|
||||
- **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 `AlertsRuntime.get(qInstance).sink().notify(new NotificationSignal(...))` (core: `QAlerts.notify`). Alerts a user subscribes to arrive in the bell as ALERT notifications.
|
||||
- **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`).
|
||||
@@ -56,8 +56,11 @@ hrefs, `notificationsHref`, `alertsHref`, `canManage` and deprecated `watching`
|
||||
|
||||
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. Follower fan-out waits
|
||||
for core QRun-IO/qqq#1164.
|
||||
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,
|
||||
@@ -67,12 +70,17 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ 4.1 (targets `4.1.0-RC.6`; release pending).
|
||||
- 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
|
||||
@@ -111,7 +119,9 @@ QAlerts.raise(new AlertSignal("cdn.purge.dead")
|
||||
|
||||
`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 remain on `AlertsRuntime.get(qInstance).sink().notify(...)`: core's notification API ([QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)) ships in RC.6; wiring the retained notification adapter to that API is a separate migration. The four `spi.Notification*` types and `spi.AlertLink` retain their binary signatures for released qbit-easypost-tracking 0.1.x. Use core `AlertLink` for alert signals and `spi.AlertLink` for notification signals until that migration.
|
||||
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)
|
||||
|
||||
@@ -145,6 +155,18 @@ How each one sends:
|
||||
- **Responses:** 2xx is sent. 408, 425, 429, 5xx and network errors are retried. Any other 4xx fails permanently.
|
||||
- **URLs** must be https, unless the channel's settings say `"allowInsecureHttp": true`, which is meant for local receivers.
|
||||
|
||||
## 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.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRu
|
||||
|
||||
Deviations from the design, and why:
|
||||
- **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 use core's RC.5 SPI** (QRun-IO/qqq#1108), including sink registration in core instance metadata. Only notification types and their `AlertLink` signature dependency remain in `spi/`, preserving released producers until QRun-IO/qqq#1125 ships in RC.6.
|
||||
- **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).
|
||||
|
||||
+23
-13
@@ -138,10 +138,9 @@ notification admins. The home stays the only Alerts dashboard.
|
||||
|
||||
## 6. Producer SPI
|
||||
|
||||
The notification API below is planned in [QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)
|
||||
for 4.1.0-RC.6. This branch now targets 4.1.0-RC.6 for the UI extension API. The retained qbit notification
|
||||
adapter still needs a separate migration to core `QAlerts.notify`; the following remains the
|
||||
target integration shape:
|
||||
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")
|
||||
@@ -153,17 +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 RC.6 ships, the qbit keeps `spi.NotificationSignal`, `NotificationAudience`,
|
||||
`NotificationCategory`, `NotificationSeverity` and `AlertLink`, and hosts call
|
||||
`AlertsRuntime.get(qInstance).sink().notify(...)`. Their binary signatures remain unchanged for
|
||||
released qbit-easypost-tracking 0.1.x. The return type of `sink()` remains `QbitAlertsSink`;
|
||||
there is no local sink interface. This direct notification path queues immediately; the
|
||||
transaction-aware `QAlerts.notify` guarantees above describe the planned core API.
|
||||
- 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
|
||||
|
||||
@@ -237,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 |
|
||||
|
||||
@@ -257,3 +265,5 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no
|
||||
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).
|
||||
+33
-3
@@ -58,9 +58,26 @@ and scope subscriptions; existing record-scoped watch rows are deprecated and re
|
||||
`watching` boolean is retained for compatibility and reports only those legacy rows; it must
|
||||
not be used as collaboration follow state.
|
||||
|
||||
There is no follower fan-out yet. `AlertRouter.destinations` contains
|
||||
`STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7)` at the point where follower destinations
|
||||
will join the existing recipient visibility and suppression pipeline.
|
||||
## 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
|
||||
|
||||
@@ -70,6 +87,10 @@ JSON request and `{raised, resolved, ignored}` response. No QQQ user session is
|
||||
|
||||
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)
|
||||
@@ -94,6 +115,15 @@ The alias is supported for the 0.1 minor line only and scheduled for removal in
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -276,11 +276,10 @@ public class AlertsRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime sink. Alert producers use core QAlerts; notification
|
||||
** producers keep calling sink().notify(spi.NotificationSignal) until RC.6.
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
** 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);
|
||||
}
|
||||
|
||||
@@ -45,12 +45,12 @@ import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.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;
|
||||
@@ -137,7 +137,7 @@ public class AlertsApi
|
||||
if(entry.unread())
|
||||
{
|
||||
unread++;
|
||||
NotificationSeverity severity = NotificationSeverity.fromName(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
NotificationSeverity severity = NotificationService.severityOf(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
highest = highest == null || severity.compareTo(highest) > 0 ? severity : highest;
|
||||
}
|
||||
if(latest.size() < INBOX_ITEMS)
|
||||
|
||||
+3
-3
@@ -30,11 +30,11 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
@@ -212,7 +212,7 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
|
||||
}
|
||||
if(record.getValues().containsKey("audienceType"))
|
||||
{
|
||||
NotificationAudience audience = NotificationAudience.parse(record.getValueString("audienceType"), record.getValueString("audienceValue"));
|
||||
NotificationAudience audience = NotificationService.audienceOf(record);
|
||||
if(audience == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Choose who the notification is for."));
|
||||
|
||||
@@ -334,7 +334,7 @@ public class DeliveryWorker
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -45,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.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;
|
||||
@@ -303,7 +310,7 @@ 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, DeliveryKind kind) throws QException
|
||||
{
|
||||
@@ -353,8 +360,24 @@ public class AlertRouter
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7): merge record followers
|
||||
// from RecordFollowersProviderInterface here, before the existing visibility/suppression checks.
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -367,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]+"))
|
||||
@@ -518,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).
|
||||
|
||||
@@ -25,13 +25,14 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
@@ -242,7 +243,7 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(input.getProcessName());
|
||||
String label = process == null || process.getLabel() == null ? input.getProcessName() : process.getLabel();
|
||||
String tableName = process == null ? null : process.getTableName();
|
||||
runtime.sink().notify(new NotificationSignal(String.format(titleFormat, label))
|
||||
QAlerts.notify(new NotificationSignal(String.format(titleFormat, label))
|
||||
.withBody(body)
|
||||
.withSeverity(severity)
|
||||
.withCategory(NotificationCategory.JOB)
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,11 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
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;
|
||||
|
||||
@@ -134,11 +134,10 @@ public class QbitAlertsSink implements AlertSinkInterface
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
**
|
||||
** 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)
|
||||
|
||||
@@ -45,11 +45,11 @@ import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
|
||||
@@ -20,12 +20,12 @@ package com.kingsrook.qbits.alerts.model;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.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;
|
||||
|
||||
@@ -26,8 +26,8 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
@@ -21,30 +21,26 @@ package com.kingsrook.qbits.alerts.notifications;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
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
|
||||
** the alerts sink (queued; it never throws into the caller). With no Alerts
|
||||
** qbit installed they do nothing.
|
||||
** 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 QLogger LOG = QLogger.getLogger(AlertsNotices.class);
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
@@ -114,7 +110,7 @@ public final class AlertsNotices
|
||||
*******************************************************************************/
|
||||
public static void shared(String userId, String title, AlertLink link, String sourceName)
|
||||
{
|
||||
sendSignal(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
QAlerts.notify(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
@@ -125,7 +121,7 @@ public final class AlertsNotices
|
||||
*******************************************************************************/
|
||||
public static void release(String version, String notesMarkdown, AlertLink link)
|
||||
{
|
||||
sendSignal(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS)
|
||||
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()));
|
||||
}
|
||||
@@ -141,29 +137,7 @@ public final class AlertsNotices
|
||||
{
|
||||
return;
|
||||
}
|
||||
sendSignal(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
QAlerts.notify(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Through the installed qbit's sink (nothing when it isn't installed).
|
||||
*******************************************************************************/
|
||||
private static void sendSignal(NotificationSignal signal)
|
||||
{
|
||||
try
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
AlertsRuntime runtime = AlertsRuntime.find(qInstance);
|
||||
if(runtime != null && signal.getAudience() != null)
|
||||
{
|
||||
runtime.sink().notify(signal);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not send a notification", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
package com.kingsrook.qbits.alerts.notifications;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
@@ -30,16 +31,20 @@ 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.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.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;
|
||||
@@ -61,7 +66,10 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** - IMMEDIATE: one email per notification at or above the minimum severity;
|
||||
** - DAILY_DIGEST: one email at the user's digest hour (in their time zone,
|
||||
** from My Alert Settings) listing the day's unread notifications.
|
||||
** Inert without a messaging provider and a From address. ALERT
|
||||
** 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.
|
||||
@@ -100,6 +108,10 @@ public class NotificationEmailer
|
||||
*******************************************************************************/
|
||||
public boolean isConfigured()
|
||||
{
|
||||
if(AlertsEmail.isActive())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
AlertsQBitConfig config = runtime.getConfig();
|
||||
return (StringUtils.hasContent(config.getMessagingProviderName()) && StringUtils.hasContent(config.getFromAddress()));
|
||||
}
|
||||
@@ -154,16 +166,11 @@ public class NotificationEmailer
|
||||
}
|
||||
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
|
||||
{
|
||||
boolean ok = send(address(settings.get(userId)), notification.getValueString("title"), List.of(notification));
|
||||
mark(recipient, ok ? EmailStatus.SENT : EmailStatus.FAILED, ok ? now : null);
|
||||
if(ok)
|
||||
{
|
||||
sent++;
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
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)
|
||||
{
|
||||
@@ -180,13 +187,14 @@ public class NotificationEmailer
|
||||
continue;
|
||||
}
|
||||
List<QRecord> items = digest.getValue().stream().map(pair -> pair[1]).toList();
|
||||
boolean ok = send(address(setting), items.size() == 1 ? "1 new notification" : items.size() + " new notifications", items);
|
||||
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], ok ? EmailStatus.DIGESTED : EmailStatus.FAILED, ok ? now : null);
|
||||
mark(pair[0], status == EmailStatus.SENT ? EmailStatus.DIGESTED : status, status == EmailStatus.SENT ? now : null);
|
||||
}
|
||||
digestCount += ok ? 1 : 0;
|
||||
failed += ok ? 0 : 1;
|
||||
digestCount += status == EmailStatus.SENT ? 1 : 0;
|
||||
failed += status == EmailStatus.FAILED ? 1 : 0;
|
||||
}
|
||||
return (new Result(sent, digestCount, skipped, failed));
|
||||
}));
|
||||
@@ -200,19 +208,28 @@ public class NotificationEmailer
|
||||
static boolean isDigestHour(QRecord setting, Instant now)
|
||||
{
|
||||
Integer hour = setting == null ? null : setting.getValueInteger("digestHour");
|
||||
ZoneId zone = ZoneOffset.UTC;
|
||||
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")))
|
||||
{
|
||||
zone = ZoneId.of(setting.getValueString("timeZone"));
|
||||
return (ZoneId.of(setting.getValueString("timeZone")));
|
||||
}
|
||||
}
|
||||
catch(DateTimeException e)
|
||||
{
|
||||
zone = ZoneOffset.UTC;
|
||||
return (ZoneOffset.UTC);
|
||||
}
|
||||
return (now.atZone(zone).getHour() == (hour == null ? 8 : hour));
|
||||
return (ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
|
||||
@@ -222,14 +239,132 @@ public class NotificationEmailer
|
||||
*******************************************************************************/
|
||||
static boolean atLeast(String severity, String minimum)
|
||||
{
|
||||
return (NotificationSeverity.fromName(severity, NotificationSeverity.INFO).compareTo(NotificationSeverity.fromName(minimum, NotificationSeverity.WARNING)) >= 0);
|
||||
return (NotificationService.severityOf(severity, NotificationSeverity.INFO).compareTo(NotificationService.severityOf(minimum, NotificationSeverity.WARNING)) >= 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One email: the notifications' titles, bodies (not for SECURITY) and
|
||||
** links. False when it could not be sent (or there is no address).
|
||||
** 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)
|
||||
{
|
||||
|
||||
@@ -22,8 +22,10 @@ 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;
|
||||
@@ -31,14 +33,14 @@ import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -335,7 +337,7 @@ public class NotificationService
|
||||
QRecord values = new QRecord()
|
||||
.withValue("title", StringUtils.safeTruncate(alert.getValueString("title"), 200))
|
||||
.withValue("body", alert.getValueString("summary"))
|
||||
.withValue("severity", NotificationSeverity.fromName(alert.getValueString("severity"), NotificationSeverity.WARNING).name())
|
||||
.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)
|
||||
@@ -402,7 +404,42 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience audienceOf(QRecord notification)
|
||||
{
|
||||
return (NotificationAudience.parse(notification.getValueString("audienceType"), notification.getValueString("audienceValue")));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -483,7 +520,7 @@ public class NotificationService
|
||||
.withValue("linkUrl", linkUrl(signal.getLink()))
|
||||
.withValue("linkLabel", signal.getLink() == null ? null : signal.getLink().getLabel())
|
||||
.withValue("audienceType", signal.getAudience().getType().name())
|
||||
.withValue("audienceValue", signal.getAudience().valuesAsString())
|
||||
.withValue("audienceValue", audienceValue(signal.getAudience()))
|
||||
.withValue("publishAt", signal.getPublishAt())
|
||||
.withValue("expireAt", signal.getExpireAt())
|
||||
.withValue("sticky", Boolean.TRUE.equals(signal.getSticky())));
|
||||
@@ -496,12 +533,23 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
private static QFilterCriteria audienceValueCriteria(NotificationAudience audience)
|
||||
{
|
||||
String value = audience.valuesAsString();
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -73,7 +73,7 @@ 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();
|
||||
|
||||
@@ -1,155 +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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
**
|
||||
** 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,197 +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.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
**
|
||||
** Who a notification is for, resolved to users when it is sent (stand-in for
|
||||
** core's com.kingsrook.qqq.backend.core.alerts.NotificationAudience).
|
||||
*******************************************************************************/
|
||||
public class NotificationAudience implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
**
|
||||
** The kinds of audience.
|
||||
*******************************************************************************/
|
||||
public enum Type
|
||||
{
|
||||
ALL_USERS, ROLES, PERMISSION, USERS
|
||||
}
|
||||
|
||||
|
||||
|
||||
private final Type type;
|
||||
private final List<String> values;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor (blank values are dropped; values are trimmed).
|
||||
*******************************************************************************/
|
||||
public NotificationAudience(Type type, List<String> values)
|
||||
{
|
||||
this.type = type == null ? Type.USERS : type;
|
||||
this.values = values == null ? List.of() : values.stream().filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every user.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience allUsers()
|
||||
{
|
||||
return (new NotificationAudience(Type.ALL_USERS, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users with any of these roles or groups.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience roles(String... roles)
|
||||
{
|
||||
return (new NotificationAudience(Type.ROLES, roles == null ? null : Arrays.asList(roles)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users whose session holds this permission.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience permission(String permission)
|
||||
{
|
||||
return (new NotificationAudience(Type.PERMISSION, permission == null ? null : List.of(permission)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** These users.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience users(String... userIds)
|
||||
{
|
||||
return (new NotificationAudience(Type.USERS, userIds == null ? null : Arrays.asList(userIds)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One user.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience user(String userId)
|
||||
{
|
||||
return (users(userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** From the stored form: a type name and a comma- or line-separated value.
|
||||
** An unknown type is null.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience parse(String type, String value)
|
||||
{
|
||||
for(Type candidate : Type.values())
|
||||
{
|
||||
if(type != null && candidate.name().equalsIgnoreCase(type.trim()))
|
||||
{
|
||||
List<String> values = new ArrayList<>();
|
||||
if(value != null)
|
||||
{
|
||||
values.addAll(Arrays.asList(value.split("[,\\n]")));
|
||||
}
|
||||
return (new NotificationAudience(candidate, values));
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored form of the values (null when there are none).
|
||||
*******************************************************************************/
|
||||
public String valuesAsString()
|
||||
{
|
||||
return (values.isEmpty() ? null : String.join(",", values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for type
|
||||
*******************************************************************************/
|
||||
public Type getType()
|
||||
{
|
||||
return (this.type);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for values
|
||||
*******************************************************************************/
|
||||
public List<String> getValues()
|
||||
{
|
||||
return (this.values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
return (o instanceof NotificationAudience other && type == other.type && values.equals(other.values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return (Objects.hash(type, values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (type + (values.isEmpty() ? "" : values.toString()));
|
||||
}
|
||||
}
|
||||
@@ -1,51 +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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
**
|
||||
** What a notification is about (stand-in for core's
|
||||
** com.kingsrook.qqq.backend.core.alerts.NotificationCategory). Users filter
|
||||
** and mute by category; SECURITY can never be muted in-app.
|
||||
*******************************************************************************/
|
||||
public enum NotificationCategory
|
||||
{
|
||||
SYSTEM, JOB, SECURITY, RELEASE, MARKETING, SHARE, ALERT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The category for a name (any case), or the fallback.
|
||||
*******************************************************************************/
|
||||
public static NotificationCategory fromName(String name, NotificationCategory fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationCategory category : values())
|
||||
{
|
||||
if(category.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (category);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
**
|
||||
** How a notification looks to its reader: INFO and SUCCESS are good news,
|
||||
** WARNING and ERROR need a look, CRITICAL makes the badge red (stand-in for
|
||||
** core's com.kingsrook.qqq.backend.core.alerts.NotificationSeverity).
|
||||
*******************************************************************************/
|
||||
public enum NotificationSeverity
|
||||
{
|
||||
INFO, SUCCESS, WARNING, ERROR, CRITICAL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The severity for a name (any case), or the fallback.
|
||||
*******************************************************************************/
|
||||
public static NotificationSeverity fromName(String name, NotificationSeverity fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationSeverity severity : values())
|
||||
{
|
||||
if(severity.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (severity);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
}
|
||||
}
|
||||
@@ -1,502 +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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
**
|
||||
** A notification for people (stand-in for core's
|
||||
** com.kingsrook.qqq.backend.core.alerts.NotificationSignal): what to tell
|
||||
** them, how urgent it is, what it is about, and who it is for. Send it with
|
||||
** the alerts sink's notify (core: QAlerts.notify).
|
||||
*******************************************************************************/
|
||||
public class NotificationSignal implements Serializable
|
||||
{
|
||||
private String title;
|
||||
private String body;
|
||||
private NotificationSeverity severity;
|
||||
private NotificationCategory category;
|
||||
private String sourceName;
|
||||
private String sourceKey;
|
||||
private String sourceTableName;
|
||||
private String sourceRecordId;
|
||||
private AlertLink link;
|
||||
private NotificationAudience audience;
|
||||
private Instant publishAt;
|
||||
private Instant expireAt;
|
||||
private Boolean sticky;
|
||||
private Instant occurredAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public NotificationSignal()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with the title.
|
||||
*******************************************************************************/
|
||||
public NotificationSignal(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title: The headline (plain text, required).
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for body: The message (markdown).
|
||||
*******************************************************************************/
|
||||
public String getBody()
|
||||
{
|
||||
return (this.body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for body
|
||||
*******************************************************************************/
|
||||
public void setBody(String body)
|
||||
{
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for body
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withBody(String body)
|
||||
{
|
||||
this.body = body;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for severity: Default INFO.
|
||||
*******************************************************************************/
|
||||
public NotificationSeverity getSeverity()
|
||||
{
|
||||
return (this.severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for severity
|
||||
*******************************************************************************/
|
||||
public void setSeverity(NotificationSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for severity
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSeverity(NotificationSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for category: Default SYSTEM.
|
||||
*******************************************************************************/
|
||||
public NotificationCategory getCategory()
|
||||
{
|
||||
return (this.category);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for category
|
||||
*******************************************************************************/
|
||||
public void setCategory(NotificationCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for category
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withCategory(NotificationCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceName: Who emits it (a qbit or app name).
|
||||
*******************************************************************************/
|
||||
public String getSourceName()
|
||||
{
|
||||
return (this.sourceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceName
|
||||
*******************************************************************************/
|
||||
public void setSourceName(String sourceName)
|
||||
{
|
||||
this.sourceName = sourceName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceName
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceName(String sourceName)
|
||||
{
|
||||
this.sourceName = sourceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceKey: Dedup key: a second notify with the same key and audience updates the first.
|
||||
*******************************************************************************/
|
||||
public String getSourceKey()
|
||||
{
|
||||
return (this.sourceKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceKey
|
||||
*******************************************************************************/
|
||||
public void setSourceKey(String sourceKey)
|
||||
{
|
||||
this.sourceKey = sourceKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceKey
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceKey(String sourceKey)
|
||||
{
|
||||
this.sourceKey = sourceKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceTableName: The table of the record it is about.
|
||||
*******************************************************************************/
|
||||
public String getSourceTableName()
|
||||
{
|
||||
return (this.sourceTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public void setSourceTableName(String sourceTableName)
|
||||
{
|
||||
this.sourceTableName = sourceTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceTableName(String sourceTableName)
|
||||
{
|
||||
this.sourceTableName = sourceTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceRecordId: The record it is about.
|
||||
*******************************************************************************/
|
||||
public String getSourceRecordId()
|
||||
{
|
||||
return (this.sourceRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public void setSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for link: Where it takes the reader.
|
||||
*******************************************************************************/
|
||||
public AlertLink getLink()
|
||||
{
|
||||
return (this.link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for link
|
||||
*******************************************************************************/
|
||||
public void setLink(AlertLink link)
|
||||
{
|
||||
this.link = link;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for link
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withLink(AlertLink link)
|
||||
{
|
||||
this.link = link;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for audience: Who it is for (required).
|
||||
*******************************************************************************/
|
||||
public NotificationAudience getAudience()
|
||||
{
|
||||
return (this.audience);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for audience
|
||||
*******************************************************************************/
|
||||
public void setAudience(NotificationAudience audience)
|
||||
{
|
||||
this.audience = audience;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for audience
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withAudience(NotificationAudience audience)
|
||||
{
|
||||
this.audience = audience;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publishAt: Not shown before this (default now).
|
||||
*******************************************************************************/
|
||||
public Instant getPublishAt()
|
||||
{
|
||||
return (this.publishAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for publishAt
|
||||
*******************************************************************************/
|
||||
public void setPublishAt(Instant publishAt)
|
||||
{
|
||||
this.publishAt = publishAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publishAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withPublishAt(Instant publishAt)
|
||||
{
|
||||
this.publishAt = publishAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for expireAt: Hidden after this.
|
||||
*******************************************************************************/
|
||||
public Instant getExpireAt()
|
||||
{
|
||||
return (this.expireAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for expireAt
|
||||
*******************************************************************************/
|
||||
public void setExpireAt(Instant expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for expireAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withExpireAt(Instant expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sticky: A banner until dismissed (meant for CRITICAL).
|
||||
*******************************************************************************/
|
||||
public Boolean getSticky()
|
||||
{
|
||||
return (this.sticky);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sticky
|
||||
*******************************************************************************/
|
||||
public void setSticky(Boolean sticky)
|
||||
{
|
||||
this.sticky = sticky;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sticky
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSticky(Boolean sticky)
|
||||
{
|
||||
this.sticky = sticky;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for occurredAt: When it happened (default now).
|
||||
*******************************************************************************/
|
||||
public Instant getOccurredAt()
|
||||
{
|
||||
return (this.occurredAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public void setOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +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(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
**
|
||||
** Only NotificationSignal, NotificationAudience, NotificationCategory,
|
||||
** NotificationSeverity and AlertLink remain here. Core 4.1.0-RC.5 ships the
|
||||
** alert SPI, but not the notification API planned in QRun-IO/qqq#1125.
|
||||
**
|
||||
** Released qbit-easypost-tracking 0.1.x calls AlertsRuntime.sink().notify
|
||||
** with these types. AlertLink remains because NotificationSignal's public
|
||||
** signatures use it. Keep their binary signatures until producers migrate
|
||||
** to core QAlerts.notify in RC.6; no local sink interface is needed because
|
||||
** AlertsRuntime.sink() still returns QbitAlertsSink.
|
||||
**
|
||||
** Alerts use com.kingsrook.qqq.backend.core.alerts. AlertsQBitProducer
|
||||
** registers AlertsSinkAdapter through core AlertsInstanceMetaData, so host
|
||||
** QAlerts.raise/resolve/heartbeat calls reach this qbit's runtime.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
@@ -24,8 +24,13 @@ 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;
|
||||
@@ -149,6 +154,38 @@ class AlertsApiHttpTest extends BaseAlertsTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -237,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");
|
||||
}
|
||||
|
||||
@@ -40,17 +40,17 @@ 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.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.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;
|
||||
@@ -307,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(com.kingsrook.qqq.backend.core.alerts.AlertLink.toRecord("backup_run", runId, "Open the run")));
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime.sink().resolve(new com.kingsrook.qqq.backend.core.alerts.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.qqq.backend.core.alerts.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)
|
||||
{
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
|
||||
QAlerts.raise(new AlertSignal("host.backup.failed").withDedupKey("nightly").withTitle("Nightly backup failed"));
|
||||
QAlerts.heartbeat("host.backup.nightly");
|
||||
runtime.sink().stop();
|
||||
runtime.getSink().stop();
|
||||
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
assertThat(alerts).hasSize(1);
|
||||
@@ -70,7 +70,7 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1);
|
||||
|
||||
QAlerts.resolve("host.backup.failed", "nightly", "Retry succeeded");
|
||||
runtime.sink().stop();
|
||||
runtime.getSink().stop();
|
||||
assertThat(all(AlertsAlert.TABLE_NAME).get(0).getValueString("status")).isEqualTo("RESOLVED");
|
||||
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
|
||||
}
|
||||
@@ -102,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);
|
||||
@@ -123,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);
|
||||
@@ -131,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);
|
||||
@@ -147,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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-7
@@ -29,11 +29,12 @@ 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.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.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;
|
||||
@@ -147,7 +148,7 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
|
||||
{
|
||||
emailSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING");
|
||||
emailSettings("viewer-2", "JOB", "IMMEDIATE", "ERROR");
|
||||
runtime.getNotifications().notify(new NotificationSignal("Export failed").withBody("Disk <full>").withSeverity(NotificationSeverity.WARNING)
|
||||
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();
|
||||
@@ -175,7 +176,7 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
|
||||
clock.set(Instant.parse("2026-10-12T09:00:00Z"));
|
||||
emailSettings("viewer-1", "RELEASE", "DAILY_DIGEST", "INFO");
|
||||
AlertsNotices.release("2.4", "Dark mode everywhere.", null);
|
||||
runtime.getNotifications().notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
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"));
|
||||
|
||||
@@ -27,25 +27,25 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsPreferences;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
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.engine.QbitAlertsSink;
|
||||
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.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.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;
|
||||
@@ -269,11 +269,11 @@ class NotificationsTest extends BaseAlertsTest
|
||||
{
|
||||
for(int i = 0; i < 4; i++)
|
||||
{
|
||||
runtime.getNotifications().notify(new NotificationSignal("Job " + i + " failed").withSeverity(i % 2 == 0 ? NotificationSeverity.ERROR : NotificationSeverity.WARNING)
|
||||
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));
|
||||
}
|
||||
runtime.getNotifications().notify(new NotificationSignal("Sign-in from a new device").withCategory(NotificationCategory.SECURITY)
|
||||
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");
|
||||
@@ -321,8 +321,12 @@ class NotificationsTest extends BaseAlertsTest
|
||||
api.markRead(Map.of("all", true));
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
|
||||
|
||||
Integer second = runtime.getNotifications().notify(signal.withTitle("Export failed again"));
|
||||
assertThat(second).isEqualTo(first);
|
||||
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");
|
||||
@@ -342,28 +346,19 @@ class NotificationsTest extends BaseAlertsTest
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// through the sink (the producer SPI path) //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
runtime.sink().notify(new NotificationSignal("Shared with you: Q3 notebook").withCategory(NotificationCategory.SHARE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
QAlerts.notify(new NotificationSignal("Shared with you: Q3 notebook").withCategory(NotificationCategory.SHARE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the method descriptors used by released notification producers,
|
||||
** including the local AlertLink, and deliver to the selected user's bell.
|
||||
** Core-only notification producers deliver to the selected user's bell.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testLegacyNotificationSinkSignaturesAndDelivery() throws Exception
|
||||
void testCoreNotificationDelivery() throws Exception
|
||||
{
|
||||
assertThat(AlertsRuntime.class.getMethod("sink").getReturnType()).isEqualTo(QbitAlertsSink.class);
|
||||
assertThat(QbitAlertsSink.class.getMethod("notify", NotificationSignal.class).getReturnType()).isEqualTo(void.class);
|
||||
assertThat(NotificationSignal.class.getMethod("withLink", AlertLink.class).getReturnType()).isEqualTo(NotificationSignal.class);
|
||||
assertThat(NotificationSignal.class.getMethod("getLink").getReturnType()).isEqualTo(AlertLink.class);
|
||||
assertThat(NotificationSignal.class.getMethod("withAudience", NotificationAudience.class).getReturnType()).isEqualTo(NotificationSignal.class);
|
||||
assertThat(AlertLink.class.getMethod("toRecord", String.class, Serializable.class, String.class).getReturnType()).isEqualTo(AlertLink.class);
|
||||
assertThat(NotificationAudience.class.getMethod("user", String.class).getReturnType()).isEqualTo(NotificationAudience.class);
|
||||
|
||||
runtime.sink().notify(new NotificationSignal("Shipment delivered")
|
||||
QAlerts.notify(new NotificationSignal("Shipment delivered")
|
||||
.withSeverity(NotificationSeverity.INFO).withCategory(NotificationCategory.JOB)
|
||||
.withLink(AlertLink.toRecord("widget", 5, "Shipment"))
|
||||
.withAudience(NotificationAudience.user("viewer-1")));
|
||||
@@ -378,6 +373,64 @@ class NotificationsTest extends BaseAlertsTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@@ -435,8 +488,8 @@ class NotificationsTest extends BaseAlertsTest
|
||||
// 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)));
|
||||
runtime.getNotifications().notify(new NotificationSignal("Big sale").withCategory(NotificationCategory.MARKETING).withAudience(NotificationAudience.users("viewer-1", "viewer-2")));
|
||||
runtime.getNotifications().notify(new NotificationSignal("Password changed").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user("viewer-1")));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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