Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
818ff99091 | ||
|
|
2854e5336e
|
||
|
|
0032343173
|
||
|
|
ab03178f5d
|
||
|
|
2954345e5d | ||
|
|
8774fb0700
|
||
|
|
db7e0e0eac
|
||
|
|
f8058639f1
|
||
|
|
a3fc6ac5aa | ||
|
|
fcdfb7ddc1 | ||
|
|
9bb529d3bd | ||
|
|
84b4c00f94 | ||
|
|
5e76611393 | ||
|
|
d1939eefd5 | ||
|
|
f4cbdae305 | ||
|
|
c0acf6bbf8 | ||
|
|
8846d0e514 | ||
|
|
7f77969f8e
|
||
|
|
33f1e8dcf4 | ||
|
|
318f176260
|
||
|
|
3adb6dc3d6 | ||
|
|
b89c95f749
|
||
|
|
86b4ca5ff4
|
||
|
|
a79ca3f696 | ||
|
|
c70210dee0
|
||
|
|
56e174444a | ||
|
|
90e389a55c
|
||
|
|
489663a946
|
||
|
|
fe4732e12f | ||
|
|
b08d41da7f
|
||
|
|
4a7dd2bb71 | ||
|
|
b638741c61
|
||
|
|
7f79f86222 | ||
|
|
69b94a13f5
|
||
|
|
884eab9432 | ||
|
|
dc04474689
|
||
|
|
912b83c2a4 | ||
|
|
e5d31991eb
|
||
|
|
fc04d0a4fb | ||
|
|
50e1b52a4d
|
||
|
|
2dcd6cd3e4 | ||
|
|
04104ad4aa
|
||
|
|
c37d5d3f2e | ||
|
|
0143caf4c9 | ||
|
|
8a4791508c | ||
|
|
1ff75ffcda
|
||
|
|
c9c1efca0a
|
||
|
|
2202037c00
|
No files matched your search
+11
-57
@@ -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+$/
|
||||
@@ -0,0 +1,25 @@
|
||||
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
|
||||
name: qrun-release
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
docker-runner: container-build
|
||||
browsers: true
|
||||
browser-runner: browser-build
|
||||
postpublish:
|
||||
if: ${{ needs.release.outputs.capture == 'true' }}
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Release candidate
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
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 release run id (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
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
release-rc:
|
||||
# The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch
|
||||
# completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus
|
||||
# merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry).
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
pull-requests: write
|
||||
if: github.event_name == 'workflow_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: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ 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: ${{ inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ inputs.bump_sha || '' }}
|
||||
branch: ${{ inputs.branch || '' }}
|
||||
version: ${{ inputs.version || '' }}
|
||||
secrets:
|
||||
QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }}
|
||||
@@ -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@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -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@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
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
@@ -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
|
||||
+7
-1
@@ -5,11 +5,16 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- SQL changelog headers: explanatory "-- Include it from the host..." comments were parsed as a licensed Liquibase `include` directive. Replaced with neutral wording; changeset bodies and schema are unchanged.
|
||||
- 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
|
||||
- Ship the full host-neutral Alerts help bundle through QQQ 4.1.0-RC.7 core registration, replacing the temporary loader. Cover owned tables, fields, actions, jobs, widgets and possible-value sources with a bundle coverage gate; support HOST overrides and hosts without help tables.
|
||||
- Starter defaults changelog `alerts-defaults.changelog.sql` (insert-only, guarded): the `in-app` and `email` channels and the `alerts` and `other` groups, equal to what the runtime creates on first use. Tested through real Liquibase on H2.
|
||||
- Help for the Alerts app and every `alerts_*` table (`help/qbit-alerts.yaml`, STAND-IN core#1198): what the table is for and how its rows get created, shown when it is empty.
|
||||
- Help for the Alerts app and every `alerts_*` table (`help/qbit-alerts.yaml`): what the table is for and how its rows get created, shown when it is empty.
|
||||
- A Setup card first on the Alerts home (alerts admins): each configuration table, its state and the next step.
|
||||
- `AlertsTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help.
|
||||
|
||||
@@ -47,6 +52,7 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- Apply reminders and escalation (#27, part 2): a minute-based hidden job sends due reminders, advances ordered USER/TEAM/CHANNEL escalation steps, and caps final-step repeats. Acknowledgement, snooze, resolution and mutes stop both; reopening restarts escalation. Delivery, events and progress commit together, with a persisted same-minute checkpoint.
|
||||
- Add admin escalation policy and step tables, child step lists, and type/group policy links in guarded Liquibase changesets 23–26. Type policies override group policies. CRITICAL reminders default to 60 minutes; explicit zero turns them off.
|
||||
- Keep EXPIRED reserved and remove it from selectable resolution values because the design specifies no expiration rule.
|
||||
- Per-notification email opt-in via recipient email status `REQUESTED`: immediate delivery without a standing category preference. It still honours the category email minimum severity, the user pause (SECURITY bypasses) and quiet hours (held until they end, for up to a day), is skipped when no address is known, and is never emailed for ALERT notifications.
|
||||
- Optional external channels:
|
||||
- Slack (an Incoming Webhook, Block Kit)
|
||||
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
|
||||
|
||||
@@ -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
|
||||
@@ -280,6 +280,23 @@ assigned automatically. The Resolve action continues to record MANUAL.
|
||||
- **Alertmanager sink:** open alerts are sent with an `endsAt` a week out, not re-posted.
|
||||
- **Not built yet:** Micrometer metrics, mirrored lock columns and a configurable table namespace. These are the rest of design phases P4 to P6.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [`help/qbit-alerts.yaml`](src/main/resources/help/qbit-alerts.yaml)
|
||||
with QQQ's help-bundle registry. It covers the Alerts app, tables and fields,
|
||||
actions, scheduled jobs, widgets and possible-value sources. QQQ syncs the bundle
|
||||
when the host installs the help-content tables; registration is harmless without them.
|
||||
|
||||
Hosts can override individual entries by registering their own bundle with
|
||||
`layer: HOST`, using the same entry `key` and `role` (default `ALL_SCREENS`).
|
||||
Register it with `HelpBundles.register(qInstance, "help/my-host.yaml")`. Host
|
||||
bundles take precedence over this `QBIT` bundle, and edits made by users remain protected.
|
||||
|
||||
`AlertsTablesGuardrailTest` runs `HelpBundleAssertions` against the produced instance
|
||||
to check owned-object coverage, summary length and live references. It also requires
|
||||
widget and possible-value-source help and preserves the table empty-state guidance.
|
||||
When releasing, keep the bundle's literal version aligned with the POM's revision.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
|
||||
+18
-5
@@ -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.
|
||||
@@ -153,7 +153,36 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-alerts.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-alerts.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<!-- the artifact version, for the help bundle version test -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -231,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.6 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>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
package com.kingsrook.qbits.alerts;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
@@ -26,7 +28,12 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
@@ -42,6 +49,9 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
*******************************************************************************/
|
||||
public final class AlertsPreferences
|
||||
{
|
||||
/** marks the bootstrap rule's subscription (createdByUserId). */
|
||||
static final String BOOTSTRAP_CREATOR = "alerts (bootstrap rule)";
|
||||
|
||||
private static final Set<String> ENSURED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
|
||||
@@ -69,36 +79,72 @@ public final class AlertsPreferences
|
||||
|
||||
boolean admin = session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN);
|
||||
String fullName = session.getUser() == null ? null : session.getUser().getFullName();
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
if(AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
|
||||
{
|
||||
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord()
|
||||
.withValue("userId", userId)
|
||||
.withValue("userLabel", fullName == null ? userId : fullName)
|
||||
.withValue("inAppEnabled", true)
|
||||
.withValue("emailEnabled", true)
|
||||
.withValue("quietBypassSeverity", "CRITICAL")
|
||||
.withValue("digestHour", 8));
|
||||
}
|
||||
|
||||
if(admin && runtime.getConfig().getBootstrapMinSeverity() != null && AlertsDb.first(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// A page load fires the home widgets and the bell together, so first //
|
||||
// calls for one user race. Serialize them in this JVM; across replicas, //
|
||||
// the clean-up below keeps one bootstrap subscription (the lowest id). //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
synchronized(AlertsPreferences.class)
|
||||
{
|
||||
if(ENSURED.contains(userId))
|
||||
{
|
||||
QRecord inApp = runtime.getChannels().defaultChannel(ChannelType.IN_APP);
|
||||
AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
|
||||
.withValue("subscriberType", SubscriberType.USER.name())
|
||||
.withValue("userId", userId)
|
||||
.withValue("scopeType", ScopeType.ALL.name())
|
||||
.withValue("minSeverity", runtime.getConfig().getBootstrapMinSeverity().name())
|
||||
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
|
||||
.withValue("deliveryMode", DeliveryMode.IMMEDIATE.name())
|
||||
.withValue("channelOverrideIds", String.valueOf(inApp.getValue("id")))
|
||||
.withValue("enabled", true)
|
||||
.withValue("createdByUserId", "alerts (bootstrap rule)"));
|
||||
return;
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
ENSURED.add(userId);
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
if(AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
|
||||
{
|
||||
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord()
|
||||
.withValue("userId", userId)
|
||||
.withValue("userLabel", fullName == null ? userId : fullName)
|
||||
.withValue("inAppEnabled", true)
|
||||
.withValue("emailEnabled", true)
|
||||
.withValue("quietBypassSeverity", "CRITICAL")
|
||||
.withValue("digestHour", 8));
|
||||
}
|
||||
|
||||
if(admin && runtime.getConfig().getBootstrapMinSeverity() != null && AlertsDb.first(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
|
||||
{
|
||||
QRecord inApp = runtime.getChannels().defaultChannel(ChannelType.IN_APP);
|
||||
AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
|
||||
.withValue("subscriberType", SubscriberType.USER.name())
|
||||
.withValue("userId", userId)
|
||||
.withValue("scopeType", ScopeType.ALL.name())
|
||||
.withValue("minSeverity", runtime.getConfig().getBootstrapMinSeverity().name())
|
||||
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
|
||||
.withValue("deliveryMode", DeliveryMode.IMMEDIATE.name())
|
||||
.withValue("channelOverrideIds", String.valueOf(inApp.getValue("id")))
|
||||
.withValue("enabled", true)
|
||||
.withValue("createdByUserId", BOOTSTRAP_CREATOR));
|
||||
}
|
||||
|
||||
removeDuplicateBootstrapSubscriptions(userId);
|
||||
return (null);
|
||||
});
|
||||
ENSURED.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep only the first bootstrap subscription of a user. Two replicas can
|
||||
** each see no subscription and insert one; every later first call (and the
|
||||
** losers' own calls) delete the extra copies, so the user is not notified
|
||||
** twice per alert. Subscriptions a person made are never touched.
|
||||
*******************************************************************************/
|
||||
static void removeDuplicateBootstrapSubscriptions(String userId) throws QException
|
||||
{
|
||||
List<QRecord> bootstrap = AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)
|
||||
.withCriteria("createdByUserId", QCriteriaOperator.EQUALS, BOOTSTRAP_CREATOR)
|
||||
.withOrderBy(new QFilterOrderBy("id")));
|
||||
if(bootstrap.size() < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<Serializable> extra = bootstrap.subList(1, bootstrap.size()).stream().map(r -> (Serializable) r.getValue("id")).toList();
|
||||
new DeleteAction().execute(new DeleteInput(AlertsSubscription.TABLE_NAME).withPrimaryKeys(extra).withTransaction(QContext.getQBackendTransaction()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,14 +17,16 @@
|
||||
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;
|
||||
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
|
||||
import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsHelp;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
@@ -38,6 +40,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
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.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
@@ -174,7 +205,8 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
|
||||
output.addSelfToInstance(qInstance);
|
||||
AlertsHelp.apply(qInstance);
|
||||
// Registration is metadata-only; core guards startup sync when helpContent is absent.
|
||||
HelpBundles.register(qInstance, "help/qbit-alerts.yaml");
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -1,127 +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.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-alerts.yaml (the QQQ help-bundle format) to the
|
||||
** Alerts app and its alerts_* tables: what each table is for and how
|
||||
** its rows get created, shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
|
||||
** QQQ release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class AlertsHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-alerts.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AlertsHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = AlertsHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-alerts".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every alerts_* table in the instance.
|
||||
** A host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(AlertsQBitConfig.APP_NAME);
|
||||
JsonNode appEntry = entries.get("app:" + AlertsQBitConfig.APP_NAME + ";slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -290,7 +290,7 @@ public final class AlertsPossibleValues
|
||||
*******************************************************************************/
|
||||
public enum EmailStatus
|
||||
{
|
||||
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
|
||||
NONE, PENDING, REQUESTED, SENT, DIGESTED, SKIPPED, FAILED
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -29,11 +29,14 @@ import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
|
||||
import com.kingsrook.qbits.alerts.email.AlertsEmail;
|
||||
import com.kingsrook.qbits.alerts.email.AlertsEmailSender;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertTiming;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
@@ -43,6 +46,7 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -67,6 +71,11 @@ 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.
|
||||
** A recipient with email status REQUESTED (a per-notification opt-in, e.g. by
|
||||
** a job) is emailed immediately without a standing category preference. It
|
||||
** still honours the category's email minimum severity when one is set, the
|
||||
** user's pause (SECURITY bypasses) and quiet hours (held, not dropped), is
|
||||
** skipped without a known address, and is only eligible for a day.
|
||||
** Sent through qbit-email when it is produced in the instance (one message
|
||||
** per recipient; templates alerts_notification and alerts_digest,
|
||||
** NOTIFICATION), else through core SendMessageAction. Inert without either
|
||||
@@ -137,12 +146,19 @@ 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)
|
||||
.withSubFilter(new QQueryFilter()
|
||||
.withCriteria("emailStatus", QCriteriaOperator.EQUALS, EmailStatus.REQUESTED.name())
|
||||
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)));
|
||||
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())));
|
||||
}
|
||||
|
||||
Map<String, QRecord> settings = userSettings(preferences.keySet());
|
||||
Map<String, QRecord> settings = new HashMap<>();
|
||||
int sent = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
@@ -151,8 +167,7 @@ public class NotificationEmailer
|
||||
while(true)
|
||||
{
|
||||
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("id", QCriteriaOperator.GREATER_THAN, afterId)
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(SCAN));
|
||||
if(recipients.isEmpty())
|
||||
@@ -161,25 +176,43 @@ public class NotificationEmailer
|
||||
}
|
||||
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
|
||||
Map<Integer, QRecord> notifications = notifications(recipients, now);
|
||||
Set<String> pageUsers = recipients.stream().map(recipient -> recipient.getValueString("userId")).collect(Collectors.toSet());
|
||||
settings.putAll(userSettings(pageUsers));
|
||||
Map<String, Map<String, QRecord>> requestedPreferences = new HashMap<>();
|
||||
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(pageUsers))))
|
||||
{
|
||||
requestedPreferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
|
||||
}
|
||||
for(QRecord recipient : recipients)
|
||||
{
|
||||
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
|
||||
String userId = recipient.getValueString("userId");
|
||||
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
|
||||
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|
||||
|| !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))
|
||||
boolean requested = EmailStatus.REQUESTED.name().equals(recipient.getValueString("emailStatus"));
|
||||
String category = notification == null ? null : notification.getValueString("category");
|
||||
QRecord preference = category == null ? null : (requested ? requestedPreferences : preferences).getOrDefault(userId, Map.of()).get(category);
|
||||
QRecord setting = settings.get(userId);
|
||||
if(notification == null || NotificationCategory.ALERT.name().equals(category)
|
||||
|| (requested ? (!StringUtils.hasContent(address(setting)) || (preference != null && StringUtils.hasContent(preference.getValueString("emailMinSeverity"))
|
||||
&& !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity"))))
|
||||
: (preference == null || !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))))
|
||||
{
|
||||
mark(recipient, EmailStatus.SKIPPED, null);
|
||||
skipped++;
|
||||
}
|
||||
else if(!NotificationCategory.SECURITY.name().equals(notification.getValueString("category"))
|
||||
&& settings.get(userId) != null && settings.get(userId).getValueInstant("pausedUntil") != null
|
||||
&& settings.get(userId).getValueInstant("pausedUntil").isAfter(now))
|
||||
else if(!NotificationCategory.SECURITY.name().equals(category)
|
||||
&& setting != null && setting.getValueInstant("pausedUntil") != null
|
||||
&& setting.getValueInstant("pausedUntil").isAfter(now))
|
||||
{
|
||||
// Leave the recipient pending so a later run can deliver it.
|
||||
skipped++;
|
||||
}
|
||||
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
|
||||
else if(requested && AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now))
|
||||
{
|
||||
// Quiet hours hold a requested email; it stays REQUESTED until the window ends or it ages out.
|
||||
skipped++;
|
||||
}
|
||||
else if(requested || EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
|
||||
{
|
||||
EmailStatus status = sendImmediate(address(settings.get(userId)), notification);
|
||||
mark(recipient, status, status == EmailStatus.SENT ? now : null);
|
||||
|
||||
@@ -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()))));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
-- qbit-alerts starter defaults: the two channels every install needs, and the
|
||||
-- two groups the qbit itself files alerts under, so Delivery health and the
|
||||
-- group lists are not empty before the first alert.
|
||||
-- Include it from the host's master changelog AFTER alerts.changelog.sql:
|
||||
-- Load this file from the host's master changelog AFTER alerts.changelog.sql:
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
|
||||
--
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- qbit-alerts schema (QRun-IO/qqq docs/specs/2026-10-12-qbit-alerts-design.md section 4).
|
||||
-- Include it from the host's master changelog:
|
||||
-- Load this file from the host's master changelog:
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-alerts/alerts.changelog.sql }
|
||||
--
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,10 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsHelp;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -58,7 +59,7 @@ class AlertsTablesGuardrailTest extends BaseAlertsTest
|
||||
Map.entry("alerts_event", "runtime: the alert timeline"),
|
||||
Map.entry("alerts_subscription", "people's own choices; the first alerts admin gets one by the bootstrap rule (AlertsPreferences)"),
|
||||
Map.entry("alerts_user_preference", "user content: created per person on their first visit (AlertsPreferences)"),
|
||||
Map.entry("alerts_team", "the host's own people (kof22 seeds its team)"),
|
||||
Map.entry("alerts_team", "the host's own people"),
|
||||
Map.entry("alerts_team_member", "the host's own people"),
|
||||
Map.entry("alerts_mute", "user content: maintenance windows and mutes people create"),
|
||||
Map.entry("alerts_delivery", "runtime log: every delivery attempt"),
|
||||
@@ -122,31 +123,44 @@ class AlertsTablesGuardrailTest extends BaseAlertsTest
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
assertThat(AlertsHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
// Core adds bulk CRUD processes during validation; the qbit owns its declared actions and jobs.
|
||||
var processes = AlertsProcessesMetaDataProducer.buildProcesses(config).stream().map(process -> process.getName()).toList();
|
||||
HelpBundleAssertions.forInstance(qInstance)
|
||||
.bundles("help/qbit-alerts.yaml")
|
||||
.owning(target -> "process".equals(target.kind()) ? processes.contains(target.name()) : target.name().startsWith("alerts"))
|
||||
.assertComplete();
|
||||
var entries = HelpBundles.of(qInstance).getBundles().get(0).getEntries();
|
||||
// Core's gate does not yet require widget and possible-value-source help.
|
||||
var keys = entries.stream().map(entry -> entry.getKey()).toList();
|
||||
qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("alerts"))
|
||||
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:help"));
|
||||
qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("alerts"))
|
||||
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
|
||||
for(String table : alertsTables())
|
||||
{
|
||||
List<QHelpContent> help = qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(AlertsHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
assertThat(entries).anySatisfy(entry ->
|
||||
{
|
||||
assertThat(entry.getKey()).isEqualTo("table:" + table + ";slot:help");
|
||||
assertThat(entry.getContent()).contains("How rows get created");
|
||||
});
|
||||
}
|
||||
assertThat(qInstance.getApp(AlertsQBitConfig.APP_NAME).getHelpContent()).containsKey(AlertsHelp.SLOT);
|
||||
assertThat(AlertsHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null,
|
||||
"help names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own help on a table is kept; the bundle fills only empty slots.
|
||||
** Bundle registration needs no help table; core safely skips startup sync.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsNotOverridden() throws Exception
|
||||
void hostWithoutHelpTableIsSupported()
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable("alerts_channel");
|
||||
table.getHelpContent().remove(AlertsHelp.SLOT);
|
||||
table.withHelpContent(AlertsHelp.SLOT, new QHelpContent().withContentAsMarkdown("host text"));
|
||||
AlertsHelp.apply(qInstance);
|
||||
assertThat(table.getHelpContent().get(AlertsHelp.SLOT)).singleElement().matches(c -> "host text".equals(c.getContent()));
|
||||
assertThat(qInstance.getTable("helpContent")).isNull();
|
||||
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-alerts");
|
||||
assertThat(bundle.getVersion()).as("the help bundle carries the artifact and producer version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(AlertsQBitProducer.VERSION);
|
||||
});
|
||||
HelpBundles.syncOnStartup(qInstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -135,6 +135,76 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** First calls for one user race (the home widgets and the bell load
|
||||
** together): only one bootstrap subscription is created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testConcurrentFirstCallsBootstrapOnce() throws Exception
|
||||
{
|
||||
com.kingsrook.qqq.backend.core.context.CapturedContext context = QContext.capture();
|
||||
java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(4);
|
||||
try
|
||||
{
|
||||
java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1);
|
||||
List<java.util.concurrent.Future<Object>> futures = new java.util.ArrayList<>();
|
||||
for(int i = 0; i < 4; i++)
|
||||
{
|
||||
futures.add(pool.submit(() ->
|
||||
{
|
||||
QContext.init(context);
|
||||
start.await();
|
||||
AlertsPreferences.ensure(runtime, QContext.getQSession());
|
||||
return (null);
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for(java.util.concurrent.Future<Object> future : futures)
|
||||
{
|
||||
future.get();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
pool.shutdownNow();
|
||||
}
|
||||
assertThat(all(AlertsSubscription.TABLE_NAME)).hasSize(1);
|
||||
assertThat(all(AlertsUserPreference.TABLE_NAME)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Another replica raced and left a second bootstrap subscription: the next
|
||||
** first call keeps the oldest and leaves a person's own subscriptions alone.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDuplicateBootstrapSubscriptionsAreRemoved() throws Exception
|
||||
{
|
||||
for(String creator : List.of("alerts (bootstrap rule)", "alerts (bootstrap rule)", ADMIN))
|
||||
{
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
|
||||
.withValue("subscriberType", "USER")
|
||||
.withValue("userId", ADMIN)
|
||||
.withValue("scopeType", "ALL")
|
||||
.withValue("minSeverity", "ERROR")
|
||||
.withValue("events", "RAISED")
|
||||
.withValue("deliveryMode", "IMMEDIATE")
|
||||
.withValue("enabled", true)
|
||||
.withValue("createdByUserId", creator)));
|
||||
}
|
||||
List<QRecord> before = all(AlertsSubscription.TABLE_NAME);
|
||||
assertThat(before).hasSize(3);
|
||||
|
||||
AlertsPreferences.ensure(runtime, QContext.getQSession());
|
||||
|
||||
List<QRecord> after = all(AlertsSubscription.TABLE_NAME);
|
||||
assertThat(after).hasSize(2);
|
||||
assertThat(after).extracting(r -> r.getValueInteger("id")).containsExactlyInAnyOrder(before.get(0).getValueInteger("id"), before.get(2).getValueInteger("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No subscriptions: nobody is notified. The bootstrap rule gives admins an
|
||||
** in-app subscription to ERROR and worse on first visit.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+226
@@ -172,6 +172,232 @@ 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", "INFO");
|
||||
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 (not the category's minimum severity).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRequestedWithDigestPreference() throws Exception
|
||||
{
|
||||
emailSettings("viewer-1", "JOB", "DAILY_DIGEST", "INFO");
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A request still honours the category's email minimum severity.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRequestedBelowMinSeverity() throws Exception
|
||||
{
|
||||
emailSettings("viewer-1", "JOB", "OFF", "ERROR");
|
||||
requested("JOB");
|
||||
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
|
||||
assertThat(result.skipped()).isEqualTo(1);
|
||||
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
|
||||
assertThat(SENT).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's pause or quiet hours hold a requested email until they end.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRequestedHeldByPauseAndQuietHours() throws Exception
|
||||
{
|
||||
clock.set(Instant.parse("2026-10-12T12:00:00Z"));
|
||||
addressOnly();
|
||||
requested("JOB");
|
||||
setUserPreference(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(3600)));
|
||||
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
|
||||
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
|
||||
|
||||
clock.set(Instant.parse("2026-10-12T13:30:00Z"));
|
||||
setUserPreference(new QRecord().withValue("timeZone", "UTC").withValue("quietHoursStart", "13:00").withValue("quietHoursEnd", "14:00"));
|
||||
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
|
||||
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
|
||||
|
||||
clock.set(Instant.parse("2026-10-12T14:00:00Z"));
|
||||
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
|
||||
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
|
||||
assertThat(SENT).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A pause does not hold a requested SECURITY email.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRequestedSecurityBypassesPause() throws Exception
|
||||
{
|
||||
addressOnly();
|
||||
requested("SECURITY");
|
||||
setUserPreference(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(3600)));
|
||||
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update the user's alert settings row.
|
||||
*******************************************************************************/
|
||||
private void setUserPreference(QRecord values) throws QException
|
||||
{
|
||||
runtime.asSystem(() ->
|
||||
{
|
||||
QRecord setting = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-1"));
|
||||
return (AlertsDb.update(AlertsUserPreference.TABLE_NAME, values.withValue("id", setting.getValue("id"))));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user