Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 2202037c00 feat(notifications): email REQUESTED recipients immediately (per-job opt-in)
A recipient with emailStatus REQUESTED is emailed at once, without a
standing category preference or minimum severity, and only once even when
an IMMEDIATE preference also applies. No address: SKIPPED. ALERT
notifications are still never emailed here. This is the delivery half of
the durable-jobs "Email me when done" opt-in (QRun-IO/qqq durable jobs spec
2.11); marking recipients REQUESTED from core's NotificationSignal
attribute emailRequested lands with the RC.6 core notify() adoption.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:37:13 -05:00
james.maes 758b68a6a9 Merge pull request #11 from QRun-IO/feature/notifications-de
feat(notifications): security, share and release notices, email digests; harden links (phases d, e)
2026-10-13 10:21:19 -05:00
james.maes 784bf0026c Merge pull request #10 from QRun-IO/feature/notifications-c-jobs
feat(notifications): notify people of their failed and partial jobs (phase c)
2026-10-13 10:15:26 -05:00
james.maes e68bdb9db2 Merge pull request #9 from QRun-IO/feature/notifications-a-tables
feat(notifications): user notifications, recipients, audiences and the bell API (phase a)
2026-10-13 10:09:01 -05:00
james.maes cd06fff114 Merge pull request #8 from QRun-IO/feature/notifications-spec
docs: user notifications design (owner decisions 2026-10-13)
2026-10-13 10:03:46 -05:00
59 changed files with 540 additions and 2220 deletions

No files matched your search

