Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 b08e729728 refactor(email): use qbit-email's public api package; no G1 allow-list
Moves the optional email integration from qbit-email internals to
com.kingsrook.qbits.email.api (qbit-email 0.1.0-RC.2) with identical
behaviour, and moves to qbit-build-parent 2.1.1, whose default G1
allow-lists include qbit-email's API (QRun-IO/qbit-bom#12), so the
per-repo allow-lists are gone.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:18:36 -05:00
61 changed files with 4120 additions and 1327 deletions

No files matched your search

+3 -3
View File
@@ -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
+14 -3
View File
@@ -5,8 +5,19 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Changed
- Build against the released QQQ 4.1.0-RC.5 BOM. Alert types and sink registration now use core's alerts SPI; 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.
- Switch the optional qbit-email integration to its published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.2`, preserving templates, categories, idempotency keys, linked records and outcome mapping. Narrow the architecture allowance to that API package while retaining all guardrails.
- Email goes through qbit-email when it is produced in the instance (optional dependency, `qbit-email.version` 0.1.0-RC.2): alert channel emails (template `alerts_alert`), notification emails (`alerts_notification`) and digests (`alerts_digest`), all NOTIFICATION, one recipient per message, with idempotency keys and the alert or notification as the linked record. `messagingProviderName` stays as the fallback when qbit-email is absent, and is not required when it is present.
- Target released QQQ `4.1.0-RC.6`; verify normally with `./mvnw -B verify` (no version override).
- Adopt `qbit-build-parent:2.1.0` with architecture guardrail enforcement and strict UI API routes enabled for tests. Dependency, extension contract and stand-in checks run in the normal build.
- Add record followers to alert routing through core `RecordFollowers.get(instance, ref, FollowEvent.ALERT)` (#16, QRun-IO/qqq#1164). Deduplicate by user/channel with subscriptions and other record refs; require source-record read access under the follower's session, including record locks. Followers use the existing alert visibility, preferences and suppression pipeline without a dependency on qbit-collaboration. Missing/disabled/failing providers preserve subscription routing.
- Register six JSON UI specs as extension `alerts` at the established `/alerts` prefix, preserving methods, paths and existing payload fields. Publish the OpenAPI fragment with a regeneration/contract test and strict server coverage (#13, F2).
- Add summary `inboxHref`, notification operation actions, alert process actions with complete hrefs, and record-panel `follow.available` (#13, F20). Follow discovery reads core extension metadata without a collaboration dependency or URL.
- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration (#16, F12).
- Move Alertmanager ingest to `/qqq/hooks/alerts/alertmanager`. The old path is an opt-in alias (`legacyAlertmanagerPath`, default false) for one minor release, incompatible with strict raw-route mode. Each alias call carries deprecation/sunset headers and logs a warning; authentication is shared (#14, F7).
- Run Checkstyle 14.3.0 in Maven validate (including tests); align the license template with this repository's existing headers and correct pre-existing switch/import formatting so the style check passes.
- Alert types and sink registration use core's alerts SPI (introduced in QQQ 4.1.0-RC.5); host `QAlerts.raise/resolve/heartbeat` calls reach the installed qbit through core instance metadata (qbit-alerts#15, F3).
- **Breaking:** remove the entire `com.kingsrook.qbits.alerts.spi` package (`NotificationSignal`, `NotificationAudience`, `NotificationCategory`, `NotificationSeverity`, `AlertLink`, package metadata) and `AlertsRuntime.sink().notify(...)`. Producers must use core RC.6 types and `QAlerts.notify`; rebuild existing callers. qbit-easypost-tracking#16 moved to core.
- Implement core `AlertSinkInterface.notify` in the registered adapter and runtime queue, retaining notification inbox, audience resolution, category preferences and email delivery behavior (qbit-alerts#15 part 2, F3/F12; QRun-IO/qqq#1125). Notification calls now receive core commit/rollback guarantees. No table or HTTP wire changes in this migration.
- Webhook signing delegates to core `WebhookSigner`/`WebhookSecret` (qbit-alerts#15, F11). Headers, `v1,` signatures and `whsec_` encoding are unchanged. A thin adapter preserves legacy keys shorter than core's 16-byte minimum using equivalent HMAC zero-padding.
### Added
@@ -16,7 +27,7 @@ All notable changes to this project are documented here. The format follows Keep
- a generic webhook, signed with Standard Webhooks headers through core's `WebhookSigner` (QRun-IO/qqq#1109)
- PagerDuty (Events API v2: trigger, acknowledge and resolve)
- an Alertmanager sink (`/api/v2/alerts`, with the qqq labels and the origin guard)
- Prometheus Alertmanager ingest at `POST /qqq/v1/alerts/ingest/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
- Prometheus Alertmanager ingest at `POST /qqq/hooks/alerts/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
- Tables (`alerts_*`), with the Liquibase changelog `db/liquibase/qbit-alerts/alerts.changelog.sql`:
- alerts, their timeline and records
- types, groups and monitors
+44 -7
View File
@@ -26,7 +26,7 @@ Any qbit or app reports exceptional conditions ("a person should do something").
- Prometheus Alertmanager, as a sink
Any channel another qbit registers works too. Every suppressed notification is logged with its reason.
- **Ingests Prometheus Alertmanager** at `POST /qqq/v1/alerts/ingest/alertmanager`, with notifications off for those types, so infrastructure alerts are triaged in one place.
- **Ingests Prometheus Alertmanager** at `POST /qqq/hooks/alerts/alertmanager`, with notifications off for those types, so infrastructure alerts are triaged in one place.
- **Owns the Alerts home:** open alerts by severity, my alerts, needs action, by group, a 30-day trend, the noisiest types, delivery health and recently resolved. Other pages get link-only summaries from `AlertSummaryLoader`.
All data is in standard QQQ tables named `alerts_*`. People change alerts through record actions, never by editing rows.
@@ -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`).
@@ -48,15 +48,39 @@ Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions i
**Upgrading from 0.1.0-RC.1:** changeset 16 renames the delivery log `alerts_notification` to `alerts_delivery`, and changeset 17 drops `alerts_inbox_item` (bell state only). Both are guarded, so a fresh schema and an RC.1 schema both end up the same.
The UI routes are published as the `alerts` extension (`1.0.0`, established prefix `/alerts`), with
[the OpenAPI fragment](src/main/resources/openapi/qqq-v1/alerts.yaml) and a contract test.
See [UI API and webhook migration](docs/UI-API.md) for operation IDs and the complete Next handoff.
Summary `inboxHref`, item/alert `actions`, and panel `follow.available` are additive. Existing
hrefs, `notificationsHref`, `alertsHref`, `canManage` and deprecated `watching` remain.
Record following belongs to collaboration: `POST /alerts/record-panel/watch` is removed. Old
RECORD subscriptions remain readable without migration. The panel creates no subscriptions;
Next uses its collaboration module when `follow.available` is true. Alerts with active record
refs also reach followers supplied by core's instance-level followers provider (`FollowEvent.ALERT`),
when FOLLOW is enabled and the follower can read the source record. Followers and subscribers
share recipient deduplication, channel preferences and suppression. No collaboration qbit dependency
is required; without a provider, subscription behavior is unchanged. See [follower routing](docs/UI-API.md#record-follower-routing).
The old Alertmanager path is available for one minor release only via
`withLegacyAlertmanagerPath(true)` (default false). It requires non-strict UI route mode,
returns `Deprecation: true` and `Sunset: Tue, 13 Apr 2027 00:00:00 GMT` on every call, and logs
a warning. Both paths use the same bearer-token check. Strict hosts use the canonical hook.
## Requirements
- Java 21.
- QQQ 4.1 (built against `4.1.0-RC.5`).
- 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
@@ -95,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)) is expected in RC.6 and is not in RC.5. 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)
@@ -107,8 +133,7 @@ User notifications remain on `AlertsRuntime.get(qInstance).sink().notify(...)`:
| POST | `/qqq/v1/alerts/inbox/unread` | `{"id": n}` or `{"ids": [..]}` |
| POST | `/qqq/v1/alerts/inbox/dismiss` | `{"id": n}`, `{"ids": [..]}` or `{"all": true}` |
| GET | `/qqq/v1/alerts/record-panel?tableName=t&recordId=id` | The "Open alerts" panel for a record (`visible: false` when there is nothing to show) |
| POST | `/qqq/v1/alerts/record-panel/watch` | `{"tableName": t, "recordId": id}`: a RECORD subscription for the user |
| POST | `/qqq/v1/alerts/ingest/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. |
| POST | `/qqq/hooks/alerts/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. |
## External channels
@@ -130,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
View File
@@ -240,7 +240,7 @@
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
<property name="ignoreLines" value="2"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
+2 -6
View File
@@ -1,15 +1,11 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
+2 -1
View File
@@ -3,7 +3,8 @@
The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). This repository implements it. Phases P1 to P3 are built (see README.md); P4 to P6 remain.
Deviations from the design, and why:
- **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.
- **UI API and record following:** the RC.6 alerts extension, collaboration ownership of follow, and machine webhook migration are specified in [UI-API.md](UI-API.md). Existing RECORD watch subscriptions remain readable without migration.
- **Alerts and notifications use core's RC.6 producer API**, including sink registration in core instance metadata (QRun-IO/qqq#1108 and #1125). `AlertsSinkAdapter` delegates `raise`, `resolve`, `heartbeat` and `notify` to the runtime queue. No local SPI types remain; storage-specific audience and severity conversion belongs to `NotificationService`.
- **Visibility checks 1 to 3 (section 7.2)** are enforced by record security locks on session keys (`alerts_permission`, `alerts_readable_table`), not by a pre-query customizer. QQQ has no pre-query customizer, and locks also apply to counts, exports and the API.
- **Heartbeats** are stored in an extra table, `alerts_heartbeat`.
- **Claims** are leases, not atomic conditional updates (see README.md, Known limits).
+29 -12
View File
@@ -138,9 +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. The baseline is 4.1.0-RC.5, which ships the core alert SPI
(`com.kingsrook.qqq.backend.core.alerts`) but does **not** yet provide `QAlerts.notify`:
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")
@@ -152,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
@@ -236,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 |
@@ -250,3 +259,11 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no
5. Core authentication events? **Later.** Hosts call `AlertsSecurityNotices` from their authentication
customizer until core offers a sign-in event.
6. Server-sent events? **Later.** Polling with backoff is enough at 60 s.
## UI contract
The implemented UI routes and additive action/follow metadata are specified in [UI-API.md](UI-API.md),
with the generated fragment at `src/main/resources/openapi/qqq-v1/alerts.yaml`. Record following
belongs to collaboration; the old alerts panel watch route is removed.
Record-linked alerts fan out through core's RC.6 followers provider with source-record access
checks and the ordinary alert preference/suppression pipeline; see [record follower routing](UI-API.md#record-follower-routing).
+138
View File
@@ -0,0 +1,138 @@
# Alerts UI API and webhook migration
The qbit targets QQQ **4.1.0-RC.6** (pending release). `AlertsQBitProducer` registers the
`alerts` `MiddlewareSpecExtension`, version `1.0.0`, with the established `/alerts` prefix.
`withRegisterRoutes(false)` opts out of both the extension and webhook registration.
The six JSON specs extend `AbstractJsonEndpointSpec`; existing `AlertsApi` logic continues
to enforce caller permissions, recipient ownership and alert visibility.
The deterministic fragment is `src/main/resources/openapi/qqq-v1/alerts.yaml`. Next should
vendor it. The live `/qqq/v1/openapi.yaml` includes these operations, and `/qqq/v1/version`
discovers the `alerts` extension so clients need not infer installation from an app name.
| Method | Path | operationId |
| --- | --- | --- |
| GET | `/qqq/v1/alerts/inbox/summary` | `alertsInboxSummary` |
| GET | `/qqq/v1/alerts/inbox` | `alertsInbox` |
| POST | `/qqq/v1/alerts/inbox/read` | `alertsInboxRead` |
| POST | `/qqq/v1/alerts/inbox/unread` | `alertsInboxUnread` |
| POST | `/qqq/v1/alerts/inbox/dismiss` | `alertsInboxDismiss` |
| GET | `/qqq/v1/alerts/record-panel` | `alertsRecordPanel` |
All existing query parameters, selector precedence, paging defaults and clamps, status
codes, JSON content-type requirements, and response keys remain. IDs refer to recipient
rows for inbox operations; legacy `alertId` selectors remain accepted. `all:true` applies
only to read/dismiss. Items in a summary omit `body`; page items and banners include it.
Nullable fields remain serialized as JSON null.
## Next handoff: additive payload fields
- Summary adds `inboxHref`, currently `/app/notifications`. Retain and honor the existing
`notificationsHref` and `alertsHref` fields for older clients.
- Every inbox item (including banners) adds `actions`: `name`, `label`, `operationId`.
An unread item offers `read` (`alertsInboxRead`); a read item offers `unread`
(`alertsInboxUnread`); both offer `dismiss` (`alertsInboxDismiss`). Invoke the operation
from the fragment with `{ "id": item.id }`. Existing item `href` semantics are unchanged.
- Every record-panel alert adds `actions`: `name`, `label`, `processName`, `href`.
Managers see `acknowledge` for OPEN alerts and `snooze` for panel alerts; viewers get an
empty list. The href is complete, including the process, alert id and table arguments:
`/app/alertsAcknowledge?recordsParam=recordIds&recordIds=9&tableName=alerts_alert`.
Clients follow the provided href without constructing a process or table name. Server
process authorization still applies when an action executes.
- Panel adds `follow: { "available": boolean }`, true exactly when core supplemental
`MiddlewareSpecExtensionsMetaData` contains an extension named `collaboration`. No
collaboration dependency or URL is embedded here. Next uses its collaboration module
to determine the follow endpoint and current follow state.
The provided Next `main` checkout had no alerts client at implementation time. The reviewed
client was `feature/notification-center:src/lib/api/alerts.ts` and its
`src/components/alerts/*` components. Its current process href and watch assumptions are
covered by the migration above.
## Removed watch operation
`POST /qqq/v1/alerts/record-panel/watch` and `AlertsApi.watchRecord` are removed. No panel
request creates a RECORD subscription. `alerts_subscription` continues to handle alert-type
and scope subscriptions; existing record-scoped watch rows are deprecated and remain readable
(and retain their existing routing behavior). No rows are migrated or deleted. The panel's
`watching` boolean is retained for compatibility and reports only those legacy rows; it must
not be used as collaboration follow state.
## Record follower routing
For every active record ref on an alert, `AlertRouter.destinations` calls RC.6's
`RecordFollowers.get(instance, ref, FollowEvent.ALERT)`. Register a core
`RecordFollowersProviderInterface` through instance `RecordFeaturesMetaData.withFollowersProvider`
and enable `RecordFeature.FOLLOW` for the source table (or by instance default). The provider
selects followers for ALERT; core recommends treating ALERT like CHANGE unless the provider
has a finer preference. The alerts qbit does not depend on qbit-collaboration.
Each follower must have a known automated recipient session and be able to query the source
record through its table permissions and record security locks. Missing records and unknown
or denied access do not add follower destinations. The query uses the source table's configured
primary key. Eligible followers join the existing destination map, deduplicated by user and
channel across subscriptions and record refs. Existing subscription destinations take precedence.
Followers use ordinary in-app/email settings, vacation pause for external delivery, alert
visibility checks, type notification switches, mutes, flapping, channel limits and duplicate
suppression. Quiet-hour scheduling remains unimplemented for all alert recipients, as listed
in the README; follower routing does not introduce a separate delivery policy. With no provider,
FOLLOW disabled, or a provider failure, existing subscription routing is unchanged.
## Alertmanager machine webhook
Canonical operation: **POST `/qqq/hooks/alerts/alertmanager`**. This is a raw machine route
outside the UI API and has no UI operationId. It preserves the bearer token, secret reference,
JSON request and `{raised, resolved, ignored}` response. No QQQ user session is required.
The previous **POST `/qqq/v1/alerts/ingest/alertmanager`** is an opt-in raw alias:
The architecture guardrail's static G3 scan reports this intentional raw UI-prefix route
as an advisory. Runtime strict-mode coverage rejects the alias; only the migration test
starts a standalone Javalin instance with it enabled. Normal application tests remain strict.
```java
new AlertsQBitConfig()
.withLegacyAlertmanagerPath(true)
.withAlertmanagerIngestTokenSecretRef("ALERTMANAGER_TOKEN");
```
`legacyAlertmanagerPath` defaults to false. Core `UiApiRouteAuditor` compares every raw v1
route against specs and offers no machine-webhook exemption. Therefore enabling the alias
requires non-strict UI route mode; strict startup rejects it. It is intentionally absent
from the UI fragment. Default hosts and all application-server tests run strict successfully.
The migration-only raw provider is tested separately, including strict-auditor rejection.
Both paths call the same handler and authenticate against the same secret. Every alias call,
including rejected authentication, logs a warning and carries:
```http
Deprecation: true
Sunset: Tue, 13 Apr 2027 00:00:00 GMT
```
The alias is supported for the 0.1 minor line only and scheduled for removal in 0.2; the
published calendar sunset is 13 April 2027. Move receivers before either retirement boundary.
No automatic runtime cutoff is applied by these informational headers.
## Core notification migration (#15 part 2)
Core `QAlerts.notify` now feeds the same inbox service through the registered sink. This
migration changes Java imports and producer entry points only: no methods, paths,
operation IDs, request fields, response fields or schemas change relative to PR #19.
The earlier client changes above still apply when adopting the full stacked branch.
The supplied Next `main` checkout still has no alerts client; the
`feature/notification-center:src/lib/api/alerts.ts` client was checked again for this work.
## Verification
Use the Maven wrapper. The committed BOM version is RC.6; while it is unreleased, verify with
an already installed local core build:
```sh
./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
```
Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test`
(and the same local version override). Normal tests compare the fragment without rewriting it.
Checkstyle uses `checkstyle/config.xml` and the repository's existing short Apache headers.
+42 -3
View File
@@ -22,12 +22,16 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
<!-- qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -80,6 +84,15 @@
<optional>true</optional>
</dependency>
<!-- Email through qbit-email when it is produced in the same instance; -->
<!-- otherwise core SendMessageAction and messagingProviderName. -->
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-email</artifactId>
<version>${qbit-email.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
@@ -107,6 +120,32 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.6.0</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>14.3.0</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
<violationSeverity>warning</violationSeverity>
</configuration>
<executions>
<execution>
<id>checkstyle</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
@@ -158,14 +197,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -74,6 +74,8 @@ public class AlertsQBitConfig implements QBitConfig
public static final String APP_NAME = "alerts";
private Boolean legacyAlertmanagerPath = false;
private String backendName;
private String schedulerName;
private String messagingProviderName;
@@ -610,7 +612,7 @@ public class AlertsQBitConfig implements QBitConfig
/*******************************************************************************
** Fluent setter for alertmanagerIngestTokenSecretRef: the reference (an
** environment variable name or ${...} expression, never the value) of the
** bearer token Alertmanager sends to POST /qqq/v1/alerts/ingest/alertmanager.
** bearer token Alertmanager sends to POST /qqq/hooks/alerts/alertmanager.
** Null (the default) turns ingest off.
*******************************************************************************/
public AlertsQBitConfig withAlertmanagerIngestTokenSecretRef(String alertmanagerIngestTokenSecretRef)
@@ -793,4 +795,26 @@ public class AlertsQBitConfig implements QBitConfig
this.jobRunLinkPattern = jobRunLinkPattern;
return (this);
}
/*******************************************************************************
** Whether to expose the deprecated UI-path webhook alias (default false).
** Requires non-strict UI route mode; remove after one minor release.
*******************************************************************************/
public Boolean getLegacyAlertmanagerPath()
{
return (this.legacyAlertmanagerPath);
}
/*******************************************************************************
** Opt in to the deprecated alias while migrating Alertmanager receivers.
*******************************************************************************/
public AlertsQBitConfig withLegacyAlertmanagerPath(Boolean legacyAlertmanagerPath)
{
this.legacyAlertmanagerPath = legacyAlertmanagerPath;
return (this);
}
}
@@ -197,7 +197,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
AlertsRuntime previous = qBitConfig.getRuntime();
if(previous != null)
{
previous.sink().stop();
previous.getSink().stop();
}
AlertsRuntime runtime = new AlertsRuntime(qBitConfig, qInstance);
qBitConfig.setRuntime(runtime);
@@ -212,6 +212,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
boolean routes = false;
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
{
com.kingsrook.qbits.alerts.api.AlertsUiApi.register(qInstance);
com.kingsrook.qbits.alerts.api.AlertsRouteProvider.register(qInstance);
routes = true;
}
@@ -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);
}
@@ -41,31 +41,29 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qbits.alerts.processes.AlertsAction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
/*******************************************************************************
@@ -139,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)
@@ -158,6 +156,7 @@ public class AlertsApi
result.put("items", latest);
result.put("banners", banners);
result.put("notificationsHref", NOTIFICATIONS_HREF);
result.put("inboxHref", NOTIFICATIONS_HREF);
result.put("alertsHref", "/app/" + AlertsQBitConfig.APP_NAME);
result.put("pollSeconds", POLL_SECONDS);
return (result);
@@ -391,6 +390,11 @@ public class AlertsApi
{
row.put("alertId", parseInteger(notification.getValueString("sourceRecordId")));
}
List<Map<String, Object>> actions = new ArrayList<>();
actions.add(Map.of("name", entry.unread() ? "read" : "unread", "label", entry.unread() ? "Mark read" : "Mark unread",
"operationId", entry.unread() ? InboxReadSpec.OPERATION_ID : InboxUnreadSpec.OPERATION_ID));
actions.add(Map.of("name", "dismiss", "label", "Dismiss", "operationId", InboxDismissSpec.OPERATION_ID));
row.put("actions", actions);
return (row);
}
@@ -582,46 +586,13 @@ public class AlertsApi
result.put("viewAllHref", allIds.isEmpty() ? null : AlertsHome.listUrl(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, dedupe(allIds))));
result.put("canManage", session.hasPermission(AlertsQBitConfig.PERMISSION_MANAGE));
result.put("watching", isWatching(tableName, recordId));
MiddlewareSpecExtensionsMetaData extensions = MiddlewareSpecExtensionsMetaData.of(runtime.getQInstance());
result.put("follow", Map.of("available", extensions != null && extensions.getExtensions().containsKey("collaboration")));
return (result);
}
/*******************************************************************************
** "Watch alerts for this record": a RECORD-scope subscription for the user.
*******************************************************************************/
public Map<String, Object> watchRecord(Map<String, Object> body) throws QException
{
String userId = requireViewer();
String tableName = body == null ? null : (String) body.get("tableName");
Object recordId = body == null ? null : body.get("recordId");
if(userId == null || !StringUtils.hasContent(tableName) || recordId == null || runtime.getQInstance().getTable(tableName) == null)
{
throw (new QBadRequestException("Send a table name and record id."));
}
if(isWatching(tableName, String.valueOf(recordId)))
{
return (Map.of("watching", true, "created", false));
}
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(AlertsSubscription.TABLE_NAME).withRecord(new QRecord()
.withValue("subscriberType", SubscriberType.USER.name())
.withValue("userId", userId)
.withValue("scopeType", ScopeType.RECORD.name())
.withValue("scopeValue", tableName)
.withValue("scopeRecordId", String.valueOf(recordId))
.withValue("minSeverity", AlertSeverity.WARNING.name())
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
.withValue("deliveryMode", "IMMEDIATE")
.withValue("enabled", true)));
if(CollectionUtils.nullSafeHasContents(inserted.get(0).getErrors()))
{
throw (new QBadRequestException(inserted.get(0).getErrors().get(0).getMessage()));
}
return (Map.of("watching", true, "created", true, "subscriptionId", inserted.get(0).getValue("id")));
}
/*******************************************************************************
** Whether the user already has a RECORD subscription for this record.
*******************************************************************************/
@@ -654,11 +625,32 @@ public class AlertsApi
row.put("age", AlertMessageRenderer.age(alert.getValueInstant("firstRaisedAt"), now));
row.put("raisedAt", alert.getValueInstant("firstRaisedAt") == null ? null : alert.getValueInstant("firstRaisedAt").toString());
row.put("href", "/app/" + AlertsAlert.TABLE_NAME + "/" + alert.getValue("id"));
List<Map<String, Object>> actions = new ArrayList<>();
if(QContext.getQSession().hasPermission(AlertsQBitConfig.PERMISSION_MANAGE))
{
if(Status.OPEN.name().equals(alert.getValueString("status")))
{
actions.add(processAction("acknowledge", AlertsAction.ACKNOWLEDGE, alert.getValueInteger("id")));
}
actions.add(processAction("snooze", AlertsAction.SNOOZE, alert.getValueInteger("id")));
}
row.put("actions", actions);
return (row);
}
/*******************************************************************************
** Complete navigation target so clients need no process or table conventions.
*******************************************************************************/
private static Map<String, Object> processAction(String name, AlertsAction action, Integer alertId)
{
return (Map.of("name", name, "label", action.getLabel(), "processName", action.getProcessName(),
"href", "/app/" + action.getProcessName() + "?recordsParam=recordIds&recordIds=" + alertId + "&tableName=" + AlertsAlert.TABLE_NAME));
}
/*******************************************************************************
** Group labels by id (empty without read permission).
*******************************************************************************/
@@ -0,0 +1,97 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.List;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.openapi.model.Method;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
/*******************************************************************************
** Shared JSON transport; AlertsApi owns authorization and behavior.
*******************************************************************************/
abstract class AlertsJsonSpec extends AbstractJsonEndpointSpec
{
/*******************************************************************************
** The stable operation identifier exposed to clients.
*******************************************************************************/
protected abstract String operationId();
/*******************************************************************************
** Publish operation identifiers in both the fragment and served document.
*******************************************************************************/
@Override
public Method defineMethod()
{
return (super.defineMethod().withOperationId(operationId()));
}
/*******************************************************************************
** Keep the existing Content-Type requirement, including empty POST bodies.
*******************************************************************************/
@Override
public JsonInput buildInput(Context context) throws Exception
{
if(defineRequestBodySchema() != null && (context.contentType() == null || !context.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("application/json")))
{
throw (new QBadRequestException("The request body must be JSON (Content-Type: application/json)."));
}
try
{
return (super.buildInput(context));
}
catch(QBadRequestException e)
{
throw (new QBadRequestException("The request body is not valid JSON."));
}
}
/*******************************************************************************
** Framework error responses retain their existing HTTP status mapping.
*******************************************************************************/
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AlertsErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "AlertsErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "AlertsErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", "AlertsErrorV1")));
}
/*******************************************************************************
** Resolve the runtime in the authenticated caller context.
*******************************************************************************/
protected AlertsApi api() throws QException
{
return (new AlertsApi(AlertsRuntime.get()));
}
}
@@ -18,10 +18,10 @@ package com.kingsrook.qbits.alerts.api;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
@@ -30,32 +30,20 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
import io.javalin.config.JavalinConfig;
import io.javalin.http.ContentType;
import io.javalin.http.Context;
/*******************************************************************************
** Javalin routes under /qqq/v1/alerts for the Next UI bell and record panel:
** - GET /inbox/summary unread count, highest unread severity, latest 10,
** sticky banners
** - GET /inbox the notifications page (status, category,
** severity, offset, limit)
** - POST /inbox/read {"id": n}, {"ids": [..]}, {"all": true} or {"alertId": n}
** - POST /inbox/unread {"id": n} or {"ids": [..]}
** - POST /inbox/dismiss {"id": n}, {"ids": [..]} or {"all": true}
** - GET /record-panel?tableName=t&amp;recordId=id the "Open alerts" panel
** - POST /record-panel/watch {"tableName": t, "recordId": id}
** - POST /ingest/alertmanager Alertmanager's webhook (bearer token; off
** unless alertmanagerIngestTokenSecretRef is configured)
** Each request gets a session from the instance's authentication module;
** AlertsApi checks alerts.view and works in that session. Errors map
** through QJavalinUtils.handleException (400, 401, 403, 404). POST bodies
** must be JSON.
** Machine webhook routes. The UI routes are registered through AlertsUiApi.
*******************************************************************************/
public class AlertsRouteProvider implements QJavalinRouteProviderInterface
{
private static final QLogger LOG = QLogger.getLogger(AlertsRouteProvider.class);
public static final String WEBHOOK_PATH = "/qqq/hooks/alerts/alertmanager";
public static final String LEGACY_SUNSET = "Tue, 13 Apr 2027 00:00:00 GMT";
public static final String BASE_PATH = "/qqq/v1/alerts";
static final int MAX_INGEST_BYTES = 1024 * 1024;
@@ -97,14 +85,17 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
@Override
public void acceptJavalinConfig(JavalinConfig config)
{
config.routes.get(BASE_PATH + "/inbox/summary", context -> respond(context, false, (api, body) -> api.inboxSummary()));
config.routes.get(BASE_PATH + "/inbox", context -> respond(context, false, (api, body) -> api.inbox(queryParams(context))));
config.routes.post(BASE_PATH + "/inbox/read", context -> respond(context, true, AlertsApi::markRead));
config.routes.post(BASE_PATH + "/inbox/unread", context -> respond(context, true, AlertsApi::markUnread));
config.routes.post(BASE_PATH + "/inbox/dismiss", context -> respond(context, true, AlertsApi::dismiss));
config.routes.get(BASE_PATH + "/record-panel", context -> respond(context, false, (api, body) -> api.recordPanel(context.queryParam("tableName"), context.queryParam("recordId"))));
config.routes.post(BASE_PATH + "/record-panel/watch", context -> respond(context, true, AlertsApi::watchRecord));
config.routes.post(BASE_PATH + "/ingest/alertmanager", this::ingestAlertmanager);
config.routes.post(WEBHOOK_PATH, this::ingestAlertmanager);
if(Boolean.TRUE.equals(AlertsRuntime.find(qInstance).getConfig().getLegacyAlertmanagerPath()))
{
config.routes.post(BASE_PATH + "/ingest/alertmanager", context ->
{
context.header("Deprecation", "true");
context.header("Sunset", LEGACY_SUNSET);
LOG.warn("Deprecated Alertmanager webhook called; migrate to " + WEBHOOK_PATH);
ingestAlertmanager(context);
});
}
}
@@ -138,43 +129,6 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
/*******************************************************************************
** Session, body, handler, JSON - or the error's status.
*******************************************************************************/
private void respond(Context context, boolean readsBody, Handler handler)
{
try
{
ExecutorSessionUtils.setupSession(context, qInstance);
Map<String, Object> body = readsBody ? readJsonBody(context) : Map.of();
Object result = handler.handle(new AlertsApi(AlertsRuntime.get(qInstance)), body);
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
}
catch(Exception e)
{
QJavalinUtils.handleException(null, context, e);
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The first value of each query parameter.
*******************************************************************************/
private static Map<String, String> queryParams(Context context)
{
Map<String, String> params = new java.util.HashMap<>();
context.queryParamMap().forEach((name, values) -> params.put(name, values == null || values.isEmpty() ? null : values.get(0)));
return (params);
}
/*******************************************************************************
** The JSON body (an object), or 400.
*******************************************************************************/
@@ -203,15 +157,4 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
/*******************************************************************************
** One endpoint's work.
*******************************************************************************/
@FunctionalInterface
interface Handler
{
/***************************************************************************
**
***************************************************************************/
Object handle(AlertsApi api, Map<String, Object> body) throws Exception;
}
}
@@ -0,0 +1,306 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Explicit versioned wire schemas, independent of persistence entities.
*******************************************************************************/
public final class AlertsSchemas
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsSchemas()
{
}
/*******************************************************************************
** The complete component set shared by the six operations.
*******************************************************************************/
public static Map<String, Schema> components()
{
return (new TreeMap<>(Map.ofEntries(
Map.entry("AlertsActionV1", new AlertsActionV1(null, null, null, null, null).toSchema()),
Map.entry("AlertsInboxItemV1", new AlertsInboxItemV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsPanelAlertV1", new AlertsPanelAlertV1(null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsFollowV1", new AlertsFollowV1(null).toSchema()),
Map.entry("AlertsInboxSummaryV1", new AlertsInboxSummaryV1(null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsInboxPageV1", new AlertsInboxPageV1(null, null, null, null).toSchema()),
Map.entry("AlertsRecordPanelV1", new AlertsRecordPanelV1(null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsMutationResultV1", new AlertsMutationResultV1(null).toSchema()),
Map.entry("AlertsInboxTargetV1", new AlertsInboxTargetV1(null, null, null, null).toSchema()),
Map.entry("AlertsUnreadTargetV1", new AlertsUnreadTargetV1(null, null, null).toSchema()),
Map.entry("AlertsErrorV1", new AlertsErrorV1(null).toSchema()))));
}
/*******************************************************************************
** An available action: operationId targets an inbox operation with this item id; process actions provide a complete href.
*******************************************************************************/
public record AlertsActionV1(String name, String label, String operationId, String processName, String href) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("An available action: operationId targets an inbox operation with this item id; process actions provide a complete href.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("name", new Schema().withType(Type.STRING)),
Map.entry("label", new Schema().withType(Type.STRING)),
Map.entry("operationId", new Schema().withType(Type.STRING)),
Map.entry("processName", new Schema().withType(Type.STRING)),
Map.entry("href", new Schema().withType(Type.STRING))))));
}
}
/*******************************************************************************
** A notification recipient. body is included only in page items and banners; alertId only for ALERT notifications.
*******************************************************************************/
public record AlertsInboxItemV1(Integer id, Integer notificationId, String title, String body, String severity, String category, String source, String href, String linkLabel, String deliveredAt, String age, Boolean unread, Boolean sticky, Integer alertId, List<AlertsActionV1> actions) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("A notification recipient. body is included only in page items and banners; alertId only for ALERT notifications.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("notificationId", new Schema().withType(Type.INTEGER)),
Map.entry("title", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("body", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("category", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("source", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("href", new Schema().withType(Type.STRING).withNullable(true).withDescription("Existing safe link, or null.")),
Map.entry("linkLabel", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("deliveredAt", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("age", new Schema().withType(Type.STRING)),
Map.entry("unread", new Schema().withType(Type.BOOLEAN)),
Map.entry("sticky", new Schema().withType(Type.BOOLEAN)),
Map.entry("alertId", new Schema().withType(Type.INTEGER).withNullable(true)),
Map.entry("actions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsActionV1")))))));
}
}
/*******************************************************************************
** A visible open alert with permission-aware process actions.
*******************************************************************************/
public record AlertsPanelAlertV1(Integer alertId, String title, String severity, String status, String group, String age, String raisedAt, String href, List<AlertsActionV1> actions) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("A visible open alert with permission-aware process actions.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("alertId", new Schema().withType(Type.INTEGER)),
Map.entry("title", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("status", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("group", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("age", new Schema().withType(Type.STRING)),
Map.entry("raisedAt", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("href", new Schema().withType(Type.STRING)),
Map.entry("actions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsActionV1")))))));
}
}
/*******************************************************************************
** Capability discovery only. Next obtains the follow URL from its collaboration module.
*******************************************************************************/
public record AlertsFollowV1(Boolean available) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Capability discovery only. Next obtains the follow URL from its collaboration module.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("available", new Schema().withType(Type.BOOLEAN).withDescription("True exactly when the collaboration UI API extension is registered."))))));
}
}
/*******************************************************************************
** Bell summary, latest ten items and up to three sticky banners.
*******************************************************************************/
public record AlertsInboxSummaryV1(Integer unreadCount, String highestUnreadSeverity, List<AlertsInboxItemV1> items, List<AlertsInboxItemV1> banners, String notificationsHref, String inboxHref, String alertsHref, Integer pollSeconds) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Bell summary, latest ten items and up to three sticky banners.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("unreadCount", new Schema().withType(Type.INTEGER)),
Map.entry("highestUnreadSeverity", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("banners", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("notificationsHref", new Schema().withType(Type.STRING).withDescription("Existing notifications page link; retained for compatibility.")),
Map.entry("inboxHref", new Schema().withType(Type.STRING).withDescription("Canonical inbox navigation link.")),
Map.entry("alertsHref", new Schema().withType(Type.STRING)),
Map.entry("pollSeconds", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Filtered notification page.
*******************************************************************************/
public record AlertsInboxPageV1(List<AlertsInboxItemV1> items, Integer total, Integer offset, Integer limit) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Filtered notification page.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("total", new Schema().withType(Type.INTEGER)),
Map.entry("offset", new Schema().withType(Type.INTEGER)),
Map.entry("limit", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Open alerts, recent resolutions and collaboration capability. watching is deprecated legacy subscription state.
*******************************************************************************/
public record AlertsRecordPanelV1(Boolean visible, Integer openCount, List<AlertsPanelAlertV1> open, Integer resolvedLast30Days, String viewAllHref, Boolean canManage, Boolean watching, AlertsFollowV1 follow) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Open alerts, recent resolutions and collaboration capability. watching is deprecated legacy subscription state.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("visible", new Schema().withType(Type.BOOLEAN)),
Map.entry("openCount", new Schema().withType(Type.INTEGER)),
Map.entry("open", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsPanelAlertV1"))),
Map.entry("resolvedLast30Days", new Schema().withType(Type.INTEGER)),
Map.entry("viewAllHref", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("canManage", new Schema().withType(Type.BOOLEAN)),
Map.entry("watching", new Schema().withType(Type.BOOLEAN).withDescription("Deprecated: existing RECORD subscriptions only; does not represent collaboration follow state.")),
Map.entry("follow", new Schema().withRefToSchema("AlertsFollowV1"))))));
}
}
/*******************************************************************************
** Number of the caller own recipient rows changed.
*******************************************************************************/
public record AlertsMutationResultV1(Integer updated) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Number of the caller own recipient rows changed.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("updated", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Select id, nonempty ids (first 500), legacy alertId, or all:true in that precedence. All scans are capped at 1000 rows.
*******************************************************************************/
public record AlertsInboxTargetV1(Integer id, List<Integer> ids, Integer alertId, Boolean all) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Select id, nonempty ids (first 500), legacy alertId, or all:true in that precedence. All scans are capped at 1000 rows.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER))),
Map.entry("alertId", new Schema().withType(Type.INTEGER)),
Map.entry("all", new Schema().withType(Type.BOOLEAN))))));
}
}
/*******************************************************************************
** Select id or nonempty ids (first 500), in that precedence. Legacy alertId is also accepted for compatibility. all is not supported.
*******************************************************************************/
public record AlertsUnreadTargetV1(Integer id, List<Integer> ids, Integer alertId) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Select id or nonempty ids (first 500), in that precedence. Legacy alertId is also accepted for compatibility. all is not supported.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER))),
Map.entry("alertId", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Framework error response.
*******************************************************************************/
public record AlertsErrorV1(String error) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Framework error response.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
/*******************************************************************************
** The alerts UI API, retaining the established /alerts namespace.
*******************************************************************************/
public final class AlertsUiApi
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsUiApi()
{
}
/*******************************************************************************
** Register once, alongside the optional Javalin webhook provider.
*******************************************************************************/
public static void register(QInstance instance)
{
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/*******************************************************************************
** The independently versioned, discoverable UI contract.
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
.withSemanticVersion("1.0.0").withDescription("Alerts inbox and record panel")
.withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class)
.withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class)
.withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class));
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Dismiss own notifications.
*******************************************************************************/
public class InboxDismissSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxDismiss";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/dismiss")
.withShortSummary("Dismiss own notifications").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().dismiss(input.getBody()));
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Mark own notifications read.
*******************************************************************************/
public class InboxReadSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxRead";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/read")
.withShortSummary("Mark own notifications read").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().markRead(input.getBody()));
}
}
@@ -0,0 +1,96 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.List;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Page the notification inbox.
*******************************************************************************/
public class InboxSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInbox";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox")
.withShortSummary("Page the notification inbox").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsInboxPageV1");
}
/*******************************************************************************
** Existing query parameters and paging semantics.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(
new Parameter().withName("status").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("all or unread; other values behave as all."),
new Parameter().withName("category").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive category filter."),
new Parameter().withName("severity").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive severity filter."),
new Parameter().withName("offset").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 0; negatives clamp to 0; malformed values use the default."),
new Parameter().withName("limit").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 25; clamped to 1..100; malformed values use the default.")));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().inbox(input.getQueryParams()));
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.Map;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Read the bell summary.
*******************************************************************************/
public class InboxSummarySpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxSummary";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox/summary")
.withShortSummary("Read the bell summary").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsInboxSummaryV1");
}
/*******************************************************************************
** Publish shared components once; all six extension specs register together.
*******************************************************************************/
@Override
public Map<String, Schema> defineComponentSchemas()
{
return (AlertsSchemas.components());
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().inboxSummary());
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Mark own notifications unread.
*******************************************************************************/
public class InboxUnreadSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxUnread";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/unread")
.withShortSummary("Mark own notifications unread").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsUnreadTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().markUnread(input.getBody()));
}
}
@@ -0,0 +1,93 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.List;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Read visible alerts for a record.
*******************************************************************************/
public class RecordPanelSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsRecordPanel";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/record-panel")
.withShortSummary("Read visible alerts for a record").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsRecordPanelV1");
}
/*******************************************************************************
** Existing query parameters and paging semantics.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(
new Parameter().withName("tableName").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent table name."),
new Parameter().withName("recordId").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent record key.")));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().recordPanel(input.getQueryParam("tableName"), input.getQueryParam("recordId")));
}
}
@@ -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."));
@@ -220,11 +220,11 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
else if(audience.getType() != NotificationAudience.Type.ALL_USERS && audience.getValues().isEmpty())
{
record.addError(new BadInputStatusMessage("List the " + switch(audience.getType())
{
case ROLES -> "roles or groups";
case PERMISSION -> "permission";
default -> "user ids";
} + " in Audience Members."));
{
case ROLES -> "roles or groups";
case PERMISSION -> "permission";
default -> "user ids";
} + " in Audience Members."));
}
}
}
@@ -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,6 +360,24 @@ public class AlertRouter
}
}
}
for(String recordKey : target.recordKeys())
{
String[] parts = recordKey.split(":", 2);
RecordReference reference = new RecordReference(parts[0], parts[1]);
for(RecordFollower follower : RecordFollowers.get(runtime.getQInstance(), reference, FollowEvent.ALERT))
{
String userId = follower == null ? null : follower.getUserId();
if(!StringUtils.hasContent(userId) || !canReadRecord(userId, reference))
{
continue;
}
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
for(QRecord channel : userChannels(null, preference))
{
destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference));
}
}
}
return (new ArrayList<>(destinations.values()));
}
@@ -365,7 +390,7 @@ public class AlertRouter
private List<QRecord> userChannels(QRecord subscription, QRecord preference) throws QException
{
List<QRecord> channels = new ArrayList<>();
String overrides = subscription.getValueString("channelOverrideIds");
String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds");
if(StringUtils.hasContent(overrides))
{
for(String id : overrides.split("[,\\s]+"))
@@ -516,6 +541,39 @@ public class AlertRouter
/*******************************************************************************
** Followers must be able to read the source record, including its record locks.
** Reuse recipient sessions; unknown access fails closed before adding channels.
*******************************************************************************/
private boolean canReadRecord(String userId, RecordReference reference)
{
try
{
QSession session = automatedSession(userId);
if(session == null || !CollectionUtils.nullSafeHasContents(session.getPermissions()))
{
return (false);
}
QInstance instance = runtime.getQInstance();
AlertsSecurity.applyToSession(instance, session);
boolean[] readable = new boolean[1];
QContext.withTemporaryContext(new CapturedContext(instance, session), () ->
{
String primaryKey = instance.getTable(reference.tableName()).getPrimaryKeyField();
readable[0] = PermissionsHelper.hasTablePermission(new AbstractActionInput(), reference.tableName(), TablePermissionSubType.READ)
&& AlertsDb.first(reference.tableName(), AlertsDb.eq(primaryKey, reference.primaryKey())) != null;
});
return (readable[0]);
}
catch(Exception e)
{
LOG.debug("Could not verify follower source-record access", e, logPair("userId", userId));
return (false);
}
}
/*******************************************************************************
** A session for a user, from the instance's authentication module (null
** when it can't make one).
@@ -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)
@@ -44,11 +44,11 @@ import com.kingsrook.qbits.alerts.model.AlertsEvent;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
@@ -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;
@@ -0,0 +1,518 @@
components:
schemas:
AlertsActionV1:
description: "An available action: operationId targets an inbox operation with\
\ this item id; process actions provide a complete href."
properties:
href:
type: "string"
label:
type: "string"
name:
type: "string"
operationId:
type: "string"
processName:
type: "string"
type: "object"
AlertsErrorV1:
description: "Framework error response."
properties:
error:
nullable: true
type: "string"
type: "object"
AlertsFollowV1:
description: "Capability discovery only. Next obtains the follow URL from its\
\ collaboration module."
properties:
available:
description: "True exactly when the collaboration UI API extension is registered."
type: "boolean"
type: "object"
AlertsInboxItemV1:
description: "A notification recipient. body is included only in page items\
\ and banners; alertId only for ALERT notifications."
properties:
actions:
items:
$ref: "#/components/schemas/AlertsActionV1"
type: "array"
age:
type: "string"
alertId:
nullable: true
type: "integer"
body:
nullable: true
type: "string"
category:
nullable: true
type: "string"
deliveredAt:
nullable: true
type: "string"
href:
description: "Existing safe link, or null."
nullable: true
type: "string"
id:
type: "integer"
linkLabel:
nullable: true
type: "string"
notificationId:
type: "integer"
severity:
nullable: true
type: "string"
source:
nullable: true
type: "string"
sticky:
type: "boolean"
title:
nullable: true
type: "string"
unread:
type: "boolean"
type: "object"
AlertsInboxPageV1:
description: "Filtered notification page."
properties:
items:
items:
$ref: "#/components/schemas/AlertsInboxItemV1"
type: "array"
limit:
type: "integer"
offset:
type: "integer"
total:
type: "integer"
type: "object"
AlertsInboxSummaryV1:
description: "Bell summary, latest ten items and up to three sticky banners."
properties:
alertsHref:
type: "string"
banners:
items:
$ref: "#/components/schemas/AlertsInboxItemV1"
type: "array"
highestUnreadSeverity:
nullable: true
type: "string"
inboxHref:
description: "Canonical inbox navigation link."
type: "string"
items:
items:
$ref: "#/components/schemas/AlertsInboxItemV1"
type: "array"
notificationsHref:
description: "Existing notifications page link; retained for compatibility."
type: "string"
pollSeconds:
type: "integer"
unreadCount:
type: "integer"
type: "object"
AlertsInboxTargetV1:
description: "Select id, nonempty ids (first 500), legacy alertId, or all:true\
\ in that precedence. All scans are capped at 1000 rows."
properties:
alertId:
type: "integer"
all:
type: "boolean"
id:
type: "integer"
ids:
items:
type: "integer"
type: "array"
type: "object"
AlertsMutationResultV1:
description: "Number of the caller own recipient rows changed."
properties:
updated:
type: "integer"
type: "object"
AlertsPanelAlertV1:
description: "A visible open alert with permission-aware process actions."
properties:
actions:
items:
$ref: "#/components/schemas/AlertsActionV1"
type: "array"
age:
type: "string"
alertId:
type: "integer"
group:
nullable: true
type: "string"
href:
type: "string"
raisedAt:
nullable: true
type: "string"
severity:
nullable: true
type: "string"
status:
nullable: true
type: "string"
title:
nullable: true
type: "string"
type: "object"
AlertsRecordPanelV1:
description: "Open alerts, recent resolutions and collaboration capability.\
\ watching is deprecated legacy subscription state."
properties:
canManage:
type: "boolean"
follow:
$ref: "#/components/schemas/AlertsFollowV1"
open:
items:
$ref: "#/components/schemas/AlertsPanelAlertV1"
type: "array"
openCount:
type: "integer"
resolvedLast30Days:
type: "integer"
viewAllHref:
nullable: true
type: "string"
visible:
type: "boolean"
watching:
description: "Deprecated: existing RECORD subscriptions only; does not represent\
\ collaboration follow state."
type: "boolean"
type: "object"
AlertsUnreadTargetV1:
description: "Select id or nonempty ids (first 500), in that precedence. Legacy\
\ alertId is also accepted for compatibility. all is not supported."
properties:
alertId:
type: "integer"
id:
type: "integer"
ids:
items:
type: "integer"
type: "array"
type: "object"
securitySchemes:
sessionUuidCookie:
in: "cookie"
name: "sessionUUID"
type: "apiKey"
info:
contact:
email: "contact@kingsrook.com"
description: "Alerts inbox and record panel"
title: "QQQ Middleware API extension: alerts"
version: "1.0.0"
openapi: "3.0.3"
paths:
/qqq/v1/alerts/inbox/summary:
get:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInboxSummary"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsInboxSummaryV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Read the bell summary"
/qqq/v1/alerts/inbox:
get:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInbox"
parameters:
- description: "all or unread; other values behave as all."
in: "query"
name: "status"
required: false
schema:
type: "string"
- description: "Case-insensitive category filter."
in: "query"
name: "category"
required: false
schema:
type: "string"
- description: "Case-insensitive severity filter."
in: "query"
name: "severity"
required: false
schema:
type: "string"
- description: "Default 0; negatives clamp to 0; malformed values use the default."
in: "query"
name: "offset"
required: false
schema:
type: "integer"
- description: "Default 25; clamped to 1..100; malformed values use the default."
in: "query"
name: "limit"
required: false
schema:
type: "integer"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsInboxPageV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Page the notification inbox"
/qqq/v1/alerts/inbox/read:
post:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInboxRead"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsInboxTargetV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Mark own notifications read"
/qqq/v1/alerts/inbox/unread:
post:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInboxUnread"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsUnreadTargetV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Mark own notifications unread"
/qqq/v1/alerts/inbox/dismiss:
post:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInboxDismiss"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsInboxTargetV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Dismiss own notifications"
/qqq/v1/alerts/record-panel:
get:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsRecordPanel"
parameters:
- description: "Parent table name."
in: "query"
name: "tableName"
required: true
schema:
type: "string"
- description: "Parent record key."
in: "query"
name: "recordId"
required: true
schema:
type: "string"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsRecordPanelV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Read visible alerts for a record"
@@ -0,0 +1,81 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts;
import java.nio.file.Files;
import java.nio.file.Path;
import com.kingsrook.qbits.alerts.api.AlertsUiApi;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The committed extension fragment describes exactly the six UI operations.
*******************************************************************************/
class AlertsApiContractTest extends BaseAlertsTest
{
/*******************************************************************************
** Regenerate only by explicit build property, then check the deterministic fragment.
*******************************************************************************/
@Test
void contract() throws Exception
{
var extension = AlertsUiApi.extension();
var version = new MiddlewareVersionV1();
String generated = UiApiContract.generateExtensionYaml(version, extension);
Path path = Path.of("src/main/resources/openapi/qqq-v1/alerts.yaml");
if(Boolean.getBoolean("alerts.openapi.update"))
{
Files.createDirectories(path.getParent());
Files.writeString(path, generated);
}
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts"));
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder(
"/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read",
"/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel");
assertThat(extension.getSpecs()).hasSize(6);
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "actions", "follow");
assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/");
}
/*******************************************************************************
** Production registers one extension; the existing route opt-out still works.
*******************************************************************************/
@Test
void registration() throws Exception
{
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).containsOnlyKeys("alerts");
AlertsUiApi.register(qInstance);
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).hasSize(1);
assertThat(AlertsUiApi.extension().getEffectivePathPrefix()).isEqualTo("/alerts");
QInstance disabled = new QInstance();
disabled.addBackend(defineH2Backend());
new AlertsQBitProducer().withQBitConfig(new AlertsQBitConfig().withBackendName(BACKEND_NAME).withRegisterRoutes(false)).produce(disabled);
assertThat(MiddlewareSpecExtensionsMetaData.of(disabled)).isNull();
assertThat(new AlertsQBitConfig().getLegacyAlertmanagerPath()).isFalse();
}
}
@@ -0,0 +1,291 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.api.AlertsRouteProvider;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import com.kingsrook.qqq.middleware.javalin.specs.UiApiRouteAuditor;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Real HTTP coverage of the extension, permission boundaries and machine webhook.
*******************************************************************************/
class AlertsApiHttpTest extends BaseAlertsTest
{
private static final String TOKEN_PROPERTY = "alerts.test.http.token";
private static final String TOKEN = "test-webhook-token";
private QApplicationJavalinServer server;
private int port;
/*******************************************************************************
** Give the HTTP mock session the permissions of its test cookie identity.
*******************************************************************************/
public static class HttpSessionCustomizer extends TestSessionCustomizer
{
/***************************************************************************
** Test identities are carried in sessionUUID cookies.
***************************************************************************/
@Override
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
{
QSession granted = sessionFor(session.getUuid());
session.setUser(granted.getUser());
session.setPermissions(granted.getPermissions());
AlertsSecurity.applyToSession(instance, session);
}
}
/*******************************************************************************
** Shut down even after a failed assertion or startup.
*******************************************************************************/
@AfterEach
void stopServer()
{
if(server != null)
{
server.stop();
}
System.clearProperty(TOKEN_PROPERTY);
}
/*******************************************************************************
** Every application server runs strict; the legacy alias is tested separately.
*******************************************************************************/
private void start() throws Exception
{
qInstance.getAuthentication().setCustomizer(new QCodeReference(HttpSessionCustomizer.class));
server = new QApplicationJavalinServer(new AbstractQQQApplication()
{
/***************************************************************************
** Use the test database and produced metadata.
***************************************************************************/
@Override
public QInstance defineQInstance()
{
return (qInstance);
}
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
.withMiddlewareVersionList(List.of(new MiddlewareVersionV1()));
server.start();
port = server.getBoundPort();
}
/*******************************************************************************
** Send through Javalin with an explicit user and optional bearer authorization.
*******************************************************************************/
private HttpResponse<String> request(String method, String path, String body, String user, String authorization) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
.header("Cookie", "sessionUUID=" + user);
if(authorization != null)
{
request.header("Authorization", authorization);
}
if(body != null)
{
request.header("Content-Type", "application/json");
}
request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
try(HttpClient client = HttpClient.newHttpClient())
{
return (client.send(request.build(), HttpResponse.BodyHandlers.ofString()));
}
}
/*******************************************************************************
** Parse success bodies while reporting any unexpected framework failure.
*******************************************************************************/
@SuppressWarnings("unchecked")
private Map<String, Object> json(HttpResponse<String> response) throws Exception
{
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
return (JsonUtils.toObject(response.body(), Map.class));
}
/*******************************************************************************
** A core producer reaches the strict HTTP inbox through the background sink.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void coreNotificationReachesInbox() throws Exception
{
config.withSynchronousSink(false);
start();
long failures = QAlerts.getProducerFailureCount();
QAlerts.notify(new NotificationSignal("Shipment delivered").withBody("Your shipment arrived.")
.withCategory(NotificationCategory.JOB).withSourceName("easypost-tracking")
.withLink(AlertLink.toRecord(WIDGET, 5, "Shipment"))
.withAudience(NotificationAudience.user("viewer-1")));
runtime.getSink().stop();
String base = "/qqq/v1/alerts/inbox";
Map<String, Object> summary = json(request("GET", base + "/summary", null, "viewer-1", null));
assertThat(summary).containsEntry("unreadCount", 1).containsEntry("highestUnreadSeverity", "INFO");
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
assertThat(items).singleElement().satisfies(item -> assertThat(item)
.containsEntry("title", "Shipment delivered").containsEntry("category", "JOB")
.containsEntry("source", "easypost-tracking").containsEntry("href", "/app/" + WIDGET + "/5"));
Map<String, Object> page = json(request("GET", base, null, "viewer-1", null));
assertThat((List<Map<String, Object>>) page.get("items")).singleElement()
.satisfies(item -> assertThat(item).containsEntry("body", "Your shipment arrived."));
assertThat(json(request("GET", base + "/summary", null, "viewer-2", null))).containsEntry("unreadCount", 0);
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
}
/*******************************************************************************
** Existing paths, selectors, paging, null fields and additive client affordances.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void strictUiRoutes() throws Exception
{
new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).inboxSummary();
Integer widget = insertWidgets("BROKEN").get(0);
var alert = runtime.getEngine().raise(new AlertSignal("test.http.problem").withDedupKey("http")
.withTitle("Needs attention").withSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR).withRecordRef(AlertRecordRef.of(WIDGET, widget)));
start();
String base = "/qqq/v1/alerts";
Map<String, Object> summary = json(request("GET", base + "/inbox/summary", null, ADMIN, null));
assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications");
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
assertThat(items).hasSize(1);
Map<String, Object> item = items.get(0);
assertThat(item).containsKeys("href", "actions", "alertId");
assertThat((List<Map<String, Object>>) item.get("actions")).extracting(a -> a.get("operationId")).containsExactly("alertsInboxRead", "alertsInboxDismiss");
String target = JsonUtils.toJson(Map.of("id", item.get("id")));
assertThat(json(request("POST", base + "/inbox/read", target, ADMIN, null))).containsEntry("updated", 1);
assertThat(json(request("POST", base + "/inbox/unread", target, ADMIN, null))).containsEntry("updated", 1);
assertThat(json(request("GET", base + "/inbox?status=unread&offset=bad&limit=1000", null, ADMIN, null)))
.containsEntry("total", 1).containsEntry("offset", 0).containsEntry("limit", 100);
Map<String, Object> panel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, ADMIN, null));
assertThat(panel).containsEntry("follow", Map.of("available", false)).containsEntry("watching", false);
Map<String, Object> row = ((List<Map<String, Object>>) panel.get("open")).get(0);
assertThat((List<Map<String, Object>>) row.get("actions")).extracting(a -> a.get("processName")).containsExactly("alertsAcknowledge", "alertsSnooze");
assertThat(((List<Map<String, Object>>) row.get("actions")).get(0).get("href"))
.isEqualTo("/app/alertsAcknowledge?recordsParam=recordIds&recordIds=" + alert.getValue("id") + "&tableName=alerts_alert");
Map<String, Object> viewerPanel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, VIEWER, null));
assertThat(((List<Map<String, Object>>) viewerPanel.get("open")).get(0).get("actions")).isEqualTo(List.of());
assertThat(json(request("POST", base + "/inbox/dismiss", target, ADMIN, null))).containsEntry("updated", 1);
assertThat(json(request("GET", base + "/inbox/summary", null, ADMIN, null))).containsEntry("highestUnreadSeverity", null);
assertThat(request("POST", base + "/record-panel/watch", "{}", ADMIN, null).statusCode()).isEqualTo(404);
HttpResponse<String> invalidJson = request("POST", base + "/inbox/read", "not json", ADMIN, null);
assertThat(invalidJson.statusCode()).isEqualTo(400);
assertThat(invalidJson.body()).contains("The request body is not valid JSON.");
assertThat(request("POST", base + "/inbox/read", null, ADMIN, null).statusCode()).isEqualTo(400);
assertThat(request("GET", base + "/inbox/summary", null, "nobody", null).statusCode()).isEqualTo(403);
assertThat(request("GET", base + "/record-panel?tableName=widget&recordId=1", null, OUTSIDER, null).statusCode()).isEqualTo(403);
String openapi = request("GET", "/qqq/v1/openapi.yaml", null, ADMIN, null).body();
assertThat(openapi).contains("alertsInboxSummary", "alertsRecordPanel").doesNotContain("/record-panel/watch", "/ingest/alertmanager");
}
/*******************************************************************************
** The canonical webhook uses the same secret and body validation outside v1.
*******************************************************************************/
@Test
void canonicalWebhook() throws Exception
{
System.setProperty(TOKEN_PROPERTY, TOKEN);
config.withAlertmanagerIngestTokenSecretRef("${prop." + TOKEN_PROPERTY + "}");
start();
assertThat(request("POST", AlertsRouteProvider.BASE_PATH + "/ingest/alertmanager", "{}", ADMIN, "Bearer " + TOKEN).statusCode()).isEqualTo(404);
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{}", "nobody", null).statusCode()).isEqualTo(401);
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{}", "nobody", "Bearer wrong").statusCode()).isEqualTo(401);
HttpResponse<String> response = request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{\"alerts\":[]}", "nobody", "Bearer " + TOKEN);
assertThat(json(response)).containsEntry("raised", 0).containsEntry("resolved", 0).containsEntry("ignored", 0);
assertThat(response.headers().firstValue("Deprecation")).isEmpty();
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "[]", "nobody", "Bearer " + TOKEN).statusCode()).isEqualTo(400);
}
/*******************************************************************************
** Test the migration-only raw provider and prove strict mode refuses its alias.
*******************************************************************************/
@Test
void legacyWebhook() throws Exception
{
System.setProperty(TOKEN_PROPERTY, TOKEN);
config.withAlertmanagerIngestTokenSecretRef("${prop." + TOKEN_PROPERTY + "}").withLegacyAlertmanagerPath(true);
AlertsRouteProvider provider = new AlertsRouteProvider();
provider.setQInstance(qInstance);
Javalin app = Javalin.create(provider::acceptJavalinConfig).start(0);
try
{
port = app.port();
String alias = AlertsRouteProvider.BASE_PATH + "/ingest/alertmanager";
for(String path : List.of(alias, AlertsRouteProvider.WEBHOOK_PATH))
{
assertThat(request("POST", path, "{}", "nobody", "Bearer wrong").statusCode()).isEqualTo(401);
HttpResponse<String> response = request("POST", path, "{\"alerts\":[]}", "nobody", "Bearer " + TOKEN);
assertThat(json(response)).containsEntry("raised", 0);
if(path.equals(alias))
{
assertThat(response.headers().firstValue("Deprecation")).contains("true");
assertThat(response.headers().firstValue("Sunset")).contains(AlertsRouteProvider.LEGACY_SUNSET);
}
}
assertThatThrownBy(() -> UiApiRouteAuditor.audit(app, List.of(new MiddlewareVersionV1()), true))
.isInstanceOf(IllegalStateException.class).hasMessageContaining(alias);
}
finally
{
app.stop();
}
}
}
@@ -201,7 +201,7 @@ class HomeAndApiTest extends BaseAlertsTest
/*******************************************************************************
** The record panel: hidden with nothing to show; open alerts and recent
** resolutions otherwise; "Watch alerts for this record".
** resolutions otherwise; legacy watch rows and collaboration capability.
*******************************************************************************/
@Test
void testRecordPanel() throws Exception
@@ -225,13 +225,18 @@ class HomeAndApiTest extends BaseAlertsTest
assertThat(other.get("visible")).isEqualTo(true);
assertThat(other.get("openCount")).isEqualTo(0);
assertThat(api.watchRecord(Map.of("tableName", WIDGET, "recordId", widgets.get(0))).get("created")).isEqualTo(true);
assertThat(api.watchRecord(Map.of("tableName", WIDGET, "recordId", widgets.get(0))).get("created")).isEqualTo(false);
assertThat(panel.get("follow")).isEqualTo(Map.of("available", false));
com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance)
.withExtension(new com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0"));
assertThat(api.recordPanel(WIDGET, String.valueOf(widgets.get(0))).get("follow")).isEqualTo(Map.of("available", true));
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
.withValue("subscriberType", "USER").withValue("userId", ADMIN).withValue("scopeType", "RECORD")
.withValue("scopeValue", WIDGET).withValue("scopeRecordId", String.valueOf(widgets.get(0)))
.withValue("enabled", true)));
assertThat(api.recordPanel(WIDGET, String.valueOf(widgets.get(0))).get("watching")).isEqualTo(true);
assertThat(all(AlertsSubscription.TABLE_NAME)).anyMatch(s -> "RECORD".equals(s.getValueString("scopeType")));
assertThatThrownBy(() -> api.recordPanel("nope", "1")).isInstanceOf(QBadRequestException.class);
assertThatThrownBy(() -> api.watchRecord(Map.of())).isInstanceOf(QBadRequestException.class);
loginAs(OUTSIDER);
assertThatThrownBy(() -> api.recordPanel(WIDGET, "1")).isInstanceOf(QPermissionDeniedException.class);
}
@@ -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");
}
@@ -22,9 +22,9 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.engine.AlertRouter;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -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();
}
@@ -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");
@@ -331,7 +335,7 @@ class NotificationsTest extends BaseAlertsTest
assertThat(NotificationService.linkUrl(AlertLink.toUrl("https://example.com/x", "x"))).isEqualTo("https://example.com/x");
assertThat(NotificationService.linkUrl(AlertLink.toPath("/app/alerts", "x"))).isEqualTo("/app/alerts");
assertThat(NotificationService.safeLink("//evil.example.com")).isNull();
for(String bad : List.of("/\\evil.com", "\\\\evil.com", "javascript:alert(1)", "data:text/html,x", "http://x.example.com", "https:///nohost", "/a b", "/a b", "https://"))
for(String bad : List.of("/\\evil.com", "\\\\evil.com", "javascript:alert(1)", "data:text/html,x", "http://x.example.com", "https:///nohost", "/a b", "/a\tb", "https://"))
{
assertThat(NotificationService.safeLink(bad)).as(bad).isNull();
}
@@ -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);
}
+241
View File
@@ -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);