Compare commits

...
Author SHA1 Message Date
dependabot[bot] 02f651aec7 build(deps): Bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml](https://github.com/qrun-io/qqq-orb) and [QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml](https://github.com/qrun-io/qqq-orb).


Updates `QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml` from c288a8864fe9054f68410e1d78f5863aea4c4d22 to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/c288a8864fe9054f68410e1d78f5863aea4c4d22...1c389210e6dfda1630d053c07ae4dae523dd5576)

Updates `QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml` from cc30f599e63c8e41629c99d23395bac1adb8bc44 to 1c389210e6dfda1630d053c07ae4dae523dd5576
- [Release notes](https://github.com/qrun-io/qqq-orb/releases)
- [Changelog](https://github.com/QRun-IO/qqq-orb/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/qrun-io/qqq-orb/compare/cc30f599e63c8e41629c99d23395bac1adb8bc44...1c389210e6dfda1630d053c07ae4dae523dd5576)

---
updated-dependencies:
- dependency-name: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
- dependency-name: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml
  dependency-version: 1c389210e6dfda1630d053c07ae4dae523dd5576
  dependency-type: direct:production
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-26 23:16:50 +00:00
james.maesandCodex a3fc6ac5aa ci: adopt managed orb 0.8 patch track (#60)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:29:39 -05:00
james.maesandCodex fcdfb7ddc1 ci: adopt shared orb 0.7.18 (#59)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:47:29 -05:00
james.maesandCodex 9bb529d3bd ci: adopt shared orb 0.7.17 (#58)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:17:24 -05:00
james.maesandCodex 84b4c00f94 ci: centralize QBit source scanning (#56)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:44:44 -05:00
james.maesandCodex 5e76611393 ci: adopt orb 0.7.12 and stable verification gate (#55)
* ci: qualify the shared stable verification gate

Co-Authored-By: Codex <noreply@openai.com>

* ci: adopt verified stable orb 0.7.12

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 03:26:52 -05:00
james.maesandCodex d1939eefd5 ci: use corrected orb rc reference lookup (#54)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:18:17 -05:00
james.maesandCodex f4cbdae305 ci: adopt verified orb rc timeout recovery (#53)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:06:06 -05:00
james.maesandCodex c0acf6bbf8 ci: adopt shared orb 0.7.11 (#52)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:57:45 -05:00
james.maes 8846d0e514 Merge pull request #51 from QRun-IO/feature/orb-rc-worker
ci: move RC coordination into shared orb
2026-10-20 18:58:23 -05:00
james.maesandCodex 7f77969f8e ci: adopt orb-owned RC coordination
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:55:16 -05:00
james.maes 33f1e8dcf4 Merge pull request #50 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:46:04 -05:00
james.maesandCodex 318f176260 ci: adopt verified central QQQ orb 0.7.2
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:42:46 -05:00
james.maes 3adb6dc3d6 Merge pull request #46 from QRun-IO/feature/service-principal
fix(routing): per-recipient checks via the alerts service principal
2026-10-16 13:33:27 -05:00
james.maesandClaude Opus 5.5 b89c95f749 build: QQQ 4.1.0-RC.19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:19:08 -05:00
james.maesandClaude Opus 5.5 86b4ca5ff4 fix(routing): per-recipient checks run via the alerts service principal
Token-less automated sessions are rejected under OAuth2, so visibility,
follower and audience checks never worked there. With the host's alerts
principal declared they run on the recipient's behalf through it.
Recording/routing bookkeeping stays system so alerting never depends on
the IdP. Undeclared: unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:30:04 -05:00
james.maes a79ca3f696 Merge pull request #45 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:05:01 -05:00
james.maesandClaude Opus 5.5 c70210dee0 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:19:32 -05:00
james.maes 56e174444a Merge pull request #44 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:12:44 -05:00
james.maesandClaude Opus 5.5 90e389a55c chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:02 -05:00
james.maesandClaude Opus 5.5 489663a946 feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Regenerated against UI API 1.6.0: every operation has an operationId and
the standard error responses (BasicErrorResponseV1: error, code, message,
fieldErrors, traceId); the extension's own error schema is kept but
deprecated; hand-written error bodies go through QJavalinUtils so they carry
code/message/traceId. Extension MINOR bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:36 -05:00
james.maes fe4732e12f Merge pull request #43 from QRun-IO/feature/runtime-fields-not-audited
fix(audit): do not record-audit engine bookkeeping updates
2026-10-15 07:48:45 -05:00
james.maesandClaude Opus 5.5 b08d41da7f fix(audit): do not record-audit engine bookkeeping updates
Monitor lease claims and run results, and alert reminder/notification
timers, changed on every scheduler pass and each wrote a FIELD audit row
(~7k alerts_monitor and ~1.8k alerts_alert audits per 6h in production).
AlertsDb.update now omits the DML audit when an update touches only the
RuntimeFields of its table; any update with a configuration or state field
is audited in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:45:58 -05:00
james.maes 4a7dd2bb71 Merge pull request #42 from QRun-IO/fix/alerts-system-session-instance
fix(alerts): asSystem builds its system session with the runtime instance
2026-10-15 07:38:26 -05:00
james.maesandClaude Opus 5.5 b638741c61 fix(alerts): asSystem builds its system session with the runtime instance
The alerts-sink drain thread (and schedulers) run engine work through
AlertsRuntime.asSystem with no QContext, so new QSystemUserSession()
read a null instance and warned "QInstance was not supplied" on every
call. AlertsSecurity.systemSession(QInstance) takes the instance; the
no-arg form delegates with the context instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:25:44 -05:00
james.maes 7f79f86222 Merge pull request #41 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:57 -05:00
james.maesandClaude Opus 5.5 69b94a13f5 fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-alerts.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:23:05 -05:00
james.maes 884eab9432 Merge pull request #40 from QRun-IO/fix/help-bundle-version-test
test(help): bundle version test compares with the artifact version
2026-10-14 23:18:42 -05:00
30 changed files with 885 additions and 145 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
+62 -13
View File
@@ -1,25 +1,74 @@
# 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:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+3
View File
@@ -5,6 +5,9 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Fixed
- Under OAuth2, per-recipient checks (alert visibility for external channels, follower record access, audience sessions) never worked: they used token-less automated sessions, which OAuth2 rejects, so external sends fell back to the conservative rule. When the host declares the QQQ `alerts` service principal (`AlertsQBitConfig.withServicePrincipal`, default `alerts`), these checks run on the recipient's behalf through it: the recipient's current access intersected with the principal's. Alert recording and routing bookkeeping stay on the system session, so alerting does not depend on the identity provider being up. With no principal declared, behavior is unchanged. Needs the QQQ release with service identities (qqq#1255, #1256).
- Audit noise: every monitor run (lease claim, then run result) and every reminder check wrote a "Record was Edited" audit (about 7,000 `alerts_monitor` and 1,800 `alerts_alert` audits in 6 hours on one production host, most with no field details). Engine updates that touch only bookkeeping fields (`RuntimeFields`: monitor `claimedBy`, `claimedUntil`, `lastRunAt`, `nextRunAt`, `lastStatus`, `lastValue`, `lastError`, `lastElapsedMs`, `consecutiveFailures`, `conditionSince`; alert `timedNotificationCheckedAt`, `lastNotifiedAt`, `recordRefCount`) are written with `omitDmlAudit`. Configuration edits, raises and alert state changes are audited as before, as are UI and API edits of any field.
- Read the QBit metadata version from the Maven-filtered artifact version so release builds report their release version.
- Record-view child lists (e.g. a notification's Recipients) failed with "Error in JSON Serialization": the child table's meta-data includes the qbit config, and `AlertsQBitConfig.getRuntime()` points at a runtime that points back at the config, so Jackson recursed to its 1000-level nesting limit. `getRuntime()` and `getAudienceResolver()` are now `@JsonIgnore`, as in the other qbits' configs. A regression test renders and serializes every child-list widget.
### Added
+1 -1
View File
@@ -83,7 +83,7 @@ Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions i
**Upgrading from 0.1.0-RC.1:** changeset 16 renames the delivery log `alerts_notification` to `alerts_delivery`, and changeset 17 drops `alerts_inbox_item` (bell state only). Both are guarded, so a fresh schema and an RC.1 schema both end up the same.
The UI routes are published as the `alerts` extension (`1.0.0`, established prefix `/alerts`), with
The UI routes are published as the `alerts` extension (`1.2.0`, established prefix `/alerts`), with
[the OpenAPI fragment](src/main/resources/openapi/qqq-v1/alerts.yaml) and a contract test.
See [UI API and webhook migration](docs/UI-API.md) for operation IDs and the complete Next handoff.
Summary `inboxHref`, item/alert `actions`, and panel `follow.available` are additive. Existing
+18 -5
View File
@@ -1,7 +1,7 @@
# Alerts UI API and webhook migration
The qbit targets QQQ **4.1.0-RC.6** (pending release). `AlertsQBitProducer` registers the
`alerts` `MiddlewareSpecExtension`, version `1.1.0`, with the established `/alerts` prefix.
The qbit targets QQQ **4.1.0-RC.15**, UI API **1.6.0**. `AlertsQBitProducer` registers the
`alerts` `MiddlewareSpecExtension`, version `1.2.0`, with the established `/alerts` prefix.
`withRegisterRoutes(false)` opts out of both the extension and webhook registration.
The six JSON specs extend `AbstractJsonEndpointSpec`; existing `AlertsApi` logic continues
to enforce caller permissions, recipient ownership and alert visibility.
@@ -20,11 +20,21 @@ discovers the `alerts` extension so clients need not infer installation from an
| GET | `/qqq/v1/alerts/record-panel` | `alertsRecordPanel` |
All existing query parameters, selector precedence, paging defaults and clamps, status
codes, JSON content-type requirements, and response keys remain. IDs refer to recipient
codes, JSON content-type requirements, and success response keys remain. IDs refer to recipient
rows for inbox operations; legacy `alertId` selectors remain accepted. `all:true` applies
only to read/dismiss. Items in a summary omit `body`; page items and banners include it.
Nullable fields remain serialized as JSON null.
## UI API extension changes
UI API extension alerts 1.2.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds,
standard error responses with BasicErrorResponseV1, and extension tags. The six existing
operationIds are preserved. Errors retain their HTTP statuses and now include `error`,
`code`, `message`, and `traceId` (with `fieldErrors` when applicable). `AlertsErrorV1`
remains in components with a deprecation description for compatibility. The core upgrade
also retires the expired Slack shim: capable providers now receive notification
idempotency keys; providers without that capability keep recipient-row deduplication.
## Next handoff: additive payload fields
- Summary adds nullable `preferencesSection`: the user Preferences section name (`notifications`) or null when not registered. Clients can use `/qqq/v1/preferences/{name}` for notification settings.
@@ -127,12 +137,15 @@ The supplied Next `main` checkout still has no alerts client; the
## Verification
Use the Maven wrapper with the released QQQ 4.1.0-RC.7 artifacts already installed locally:
Use the Maven wrapper offline against the locally installed migration build:
```sh
./mvnw -o -q verify
./mvnw -o verify -Dqqq.snapshot.version=4.1.99-SNAPSHOT -Denforcer.skipRules=requireReleaseDeps
```
Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test`
(with `-o` for an offline build). Normal tests compare the fragment without rewriting it.
Checkstyle uses `checkstyle/config.xml` and the repository's existing short Apache headers.
The Enforcer switch skips only the release-dependency rule for the local SNAPSHOT;
omit it and the version override when building against the released RC.12 BOM.
+4 -2
View File
@@ -160,6 +160,7 @@
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-alerts.properties</include>
</includes>
</resource>
<resource>
@@ -167,6 +168,7 @@
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-alerts.properties</exclude>
</excludes>
</resource>
</resources>
@@ -258,14 +260,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.7 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.15 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.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.19</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -80,6 +80,7 @@ public class AlertsQBitConfig implements QBitConfig
private Boolean legacyAlertmanagerPath = false;
private String backendName;
private String servicePrincipal = "alerts";
private String schedulerName;
private String messagingProviderName;
private String fromAddress;
@@ -893,4 +894,30 @@ public class AlertsQBitConfig implements QBitConfig
this.legacyAlertmanagerPath = legacyAlertmanagerPath;
return (this);
}
/*******************************************************************************
** Getter for servicePrincipal: when the host declares this QQQ service
** principal, per-recipient checks (alert visibility, follower record access,
** audience) run on the recipient's behalf through it - their current access
** intersected with the principal's - instead of a token-less automated
** session (rejected under OAuth2). Alert recording and routing bookkeeping
** stay on the system session so alerting never depends on the identity
** provider being up.
*******************************************************************************/
public String getServicePrincipal()
{
return (this.servicePrincipal);
}
/*******************************************************************************
** Fluent setter for servicePrincipal (default alerts).
*******************************************************************************/
public AlertsQBitConfig withServicePrincipal(String servicePrincipal)
{
this.servicePrincipal = servicePrincipal;
return (this);
}
}
@@ -17,8 +17,11 @@
package com.kingsrook.qbits.alerts;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
import com.kingsrook.qbits.alerts.engine.AlertsProcessListener;
import com.kingsrook.qbits.alerts.engine.AlertsSinkAdapter;
@@ -66,7 +69,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-alerts";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private static final String JAVALIN_CLASS = "com.kingsrook.qqq.middleware.javalin.QJavalinMetaData";
@@ -75,6 +78,34 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
/*******************************************************************************
** Read the artifact version from the Maven-filtered resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = AlertsQBitProducer.class.getClassLoader().getResourceAsStream("qbit-alerts.properties"))
{
if(inputStream == null)
{
throw (new IllegalStateException("Missing qbit-alerts.properties; build with Maven to generate the artifact version"));
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw (new IllegalStateException("Missing or invalid version in qbit-alerts.properties; build with Maven resource filtering enabled"));
}
return (version);
}
catch(IOException e)
{
throw (new IllegalStateException("Could not read artifact version from qbit-alerts.properties", e));
}
}
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
@@ -144,7 +144,7 @@ public class AlertsRuntime
public <R> R asSystem(SystemWork<R> work) throws QException
{
Object[] result = new Object[1];
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession()), () ->
QContext.withTemporaryContext(new CapturedContext(qInstance, AlertsSecurity.systemSession(qInstance)), () ->
{
result[0] = work.run();
});
@@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.security.MultiRecordSecurityLock;
@@ -34,6 +35,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -278,7 +282,19 @@ public final class AlertsSecurity
*******************************************************************************/
public static QSystemUserSession systemSession()
{
QSystemUserSession session = new QSystemUserSession();
return (systemSession(QContext.getQInstance()));
}
/*******************************************************************************
** A system-user session for this instance, holding the alerts all-access
** keys. Background threads (the alerts sink, schedulers) have no QContext
** yet, so they must pass the instance in.
*******************************************************************************/
public static QSystemUserSession systemSession(QInstance qInstance)
{
QSystemUserSession session = new QSystemUserSession(qInstance);
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
session.withSecurityKeyValue(KEY_PERMISSION_ALL, true);
session.withSecurityKeyValue(KEY_READABLE_TABLE_ALL, true);
@@ -295,4 +311,25 @@ public final class AlertsSecurity
{
return (QContext.getQSession() instanceof QSystemUserSession);
}
/*******************************************************************************
** A session for a recipient: on their behalf through the alerts service
** principal when the host declares it (QQQ service identities), else the
** authentication module's automated session (previous behavior). Null when
** neither can be made - callers fail closed.
*******************************************************************************/
public static QSession recipientSession(QInstance qInstance, String principalName, String userId) throws QException
{
if(qInstance == null || qInstance.getAuthentication() == null)
{
return (null);
}
if(StringUtils.hasContent(principalName) && ServiceIdentities.isDeclared(qInstance, principalName))
{
return (ServiceIdentities.sessionOnBehalfOf(qInstance, principalName, userId));
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
}
}
@@ -23,6 +23,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.Method;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
@@ -79,10 +80,10 @@ abstract class AlertsJsonSpec extends AbstractJsonEndpointSpec
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AlertsErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "AlertsErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "AlertsErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", "AlertsErrorV1")));
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", BasicErrorResponseV1.class.getSimpleName())));
}
@@ -300,7 +300,7 @@ public final class AlertsSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Framework error response.").withProperties(new TreeMap<>(Map.ofEntries(
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.2.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
}
}
@@ -52,7 +52,7 @@ public final class AlertsUiApi
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
.withSemanticVersion("1.1.0").withDescription("Alerts inbox and record panel")
.withSemanticVersion("1.2.0").withDescription("Alerts inbox and record panel")
.withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class)
.withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class)
.withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class));
@@ -66,8 +66,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -882,18 +880,12 @@ public class AlertRouter
/*******************************************************************************
** A session for a user, from the instance's authentication module (null
** when it can't make one).
** A session for a user: on their behalf via the alerts service principal
** when declared, else the authentication module's (null when neither).
*******************************************************************************/
private QSession automatedSession(String userId) throws QException
{
QInstance qInstance = runtime.getQInstance();
if(qInstance.getAuthentication() == null)
{
return (null);
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
}
@@ -40,7 +40,8 @@ import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** Small helpers over QQQ's table actions, for the engine. They run in
** whatever context the caller set up (the engine uses the system user), so
** customizers, audits and ESB listeners apply as for any other write.
** customizers, audits and ESB listeners apply as for any other write, except
** that bookkeeping-only updates (RuntimeFields) are not record-audited.
*******************************************************************************/
public final class AlertsDb
{
@@ -114,11 +115,14 @@ public final class AlertsDb
/*******************************************************************************
** Update one row (it must carry its id); throws on an error.
** Update one row (it must carry its id); throws on an error. An update of
** engine bookkeeping fields only (RuntimeFields) is not record-audited.
*******************************************************************************/
public static QRecord update(String tableName, QRecord record) throws QException
{
List<QRecord> updated = UpdateAction.executeForRecords(new UpdateInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction()));
UpdateInput input = new UpdateInput(tableName).withRecord(record).withTransaction(QContext.getQBackendTransaction())
.withOmitDmlAudit(RuntimeFields.onlyRuntime(tableName, record));
List<QRecord> updated = UpdateAction.executeForRecords(input);
QRecord result = updated.get(0);
throwIfErrors(tableName, result);
return (result);
@@ -0,0 +1,76 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.engine;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Engine bookkeeping columns: evaluation leases, run results and notification
** timers that change on every scheduler pass. An update that touches only
** these (plus the primary key and modifyDate) is not record-audited; the core
** FIELD audit would otherwise write an audit row per monitor run and per
** reminder check. Any update that also changes a configuration or alert-state
** field (status, severity, acknowledgement, assignment, a monitor's settings)
** is audited in full, as before. Run history lives in the run fields
** themselves; alert raises, transitions and notifications have their own
** event and delivery tables.
*******************************************************************************/
public final class RuntimeFields
{
private static final Set<String> ALWAYS = Set.of("id", "modifyDate");
private static final Map<String, Set<String>> BY_TABLE = Map.of(
AlertsMonitor.TABLE_NAME, Set.of("claimedBy", "claimedUntil", "lastRunAt", "nextRunAt", "lastStatus", "lastValue", "lastError",
"lastElapsedMs", "consecutiveFailures", "conditionSince"),
AlertsAlert.TABLE_NAME, Set.of("timedNotificationCheckedAt", "lastNotifiedAt", "recordRefCount"));
/*******************************************************************************
** Constants only.
*******************************************************************************/
private RuntimeFields()
{
}
/*******************************************************************************
** The bookkeeping fields of a table (empty when it has none).
*******************************************************************************/
public static Set<String> of(String tableName)
{
return (BY_TABLE.getOrDefault(tableName, Set.of()));
}
/*******************************************************************************
** Whether an update record carries nothing but bookkeeping fields.
*******************************************************************************/
public static boolean onlyRuntime(String tableName, QRecord update)
{
Set<String> runtime = of(tableName);
return (!runtime.isEmpty() && update.getValues().keySet().stream().allMatch(field -> runtime.contains(field) || ALWAYS.contains(field)));
}
}
@@ -26,6 +26,7 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -33,8 +34,6 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -218,20 +217,14 @@ public class AlertsAudienceResolver
/*******************************************************************************
** A session for a user from the instance's authentication module (null
** when it can't make one).
** A session for a user: on their behalf via the alerts service principal
** when declared, else the authentication module's (null when neither).
*******************************************************************************/
private QSession automatedSession(String userId)
{
try
{
QInstance qInstance = runtime.getQInstance();
if(qInstance.getAuthentication() == null)
{
return (null);
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
return (AlertsSecurity.recipientSession(runtime.getQInstance(), runtime.getConfig().getServicePrincipal(), userId));
}
catch(Exception e)
{
@@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperat
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -232,9 +233,8 @@ public class NotificationSlackSender
** when the provider says the user can't be reached (no Slack connection),
** with no retry; FAILED for anything else.
**
** STAND-IN(core=QRun-IO/qqq#1194, removeBy=4.1.0-RC.8): RC.6 SendMessageInput has no
** idempotency key (notify:<notificationId>:<userId>:slack), so the
** recipient row's slackStatus is what prevents a resend.
** Pass the notification idempotency key when the provider supports it.
** The recipient row's slackStatus also prevents a resend.
*******************************************************************************/
private SlackStatus sendExternal(String userId, String subject, List<QRecord> items, Integer notificationId)
{
@@ -258,6 +258,7 @@ public class NotificationSlackSender
{
new SendMessageAction().execute(new SendMessageInput()
.withMessagingProviderName(runtime.getConfig().getSlackProviderName())
.withIdempotencyKey(SendMessageAction.capabilities(runtime.getConfig().getSlackProviderName()).contains(MessagingProviderCapability.IDEMPOTENCY) ? key : null)
.withTo(new Party().withAddress("user:" + userId))
.withSubject(subject)
.withContentList(List.of(new Content().withContentRole(ContentRole.Default.DEFAULT).withBody(text.toString().stripTrailing()))));
+230 -26
View File
@@ -16,7 +16,7 @@ components:
type: "string"
type: "object"
AlertsErrorV1:
description: "Framework error response."
description: "Deprecated since 1.2.0: errors use BasicErrorResponseV1."
properties:
error:
nullable: true
@@ -212,17 +212,89 @@ components:
type: "integer"
type: "array"
type: "object"
BasicErrorResponseV1:
description: "The error body of every UI API operation (core and extensions)."
properties:
error:
description: "The error message (the same as message; kept for clients of\
\ UI API 1.0 to 1.5)."
type: "string"
code:
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
\ Extensions may add codes; treat an unknown code by the HTTP status.\
\ Since 1.6.0."
type: "string"
message:
description: "A message to show the user. Since 1.6.0."
type: "string"
fieldErrors:
description: "Errors about particular request fields, when known. Since\
\ 1.6.0."
items:
properties:
field:
description: "The field (or parameter) name."
type: "string"
message:
description: "The error about that field."
type: "string"
code:
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
\ PATTERN."
type: "string"
type: "object"
type: "array"
traceId:
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
\ response header); quote it when reporting a problem. Since 1.6.0."
type: "string"
type: "object"
securitySchemes:
sessionUuidCookie:
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
in: "cookie"
name: "sessionUUID"
type: "apiKey"
bearerAuth:
bearerFormat: "JWT or session UUID"
description: "`Authorization: Bearer <token>`, where the token is either\n-\
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
\ to the app's client,\n or to its native client (`values.nativeClientId`\
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
\ as signing in on the web. When it expires (401), refresh it at the provider\
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
\ which the server keeps refreshed."
scheme: "bearer"
type: "http"
oauth2:
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
\ token\nas `Authorization: Bearer`."
flows:
authorizationCode:
authorizationUrl: "/authorize"
scopes:
email: "Email address"
offline_access: "A refresh token, to renew the access token without signing\
\ in again"
openid: "OpenID Connect sign-in"
profile: "Name, username and groups (permissions come from the groups)"
tokenUrl: "/token"
type: "oauth2"
info:
contact:
email: "contact@kingsrook.com"
description: "Alerts inbox and record panel"
title: "QQQ Middleware API extension: alerts"
version: "1.1.0"
version: "1.2.0"
openapi: "3.0.3"
paths:
/qqq/v1/alerts/inbox/summary:
@@ -234,25 +306,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -260,10 +332,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsInboxSummaryV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Read the bell summary"
tags:
- "alerts"
/qqq/v1/alerts/inbox:
get:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -304,25 +397,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -330,10 +423,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsInboxPageV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Page the notification inbox"
tags:
- "alerts"
/qqq/v1/alerts/inbox/read:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -349,25 +463,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -375,10 +489,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Mark own notifications read"
tags:
- "alerts"
/qqq/v1/alerts/inbox/unread:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -394,25 +529,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -420,10 +555,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Mark own notifications unread"
tags:
- "alerts"
/qqq/v1/alerts/inbox/dismiss:
post:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -439,25 +595,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -465,10 +621,31 @@ paths:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Dismiss own notifications"
tags:
- "alerts"
/qqq/v1/alerts/record-panel:
get:
description: "Uses the signed-in caller's permissions and record visibility."
@@ -491,25 +668,25 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found"
200:
content:
@@ -517,7 +694,34 @@ paths:
schema:
$ref: "#/components/schemas/AlertsRecordPanelV1"
description: "Successful Response"
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Read visible alerts for a record"
tags:
- "alerts"
servers:
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
url: "/"
tags:
- description: "Operations of the alerts extension."
name: "alerts"
@@ -0,0 +1 @@
version=${project.version}
@@ -51,12 +51,12 @@ class AlertsApiContractTest extends BaseAlertsTest
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts"));
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts") || name.equals("BasicErrorResponseV1"));
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder(
"/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read",
"/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel");
assertThat(extension.getSpecs()).hasSize(6);
assertThat(extension.getSemanticVersion()).isEqualTo("1.1.0");
assertThat(extension.getSemanticVersion()).isEqualTo("1.2.0");
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "preferencesSection", "actions", "follow");
assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/");
}
@@ -224,7 +224,12 @@ class AlertsApiHttpTest extends BaseAlertsTest
assertThat(request("POST", base + "/record-panel/watch", "{}", ADMIN, null).statusCode()).isEqualTo(404);
HttpResponse<String> invalidJson = request("POST", base + "/inbox/read", "not json", ADMIN, null);
assertThat(invalidJson.statusCode()).isEqualTo(400);
assertThat(invalidJson.body()).contains("The request body is not valid JSON.");
Map<String, Object> error = JsonUtils.toObject(invalidJson.body(), Map.class);
assertThat(error).containsKeys("error", "code", "message", "traceId")
.containsEntry("message", "The request body is not valid JSON.");
assertThat(error.get("error")).isEqualTo(error.get("message"));
assertThat(error.get("code")).isNotNull();
assertThat((String) error.get("traceId")).isNotBlank();
assertThat(request("POST", base + "/inbox/read", null, ADMIN, null).statusCode()).isEqualTo(400);
assertThat(request("GET", base + "/inbox/summary", null, "nobody", null).statusCode()).isEqualTo(403);
assertThat(request("GET", base + "/record-panel?tableName=widget&recordId=1", null, OUTSIDER, null).statusCode()).isEqualTo(403);
@@ -64,6 +64,8 @@ class AlertsQBitProducerTest extends BaseAlertsTest
void testProduce()
{
assertThat(qInstance.getQBits()).hasSize(1);
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank().isEqualTo(AlertsQBitProducer.VERSION);
assertThat(qInstance.getQBits().values().iterator().next().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion"));
assertThat(qInstance.getQBits().values().iterator().next().getArtifactId()).isEqualTo(AlertsQBitProducer.ARTIFACT_ID);
assertThat(AlertsRuntime.find(qInstance)).isSameAs(runtime);
assertThat(AlertsRuntime.find(new QInstance())).isNull();
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.serviceidentity.QDelegatedSession;
import com.kingsrook.qqq.backend.core.serviceidentity.QServicePrincipalMetaData;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentities;
import com.kingsrook.qqq.backend.core.serviceidentity.ServiceIdentitiesMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Per-recipient checks run on the recipient's behalf through the alerts
** service principal when the host declares it; otherwise unchanged.
*******************************************************************************/
class AlertsServicePrincipalTest extends BaseAlertsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void recipientSessionsGoThroughTheDeclaredPrincipal() throws Exception
{
QSession before = AlertsSecurity.recipientSession(qInstance, config.getServicePrincipal(), "alice");
assertThat(before).isNotInstanceOf(QDelegatedSession.class);
assertThat(before.getUser().getIdReference()).isEqualTo("alice");
ServiceIdentitiesMetaData.declare(qInstance, new QServicePrincipalMetaData().withName("alerts").withMayActOnBehalfOfUsers(true));
QSession after = AlertsSecurity.recipientSession(qInstance, config.getServicePrincipal(), "alice");
assertThat(after).isInstanceOf(QDelegatedSession.class);
assertThat(after.getUser().getIdReference()).isEqualTo("alice");
assertThat(ServiceIdentities.principalOf(after)).isEqualTo("alerts");
assertThat(ServiceIdentities.attributionName(after)).endsWith("via service:alerts");
}
}
@@ -0,0 +1,56 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Background threads (the alerts sink, schedulers) run engine work through
** asSystem with no QContext of their own; the system session must be built
** from the runtime instance, not from the (empty) context.
*******************************************************************************/
class AlertsSystemSessionTest extends BaseAlertsTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void asSystemWithoutContextUsesTheRuntimeInstance() throws Exception
{
QContext.clear();
QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(QSystemUserSession.class);
try
{
Boolean systemUser = runtime.asSystem(() -> QContext.getQInstance() != null && AlertsSecurity.isSystemUser());
assertThat(systemUser).isTrue();
assertThat(collectingLogger.getCollectedMessages())
.noneMatch(m -> m.getMessage().contains("QInstance was not supplied"));
}
finally
{
QLogger.deactivateCollectingLoggerForClass(QSystemUserSession.class);
}
}
}
@@ -158,7 +158,7 @@ class AlertsTablesGuardrailTest extends BaseAlertsTest
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-alerts");
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion"));
assertThat(bundle.getVersion()).as("the help bundle carries the artifact and producer version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(AlertsQBitProducer.VERSION);
});
HelpBundles.syncOnStartup(qInstance);
}
@@ -0,0 +1,166 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.engine;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Engine bookkeeping writes (monitor leases and run results, alert reminder
** and notification timers) create no audit rows; configuration edits and
** alert state changes still do.
*******************************************************************************/
class RuntimeFieldsAuditTest extends BaseAlertsTest
{
static final String TYPE = "test.widget.broken";
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeInstance(QInstance qInstance)
{
AlertsInstanceMetaData.ofOrWithNew(qInstance)
.withAlertType(new QAlertTypeMetaData().withName(TYPE).withLabel("Widget broken").withGroupName("widgets").withSubjectTableName(WIDGET).withDefaultSeverity(AlertSeverity.WARNING));
}
/*******************************************************************************
** Core audit tables on the test backend.
*******************************************************************************/
@BeforeEach
void auditTables() throws Exception
{
new AuditsMetaDataProvider().defineAll(qInstance, BACKEND_NAME, null);
new QInstanceEnricher(qInstance).enrich();
try(Connection connection = ConnectionManager.getConnection((RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME)); Statement statement = connection.createStatement())
{
for(String table : List.of("auditDetail", "audit", "auditUser", "auditTable"))
{
statement.execute("DROP TABLE IF EXISTS " + table);
}
statement.execute("CREATE TABLE auditUser (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE auditTable (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200), label VARCHAR(200), createDate TIMESTAMP, modifyDate TIMESTAMP)");
statement.execute("CREATE TABLE audit (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditTableId INTEGER, auditUserId INTEGER, recordId INTEGER, message VARCHAR(250), timestamp TIMESTAMP)");
statement.execute("CREATE TABLE auditDetail (id BIGINT AUTO_INCREMENT PRIMARY KEY, auditId BIGINT, message VARCHAR(250), fieldName VARCHAR(100), oldValue VARCHAR(250), newValue VARCHAR(250))");
}
}
/*******************************************************************************
** Edited-record audits for one table.
*******************************************************************************/
private List<QRecord> edits(String tableName) throws Exception
{
List<QRecord> tables = where("auditTable", AlertsDb.eq("name", tableName));
if(tables.isEmpty())
{
return (List.of());
}
return (where("audit", AlertsDb.eq("auditTableId", tables.get(0).getValue("id")).withCriteria("message", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, "Record was Edited")));
}
/*******************************************************************************
** Changed field names of one table's edit audits.
*******************************************************************************/
private List<String> editedFields(String tableName) throws Exception
{
List<Object> ids = edits(tableName).stream().map(r -> r.getValue("id")).map(Object.class::cast).toList();
return (all("auditDetail").stream().filter(d -> ids.contains(d.getValue("auditId"))).map(d -> d.getValueString("fieldName")).toList());
}
/*******************************************************************************
** The classifier: bookkeeping only, or not.
*******************************************************************************/
@Test
void classifiesBookkeepingOnlyUpdates()
{
assertThat(RuntimeFields.onlyRuntime(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", 1).withValue("claimedBy", "x").withValue("claimedUntil", Instant.now()))).isTrue();
assertThat(RuntimeFields.onlyRuntime(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", 1).withValue("lastRunAt", Instant.now()).withValue("enabled", false))).isFalse();
assertThat(RuntimeFields.onlyRuntime(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", 1).withValue("timedNotificationCheckedAt", Instant.now()))).isTrue();
assertThat(RuntimeFields.onlyRuntime(AlertsAlert.TABLE_NAME, new QRecord().withValue("id", 1).withValue("status", "ACKNOWLEDGED"))).isFalse();
assertThat(RuntimeFields.onlyRuntime(WIDGET, new QRecord().withValue("id", 1).withValue("checkedAt", Instant.now()))).isFalse();
}
/*******************************************************************************
** Evaluations and reminder checks write no monitor or alert edit audits;
** an admin edit and an acknowledgement do.
*******************************************************************************/
@Test
void evaluationsAreNotAuditedButEditsAndTransitionsAre() throws Exception
{
insertWidgets("BROKEN", "OK");
runtime.getTypeSync().syncIfNeeded();
QRecord type = runtime.asSystem(() -> AlertsDb.first(AlertsType.TABLE_NAME, AlertsDb.eq("name", TYPE)));
Integer monitorId = runtime.asSystem(() -> AlertsDb.insert(AlertsMonitor.TABLE_NAME, new QRecord()
.withValue("name", "Broken widgets").withValue("alertTypeId", type.getValue("id")).withValue("kind", "TABLE_QUERY").withValue("enabled", true)
.withValue("definition", JsonUtils.toJson(Map.of("tableName", WIDGET, "filter", Map.of("criteria", List.of(Map.of("fieldName", "status", "operator", "EQUALS", "values", List.of("BROKEN")))))))
.withValue("perRecord", false).withValue("scheduleSeconds", 60).withValue("forMinutes", 0).withValue("consecutiveFailures", 0)).getValueInteger("id"));
for(int run = 0; run < 3; run++)
{
runtime.getEvaluator().evaluateDue();
new EscalationAndReminder(runtime).runDue();
clock.advance(java.time.Duration.ofMinutes(2));
}
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsMonitor.TABLE_NAME, monitorId)).getValueInstant("lastRunAt")).isNotNull();
assertThat(edits(AlertsMonitor.TABLE_NAME)).as("monitor runs are bookkeeping").isEmpty();
assertThat(editedFields(AlertsAlert.TABLE_NAME)).as("reminder checks and ref counts are bookkeeping")
.doesNotContain("timedNotificationCheckedAt", "recordRefCount", "lastNotifiedAt");
runtime.asSystem(() -> AlertsDb.update(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", monitorId).withValue("name", "Broken widgets (renamed)")));
runtime.asSystem(() -> AlertsDb.update(AlertsMonitor.TABLE_NAME, new QRecord().withValue("id", monitorId).withValue("enabled", false).withValue("nextRunAt", null)));
assertThat(edits(AlertsMonitor.TABLE_NAME)).hasSize(2);
assertThat(editedFields(AlertsMonitor.TABLE_NAME)).contains("name", "enabled");
QRecord alert = where(AlertsAlert.TABLE_NAME, AlertsDb.eq("monitorId", monitorId)).get(0);
runtime.getEngine().acknowledge(alert.getValueInteger("id"), ADMIN);
assertThat(editedFields(AlertsAlert.TABLE_NAME)).contains("status");
}
}
@@ -21,6 +21,7 @@ package com.kingsrook.qbits.alerts.notifications;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import com.kingsrook.qbits.alerts.AlertsPreferences;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
@@ -42,6 +43,7 @@ import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.messaging.QMessagingProviderMetaData;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderCapability;
import com.kingsrook.qqq.backend.core.modules.messaging.MessagingProviderInterface;
import com.kingsrook.qqq.backend.core.modules.messaging.QMessagingProviderDispatcher;
import org.junit.jupiter.api.Test;
@@ -57,6 +59,7 @@ class NotificationSlackTest extends BaseAlertsTest
static final List<SendMessageInput> SENT = new CopyOnWriteArrayList<>();
static volatile String failureMode = "NONE";
static boolean idempotency;
@@ -76,6 +79,17 @@ class NotificationSlackTest extends BaseAlertsTest
/*******************************************************************************
** Advertise optional provider deduplication.
*******************************************************************************/
@Override
public Set<MessagingProviderCapability> getCapabilities()
{
return (idempotency ? Set.of(MessagingProviderCapability.IDEMPOTENCY) : Set.of());
}
/*******************************************************************************
**
*******************************************************************************/
@@ -107,6 +121,7 @@ class NotificationSlackTest extends BaseAlertsTest
qInstance.addMessagingProvider(new QMessagingProviderMetaData().withName("slack").withType("alerts-slack-capture"));
SENT.clear();
failureMode = "NONE";
idempotency = false;
}
@@ -187,6 +202,7 @@ class NotificationSlackTest extends BaseAlertsTest
assertThat(message.getMessagingProviderName()).isEqualTo("slack");
assertThat(message.getTo().getAddress()).isEqualTo("user:viewer-1");
assertThat(message.getSubject()).isEqualTo("Export failed");
assertThat(message.getIdempotencyKey()).isNull();
assertThat(text(message)).contains("Export failed").contains("Disk full").contains("https://admin.example.com/app/widget");
});
assertThat(slackStatus("viewer-1")).isEqualTo("SENT");
@@ -200,6 +216,22 @@ class NotificationSlackTest extends BaseAlertsTest
/*******************************************************************************
** Capable providers receive a stable key for immediate notifications.
*******************************************************************************/
@Test
void testIdempotencyKey() throws Exception
{
idempotency = true;
slackSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING", null);
notifyJob("Export failed", NotificationSeverity.WARNING, "viewer-1");
assertThat(new NotificationSlackSender(runtime).run().sent()).isEqualTo(1);
assertThat(SENT).singleElement().satisfies(message ->
assertThat(message.getIdempotencyKey()).matches("notify:[0-9]+:viewer-1:slack"));
}
/*******************************************************************************
** A category muted in-app gets no recipient row, so no Slack message.
*******************************************************************************/