+1 -12
View File
@@ -22,7 +22,7 @@ workflows:
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
@@ -58,14 +58,3 @@ workflows:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
publish_hotfix_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: hotfix
filters:
branches:
only: [/hotfix\/.*/]
+25
View File
@@ -0,0 +1,25 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+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 core SPI stand-in in `spi/`.
**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the notification compatibility types in `spi/`.
## Build Commands
@@ -25,7 +25,7 @@ Packages:
- `processes`: the record actions (`AlertsAction`, `AlertsActionSteps`) and the scheduled steps.
- `home`: the Alerts home and `AlertSummaryLoader`.
- `api`: the bell and record-panel routes.
- `spi`: the stand-in for the core producer SPI.
- `spi`: notification types and their AlertLink dependency retained for binary compatibility until core RC.6; alerts use core RC.5.
## Naming (owner rule)
@@ -44,7 +44,7 @@ Packages:
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Parent `com.kingsrook:qbit-build-parent:2.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.
- Tests: JUnit 5, AssertJ.
## Release
+8 -2
View File
@@ -4,11 +4,17 @@ 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.
- 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
- Per-notification email opt-in via recipient email status `REQUESTED`: immediate delivery without a standing category preference, skipped when no address is known, and never emailed for ALERT notifications.
- Optional external channels:
- Slack (an Incoming Webhook, Block Kit)
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
- a generic webhook, signed with Standard Webhooks headers through a stand-in for core's `WebhookSigner` (QRun-IO/qqq#1109)
- a generic webhook, signed with Standard Webhooks headers through core's `WebhookSigner` (QRun-IO/qqq#1109)
- PagerDuty (Events API v2: trigger, acknowledge and resolve)
- an Alertmanager sink (`/api/v2/alerts`, with the qqq labels and the origin guard)
- Prometheus Alertmanager ingest at `POST /qqq/v1/alerts/ingest/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
@@ -43,5 +49,5 @@ All notable changes to this project are documented here. The format follows Keep
- The top-level Alerts app and its home dashboard, plus `AlertSummaryLoader` for link-only summaries.
- `/qqq/v1/alerts` routes for the Next UI bell and record panel.
- Record security, through three key types (`alerts_user`, `alerts_permission`, `alerts_readable_table`).
- A stand-in for the core producer SPI (QRun-IO/qqq#1108), to be removed when that RC ships.
- Core alert producer SPI integration (QRun-IO/qqq#1108), plus temporary notification SPI compatibility until RC.6 (QRun-IO/qqq#1125).
- Repository scaffold: `AlertsQBitConfig` and `AlertsQBitProducer` (table prefix `alerts_`, permissions `alerts.*`), build, CI and repository standard files.
+7 -5
View File
@@ -51,7 +51,7 @@ Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions i
## Requirements
- Java 21.
- QQQ 4.1 (built against `4.1.0-RC.4`).
- QQQ 4.1 (built against `4.1.0-RC.5`).
- An RDBMS backend.
- A scheduler, for monitors and email delivery.
- A messaging provider, for email.
@@ -84,16 +84,18 @@ new AlertsQBitProducer()
## Raising alerts
Until the core producer SPI ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)) is in a released QQQ RC, the SPI types are a stand-in in `com.kingsrook.qbits.alerts.spi` (see its `package-info.java`). In the meantime, call the qbit's sink directly:
Use the producer SPI from `com.kingsrook.qqq.backend.core.alerts`, released in QQQ 4.1.0-RC.5 ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)). `AlertsQBitProducer` registers the qbit's sink through core's `AlertsInstanceMetaData`, so host producers need only core:
```java
AlertsRuntime.get().sink().raise(new AlertSignal("cdn.purge.dead")
QAlerts.raise(new AlertSignal("cdn.purge.dead")
.withDedupKey("provider:" + providerId)
.withTitle("3 CDN purges failed after retries") // external-safe: no personal data
.withRecordRefs(AlertRecordRef.of("cdn_purge_request", ids)));
```
Once the RC ships, producers call `QAlerts.raise(...)` from core and need no dependency on this qbit.
`QAlerts.resolve(...)` and `QAlerts.heartbeat(...)` reach the same sink. Declare types with core's `QAlertTypeMetaData`. Calls through `QAlerts.raise/resolve` defer to transaction commit when a transaction is present; without this qbit installed, core uses its logging sink.
User notifications 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.
## Endpoints (for the Next UI)
@@ -122,7 +124,7 @@ Each external channel is an `alerts_channel` row that an admin creates. Its secr
How each one sends:
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). This is the shared format from the qbit-webhooks assessment (QRun-IO/qqq#1105). The signer is a stand-in until core's `WebhookSigner` (QRun-IO/qqq#1109) is released.
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed by core's `WebhookSigner` with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). Both raw UTF-8 and `whsec_` base64 secrets keep the same wire format. Core's `WebhookSecret` requires 16 key bytes; a thin adapter zero-pads legacy shorter keys to 16 bytes, which is HMAC-SHA256-equivalent and preserves their signatures. New secrets should use `WebhookSecret.generate()`.
- **PagerDuty** gets one event per alert: `trigger`, `acknowledge` or `resolve`, with `dedup_key` set to the type and key.
- **Alertmanager** alerts carry the labels `qqq_type`, `qqq_group`, `qqq_key` and `qqq_origin="qbit-alerts"`. Ingest ignores alerts with that origin, so nothing loops.
- **Responses:** 2xx is sent. 408, 425, 429, 5xx and network errors are retried. Any other 4xx fails permanently.
+1 -1
View File
@@ -3,7 +3,7 @@
The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). This repository implements it. Phases P1 to P3 are built (see README.md); P4 to P6 remain.
Deviations from the design, and why:
- **The core SPI is a stand-in** (`spi/`) until QRun-IO/qqq#1108 is released.
- **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.
- **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).
+9 -4
View File
@@ -138,8 +138,9 @@ notification admins. The home stays the only Alerts dashboard.
## 6. Producer SPI
It extends the core alerts SPI (`com.kingsrook.qqq.backend.core.alerts`) through a core PR to qqq
`develop`, for the next 4.1 RC:
The notification API 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`:
```java
QAlerts.notify(new NotificationSignal("Your export failed")
@@ -156,8 +157,12 @@ QAlerts.notify(new NotificationSignal("Your export failed")
- The guarantees match `raise`: it never throws or blocks, it waits for the commit when a transaction is
open, and bad input is counted.
- `NotificationAudience`: `allUsers()`, `roles(...)`, `permission(p)`, `users(...)`, `user(id)`.
- Until that RC ships, the qbit carries the same classes in its `spi` stand-in package, and hosts call
`AlertsRuntime.get(qInstance).sink().notify(...)`.
- 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.
### 6.1 Automatic sources
+2 -2
View File
@@ -158,14 +158,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -36,7 +36,7 @@ import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
@@ -27,11 +27,11 @@ import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
@@ -276,8 +276,9 @@ public class AlertsRuntime
/*******************************************************************************
** The sink producers send signals to (until the core SPI ships, hosts call
** it directly: AlertsRuntime.get().sink().raise(...)).
** The runtime sink. 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)
*******************************************************************************/
public QbitAlertsSink sink()
{
@@ -45,13 +45,13 @@ 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.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -25,8 +25,8 @@ import java.util.Map;
import com.kingsrook.qbits.alerts.home.HomeHtml;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -38,9 +38,9 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -327,7 +327,7 @@ public class DeliveryWorker
.withTitle("Alert channel \"" + channel.getValueString("label") + "\" is failing")
.withSummary(failures + " deliveries in a row failed.")
.withDetails(result.error())
.withRecordRef(com.kingsrook.qbits.alerts.spi.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
.withRecordRef(com.kingsrook.qqq.backend.core.alerts.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
}
}
@@ -17,36 +17,25 @@
package com.kingsrook.qbits.alerts.delivery.channels;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSigner;
/*******************************************************************************
** STAND-IN for core's WebhookSigner (QRun-IO/qqq#1109, the shared signer of
** the qbit-webhooks assessment, QRun-IO/qqq#1105 D2). Same format: the
** Standard Webhooks headers webhook-id, webhook-timestamp and
** webhook-signature = "v1," + base64(HMAC-SHA256(key, id + "." + timestamp +
** "." + body)); a "whsec_" secret is base64 after the prefix, any other text
** is the raw key. Replace with com.kingsrook.qqq.backend.core.utils.webhooks
** .WebhookSigner when the QQQ RC with #1109 ships, so receivers verify Alerts
** and qbit-webhooks deliveries the same way.
** Compatibility adapter for the qbit's existing webhook secret strings.
** Core signs every request. Core requires at least 16 key bytes, whereas the
** previous signer accepted shorter keys. HMAC-SHA256 zero-pads keys to its
** 64-byte block size, so padding a short legacy key to 16 bytes preserves
** its signature exactly. New secrets should use WebhookSecret.generate().
*******************************************************************************/
public final class StandardWebhookSigner
{
public static final String HEADER_ID = "webhook-id";
public static final String HEADER_TIMESTAMP = "webhook-timestamp";
public static final String HEADER_SIGNATURE = "webhook-signature";
private static final String PREFIX = "whsec_";
/*******************************************************************************
** Signing only.
*******************************************************************************/
@@ -57,55 +46,36 @@ public final class StandardWebhookSigner
/*******************************************************************************
** The three signature headers.
** The three core Standard Webhooks headers, preserving legacy short keys.
*******************************************************************************/
public static Map<String, String> headers(String messageId, Instant timestamp, byte[] body, String secret)
{
long seconds = timestamp.getEpochSecond();
Map<String, String> headers = new LinkedHashMap<>();
headers.put(HEADER_ID, messageId);
headers.put(HEADER_TIMESTAMP, Long.toString(seconds));
headers.put(HEADER_SIGNATURE, "v1," + sign(messageId, seconds, body, secret));
return (headers);
return (WebhookSigner.headers(messageId, timestamp, body, List.of(parseSecret(secret))));
}
/*******************************************************************************
** base64 HMAC-SHA256 over id.timestamp.body.
** Preserve raw UTF-8 and whsec_ base64 secrets, including legacy short keys.
*******************************************************************************/
public static String sign(String messageId, long timestampSeconds, byte[] body, String secret)
{
if(messageId == null || messageId.isEmpty() || messageId.contains(".") || messageId.chars().anyMatch(Character::isWhitespace))
{
throw (new IllegalArgumentException("A webhook message id is required and may not contain '.' or whitespace"));
}
try
{
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(keyBytes(secret), "HmacSHA256"));
mac.update((messageId + "." + timestampSeconds + ".").getBytes(StandardCharsets.UTF_8));
mac.update(body == null ? new byte[0] : body);
return (Base64.getEncoder().encodeToString(mac.doFinal()));
}
catch(GeneralSecurityException e)
{
throw (new IllegalStateException("HMAC-SHA256 is unavailable", e));
}
}
/*******************************************************************************
** The key bytes of a secret's text form.
*******************************************************************************/
static byte[] keyBytes(String secret)
private static WebhookSecret parseSecret(String secret)
{
if(secret == null || secret.isBlank())
{
throw (new IllegalArgumentException("A webhook signing secret is required"));
}
String trimmed = secret.trim();
return (trimmed.startsWith(PREFIX) ? Base64.getDecoder().decode(trimmed.substring(PREFIX.length())) : trimmed.getBytes(StandardCharsets.UTF_8));
byte[] key = trimmed.startsWith(WebhookSecret.PREFIX)
? Base64.getDecoder().decode(trimmed.substring(WebhookSecret.PREFIX.length()))
: trimmed.getBytes(StandardCharsets.UTF_8);
if(key.length == 0)
{
throw (new IllegalArgumentException("A webhook signing secret is required"));
}
if(key.length < WebhookSecret.MINIMUM_KEY_BYTES)
{
return (WebhookSecret.parse(WebhookSecret.PREFIX + Base64.getEncoder().encodeToString(Arrays.copyOf(key, WebhookSecret.MINIMUM_KEY_BYTES))));
}
return (WebhookSecret.parse(trimmed));
}
}
@@ -21,7 +21,7 @@ import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -45,7 +45,7 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qqq.backend.core.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;
@@ -27,12 +27,12 @@ import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -29,11 +29,11 @@ import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
import com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -38,10 +38,10 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -26,12 +26,12 @@ import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
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;
@@ -19,10 +19,9 @@ package com.kingsrook.qbits.alerts.engine;
import java.time.Instant;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
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.context.QContext;
@@ -77,19 +76,4 @@ public class AlertsSinkAdapter implements AlertSinkInterface
runtime.sink().heartbeat(heartbeatKey, at);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void notify(NotificationSignal signal)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime != null)
{
runtime.sink().notify(signal);
}
}
}
@@ -45,13 +45,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -28,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.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
import com.kingsrook.qqq.backend.core.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.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -134,10 +134,11 @@ 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)
@@ -27,7 +27,7 @@ import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -44,17 +44,17 @@ 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;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
@@ -20,13 +20,13 @@ package com.kingsrook.qbits.alerts.model;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.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.ResolveMode;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
@@ -288,7 +288,7 @@ public final class AlertsPossibleValues
*******************************************************************************/
public enum EmailStatus
{
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
NONE, PENDING, REQUESTED, SENT, DIGESTED, SKIPPED, FAILED
}
@@ -28,6 +28,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
@@ -61,6 +62,8 @@ 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.
** An explicit REQUESTED recipient is emailed immediately without a standing
** category preference or minimum severity.
** Inert without a messaging provider and a From address. ALERT
** notifications are never emailed here (alert subscriptions already email
** them). SECURITY emails carry the title and link only. Runs as the system
@@ -124,18 +127,25 @@ public class NotificationEmailer
{
preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
}
if(preferences.isEmpty())
QQueryFilter eligible = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withCriteria("emailStatus", QCriteriaOperator.EQUALS, EmailStatus.REQUESTED.name());
if(!preferences.isEmpty())
{
return (new Result(0, 0, 0, 0));
eligible.withSubFilter(new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name())));
}
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name()))
.withSubFilter(eligible)
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS))
.withLimit(SCAN));
if(recipients.isEmpty())
{
return (new Result(0, 0, 0, 0));
}
Map<Integer, QRecord> notifications = notifications(recipients, now);
Map<String, QRecord> settings = userSettings(preferences.keySet());
Map<String, QRecord> settings = userSettings(recipients.stream().map(recipient -> recipient.getValueString("userId")).collect(Collectors.toSet()));
int sent = 0;
int skipped = 0;
@@ -145,14 +155,16 @@ public class NotificationEmailer
{
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
String userId = recipient.getValueString("userId");
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|| !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))
QRecord preference = notification == null ? null : preferences.getOrDefault(userId, Map.of()).get(notification.getValueString("category"));
boolean requested = EmailStatus.REQUESTED.name().equals(recipient.getValueString("emailStatus"));
if(notification == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|| (!requested && (preference == null || !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity"))))
|| (requested && !StringUtils.hasContent(address(settings.get(userId)))))
{
mark(recipient, EmailStatus.SKIPPED, null);
skipped++;
}
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
else if(requested || 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);
@@ -1,219 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
/*******************************************************************************
** A monitor that an alert type ships with ("seeded"). An alerts
** implementation inserts it once; after that it belongs to the admin, who may
** tune or disable it. Without an implementation installed it does nothing.
**
** The definition's keys depend on the kind (e.g. HEARTBEAT takes heartbeatKey
** or processName, plus expectedEverySeconds and graceSeconds; TABLE_QUERY
** takes tableName and filter). Values must be JSON-friendly.
*******************************************************************************/
public class AlertDefaultMonitor implements Serializable
{
private String name;
private AlertMonitorKind kind;
private Map<String, Serializable> definition = new LinkedHashMap<>();
private Boolean perRecord = false;
private AlertSeverity severity;
private Integer scheduleSeconds;
private Integer forMinutes = 0;
/*******************************************************************************
** A heartbeat monitor: raise when the key has not beat within the expected
** interval plus grace.
*******************************************************************************/
public static AlertDefaultMonitor heartbeat(String name, String heartbeatKey, int expectedEverySeconds, int graceSeconds)
{
return (new AlertDefaultMonitor()
.withName(name)
.withKind(AlertMonitorKind.HEARTBEAT)
.withDefinitionValue("heartbeatKey", heartbeatKey)
.withDefinitionValue("expectedEverySeconds", expectedEverySeconds)
.withDefinitionValue("graceSeconds", graceSeconds));
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
public String getName()
{
return (this.name);
}
/*******************************************************************************
** Fluent setter for name
*******************************************************************************/
public AlertDefaultMonitor withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Getter for kind
*******************************************************************************/
public AlertMonitorKind getKind()
{
return (this.kind);
}
/*******************************************************************************
** Fluent setter for kind
*******************************************************************************/
public AlertDefaultMonitor withKind(AlertMonitorKind kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Getter for definition
*******************************************************************************/
public Map<String, Serializable> getDefinition()
{
return (this.definition);
}
/*******************************************************************************
** Fluent setter for definition
*******************************************************************************/
public AlertDefaultMonitor withDefinition(Map<String, Serializable> definition)
{
this.definition = definition == null ? new LinkedHashMap<>() : new LinkedHashMap<>(definition);
return (this);
}
/*******************************************************************************
** Set one definition value.
*******************************************************************************/
public AlertDefaultMonitor withDefinitionValue(String key, Serializable value)
{
this.definition.put(key, value);
return (this);
}
/*******************************************************************************
** Getter for perRecord
*******************************************************************************/
public Boolean getPerRecord()
{
return (this.perRecord);
}
/*******************************************************************************
** Fluent setter for perRecord (one alert per matching record, instead of one
** aggregate alert)
*******************************************************************************/
public AlertDefaultMonitor withPerRecord(Boolean perRecord)
{
this.perRecord = perRecord;
return (this);
}
/*******************************************************************************
** Getter for severity
*******************************************************************************/
public AlertSeverity getSeverity()
{
return (this.severity);
}
/*******************************************************************************
** Fluent setter for severity (default: the type's severity)
*******************************************************************************/
public AlertDefaultMonitor withSeverity(AlertSeverity severity)
{
this.severity = severity;
return (this);
}
/*******************************************************************************
** Getter for scheduleSeconds
*******************************************************************************/
public Integer getScheduleSeconds()
{
return (this.scheduleSeconds);
}
/*******************************************************************************
** Fluent setter for scheduleSeconds (how often to evaluate)
*******************************************************************************/
public AlertDefaultMonitor withScheduleSeconds(Integer scheduleSeconds)
{
this.scheduleSeconds = scheduleSeconds;
return (this);
}
/*******************************************************************************
** Getter for forMinutes
*******************************************************************************/
public Integer getForMinutes()
{
return (this.forMinutes);
}
/*******************************************************************************
** Fluent setter for forMinutes (how long a condition must hold before it fires)
*******************************************************************************/
public AlertDefaultMonitor withForMinutes(Integer forMinutes)
{
this.forMinutes = forMinutes;
return (this);
}
}
@@ -23,6 +23,8 @@ 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.
@@ -1,48 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
/*******************************************************************************
** The kinds of monitor an alerts implementation evaluates. Declared in core
** so producers can describe a seeded (default) monitor for their alert types
** without depending on the implementation.
*******************************************************************************/
public enum AlertMonitorKind
{
/** count of rows matching a filter passes a threshold. */
TABLE_QUERY,
/** an aggregate (count, sum, avg, min, max) crosses a band. */
AGGREGATE,
/** nothing was seen (a heartbeat, a process success, a newest row) within an interval. */
HEARTBEAT,
/** a process run failed. */
PROCESS_FAILED,
/** a matching ESB event arrived. */
ESB_EVENT,
/** a code check returned the currently-failing conditions. */
CODE,
/** alerts ingested from an external system (e.g. Alertmanager). */
EXTERNAL
}
@@ -1,223 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
/*******************************************************************************
** A record that an alert is about. The label is internal (shown in-app to
** users who can see the record), never sent to an external channel.
*******************************************************************************/
public class AlertRecordRef implements Serializable
{
private String tableName;
private Serializable primaryKey;
private String label;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertRecordRef()
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertRecordRef(String tableName, Serializable primaryKey)
{
this.tableName = tableName;
this.primaryKey = primaryKey;
}
/*******************************************************************************
** One ref per primary key, all in the same table. Null keys are skipped.
*******************************************************************************/
public static List<AlertRecordRef> of(String tableName, Collection<? extends Serializable> primaryKeys)
{
List<AlertRecordRef> refs = new ArrayList<>();
if(primaryKeys != null)
{
for(Serializable primaryKey : primaryKeys)
{
if(primaryKey != null)
{
refs.add(new AlertRecordRef(tableName, primaryKey));
}
}
}
return (refs);
}
/*******************************************************************************
** One ref to one record.
*******************************************************************************/
public static AlertRecordRef of(String tableName, Serializable primaryKey)
{
return (new AlertRecordRef(tableName, primaryKey));
}
/*******************************************************************************
** Two refs are equal when they name the same table and the same key (keys
** compared by their string form, so 1 and "1" match).
*******************************************************************************/
@Override
public boolean equals(Object o)
{
if(this == o)
{
return (true);
}
if(!(o instanceof AlertRecordRef that))
{
return (false);
}
return (Objects.equals(tableName, that.tableName) && Objects.equals(String.valueOf(primaryKey), String.valueOf(that.primaryKey)));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public int hashCode()
{
return (Objects.hash(tableName, String.valueOf(primaryKey)));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String toString()
{
return (tableName + ":" + primaryKey);
}
/*******************************************************************************
** Getter for tableName
*******************************************************************************/
public String getTableName()
{
return (this.tableName);
}
/*******************************************************************************
** Setter for tableName
*******************************************************************************/
public void setTableName(String tableName)
{
this.tableName = tableName;
}
/*******************************************************************************
** Fluent setter for tableName
*******************************************************************************/
public AlertRecordRef withTableName(String tableName)
{
this.tableName = tableName;
return (this);
}
/*******************************************************************************
** Getter for primaryKey
*******************************************************************************/
public Serializable getPrimaryKey()
{
return (this.primaryKey);
}
/*******************************************************************************
** Setter for primaryKey
*******************************************************************************/
public void setPrimaryKey(Serializable primaryKey)
{
this.primaryKey = primaryKey;
}
/*******************************************************************************
** Fluent setter for primaryKey
*******************************************************************************/
public AlertRecordRef withPrimaryKey(Serializable primaryKey)
{
this.primaryKey = primaryKey;
return (this);
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (this.label);
}
/*******************************************************************************
** Setter for label
*******************************************************************************/
public void setLabel(String label)
{
this.label = label;
}
/*******************************************************************************
** Fluent setter for label (internal: shown in-app only, never sent out)
*******************************************************************************/
public AlertRecordRef withLabel(String label)
{
this.label = label;
return (this);
}
}
@@ -1,150 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.io.Serializable;
import java.time.Instant;
/*******************************************************************************
** A producer's "this problem is gone", passed to QAlerts.resolve. Resolving
** a key that is not open is a no-op.
*******************************************************************************/
public class AlertResolution implements Serializable
{
private String alertTypeName;
private String dedupKey;
private String note;
private Instant resolvedAt;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertResolution()
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertResolution(String alertTypeName, String dedupKey, String note)
{
this.alertTypeName = alertTypeName;
this.dedupKey = dedupKey;
this.note = note;
}
/*******************************************************************************
** The dedup key to use: the one given, or the type name.
*******************************************************************************/
public String getEffectiveDedupKey()
{
return (dedupKey == null || dedupKey.isBlank() ? alertTypeName : dedupKey);
}
/*******************************************************************************
** Getter for alertTypeName
*******************************************************************************/
public String getAlertTypeName()
{
return (this.alertTypeName);
}
/*******************************************************************************
** Fluent setter for alertTypeName
*******************************************************************************/
public AlertResolution withAlertTypeName(String alertTypeName)
{
this.alertTypeName = alertTypeName;
return (this);
}
/*******************************************************************************
** Getter for dedupKey
*******************************************************************************/
public String getDedupKey()
{
return (this.dedupKey);
}
/*******************************************************************************
** Fluent setter for dedupKey
*******************************************************************************/
public AlertResolution withDedupKey(String dedupKey)
{
this.dedupKey = dedupKey;
return (this);
}
/*******************************************************************************
** Getter for note
*******************************************************************************/
public String getNote()
{
return (this.note);
}
/*******************************************************************************
** Fluent setter for note
*******************************************************************************/
public AlertResolution withNote(String note)
{
this.note = note;
return (this);
}
/*******************************************************************************
** Getter for resolvedAt
*******************************************************************************/
public Instant getResolvedAt()
{
return (this.resolvedAt);
}
/*******************************************************************************
** Fluent setter for resolvedAt (default: when QAlerts.resolve is called)
*******************************************************************************/
public AlertResolution withResolvedAt(Instant resolvedAt)
{
this.resolvedAt = resolvedAt;
return (this);
}
}
@@ -1,73 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.util.Locale;
/*******************************************************************************
** How urgent an alert is. Ordered from least to most severe, so
** isAtLeast / compareTo can be used for "minimum severity" checks.
**
** An alert means "a person should do something". INFO is for worth-a-glance
** items that never page anyone; external channels ignore it by default.
*******************************************************************************/
public enum AlertSeverity
{
INFO,
WARNING,
ERROR,
CRITICAL;
/*******************************************************************************
** True when this severity is the same as, or more severe than, the other.
** A null other is treated as INFO (so everything is at least it).
*******************************************************************************/
public boolean isAtLeast(AlertSeverity other)
{
return (other == null || this.compareTo(other) >= 0);
}
/*******************************************************************************
** Leniently map a severity label from another system (Prometheus, a
** producer's own enum, etc) to an AlertSeverity. Case-insensitive; accepts
** common synonyms ("warn", "err", "fatal", "page", "info"/"informational").
** Returns the fallback for null, blank or unknown values.
*******************************************************************************/
public static AlertSeverity fromLabel(String label, AlertSeverity fallback)
{
if(label == null || label.isBlank())
{
return (fallback);
}
return switch(label.trim().toLowerCase(Locale.ROOT))
{
case "info", "informational", "notice", "low" -> INFO;
case "warning", "warn", "medium" -> WARNING;
case "error", "err", "high" -> ERROR;
case "critical", "crit", "fatal", "page", "emergency" -> CRITICAL;
default -> fallback;
};
}
}
@@ -1,489 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.io.Serializable;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/*******************************************************************************
** One "this is a problem" signal from a producer, passed to QAlerts.raise.
** Raising is idempotent on (alertTypeName, dedupKey): repeats refresh one open
** alert, they never duplicate it.
**
** Text fields have different reach. title and summary are external-safe: a
** producer must write them without personal data, because they may be sent
** to email or chat. details (and record ref labels) are internal: shown
** in-app only, to users who can see the alert.
**
** Bounds: at most MAX_RECORD_REFS record refs (extras are dropped, and count
** keeps the true total) and MAX_ATTRIBUTES attributes (extras are ignored).
*******************************************************************************/
public class AlertSignal implements Serializable
{
public static final int MAX_RECORD_REFS = 100;
public static final int MAX_ATTRIBUTES = 20;
private String alertTypeName;
private String dedupKey;
private AlertSeverity severity;
private String title;
private String summary;
private String details;
private List<AlertRecordRef> recordRefs = new ArrayList<>();
private List<AlertLink> links = new ArrayList<>();
private Map<String, String> attributes = new LinkedHashMap<>();
private Instant occurredAt;
private Integer count;
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertSignal()
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertSignal(String alertTypeName)
{
this.alertTypeName = alertTypeName;
}
/*******************************************************************************
** The dedup key to use: the one given, or the type name (one alert per type).
*******************************************************************************/
public String getEffectiveDedupKey()
{
return (dedupKey == null || dedupKey.isBlank() ? alertTypeName : dedupKey);
}
/*******************************************************************************
** The record count this signal describes: count when set, otherwise the
** number of record refs (null when there are none).
*******************************************************************************/
public Integer getEffectiveCount()
{
if(count != null)
{
return (count);
}
return (recordRefs.isEmpty() ? null : recordRefs.size());
}
/*******************************************************************************
** Getter for alertTypeName
*******************************************************************************/
public String getAlertTypeName()
{
return (this.alertTypeName);
}
/*******************************************************************************
** Setter for alertTypeName
*******************************************************************************/
public void setAlertTypeName(String alertTypeName)
{
this.alertTypeName = alertTypeName;
}
/*******************************************************************************
** Fluent setter for alertTypeName
*******************************************************************************/
public AlertSignal withAlertTypeName(String alertTypeName)
{
this.alertTypeName = alertTypeName;
return (this);
}
/*******************************************************************************
** Getter for dedupKey
*******************************************************************************/
public String getDedupKey()
{
return (this.dedupKey);
}
/*******************************************************************************
** Setter for dedupKey
*******************************************************************************/
public void setDedupKey(String dedupKey)
{
this.dedupKey = dedupKey;
}
/*******************************************************************************
** Fluent setter for dedupKey - a stable key, so repeats update one open alert.
*******************************************************************************/
public AlertSignal withDedupKey(String dedupKey)
{
this.dedupKey = dedupKey;
return (this);
}
/*******************************************************************************
** Getter for severity
*******************************************************************************/
public AlertSeverity getSeverity()
{
return (this.severity);
}
/*******************************************************************************
** Setter for severity
*******************************************************************************/
public void setSeverity(AlertSeverity severity)
{
this.severity = severity;
}
/*******************************************************************************
** Fluent setter for severity (optional; defaults to the type's severity)
*******************************************************************************/
public AlertSignal withSeverity(AlertSeverity severity)
{
this.severity = severity;
return (this);
}
/*******************************************************************************
** Getter for title
*******************************************************************************/
public String getTitle()
{
return (this.title);
}
/*******************************************************************************
** Setter for title
*******************************************************************************/
public void setTitle(String title)
{
this.title = title;
}
/*******************************************************************************
** Fluent setter for title (external-safe: no personal data)
*******************************************************************************/
public AlertSignal withTitle(String title)
{
this.title = title;
return (this);
}
/*******************************************************************************
** Getter for summary
*******************************************************************************/
public String getSummary()
{
return (this.summary);
}
/*******************************************************************************
** Setter for summary
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** Fluent setter for summary (external-safe: no personal data)
*******************************************************************************/
public AlertSignal withSummary(String summary)
{
this.summary = summary;
return (this);
}
/*******************************************************************************
** Getter for details
*******************************************************************************/
public String getDetails()
{
return (this.details);
}
/*******************************************************************************
** Setter for details
*******************************************************************************/
public void setDetails(String details)
{
this.details = details;
}
/*******************************************************************************
** Fluent setter for details (internal: in-app only, never sent externally)
*******************************************************************************/
public AlertSignal withDetails(String details)
{
this.details = details;
return (this);
}
/*******************************************************************************
** Getter for recordRefs
*******************************************************************************/
public List<AlertRecordRef> getRecordRefs()
{
return (this.recordRefs);
}
/*******************************************************************************
** Setter for recordRefs
*******************************************************************************/
public void setRecordRefs(List<AlertRecordRef> recordRefs)
{
this.recordRefs = new ArrayList<>();
withRecordRefs(recordRefs);
}
/*******************************************************************************
** Add record refs. Beyond MAX_RECORD_REFS they are dropped, and count is
** set to the true total so the alert still says how many there were.
*******************************************************************************/
public AlertSignal withRecordRefs(Collection<AlertRecordRef> recordRefs)
{
if(recordRefs == null)
{
return (this);
}
int total = this.recordRefs.size();
for(AlertRecordRef recordRef : recordRefs)
{
if(recordRef == null)
{
continue;
}
total++;
if(this.recordRefs.size() < MAX_RECORD_REFS)
{
this.recordRefs.add(recordRef);
}
}
if(total > MAX_RECORD_REFS && (count == null || count < total))
{
count = total;
}
return (this);
}
/*******************************************************************************
** Add one record ref.
*******************************************************************************/
public AlertSignal withRecordRef(AlertRecordRef recordRef)
{
return (withRecordRefs(recordRef == null ? null : List.of(recordRef)));
}
/*******************************************************************************
** Getter for links
*******************************************************************************/
public List<AlertLink> getLinks()
{
return (this.links);
}
/*******************************************************************************
** Setter for links
*******************************************************************************/
public void setLinks(List<AlertLink> links)
{
this.links = links == null ? new ArrayList<>() : new ArrayList<>(links);
}
/*******************************************************************************
** Add a link to where the problem is fixed.
*******************************************************************************/
public AlertSignal withLink(AlertLink link)
{
if(link != null)
{
this.links.add(link);
}
return (this);
}
/*******************************************************************************
** Getter for attributes
*******************************************************************************/
public Map<String, String> getAttributes()
{
return (this.attributes);
}
/*******************************************************************************
** Setter for attributes
*******************************************************************************/
public void setAttributes(Map<String, String> attributes)
{
this.attributes = new LinkedHashMap<>();
if(attributes != null)
{
attributes.forEach(this::withAttribute);
}
}
/*******************************************************************************
** Add a routing label (not displayed by default). At most MAX_ATTRIBUTES
** keys; a new key beyond that is ignored (replacing an existing key is fine).
*******************************************************************************/
public AlertSignal withAttribute(String key, String value)
{
if(key != null && (attributes.containsKey(key) || attributes.size() < MAX_ATTRIBUTES))
{
attributes.put(key, value);
}
return (this);
}
/*******************************************************************************
** Getter for occurredAt
*******************************************************************************/
public Instant getOccurredAt()
{
return (this.occurredAt);
}
/*******************************************************************************
** Setter for occurredAt
*******************************************************************************/
public void setOccurredAt(Instant occurredAt)
{
this.occurredAt = occurredAt;
}
/*******************************************************************************
** Fluent setter for occurredAt (default: when QAlerts.raise is called)
*******************************************************************************/
public AlertSignal withOccurredAt(Instant occurredAt)
{
this.occurredAt = occurredAt;
return (this);
}
/*******************************************************************************
** Getter for count
*******************************************************************************/
public Integer getCount()
{
return (this.count);
}
/*******************************************************************************
** Setter for count
*******************************************************************************/
public void setCount(Integer count)
{
this.count = count;
}
/*******************************************************************************
** Fluent setter for count - for aggregate alerts, e.g. "37 records".
*******************************************************************************/
public AlertSignal withCount(Integer count)
{
this.count = count;
return (this);
}
}
@@ -1,66 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.time.Instant;
/*******************************************************************************
** Where QAlerts sends signals. The default is LoggingAlertSink; an alerts
** implementation (such as qbit-alerts) registers its own through
** AlertsInstanceMetaData.withSink.
**
** Methods are called after the caller's transaction commits (or at once, when
** there is none), on the caller's thread, so they must be fast: queue the
** work, don't do database writes or network calls inline. Anything they
** throw is caught, logged and counted by QAlerts; it never reaches the
** producer.
**
** A new instance is not loaded per call: QAlerts loads the sink once per
** QInstance and reuses it, so implementations must be thread-safe.
*******************************************************************************/
public interface AlertSinkInterface
{
/***************************************************************************
** Record (or refresh) an alert. The signal's occurredAt is always set.
***************************************************************************/
void raise(AlertSignal signal);
/***************************************************************************
** Resolve an open alert, if there is one. resolvedAt is always set.
***************************************************************************/
void resolve(AlertResolution resolution);
/***************************************************************************
** Note that a dead-man's switch beat.
***************************************************************************/
void heartbeat(String heartbeatKey, Instant at);
/***************************************************************************
** Send a notification to people (an audience). The default does nothing,
** so sinks written before notifications keep working.
***************************************************************************/
default void notify(NotificationSignal signal)
{
//////////////////////////////////////////////////////////////
// noop - sinks that don't carry notifications ignore them //
//////////////////////////////////////////////////////////////
}
}
@@ -1,228 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
/*******************************************************************************
** Instance-level alerts meta-data: the sink that QAlerts sends signals to
** (default: LoggingAlertSink) and every declared QAlertTypeMetaData. It is
** supplemental meta-data, so any module or qbit can add to it.
*******************************************************************************/
public class AlertsInstanceMetaData implements QSupplementalInstanceMetaData
{
public static final String NAME = "alertsQBitStandIn";
/** alert type names: lowercase, dotted, at least two segments. */
public static final Pattern ALERT_TYPE_NAME_PATTERN = Pattern.compile("^[a-z0-9]+(\\.[a-z0-9-]+)+$");
private QCodeReference sink;
private Map<String, QAlertTypeMetaData> alertTypes = new LinkedHashMap<>();
private List<String> duplicateAlertTypes = new ArrayList<>();
private transient AlertSinkInterface loadedSink;
/*******************************************************************************
** Get the instance's alerts meta-data, or null when nothing added it.
*******************************************************************************/
public static AlertsInstanceMetaData of(QInstance qInstance)
{
return (qInstance == null ? null : QSupplementalInstanceMetaData.of(qInstance, NAME));
}
/*******************************************************************************
** Get the instance's alerts meta-data, adding an empty one if it has none.
*******************************************************************************/
public static AlertsInstanceMetaData ofOrWithNew(QInstance qInstance)
{
return (QSupplementalInstanceMetaData.ofOrWithNew(qInstance, NAME, AlertsInstanceMetaData::new));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getName()
{
return (NAME);
}
/*******************************************************************************
** Getter for sink
*******************************************************************************/
public QCodeReference getSink()
{
return (this.sink);
}
/*******************************************************************************
** Fluent setter for sink - an AlertSinkInterface implementation. An
** alerts implementation sets this; producers never do.
*******************************************************************************/
public AlertsInstanceMetaData withSink(QCodeReference sink)
{
this.sink = sink;
this.loadedSink = null;
return (this);
}
/*******************************************************************************
** The sink instance QAlerts loaded for this meta-data (cached; null until
** first use).
*******************************************************************************/
AlertSinkInterface getLoadedSink()
{
return (this.loadedSink);
}
/*******************************************************************************
** Cache the loaded sink instance.
*******************************************************************************/
void setLoadedSink(AlertSinkInterface loadedSink)
{
this.loadedSink = loadedSink;
}
/*******************************************************************************
** Getter for alertTypes
*******************************************************************************/
public Map<String, QAlertTypeMetaData> getAlertTypes()
{
return (this.alertTypes);
}
/*******************************************************************************
** Find a declared type by name, or null.
*******************************************************************************/
public QAlertTypeMetaData getAlertType(String name)
{
return (name == null ? null : alertTypes.get(name));
}
/*******************************************************************************
** Declare an alert type. Adding the same object again is a no-op; adding a
** different type with a name already taken is a validation error.
*******************************************************************************/
public AlertsInstanceMetaData withAlertType(QAlertTypeMetaData alertType)
{
if(alertType == null)
{
return (this);
}
QAlertTypeMetaData existing = alertTypes.get(alertType.getName());
if(existing != null && existing != alertType)
{
duplicateAlertTypes.add(alertType.getName());
return (this);
}
alertTypes.put(alertType.getName(), alertType);
return (this);
}
/*******************************************************************************
** Names must be unique and well-formed, subject tables must exist, a
** visibility permission must not be blank, heartbeat default monitors need
** an interval, and the sink must implement AlertSinkInterface.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, QInstanceValidator validator)
{
if(sink != null)
{
validator.validateSimpleCodeReference("Alerts sink: ", sink, AlertSinkInterface.class);
}
for(String duplicate : duplicateAlertTypes)
{
validator.assertCondition(false, "Alert type " + duplicate + " is declared more than once.");
}
for(Map.Entry<String, QAlertTypeMetaData> entry : alertTypes.entrySet())
{
String name = entry.getKey();
QAlertTypeMetaData alertType = entry.getValue();
String prefix = "Alert type " + name + ": ";
if(!validator.assertCondition(name != null && ALERT_TYPE_NAME_PATTERN.matcher(name).matches(), prefix + "name must be lowercase and dotted (e.g. cdn.purge.dead), matching " + ALERT_TYPE_NAME_PATTERN.pattern()))
{
continue;
}
validator.assertCondition(alertType.getDefaultSeverity() != null, prefix + "defaultSeverity is required.");
if(alertType.getSubjectTableName() != null)
{
validator.assertCondition(qInstance.getTable(alertType.getSubjectTableName()) != null, prefix + "subjectTableName " + alertType.getSubjectTableName() + " is not a table in the instance.");
}
if(alertType.getVisibilityPermission() != null)
{
validator.assertCondition(!alertType.getVisibilityPermission().isBlank(), prefix + "visibilityPermission must not be blank.");
}
if(alertType.getResolveMode() == ResolveMode.QUIET_PERIOD)
{
validator.assertCondition(alertType.getQuietPeriod() != null && !alertType.getQuietPeriod().isNegative() && !alertType.getQuietPeriod().isZero(), prefix + "a QUIET_PERIOD resolve mode needs a positive quietPeriod.");
}
for(AlertDefaultMonitor monitor : alertType.getDefaultMonitors())
{
validator.assertCondition(monitor.getKind() != null, prefix + "a default monitor needs a kind.");
if(monitor.getKind() == AlertMonitorKind.HEARTBEAT)
{
Object every = monitor.getDefinition().get("expectedEverySeconds");
validator.assertCondition(every instanceof Number number && number.longValue() > 0, prefix + "a heartbeat default monitor needs a positive expectedEverySeconds.");
}
}
}
}
}
@@ -1,54 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
/*******************************************************************************
** How much of an alert an external channel (email, chat, paging, webhook) may
** include. A channel has a level; an alert type has a ceiling, and the lower
** of the two applies.
*******************************************************************************/
public enum DetailLevel
{
/** severity, type label, group, title, age and one link back to the alert. */
MINIMAL,
/** MINIMAL plus the summary and the counts. */
STANDARD,
/** STANDARD plus details and record labels (admin opt-in). */
FULL;
/*******************************************************************************
** The lower (less revealing) of two levels. Null means "no limit".
*******************************************************************************/
public static DetailLevel lowerOf(DetailLevel a, DetailLevel b)
{
if(a == null)
{
return (b);
}
if(b == null)
{
return (a);
}
return (a.compareTo(b) <= 0 ? a : b);
}
}
@@ -26,12 +26,16 @@ 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
@@ -19,6 +19,8 @@ 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.
@@ -19,6 +19,8 @@ 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).
@@ -23,6 +23,8 @@ 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
@@ -1,410 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.TopLevelMetaDataInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
/*******************************************************************************
** Declares a kind of problem a qbit or app can report (e.g. cdn.purge.dead),
** so the system "knows about" every alert source. An alerts implementation
** syncs these into a table at startup, where admins can see and tune them.
**
** Add it to an instance like any top-level meta-data (qInstance.add(...), or
** from a MetaDataProducer); it lands in AlertsInstanceMetaData.alertTypes.
** Names are lowercase and dotted: ^[a-z0-9]+(\.[a-z0-9-]+)+$
*******************************************************************************/
public class QAlertTypeMetaData implements TopLevelMetaDataInterface, SourceQBitAware
{
private String name;
private String label;
private String sourceName;
private String sourceQBitName;
private String groupName;
private AlertSeverity defaultSeverity = AlertSeverity.WARNING;
private String description;
private String howToFix;
private String subjectTableName;
private String visibilityPermission;
private ResolveMode resolveMode = ResolveMode.PRODUCER;
private Duration quietPeriod;
private List<AlertDefaultMonitor> defaultMonitors = new ArrayList<>();
private DetailLevel externalDetailCeiling = DetailLevel.STANDARD;
/*******************************************************************************
** Find a declared alert type by name, or null.
*******************************************************************************/
public static QAlertTypeMetaData of(QInstance qInstance, String name)
{
AlertsInstanceMetaData alertsInstanceMetaData = AlertsInstanceMetaData.of(qInstance);
return (alertsInstanceMetaData == null ? null : alertsInstanceMetaData.getAlertType(name));
}
/*******************************************************************************
** Adds this type to the instance's AlertsInstanceMetaData (creating it).
*******************************************************************************/
@Override
public void addSelfToInstance(QInstance qInstance)
{
AlertsInstanceMetaData.ofOrWithNew(qInstance).withAlertType(this);
}
/*******************************************************************************
** The source shown to people: sourceName if set, otherwise the qbit that
** produced this meta-data.
*******************************************************************************/
public String getEffectiveSourceName()
{
return (sourceName != null ? sourceName : sourceQBitName);
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
@Override
public String getName()
{
return (this.name);
}
/*******************************************************************************
** Setter for name
*******************************************************************************/
public void setName(String name)
{
this.name = name;
}
/*******************************************************************************
** Fluent setter for name
*******************************************************************************/
public QAlertTypeMetaData withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Getter for label
*******************************************************************************/
public String getLabel()
{
return (this.label);
}
/*******************************************************************************
** Fluent setter for label
*******************************************************************************/
public QAlertTypeMetaData withLabel(String label)
{
this.label = label;
return (this);
}
/*******************************************************************************
** Getter for sourceName
*******************************************************************************/
public String getSourceName()
{
return (this.sourceName);
}
/*******************************************************************************
** Fluent setter for sourceName
*******************************************************************************/
public QAlertTypeMetaData withSourceName(String sourceName)
{
this.sourceName = sourceName;
return (this);
}
/*******************************************************************************
** Getter for sourceQBitName
*******************************************************************************/
@Override
public String getSourceQBitName()
{
return (this.sourceQBitName);
}
/*******************************************************************************
** Setter for sourceQBitName
*******************************************************************************/
@Override
public void setSourceQBitName(String sourceQBitName)
{
this.sourceQBitName = sourceQBitName;
}
/*******************************************************************************
** Fluent setter for sourceQBitName
*******************************************************************************/
@Override
public QAlertTypeMetaData withSourceQBitName(String sourceQBitName)
{
this.sourceQBitName = sourceQBitName;
return (this);
}
/*******************************************************************************
** Getter for groupName
*******************************************************************************/
public String getGroupName()
{
return (this.groupName);
}
/*******************************************************************************
** Fluent setter for groupName (the default group; created if missing)
*******************************************************************************/
public QAlertTypeMetaData withGroupName(String groupName)
{
this.groupName = groupName;
return (this);
}
/*******************************************************************************
** Getter for defaultSeverity
*******************************************************************************/
public AlertSeverity getDefaultSeverity()
{
return (this.defaultSeverity);
}
/*******************************************************************************
** Fluent setter for defaultSeverity
*******************************************************************************/
public QAlertTypeMetaData withDefaultSeverity(AlertSeverity defaultSeverity)
{
this.defaultSeverity = defaultSeverity;
return (this);
}
/*******************************************************************************
** Getter for description
*******************************************************************************/
public String getDescription()
{
return (this.description);
}
/*******************************************************************************
** Fluent setter for description
*******************************************************************************/
public QAlertTypeMetaData withDescription(String description)
{
this.description = description;
return (this);
}
/*******************************************************************************
** Getter for howToFix
*******************************************************************************/
public String getHowToFix()
{
return (this.howToFix);
}
/*******************************************************************************
** Fluent setter for howToFix
*******************************************************************************/
public QAlertTypeMetaData withHowToFix(String howToFix)
{
this.howToFix = howToFix;
return (this);
}
/*******************************************************************************
** Getter for subjectTableName
*******************************************************************************/
public String getSubjectTableName()
{
return (this.subjectTableName);
}
/*******************************************************************************
** Fluent setter for subjectTableName (drives visibility and the record panel)
*******************************************************************************/
public QAlertTypeMetaData withSubjectTableName(String subjectTableName)
{
this.subjectTableName = subjectTableName;
return (this);
}
/*******************************************************************************
** Getter for visibilityPermission
*******************************************************************************/
public String getVisibilityPermission()
{
return (this.visibilityPermission);
}
/*******************************************************************************
** Fluent setter for visibilityPermission (an extra gate on who may see it)
*******************************************************************************/
public QAlertTypeMetaData withVisibilityPermission(String visibilityPermission)
{
this.visibilityPermission = visibilityPermission;
return (this);
}
/*******************************************************************************
** Getter for resolveMode
*******************************************************************************/
public ResolveMode getResolveMode()
{
return (this.resolveMode);
}
/*******************************************************************************
** Fluent setter for resolveMode
*******************************************************************************/
public QAlertTypeMetaData withResolveMode(ResolveMode resolveMode)
{
this.resolveMode = resolveMode;
return (this);
}
/*******************************************************************************
** Resolve automatically after this long with no raise (sets the mode to
** QUIET_PERIOD).
*******************************************************************************/
public QAlertTypeMetaData withQuietPeriod(Duration quietPeriod)
{
this.quietPeriod = quietPeriod;
this.resolveMode = ResolveMode.QUIET_PERIOD;
return (this);
}
/*******************************************************************************
** Getter for quietPeriod
*******************************************************************************/
public Duration getQuietPeriod()
{
return (this.quietPeriod);
}
/*******************************************************************************
** Getter for defaultMonitors
*******************************************************************************/
public List<AlertDefaultMonitor> getDefaultMonitors()
{
return (this.defaultMonitors);
}
/*******************************************************************************
** Add a seeded monitor for this type.
*******************************************************************************/
public QAlertTypeMetaData withDefaultMonitor(AlertDefaultMonitor defaultMonitor)
{
if(defaultMonitor != null)
{
this.defaultMonitors.add(defaultMonitor);
}
return (this);
}
/*******************************************************************************
** Getter for externalDetailCeiling
*******************************************************************************/
public DetailLevel getExternalDetailCeiling()
{
return (this.externalDetailCeiling);
}
/*******************************************************************************
** Fluent setter for externalDetailCeiling (the most any channel may include)
*******************************************************************************/
public QAlertTypeMetaData withExternalDetailCeiling(DetailLevel externalDetailCeiling)
{
this.externalDetailCeiling = externalDetailCeiling;
return (this);
}
}
@@ -1,38 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
/*******************************************************************************
** How an open alert of a type is closed automatically. Any mode also allows
** a person to resolve an alert manually, with a note.
*******************************************************************************/
public enum ResolveMode
{
/** the producer calls QAlerts.resolve when the condition clears. */
PRODUCER,
/** the monitor that raised it resolves it when its next evaluation is clean. */
MONITOR,
/** it resolves when no raise has been seen for the type's quiet period. */
QUIET_PERIOD,
/** only a person resolves it. */
MANUAL
}
@@ -16,16 +16,20 @@
/*******************************************************************************
** STAND-IN for the QQQ core alerts producer SPI (QRun-IO/qqq#1108), which is
** not yet in a released QQQ 4.1.0 RC. These classes are copies of
** com.kingsrook.qqq.backend.core.alerts (AlertSignal, AlertSeverity,
** QAlertTypeMetaData and friends) under this package, so the qbit can be
** built and tested against 4.1.0-RC.4 now.
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
**
** Replace when the RC with qqq#1108 ships: delete this package, change the
** imports to com.kingsrook.qqq.backend.core.alerts, and register
** QbitAlertsSink in core's AlertsInstanceMetaData (see AlertsQBitProducer).
** Until then, producers in a host reach the qbit through
** AlertsRuntime.sink() (or its monitors), not through core's QAlerts.
** 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;
@@ -27,8 +27,8 @@ import com.kingsrook.qbits.alerts.metadata.AlertsAppMetaDataProducer;
import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qbits.alerts.processes.AlertsAction;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -28,12 +28,12 @@ import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
@@ -32,16 +32,16 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.processes.AlertsAction;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
@@ -25,8 +25,8 @@ import com.kingsrook.qbits.alerts.delivery.AlertMessage;
import com.kingsrook.qbits.alerts.delivery.AlertSecretResolver;
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
import com.kingsrook.qbits.alerts.engine.AlertCheckInterface;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
@@ -26,10 +26,11 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels;
import com.kingsrook.qbits.alerts.delivery.channels.StandardWebhookSigner;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookVerifier;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -172,8 +173,8 @@ class ExternalChannelsTest
String timestamp = request.headers().get("Webhook-timestamp").get(0);
String signature = request.headers().get("Webhook-signature").get(0);
assertThat(id).startsWith("msg_");
String expected = StandardWebhookSigner.sign(id, Long.parseLong(timestamp), request.body().getBytes(StandardCharsets.UTF_8), "whsec_c2VjcmV0LWtleS1mb3ItdGVzdHM=");
assertThat(signature).isEqualTo("v1," + expected);
new WebhookVerifier(List.of(WebhookSecret.parse("whsec_c2VjcmV0LWtleS1mb3ItdGVzdHM=")))
.verify(Map.of("webhook-id", id, "webhook-timestamp", timestamp, "webhook-signature", signature), request.body().getBytes(StandardCharsets.UTF_8));
assertThat(request.body()).contains("\"type\":\"qqq.alert.resolved\"").contains("\"dedupKey\":\"provider:cf\"");
////////////////////////////////////////////////////
@@ -22,10 +22,10 @@ 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.spi.DetailLevel;
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;
import org.junit.jupiter.api.Test;
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.delivery;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.stream.Stream;
import com.kingsrook.qbits.alerts.delivery.channels.StandardWebhookSigner;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Fixed wire vectors generated independently with Python's hmac/hashlib.
** Cover both secret encodings, short legacy keys and a key over one block.
*******************************************************************************/
class StandardWebhookSignerTest
{
/*******************************************************************************
** Raw and whsec_ secrets produce the exact same legacy header values.
*******************************************************************************/
@ParameterizedTest
@MethodSource("wireVectors")
void testLegacyWireFormat(String key, String expectedSignature)
{
Instant timestamp = Instant.ofEpochSecond(1791806400L);
byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8);
Map<String, String> expected = Map.of("webhook-id", "msg_test", "webhook-timestamp", "1791806400", "webhook-signature", expectedSignature);
assertThat(StandardWebhookSigner.headers("msg_test", timestamp, body, " " + key + " ")).isEqualTo(expected);
String encoded = "whsec_" + Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
assertThat(StandardWebhookSigner.headers("msg_test", timestamp, body, encoded)).isEqualTo(expected);
}
/*******************************************************************************
** Independent expected signatures, including the 15/16-byte core boundary.
*******************************************************************************/
private static Stream<Arguments> wireVectors()
{
return (Stream.of(
Arguments.of("short", "v1,Jz84jUiA/gQwIt0Td1DzUA/OE5soKzC5H9C5e94lliM="),
Arguments.of("sixteen-byte-key", "v1,qep/FxanCVlGy2Zohku2Bgd3ufM4KNRcj5qT8t7blNA="),
Arguments.of("sixteen-byte-key!", "v1,tbs9jXA3r4N06ZLpuOe6V4RasiRwYwbM2+2ZxRu8lNU="),
Arguments.of("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "v1,K6s1XISWGs8vzyUo3Ecush1eoxy/d5CiND+gzS70I8U=")));
}
/*******************************************************************************
** Empty or malformed keys still fail before any request is sent.
*******************************************************************************/
@Test
void testInvalidSecrets()
{
for(String secret : new String[] { null, "", " ", "whsec_", "whsec_!" })
{
assertThatThrownBy(() -> StandardWebhookSigner.headers("msg_test", Instant.EPOCH, null, secret)).isInstanceOf(IllegalArgumentException.class);
}
}
}
@@ -40,19 +40,19 @@ 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.AlertDefaultMonitor;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.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.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
import com.kingsrook.qqq.backend.core.alerts.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.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
@@ -309,11 +309,11 @@ public class AlertsDemoServer extends AbstractQQQApplication
{
runtime.sink().raise(new AlertSignal("backups.job.failed").withDedupKey("nightly-db").withTitle("Nightly database backup failed")
.withSummary("The dump step exited with an error.").withDetails("pg_dump: error: connection to server lost")
.withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(AlertLink.toRecord("backup_run", runId, "Open the run")));
.withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(com.kingsrook.qqq.backend.core.alerts.AlertLink.toRecord("backup_run", runId, "Open the run")));
}
else
{
runtime.sink().resolve(new com.kingsrook.qbits.alerts.spi.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded"));
runtime.sink().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());
}
}
@@ -324,7 +324,7 @@ public class AlertsDemoServer extends AbstractQQQApplication
if(day != 27)
{
clock.set(clock.instant().plus(Duration.ofHours(3)));
runtime.sink().resolve(new com.kingsrook.qbits.alerts.spi.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded"));
runtime.sink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded"));
}
}
if(day == 25)
@@ -35,13 +35,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertLink;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
@@ -27,13 +27,13 @@ import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -29,19 +29,19 @@ import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -24,9 +24,13 @@ import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
import com.kingsrook.qbits.alerts.processes.AlertsScheduledSteps;
import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -41,6 +45,38 @@ import static org.assertj.core.api.Assertions.assertThat;
class SinkAndScheduleTest extends BaseAlertsTest
{
/*******************************************************************************
** A host using only core reaches the qbit through instance metadata and
** the loadable adapter, including the asynchronous queue and persistence.
*******************************************************************************/
@Test
void testCoreProducerReachesInstalledSink() throws Exception
{
AlertsInstanceMetaData metadata = AlertsInstanceMetaData.of(qInstance);
assertThat(metadata.getSink().getName()).isEqualTo(AlertsSinkAdapter.class.getName());
assertThat(qInstance.getSupplementalMetaData("alertsQBitStandIn")).isNull();
metadata.withAlertType(new QAlertTypeMetaData().withName("host.backup.failed").withDefaultSeverity(AlertSeverity.ERROR));
long failures = QAlerts.getProducerFailureCount();
QAlerts.raise(new AlertSignal("host.backup.failed").withDedupKey("nightly").withTitle("Nightly backup failed"));
QAlerts.heartbeat("host.backup.nightly");
runtime.sink().stop();
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
assertThat(alerts).hasSize(1);
assertThat(alerts.get(0).getValueString("title")).isEqualTo("Nightly backup failed");
assertThat(alerts.get(0).getValueString("severity")).isEqualTo("ERROR");
assertThat(alerts.get(0).getValueString("status")).isEqualTo("OPEN");
assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1);
QAlerts.resolve("host.backup.failed", "nightly", "Retry succeeded");
runtime.sink().stop();
assertThat(all(AlertsAlert.TABLE_NAME).get(0).getValueString("status")).isEqualTo("RESOLVED");
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
}
/*******************************************************************************
** Process signals on the background queue in these tests.
*******************************************************************************/
@@ -166,6 +166,162 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
/*******************************************************************************
** A requested email does not require a standing category preference.
*******************************************************************************/
@Test
void testRequestedWithoutPreference() throws Exception
{
addressOnly();
requested("JOB");
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** An explicit request and an immediate preference still send only once.
*******************************************************************************/
@Test
void testRequestedWithImmediatePreference() throws Exception
{
emailSettings("viewer-1", "JOB", "IMMEDIATE", "ERROR");
requested("JOB");
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** A request overrides digest timing and category severity preferences.
*******************************************************************************/
@Test
void testRequestedWithDigestPreference() throws Exception
{
emailSettings("viewer-1", "JOB", "DAILY_DIGEST", "ERROR");
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.sent()).isEqualTo(1);
assertThat(result.digests()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** Knowing an address alone does not opt a notification into email.
*******************************************************************************/
@Test
void testNotRequestedWithoutPreference() throws Exception
{
addressOnly();
runtime.getNotifications().notify(new NotificationSignal("Done").withCategory(NotificationCategory.JOB)
.withAudience(NotificationAudience.user("viewer-1")));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("NONE");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Missing addresses are skipped rather than counted as delivery failures.
*******************************************************************************/
@Test
void testRequestedWithoutAddress() throws Exception
{
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.skipped()).isEqualTo(1);
assertThat(result.failed()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Alert subscriptions own alert emails, even with an explicit request.
*******************************************************************************/
@Test
void testRequestedAlertNeverEmailed() throws Exception
{
emailSettings("viewer-1", "ALERT", "IMMEDIATE", "INFO");
requested("ALERT");
assertThat(new NotificationEmailer(runtime).run().skipped()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Requests older than the delivery window are not emailed.
*******************************************************************************/
@Test
void testRequestedOutsideDeliveryWindow() throws Exception
{
addressOnly();
requested("JOB");
clock.set(clock.instant().plusSeconds(86401));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Provider failures remain terminal and do not cause repeated sends.
*******************************************************************************/
@Test
void testRequestedDeliveryFailure() throws Exception
{
addressOnly();
requested("JOB");
runtime.getConfig().withMessagingProviderName("missing-provider");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.failed()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("FAILED");
assertThat(new NotificationEmailer(runtime).run().failed()).isZero();
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Store an address without opting into any category.
*******************************************************************************/
private void addressOnly() throws QException
{
runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME,
new QRecord().withValue("userId", "viewer-1").withValue("emailAddress", "viewer-1@example.com")));
}
/*******************************************************************************
** Set up a recipient explicitly requesting email for worker tests.
*******************************************************************************/
private void requested(String category) throws QException
{
Integer id = runtime.getNotifications().notify(new NotificationSignal("Done").withCategory(NotificationCategory.valueOf(category))
.withAudience(NotificationAudience.user("viewer-1")));
runtime.asSystem(() ->
{
QRecord recipient = AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", id));
return (AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME,
new QRecord().withValue("id", recipient.getValue("id")).withValue("emailStatus", "REQUESTED")));
});
}
/*******************************************************************************
** The daily digest waits for the user's hour, then lists the unread ones.
*******************************************************************************/
@@ -27,9 +27,11 @@ 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;
@@ -346,6 +348,36 @@ class NotificationsTest extends BaseAlertsTest
/*******************************************************************************
** Preserve the method descriptors used by released notification producers,
** including the local AlertLink, and deliver to the selected user's bell.
*******************************************************************************/
@Test
void testLegacyNotificationSinkSignaturesAndDelivery() 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")
.withSeverity(NotificationSeverity.INFO).withCategory(NotificationCategory.JOB)
.withLink(AlertLink.toRecord("widget", 5, "Shipment"))
.withAudience(NotificationAudience.user("viewer-1")));
loginAs("viewer-1");
Map<String, Object> summary = new AlertsApi(runtime).inboxSummary();
assertThat(summary.get("unreadCount")).isEqualTo(1);
assertThat(items(summary).get(0)).containsEntry("title", "Shipment delivered").containsEntry("href", "/app/widget/5");
loginAs("viewer-2");
assertThat(new AlertsApi(runtime).inboxSummary().get("unreadCount")).isEqualTo(0);
}
/*******************************************************************************
** Audiences: roles from a directory, permission holders, warnings.
*******************************************************************************/