Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5348462c7f
|
||
|
|
6460aadb0a | ||
|
|
a65b019ffa
|
||
|
|
60b68cf722
|
||
|
|
2692f8b1dc
|
||
|
|
3044f15fbc | ||
|
|
cb3d206e0f
|
||
|
|
110f15d866 | ||
|
|
f50b3c1e18
|
||
|
|
758b68a6a9 | ||
|
|
784bf0026c | ||
|
|
e68bdb9db2 | ||
|
|
cd06fff114 |
No files matched your search
+1
-12
@@ -22,7 +22,7 @@ workflows:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
@@ -58,14 +58,3 @@ workflows:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the core SPI stand-in in `spi/`.
|
||||
**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the core notification integration in `notifications/`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -25,7 +25,7 @@ Packages:
|
||||
- `processes`: the record actions (`AlertsAction`, `AlertsActionSteps`) and the scheduled steps.
|
||||
- `home`: the Alerts home and `AlertSummaryLoader`.
|
||||
- `api`: the bell and record-panel routes.
|
||||
- `spi`: the stand-in for the core producer SPI.
|
||||
- `notifications`: core RC.6 notification delivery, audience resolution and email; all producer types come from `com.kingsrook.qqq.backend.core.alerts`.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
@@ -44,7 +44,7 @@ Packages:
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.4` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
+15
-3
@@ -4,14 +4,26 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Changed
|
||||
- Target QQQ `4.1.0-RC.6` for the UI extension API; local verification uses a command-line version override only.
|
||||
- Register six JSON UI specs as extension `alerts` at the established `/alerts` prefix, preserving methods, paths and existing payload fields. Publish the OpenAPI fragment with a regeneration/contract test and strict server coverage (#13, F2).
|
||||
- Add summary `inboxHref`, notification operation actions, alert process actions with complete hrefs, and record-panel `follow.available` (#13, F20). Follow discovery reads core extension metadata without a collaboration dependency or URL.
|
||||
- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration. Follower fan-out waits for core #1164 (#16, F12).
|
||||
- Move Alertmanager ingest to `/qqq/hooks/alerts/alertmanager`. The old path is an opt-in alias (`legacyAlertmanagerPath`, default false) for one minor release, incompatible with strict raw-route mode. Each alias call carries deprecation/sunset headers and logs a warning; authentication is shared (#14, F7).
|
||||
- Run Checkstyle 14.3.0 in Maven validate (including tests); align the license template with this repository's existing headers and correct pre-existing switch/import formatting so the style check passes.
|
||||
- Alert types and sink registration use core's alerts SPI (introduced in QQQ 4.1.0-RC.5); host `QAlerts.raise/resolve/heartbeat` calls reach the installed qbit through core instance metadata (qbit-alerts#15, F3).
|
||||
- **Breaking:** remove the entire `com.kingsrook.qbits.alerts.spi` package (`NotificationSignal`, `NotificationAudience`, `NotificationCategory`, `NotificationSeverity`, `AlertLink`, package metadata) and `AlertsRuntime.sink().notify(...)`. Producers must use core RC.6 types and `QAlerts.notify`; rebuild existing callers. qbit-easypost-tracking#16 moved to core.
|
||||
- Implement core `AlertSinkInterface.notify` in the registered adapter and runtime queue, retaining notification inbox, audience resolution, category preferences and email delivery behavior (qbit-alerts#15 part 2, F3/F12; QRun-IO/qqq#1125). Notification calls now receive core commit/rollback guarantees. No table or HTTP wire changes in this migration.
|
||||
- Webhook signing delegates to core `WebhookSigner`/`WebhookSecret` (qbit-alerts#15, F11). Headers, `v1,` signatures and `whsec_` encoding are unchanged. A thin adapter preserves legacy keys shorter than core's 16-byte minimum using equivalent HMAC zero-padding.
|
||||
|
||||
### Added
|
||||
- Optional external channels:
|
||||
- Slack (an Incoming Webhook, Block Kit)
|
||||
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
|
||||
- a generic webhook, signed with Standard Webhooks headers through a stand-in for core's `WebhookSigner` (QRun-IO/qqq#1109)
|
||||
- a generic webhook, signed with Standard Webhooks headers through core's `WebhookSigner` (QRun-IO/qqq#1109)
|
||||
- PagerDuty (Events API v2: trigger, acknowledge and resolve)
|
||||
- an Alertmanager sink (`/api/v2/alerts`, with the qqq labels and the origin guard)
|
||||
- Prometheus Alertmanager ingest at `POST /qqq/v1/alerts/ingest/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
|
||||
- Prometheus Alertmanager ingest at `POST /qqq/hooks/alerts/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard.
|
||||
- Tables (`alerts_*`), with the Liquibase changelog `db/liquibase/qbit-alerts/alerts.changelog.sql`:
|
||||
- alerts, their timeline and records
|
||||
- types, groups and monitors
|
||||
@@ -43,5 +55,5 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
- The top-level Alerts app and its home dashboard, plus `AlertSummaryLoader` for link-only summaries.
|
||||
- `/qqq/v1/alerts` routes for the Next UI bell and record panel.
|
||||
- Record security, through three key types (`alerts_user`, `alerts_permission`, `alerts_readable_table`).
|
||||
- A stand-in for the core producer SPI (QRun-IO/qqq#1108), to be removed when that RC ships.
|
||||
- Core alert producer SPI integration (QRun-IO/qqq#1108), plus temporary notification SPI compatibility until RC.6 (QRun-IO/qqq#1125).
|
||||
- Repository scaffold: `AlertsQBitConfig` and `AlertsQBitProducer` (table prefix `alerts_`, permissions `alerts.*`), build, CI and repository standard files.
|
||||
@@ -26,7 +26,7 @@ Any qbit or app reports exceptional conditions ("a person should do something").
|
||||
- Prometheus Alertmanager, as a sink
|
||||
|
||||
Any channel another qbit registers works too. Every suppressed notification is logged with its reason.
|
||||
- **Ingests Prometheus Alertmanager** at `POST /qqq/v1/alerts/ingest/alertmanager`, with notifications off for those types, so infrastructure alerts are triaged in one place.
|
||||
- **Ingests Prometheus Alertmanager** at `POST /qqq/hooks/alerts/alertmanager`, with notifications off for those types, so infrastructure alerts are triaged in one place.
|
||||
- **Owns the Alerts home:** open alerts by severity, my alerts, needs action, by group, a 30-day trend, the noisiest types, delivery health and recently resolved. Other pages get link-only summaries from `AlertSummaryLoader`.
|
||||
|
||||
All data is in standard QQQ tables named `alerts_*`. People change alerts through record actions, never by editing rows.
|
||||
@@ -37,7 +37,7 @@ The qbit also carries ordinary notifications for people (design: [docs/NOTIFICAT
|
||||
|
||||
- **Admins** with `alerts.notification.admin` write notifications in the Notifications table (title, markdown body, severity, category, link, audience, publish and expire times, a sticky banner flag), then use the record actions Preview audience, Send now, Schedule and Retract. Edits after sending show at once; retracting hides it.
|
||||
- **Audiences** are resolved when sent: all users, roles or groups, holders of a permission, or named users. Each user gets one `alerts_notification_recipient` row, so read, unread and dismissed are per user. Roles need a user directory (`AlertsQBitConfig.withUserDirectory`, an `AlertsUserDirectoryInterface`); without one, "all users" means everyone who has used Alerts.
|
||||
- **Producers** send with `AlertsRuntime.get(qInstance).sink().notify(new NotificationSignal(...))` (core: `QAlerts.notify`). Alerts a user subscribes to arrive in the bell as ALERT notifications.
|
||||
- **Producers** send with `QAlerts.notify(new NotificationSignal(...))` using `com.kingsrook.qqq.backend.core.alerts` types (RC.6). Alerts a user subscribes to arrive in the bell as ALERT notifications.
|
||||
- **Ready-made notices** in `AlertsNotices`: `newSignIn`, `apiKeyCreated`, `apiKeyExpiring`, `permissionChanged` (SECURITY), `shared` (SHARE) and `release` (RELEASE, all users). Core has no sign-in or API key events yet, so hosts call these from their authentication customizer and API key screens.
|
||||
- **Email** is opt-in per user and category (IMMEDIATE, or DAILY_DIGEST at the digest hour in My Alert Settings), through the configured messaging provider; without one it is off. ALERT notifications are not emailed here (alert subscriptions already do), and SECURITY emails carry only the title and link.
|
||||
- **Failed and partial jobs** notify the person who started them (`notifyFailedJobs`, `jobRunLinkPattern`).
|
||||
@@ -48,10 +48,26 @@ Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions i
|
||||
|
||||
**Upgrading from 0.1.0-RC.1:** changeset 16 renames the delivery log `alerts_notification` to `alerts_delivery`, and changeset 17 drops `alerts_inbox_item` (bell state only). Both are guarded, so a fresh schema and an RC.1 schema both end up the same.
|
||||
|
||||
The UI routes are published as the `alerts` extension (`1.0.0`, established prefix `/alerts`), with
|
||||
[the OpenAPI fragment](src/main/resources/openapi/qqq-v1/alerts.yaml) and a contract test.
|
||||
See [UI API and webhook migration](docs/UI-API.md) for operation IDs and the complete Next handoff.
|
||||
Summary `inboxHref`, item/alert `actions`, and panel `follow.available` are additive. Existing
|
||||
hrefs, `notificationsHref`, `alertsHref`, `canManage` and deprecated `watching` remain.
|
||||
|
||||
Record following belongs to collaboration: `POST /alerts/record-panel/watch` is removed. Old
|
||||
RECORD subscriptions remain readable without migration. The panel creates no subscriptions;
|
||||
Next uses its collaboration module when `follow.available` is true. Follower fan-out waits
|
||||
for core QRun-IO/qqq#1164.
|
||||
|
||||
The old Alertmanager path is available for one minor release only via
|
||||
`withLegacyAlertmanagerPath(true)` (default false). It requires non-strict UI route mode,
|
||||
returns `Deprecation: true` and `Sunset: Tue, 13 Apr 2027 00:00:00 GMT` on every call, and logs
|
||||
a warning. Both paths use the same bearer-token check. Strict hosts use the canonical hook.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ 4.1 (built against `4.1.0-RC.4`).
|
||||
- QQQ 4.1 (targets `4.1.0-RC.6`; release pending).
|
||||
- An RDBMS backend.
|
||||
- A scheduler, for monitors and email delivery.
|
||||
- A messaging provider, for email.
|
||||
@@ -84,16 +100,20 @@ new AlertsQBitProducer()
|
||||
|
||||
## Raising alerts
|
||||
|
||||
Until the core producer SPI ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)) is in a released QQQ RC, the SPI types are a stand-in in `com.kingsrook.qbits.alerts.spi` (see its `package-info.java`). In the meantime, call the qbit's sink directly:
|
||||
Use the producer SPI from `com.kingsrook.qqq.backend.core.alerts`, released in QQQ 4.1.0-RC.5 ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)). `AlertsQBitProducer` registers the qbit's sink through core's `AlertsInstanceMetaData`, so host producers need only core:
|
||||
|
||||
```java
|
||||
AlertsRuntime.get().sink().raise(new AlertSignal("cdn.purge.dead")
|
||||
QAlerts.raise(new AlertSignal("cdn.purge.dead")
|
||||
.withDedupKey("provider:" + providerId)
|
||||
.withTitle("3 CDN purges failed after retries") // external-safe: no personal data
|
||||
.withRecordRefs(AlertRecordRef.of("cdn_purge_request", ids)));
|
||||
```
|
||||
|
||||
Once the RC ships, producers call `QAlerts.raise(...)` from core and need no dependency on this qbit.
|
||||
`QAlerts.resolve(...)` and `QAlerts.heartbeat(...)` reach the same sink. Declare types with core's `QAlertTypeMetaData`. Calls through `QAlerts.raise/resolve` defer to transaction commit when a transaction is present; without this qbit installed, core uses its logging sink.
|
||||
|
||||
User notifications use core `QAlerts.notify(NotificationSignal)` (RC.6, QRun-IO/qqq#1125). Core dispatches through `AlertsSinkAdapter` into the same bounded queue, notification service, audience resolution and email preferences. Calls wait for an explicit or ambient transaction to commit and are dropped on rollback.
|
||||
|
||||
**Breaking Java API migration:** the entire `com.kingsrook.qbits.alerts.spi` package and `AlertsRuntime.sink()` are removed. Replace notification and `AlertLink` imports with `com.kingsrook.qqq.backend.core.alerts`, and replace `AlertsRuntime.get(qInstance).sink().notify(signal)` with `QAlerts.notify(signal)` in the producer's `QContext`. Rebuild callers; qbit-easypost-tracking#16 already moved to core. `getSink()` is for adapter delegation and queue lifecycle management, not a producer API. Notification tables and HTTP contracts are unchanged by this migration.
|
||||
|
||||
## Endpoints (for the Next UI)
|
||||
|
||||
@@ -105,8 +125,7 @@ Once the RC ships, producers call `QAlerts.raise(...)` from core and need no dep
|
||||
| POST | `/qqq/v1/alerts/inbox/unread` | `{"id": n}` or `{"ids": [..]}` |
|
||||
| POST | `/qqq/v1/alerts/inbox/dismiss` | `{"id": n}`, `{"ids": [..]}` or `{"all": true}` |
|
||||
| GET | `/qqq/v1/alerts/record-panel?tableName=t&recordId=id` | The "Open alerts" panel for a record (`visible: false` when there is nothing to show) |
|
||||
| POST | `/qqq/v1/alerts/record-panel/watch` | `{"tableName": t, "recordId": id}`: a RECORD subscription for the user |
|
||||
| POST | `/qqq/v1/alerts/ingest/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. |
|
||||
| POST | `/qqq/hooks/alerts/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. |
|
||||
|
||||
## External channels
|
||||
|
||||
@@ -122,7 +141,7 @@ Each external channel is an `alerts_channel` row that an admin creates. Its secr
|
||||
|
||||
How each one sends:
|
||||
|
||||
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). This is the shared format from the qbit-webhooks assessment (QRun-IO/qqq#1105). The signer is a stand-in until core's `WebhookSigner` (QRun-IO/qqq#1109) is released.
|
||||
- **Webhook** bodies are `{type, subject, severity, link, alerts: [...]}`, signed by core's `WebhookSigner` with the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). Both raw UTF-8 and `whsec_` base64 secrets keep the same wire format. Core's `WebhookSecret` requires 16 key bytes; a thin adapter zero-pads legacy shorter keys to 16 bytes, which is HMAC-SHA256-equivalent and preserves their signatures. New secrets should use `WebhookSecret.generate()`.
|
||||
- **PagerDuty** gets one event per alert: `trigger`, `acknowledge` or `resolve`, with `dedup_key` set to the type and key.
|
||||
- **Alertmanager** alerts carry the labels `qqq_type`, `qqq_group`, `qqq_key` and `qqq_origin="qbit-alerts"`. Ingest ignores alerts with that origin, so nothing loops.
|
||||
- **Responses:** 2xx is sent. 408, 425, 429, 5xx and network errors are retried. Any other 4xx fails permanently.
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
<property name="ignoreLines" value="2"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -1,15 +1,11 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). This repository implements it. Phases P1 to P3 are built (see README.md); P4 to P6 remain.
|
||||
|
||||
Deviations from the design, and why:
|
||||
- **The core SPI is a stand-in** (`spi/`) until QRun-IO/qqq#1108 is released.
|
||||
- **UI API and record following:** the RC.6 alerts extension, collaboration ownership of follow, and machine webhook migration are specified in [UI-API.md](UI-API.md). Existing RECORD watch subscriptions remain readable without migration.
|
||||
- **Alerts and notifications use core's RC.6 producer API**, including sink registration in core instance metadata (QRun-IO/qqq#1108 and #1125). `AlertsSinkAdapter` delegates `raise`, `resolve`, `heartbeat` and `notify` to the runtime queue. No local SPI types remain; storage-specific audience and severity conversion belongs to `NotificationService`.
|
||||
- **Visibility checks 1 to 3 (section 7.2)** are enforced by record security locks on session keys (`alerts_permission`, `alerts_readable_table`), not by a pre-query customizer. QQQ has no pre-query customizer, and locks also apply to counts, exports and the API.
|
||||
- **Heartbeats** are stored in an extra table, `alerts_heartbeat`.
|
||||
- **Claims** are leases, not atomic conditional updates (see README.md, Known limits).
|
||||
|
||||
+27
-7
@@ -138,8 +138,9 @@ notification admins. The home stays the only Alerts dashboard.
|
||||
|
||||
## 6. Producer SPI
|
||||
|
||||
It extends the core alerts SPI (`com.kingsrook.qqq.backend.core.alerts`) through a core PR to qqq
|
||||
`develop`, for the next 4.1 RC:
|
||||
The notification API from [QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)
|
||||
ships in 4.1.0-RC.6. This branch targets that release and delivers core `QAlerts.notify`
|
||||
calls through the installed qbit sink:
|
||||
|
||||
```java
|
||||
QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
@@ -151,13 +152,26 @@ QAlerts.notify(new NotificationSignal("Your export failed")
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
```
|
||||
|
||||
- `AlertSinkInterface` gets a default method, `notify(NotificationSignal)`, that logs; existing sinks keep
|
||||
working. `LoggingAlertSink` logs at INFO. The qbit's sink queues the signal like an alert signal.
|
||||
- `AlertSinkInterface` has a default method, `notify(NotificationSignal)`, that logs; existing sinks keep
|
||||
working. `LoggingAlertSink` logs at INFO (WARN for ERROR/CRITICAL). The qbit's sink queues the signal like an alert signal.
|
||||
- The guarantees match `raise`: it never throws or blocks, it waits for the commit when a transaction is
|
||||
open, and bad input is counted.
|
||||
- `NotificationAudience`: `allUsers()`, `roles(...)`, `permission(p)`, `users(...)`, `user(id)`.
|
||||
- Until that RC ships, the qbit carries the same classes in its `spi` stand-in package, and hosts call
|
||||
`AlertsRuntime.get(qInstance).sink().notify(...)`.
|
||||
- All producer types, including `AlertLink`, are from `com.kingsrook.qqq.backend.core.alerts`.
|
||||
The qbit implements core's `notify` override in `AlertsSinkAdapter` and `QbitAlertsSink`;
|
||||
signals use the existing bounded queue and `NotificationService` for audience resolution,
|
||||
inbox recipients, scheduling and category/email preferences. Alert subscription channels
|
||||
and their quiet-hour suppression keep their existing behavior.
|
||||
- **Breaking migration:** the local `spi` package and `AlertsRuntime.sink()` are removed.
|
||||
Change imports to core, call `QAlerts.notify(signal)` in the producer's `QContext`, and
|
||||
rebuild. qbit-easypost-tracking#16 uses core. The runtime's `getSink()` supports adapter
|
||||
delegation and queue lifecycle operations; producers use `QAlerts`.
|
||||
- Stored audience types and comma/newline-separated values remain readable. The service
|
||||
retains trimming, duplicate removal and null storage for empty audiences, so existing
|
||||
notification dedup keys and rows need no migration. Core types do not own database parsing.
|
||||
- Verification covers core producer calls through the asynchronous queue to the strict HTTP
|
||||
inbox, all audience factories, commit/rollback, category mutes, immediate email and digests.
|
||||
The UI fragment and all six HTTP operations are unchanged by this notification migration.
|
||||
|
||||
### 6.1 Automatic sources
|
||||
|
||||
@@ -231,7 +245,7 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no
|
||||
|---|---|
|
||||
| a | Tables, the rename and retirement, admin screens and actions, audiences and recipients, the bell API, ALERT notifications |
|
||||
| b | Next UI: badge, popover, page, banner |
|
||||
| c | Producer SPI (core PR plus the stand-in), job failures |
|
||||
| c | Core RC.6 producer SPI and sink integration, job failures |
|
||||
| d | Security notices, shares, release and marketing producers |
|
||||
| e | Email: immediate and daily digest |
|
||||
|
||||
@@ -245,3 +259,9 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no
|
||||
5. Core authentication events? **Later.** Hosts call `AlertsSecurityNotices` from their authentication
|
||||
customizer until core offers a sign-in event.
|
||||
6. Server-sent events? **Later.** Polling with backoff is enough at 60 s.
|
||||
|
||||
## UI contract
|
||||
|
||||
The implemented UI routes and additive action/follow metadata are specified in [UI-API.md](UI-API.md),
|
||||
with the generated fragment at `src/main/resources/openapi/qqq-v1/alerts.yaml`. Record following
|
||||
belongs to collaboration; the old alerts panel watch route is removed.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
# Alerts UI API and webhook migration
|
||||
|
||||
The qbit targets QQQ **4.1.0-RC.6** (pending release). `AlertsQBitProducer` registers the
|
||||
`alerts` `MiddlewareSpecExtension`, version `1.0.0`, with the established `/alerts` prefix.
|
||||
`withRegisterRoutes(false)` opts out of both the extension and webhook registration.
|
||||
The six JSON specs extend `AbstractJsonEndpointSpec`; existing `AlertsApi` logic continues
|
||||
to enforce caller permissions, recipient ownership and alert visibility.
|
||||
|
||||
The deterministic fragment is `src/main/resources/openapi/qqq-v1/alerts.yaml`. Next should
|
||||
vendor it. The live `/qqq/v1/openapi.yaml` includes these operations, and `/qqq/v1/version`
|
||||
discovers the `alerts` extension so clients need not infer installation from an app name.
|
||||
|
||||
| Method | Path | operationId |
|
||||
| --- | --- | --- |
|
||||
| GET | `/qqq/v1/alerts/inbox/summary` | `alertsInboxSummary` |
|
||||
| GET | `/qqq/v1/alerts/inbox` | `alertsInbox` |
|
||||
| POST | `/qqq/v1/alerts/inbox/read` | `alertsInboxRead` |
|
||||
| POST | `/qqq/v1/alerts/inbox/unread` | `alertsInboxUnread` |
|
||||
| POST | `/qqq/v1/alerts/inbox/dismiss` | `alertsInboxDismiss` |
|
||||
| GET | `/qqq/v1/alerts/record-panel` | `alertsRecordPanel` |
|
||||
|
||||
All existing query parameters, selector precedence, paging defaults and clamps, status
|
||||
codes, JSON content-type requirements, and response keys remain. IDs refer to recipient
|
||||
rows for inbox operations; legacy `alertId` selectors remain accepted. `all:true` applies
|
||||
only to read/dismiss. Items in a summary omit `body`; page items and banners include it.
|
||||
Nullable fields remain serialized as JSON null.
|
||||
|
||||
## Next handoff: additive payload fields
|
||||
|
||||
- Summary adds `inboxHref`, currently `/app/notifications`. Retain and honor the existing
|
||||
`notificationsHref` and `alertsHref` fields for older clients.
|
||||
- Every inbox item (including banners) adds `actions`: `name`, `label`, `operationId`.
|
||||
An unread item offers `read` (`alertsInboxRead`); a read item offers `unread`
|
||||
(`alertsInboxUnread`); both offer `dismiss` (`alertsInboxDismiss`). Invoke the operation
|
||||
from the fragment with `{ "id": item.id }`. Existing item `href` semantics are unchanged.
|
||||
- Every record-panel alert adds `actions`: `name`, `label`, `processName`, `href`.
|
||||
Managers see `acknowledge` for OPEN alerts and `snooze` for panel alerts; viewers get an
|
||||
empty list. The href is complete, including the process, alert id and table arguments:
|
||||
`/app/alertsAcknowledge?recordsParam=recordIds&recordIds=9&tableName=alerts_alert`.
|
||||
Clients follow the provided href without constructing a process or table name. Server
|
||||
process authorization still applies when an action executes.
|
||||
- Panel adds `follow: { "available": boolean }`, true exactly when core supplemental
|
||||
`MiddlewareSpecExtensionsMetaData` contains an extension named `collaboration`. No
|
||||
collaboration dependency or URL is embedded here. Next uses its collaboration module
|
||||
to determine the follow endpoint and current follow state.
|
||||
|
||||
The provided Next `main` checkout had no alerts client at implementation time. The reviewed
|
||||
client was `feature/notification-center:src/lib/api/alerts.ts` and its
|
||||
`src/components/alerts/*` components. Its current process href and watch assumptions are
|
||||
covered by the migration above.
|
||||
|
||||
## Removed watch operation
|
||||
|
||||
`POST /qqq/v1/alerts/record-panel/watch` and `AlertsApi.watchRecord` are removed. No panel
|
||||
request creates a RECORD subscription. `alerts_subscription` continues to handle alert-type
|
||||
and scope subscriptions; existing record-scoped watch rows are deprecated and remain readable
|
||||
(and retain their existing routing behavior). No rows are migrated or deleted. The panel's
|
||||
`watching` boolean is retained for compatibility and reports only those legacy rows; it must
|
||||
not be used as collaboration follow state.
|
||||
|
||||
There is no follower fan-out yet. `AlertRouter.destinations` contains
|
||||
`STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7)` at the point where follower destinations
|
||||
will join the existing recipient visibility and suppression pipeline.
|
||||
|
||||
## Alertmanager machine webhook
|
||||
|
||||
Canonical operation: **POST `/qqq/hooks/alerts/alertmanager`**. This is a raw machine route
|
||||
outside the UI API and has no UI operationId. It preserves the bearer token, secret reference,
|
||||
JSON request and `{raised, resolved, ignored}` response. No QQQ user session is required.
|
||||
|
||||
The previous **POST `/qqq/v1/alerts/ingest/alertmanager`** is an opt-in raw alias:
|
||||
|
||||
```java
|
||||
new AlertsQBitConfig()
|
||||
.withLegacyAlertmanagerPath(true)
|
||||
.withAlertmanagerIngestTokenSecretRef("ALERTMANAGER_TOKEN");
|
||||
```
|
||||
|
||||
`legacyAlertmanagerPath` defaults to false. Core `UiApiRouteAuditor` compares every raw v1
|
||||
route against specs and offers no machine-webhook exemption. Therefore enabling the alias
|
||||
requires non-strict UI route mode; strict startup rejects it. It is intentionally absent
|
||||
from the UI fragment. Default hosts and all application-server tests run strict successfully.
|
||||
The migration-only raw provider is tested separately, including strict-auditor rejection.
|
||||
|
||||
Both paths call the same handler and authenticate against the same secret. Every alias call,
|
||||
including rejected authentication, logs a warning and carries:
|
||||
|
||||
```http
|
||||
Deprecation: true
|
||||
Sunset: Tue, 13 Apr 2027 00:00:00 GMT
|
||||
```
|
||||
|
||||
The alias is supported for the 0.1 minor line only and scheduled for removal in 0.2; the
|
||||
published calendar sunset is 13 April 2027. Move receivers before either retirement boundary.
|
||||
No automatic runtime cutoff is applied by these informational headers.
|
||||
|
||||
## Core notification migration (#15 part 2)
|
||||
|
||||
Core `QAlerts.notify` now feeds the same inbox service through the registered sink. This
|
||||
migration changes Java imports and producer entry points only: no methods, paths,
|
||||
operation IDs, request fields, response fields or schemas change relative to PR #19.
|
||||
The earlier client changes above still apply when adopting the full stacked branch.
|
||||
The supplied Next `main` checkout still has no alerts client; the
|
||||
`feature/notification-center:src/lib/api/alerts.ts` client was checked again for this work.
|
||||
|
||||
## Verification
|
||||
|
||||
Use the Maven wrapper. The committed BOM version is RC.6; while it is unreleased, verify with
|
||||
an already installed local core build:
|
||||
|
||||
```sh
|
||||
./mvnw -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
|
||||
```
|
||||
|
||||
Regenerate intentionally with `-Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test`
|
||||
(and the same local version override). Normal tests compare the fragment without rewriting it.
|
||||
Checkstyle uses `checkstyle/config.xml` and the repository's existing short Apache headers.
|
||||
@@ -107,6 +107,32 @@
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>14.3.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle/config.xml</configLocation>
|
||||
<includeTestSourceDirectory>true</includeTestSourceDirectory>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>checkstyle</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
@@ -158,14 +184,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.4 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -36,7 +36,7 @@ import com.kingsrook.qbits.alerts.model.AlertsTeam;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
@@ -74,6 +74,8 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
|
||||
public static final String APP_NAME = "alerts";
|
||||
|
||||
private Boolean legacyAlertmanagerPath = false;
|
||||
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private String messagingProviderName;
|
||||
@@ -610,7 +612,7 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertmanagerIngestTokenSecretRef: the reference (an
|
||||
** environment variable name or ${...} expression, never the value) of the
|
||||
** bearer token Alertmanager sends to POST /qqq/v1/alerts/ingest/alertmanager.
|
||||
** bearer token Alertmanager sends to POST /qqq/hooks/alerts/alertmanager.
|
||||
** Null (the default) turns ingest off.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withAlertmanagerIngestTokenSecretRef(String alertmanagerIngestTokenSecretRef)
|
||||
@@ -793,4 +795,26 @@ public class AlertsQBitConfig implements QBitConfig
|
||||
this.jobRunLinkPattern = jobRunLinkPattern;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether to expose the deprecated UI-path webhook alias (default false).
|
||||
** Requires non-strict UI route mode; remove after one minor release.
|
||||
*******************************************************************************/
|
||||
public Boolean getLegacyAlertmanagerPath()
|
||||
{
|
||||
return (this.legacyAlertmanagerPath);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt in to the deprecated alias while migrating Alertmanager receivers.
|
||||
*******************************************************************************/
|
||||
public AlertsQBitConfig withLegacyAlertmanagerPath(Boolean legacyAlertmanagerPath)
|
||||
{
|
||||
this.legacyAlertmanagerPath = legacyAlertmanagerPath;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,11 @@ import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -197,7 +197,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
AlertsRuntime previous = qBitConfig.getRuntime();
|
||||
if(previous != null)
|
||||
{
|
||||
previous.sink().stop();
|
||||
previous.getSink().stop();
|
||||
}
|
||||
AlertsRuntime runtime = new AlertsRuntime(qBitConfig, qInstance);
|
||||
qBitConfig.setRuntime(runtime);
|
||||
@@ -212,6 +212,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
boolean routes = false;
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.alerts.api.AlertsUiApi.register(qInstance);
|
||||
com.kingsrook.qbits.alerts.api.AlertsRouteProvider.register(qInstance);
|
||||
routes = true;
|
||||
}
|
||||
|
||||
@@ -276,10 +276,10 @@ public class AlertsRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sink producers send signals to (until the core SPI ships, hosts call
|
||||
** it directly: AlertsRuntime.get().sink().raise(...)).
|
||||
** The runtime sink for adapter delegation and queue lifecycle management.
|
||||
** Producers use core QAlerts for both alerts and notifications.
|
||||
*******************************************************************************/
|
||||
public QbitAlertsSink sink()
|
||||
public QbitAlertsSink getSink()
|
||||
{
|
||||
return (this.sink);
|
||||
}
|
||||
|
||||
@@ -41,31 +41,29 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -139,7 +137,7 @@ public class AlertsApi
|
||||
if(entry.unread())
|
||||
{
|
||||
unread++;
|
||||
NotificationSeverity severity = NotificationSeverity.fromName(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
NotificationSeverity severity = NotificationService.severityOf(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
highest = highest == null || severity.compareTo(highest) > 0 ? severity : highest;
|
||||
}
|
||||
if(latest.size() < INBOX_ITEMS)
|
||||
@@ -158,6 +156,7 @@ public class AlertsApi
|
||||
result.put("items", latest);
|
||||
result.put("banners", banners);
|
||||
result.put("notificationsHref", NOTIFICATIONS_HREF);
|
||||
result.put("inboxHref", NOTIFICATIONS_HREF);
|
||||
result.put("alertsHref", "/app/" + AlertsQBitConfig.APP_NAME);
|
||||
result.put("pollSeconds", POLL_SECONDS);
|
||||
return (result);
|
||||
@@ -391,6 +390,11 @@ public class AlertsApi
|
||||
{
|
||||
row.put("alertId", parseInteger(notification.getValueString("sourceRecordId")));
|
||||
}
|
||||
List<Map<String, Object>> actions = new ArrayList<>();
|
||||
actions.add(Map.of("name", entry.unread() ? "read" : "unread", "label", entry.unread() ? "Mark read" : "Mark unread",
|
||||
"operationId", entry.unread() ? InboxReadSpec.OPERATION_ID : InboxUnreadSpec.OPERATION_ID));
|
||||
actions.add(Map.of("name", "dismiss", "label", "Dismiss", "operationId", InboxDismissSpec.OPERATION_ID));
|
||||
row.put("actions", actions);
|
||||
return (row);
|
||||
}
|
||||
|
||||
@@ -582,46 +586,13 @@ public class AlertsApi
|
||||
result.put("viewAllHref", allIds.isEmpty() ? null : AlertsHome.listUrl(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, dedupe(allIds))));
|
||||
result.put("canManage", session.hasPermission(AlertsQBitConfig.PERMISSION_MANAGE));
|
||||
result.put("watching", isWatching(tableName, recordId));
|
||||
MiddlewareSpecExtensionsMetaData extensions = MiddlewareSpecExtensionsMetaData.of(runtime.getQInstance());
|
||||
result.put("follow", Map.of("available", extensions != null && extensions.getExtensions().containsKey("collaboration")));
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Watch alerts for this record": a RECORD-scope subscription for the user.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> watchRecord(Map<String, Object> body) throws QException
|
||||
{
|
||||
String userId = requireViewer();
|
||||
String tableName = body == null ? null : (String) body.get("tableName");
|
||||
Object recordId = body == null ? null : body.get("recordId");
|
||||
if(userId == null || !StringUtils.hasContent(tableName) || recordId == null || runtime.getQInstance().getTable(tableName) == null)
|
||||
{
|
||||
throw (new QBadRequestException("Send a table name and record id."));
|
||||
}
|
||||
if(isWatching(tableName, String.valueOf(recordId)))
|
||||
{
|
||||
return (Map.of("watching", true, "created", false));
|
||||
}
|
||||
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(AlertsSubscription.TABLE_NAME).withRecord(new QRecord()
|
||||
.withValue("subscriberType", SubscriberType.USER.name())
|
||||
.withValue("userId", userId)
|
||||
.withValue("scopeType", ScopeType.RECORD.name())
|
||||
.withValue("scopeValue", tableName)
|
||||
.withValue("scopeRecordId", String.valueOf(recordId))
|
||||
.withValue("minSeverity", AlertSeverity.WARNING.name())
|
||||
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
|
||||
.withValue("deliveryMode", "IMMEDIATE")
|
||||
.withValue("enabled", true)));
|
||||
if(CollectionUtils.nullSafeHasContents(inserted.get(0).getErrors()))
|
||||
{
|
||||
throw (new QBadRequestException(inserted.get(0).getErrors().get(0).getMessage()));
|
||||
}
|
||||
return (Map.of("watching", true, "created", true, "subscriptionId", inserted.get(0).getValue("id")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the user already has a RECORD subscription for this record.
|
||||
*******************************************************************************/
|
||||
@@ -654,11 +625,32 @@ public class AlertsApi
|
||||
row.put("age", AlertMessageRenderer.age(alert.getValueInstant("firstRaisedAt"), now));
|
||||
row.put("raisedAt", alert.getValueInstant("firstRaisedAt") == null ? null : alert.getValueInstant("firstRaisedAt").toString());
|
||||
row.put("href", "/app/" + AlertsAlert.TABLE_NAME + "/" + alert.getValue("id"));
|
||||
List<Map<String, Object>> actions = new ArrayList<>();
|
||||
if(QContext.getQSession().hasPermission(AlertsQBitConfig.PERMISSION_MANAGE))
|
||||
{
|
||||
if(Status.OPEN.name().equals(alert.getValueString("status")))
|
||||
{
|
||||
actions.add(processAction("acknowledge", AlertsAction.ACKNOWLEDGE, alert.getValueInteger("id")));
|
||||
}
|
||||
actions.add(processAction("snooze", AlertsAction.SNOOZE, alert.getValueInteger("id")));
|
||||
}
|
||||
row.put("actions", actions);
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete navigation target so clients need no process or table conventions.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> processAction(String name, AlertsAction action, Integer alertId)
|
||||
{
|
||||
return (Map.of("name", name, "label", action.getLabel(), "processName", action.getProcessName(),
|
||||
"href", "/app/" + action.getProcessName() + "?recordsParam=recordIds&recordIds=" + alertId + "&tableName=" + AlertsAlert.TABLE_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group labels by id (empty without read permission).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared JSON transport; AlertsApi owns authorization and behavior.
|
||||
*******************************************************************************/
|
||||
abstract class AlertsJsonSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The stable operation identifier exposed to clients.
|
||||
*******************************************************************************/
|
||||
protected abstract String operationId();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish operation identifiers in both the fragment and served document.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Method defineMethod()
|
||||
{
|
||||
return (super.defineMethod().withOperationId(operationId()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the existing Content-Type requirement, including empty POST bodies.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public JsonInput buildInput(Context context) throws Exception
|
||||
{
|
||||
if(defineRequestBodySchema() != null && (context.contentType() == null || !context.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("application/json")))
|
||||
{
|
||||
throw (new QBadRequestException("The request body must be JSON (Content-Type: application/json)."));
|
||||
}
|
||||
try
|
||||
{
|
||||
return (super.buildInput(context));
|
||||
}
|
||||
catch(QBadRequestException e)
|
||||
{
|
||||
throw (new QBadRequestException("The request body is not valid JSON."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Framework error responses retain their existing HTTP status mapping.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AlertsErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "AlertsErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "AlertsErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", "AlertsErrorV1")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the runtime in the authenticated caller context.
|
||||
*******************************************************************************/
|
||||
protected AlertsApi api() throws QException
|
||||
{
|
||||
return (new AlertsApi(AlertsRuntime.get()));
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
@@ -30,32 +30,20 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.ContentType;
|
||||
import io.javalin.http.Context;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Javalin routes under /qqq/v1/alerts for the Next UI bell and record panel:
|
||||
** - GET /inbox/summary unread count, highest unread severity, latest 10,
|
||||
** sticky banners
|
||||
** - GET /inbox the notifications page (status, category,
|
||||
** severity, offset, limit)
|
||||
** - POST /inbox/read {"id": n}, {"ids": [..]}, {"all": true} or {"alertId": n}
|
||||
** - POST /inbox/unread {"id": n} or {"ids": [..]}
|
||||
** - POST /inbox/dismiss {"id": n}, {"ids": [..]} or {"all": true}
|
||||
** - GET /record-panel?tableName=t&recordId=id the "Open alerts" panel
|
||||
** - POST /record-panel/watch {"tableName": t, "recordId": id}
|
||||
** - POST /ingest/alertmanager Alertmanager's webhook (bearer token; off
|
||||
** unless alertmanagerIngestTokenSecretRef is configured)
|
||||
** Each request gets a session from the instance's authentication module;
|
||||
** AlertsApi checks alerts.view and works in that session. Errors map
|
||||
** through QJavalinUtils.handleException (400, 401, 403, 404). POST bodies
|
||||
** must be JSON.
|
||||
** Machine webhook routes. The UI routes are registered through AlertsUiApi.
|
||||
*******************************************************************************/
|
||||
public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsRouteProvider.class);
|
||||
|
||||
public static final String WEBHOOK_PATH = "/qqq/hooks/alerts/alertmanager";
|
||||
public static final String LEGACY_SUNSET = "Tue, 13 Apr 2027 00:00:00 GMT";
|
||||
public static final String BASE_PATH = "/qqq/v1/alerts";
|
||||
|
||||
static final int MAX_INGEST_BYTES = 1024 * 1024;
|
||||
@@ -97,14 +85,17 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.get(BASE_PATH + "/inbox/summary", context -> respond(context, false, (api, body) -> api.inboxSummary()));
|
||||
config.routes.get(BASE_PATH + "/inbox", context -> respond(context, false, (api, body) -> api.inbox(queryParams(context))));
|
||||
config.routes.post(BASE_PATH + "/inbox/read", context -> respond(context, true, AlertsApi::markRead));
|
||||
config.routes.post(BASE_PATH + "/inbox/unread", context -> respond(context, true, AlertsApi::markUnread));
|
||||
config.routes.post(BASE_PATH + "/inbox/dismiss", context -> respond(context, true, AlertsApi::dismiss));
|
||||
config.routes.get(BASE_PATH + "/record-panel", context -> respond(context, false, (api, body) -> api.recordPanel(context.queryParam("tableName"), context.queryParam("recordId"))));
|
||||
config.routes.post(BASE_PATH + "/record-panel/watch", context -> respond(context, true, AlertsApi::watchRecord));
|
||||
config.routes.post(BASE_PATH + "/ingest/alertmanager", this::ingestAlertmanager);
|
||||
config.routes.post(WEBHOOK_PATH, this::ingestAlertmanager);
|
||||
if(Boolean.TRUE.equals(AlertsRuntime.find(qInstance).getConfig().getLegacyAlertmanagerPath()))
|
||||
{
|
||||
config.routes.post(BASE_PATH + "/ingest/alertmanager", context ->
|
||||
{
|
||||
context.header("Deprecation", "true");
|
||||
context.header("Sunset", LEGACY_SUNSET);
|
||||
LOG.warn("Deprecated Alertmanager webhook called; migrate to " + WEBHOOK_PATH);
|
||||
ingestAlertmanager(context);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,43 +129,6 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session, body, handler, JSON - or the error's status.
|
||||
*******************************************************************************/
|
||||
private void respond(Context context, boolean readsBody, Handler handler)
|
||||
{
|
||||
try
|
||||
{
|
||||
ExecutorSessionUtils.setupSession(context, qInstance);
|
||||
Map<String, Object> body = readsBody ? readJsonBody(context) : Map.of();
|
||||
Object result = handler.handle(new AlertsApi(AlertsRuntime.get(qInstance)), body);
|
||||
context.contentType(ContentType.APPLICATION_JSON);
|
||||
context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
QJavalinUtils.handleException(null, context, e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first value of each query parameter.
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> queryParams(Context context)
|
||||
{
|
||||
Map<String, String> params = new java.util.HashMap<>();
|
||||
context.queryParamMap().forEach((name, values) -> params.put(name, values == null || values.isEmpty() ? null : values.get(0)));
|
||||
return (params);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The JSON body (an object), or 400.
|
||||
*******************************************************************************/
|
||||
@@ -203,15 +157,4 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One endpoint's work.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface Handler
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
Object handle(AlertsApi api, Map<String, Object> body) throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit versioned wire schemas, independent of persistence entities.
|
||||
*******************************************************************************/
|
||||
public final class AlertsSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AlertsSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The complete component set shared by the six operations.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Schema> components()
|
||||
{
|
||||
return (new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("AlertsActionV1", new AlertsActionV1(null, null, null, null, null).toSchema()),
|
||||
Map.entry("AlertsInboxItemV1", new AlertsInboxItemV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema()),
|
||||
Map.entry("AlertsPanelAlertV1", new AlertsPanelAlertV1(null, null, null, null, null, null, null, null, null).toSchema()),
|
||||
Map.entry("AlertsFollowV1", new AlertsFollowV1(null).toSchema()),
|
||||
Map.entry("AlertsInboxSummaryV1", new AlertsInboxSummaryV1(null, null, null, null, null, null, null, null).toSchema()),
|
||||
Map.entry("AlertsInboxPageV1", new AlertsInboxPageV1(null, null, null, null).toSchema()),
|
||||
Map.entry("AlertsRecordPanelV1", new AlertsRecordPanelV1(null, null, null, null, null, null, null, null).toSchema()),
|
||||
Map.entry("AlertsMutationResultV1", new AlertsMutationResultV1(null).toSchema()),
|
||||
Map.entry("AlertsInboxTargetV1", new AlertsInboxTargetV1(null, null, null, null).toSchema()),
|
||||
Map.entry("AlertsUnreadTargetV1", new AlertsUnreadTargetV1(null, null, null).toSchema()),
|
||||
Map.entry("AlertsErrorV1", new AlertsErrorV1(null).toSchema()))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An available action: operationId targets an inbox operation with this item id; process actions provide a complete href.
|
||||
*******************************************************************************/
|
||||
public record AlertsActionV1(String name, String label, String operationId, String processName, String href) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("An available action: operationId targets an inbox operation with this item id; process actions provide a complete href.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING)),
|
||||
Map.entry("operationId", new Schema().withType(Type.STRING)),
|
||||
Map.entry("processName", new Schema().withType(Type.STRING)),
|
||||
Map.entry("href", new Schema().withType(Type.STRING))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification recipient. body is included only in page items and banners; alertId only for ALERT notifications.
|
||||
*******************************************************************************/
|
||||
public record AlertsInboxItemV1(Integer id, Integer notificationId, String title, String body, String severity, String category, String source, String href, String linkLabel, String deliveredAt, String age, Boolean unread, Boolean sticky, Integer alertId, List<AlertsActionV1> actions) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A notification recipient. body is included only in page items and banners; alertId only for ALERT notifications.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("notificationId", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("title", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("body", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("category", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("source", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("href", new Schema().withType(Type.STRING).withNullable(true).withDescription("Existing safe link, or null.")),
|
||||
Map.entry("linkLabel", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("deliveredAt", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("age", new Schema().withType(Type.STRING)),
|
||||
Map.entry("unread", new Schema().withType(Type.BOOLEAN)),
|
||||
Map.entry("sticky", new Schema().withType(Type.BOOLEAN)),
|
||||
Map.entry("alertId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("actions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsActionV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A visible open alert with permission-aware process actions.
|
||||
*******************************************************************************/
|
||||
public record AlertsPanelAlertV1(Integer alertId, String title, String severity, String status, String group, String age, String raisedAt, String href, List<AlertsActionV1> actions) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("A visible open alert with permission-aware process actions.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("alertId", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("title", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("status", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("group", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("age", new Schema().withType(Type.STRING)),
|
||||
Map.entry("raisedAt", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("href", new Schema().withType(Type.STRING)),
|
||||
Map.entry("actions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsActionV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capability discovery only. Next obtains the follow URL from its collaboration module.
|
||||
*******************************************************************************/
|
||||
public record AlertsFollowV1(Boolean available) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Capability discovery only. Next obtains the follow URL from its collaboration module.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("available", new Schema().withType(Type.BOOLEAN).withDescription("True exactly when the collaboration UI API extension is registered."))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bell summary, latest ten items and up to three sticky banners.
|
||||
*******************************************************************************/
|
||||
public record AlertsInboxSummaryV1(Integer unreadCount, String highestUnreadSeverity, List<AlertsInboxItemV1> items, List<AlertsInboxItemV1> banners, String notificationsHref, String inboxHref, String alertsHref, Integer pollSeconds) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Bell summary, latest ten items and up to three sticky banners.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("unreadCount", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("highestUnreadSeverity", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
|
||||
Map.entry("banners", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
|
||||
Map.entry("notificationsHref", new Schema().withType(Type.STRING).withDescription("Existing notifications page link; retained for compatibility.")),
|
||||
Map.entry("inboxHref", new Schema().withType(Type.STRING).withDescription("Canonical inbox navigation link.")),
|
||||
Map.entry("alertsHref", new Schema().withType(Type.STRING)),
|
||||
Map.entry("pollSeconds", new Schema().withType(Type.INTEGER))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Filtered notification page.
|
||||
*******************************************************************************/
|
||||
public record AlertsInboxPageV1(List<AlertsInboxItemV1> items, Integer total, Integer offset, Integer limit) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Filtered notification page.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
|
||||
Map.entry("total", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("offset", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("limit", new Schema().withType(Type.INTEGER))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open alerts, recent resolutions and collaboration capability. watching is deprecated legacy subscription state.
|
||||
*******************************************************************************/
|
||||
public record AlertsRecordPanelV1(Boolean visible, Integer openCount, List<AlertsPanelAlertV1> open, Integer resolvedLast30Days, String viewAllHref, Boolean canManage, Boolean watching, AlertsFollowV1 follow) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Open alerts, recent resolutions and collaboration capability. watching is deprecated legacy subscription state.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("visible", new Schema().withType(Type.BOOLEAN)),
|
||||
Map.entry("openCount", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("open", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsPanelAlertV1"))),
|
||||
Map.entry("resolvedLast30Days", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("viewAllHref", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("canManage", new Schema().withType(Type.BOOLEAN)),
|
||||
Map.entry("watching", new Schema().withType(Type.BOOLEAN).withDescription("Deprecated: existing RECORD subscriptions only; does not represent collaboration follow state.")),
|
||||
Map.entry("follow", new Schema().withRefToSchema("AlertsFollowV1"))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Number of the caller own recipient rows changed.
|
||||
*******************************************************************************/
|
||||
public record AlertsMutationResultV1(Integer updated) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Number of the caller own recipient rows changed.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("updated", new Schema().withType(Type.INTEGER))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select id, nonempty ids (first 500), legacy alertId, or all:true in that precedence. All scans are capped at 1000 rows.
|
||||
*******************************************************************************/
|
||||
public record AlertsInboxTargetV1(Integer id, List<Integer> ids, Integer alertId, Boolean all) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Select id, nonempty ids (first 500), legacy alertId, or all:true in that precedence. All scans are capped at 1000 rows.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER))),
|
||||
Map.entry("alertId", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("all", new Schema().withType(Type.BOOLEAN))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select id or nonempty ids (first 500), in that precedence. Legacy alertId is also accepted for compatibility. all is not supported.
|
||||
*******************************************************************************/
|
||||
public record AlertsUnreadTargetV1(Integer id, List<Integer> ids, Integer alertId) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Select id or nonempty ids (first 500), in that precedence. Legacy alertId is also accepted for compatibility. all is not supported.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER)),
|
||||
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER))),
|
||||
Map.entry("alertId", new Schema().withType(Type.INTEGER))))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Framework error response.
|
||||
*******************************************************************************/
|
||||
public record AlertsErrorV1(String error) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the public JSON shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Framework error response.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The alerts UI API, retaining the established /alerts namespace.
|
||||
*******************************************************************************/
|
||||
public final class AlertsUiApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AlertsUiApi()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register once, alongside the optional Javalin webhook provider.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The independently versioned, discoverable UI contract.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
|
||||
.withSemanticVersion("1.0.0").withDescription("Alerts inbox and record panel")
|
||||
.withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class)
|
||||
.withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class)
|
||||
.withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Dismiss own notifications.
|
||||
*******************************************************************************/
|
||||
public class InboxDismissSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInboxDismiss";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/dismiss")
|
||||
.withShortSummary("Dismiss own notifications").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsMutationResultV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient selectors; precedence is id, ids, alertId, then all.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().dismiss(input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark own notifications read.
|
||||
*******************************************************************************/
|
||||
public class InboxReadSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInboxRead";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/read")
|
||||
.withShortSummary("Mark own notifications read").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsMutationResultV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient selectors; precedence is id, ids, alertId, then all.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().markRead(input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page the notification inbox.
|
||||
*******************************************************************************/
|
||||
public class InboxSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInbox";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox")
|
||||
.withShortSummary("Page the notification inbox").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsInboxPageV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing query parameters and paging semantics.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (List.of(
|
||||
new Parameter().withName("status").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("all or unread; other values behave as all."),
|
||||
new Parameter().withName("category").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive category filter."),
|
||||
new Parameter().withName("severity").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive severity filter."),
|
||||
new Parameter().withName("offset").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 0; negatives clamp to 0; malformed values use the default."),
|
||||
new Parameter().withName("limit").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 25; clamped to 1..100; malformed values use the default.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().inbox(input.getQueryParams()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the bell summary.
|
||||
*******************************************************************************/
|
||||
public class InboxSummarySpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInboxSummary";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox/summary")
|
||||
.withShortSummary("Read the bell summary").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsInboxSummaryV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish shared components once; all six extension specs register together.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (AlertsSchemas.components());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().inboxSummary());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark own notifications unread.
|
||||
*******************************************************************************/
|
||||
public class InboxUnreadSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsInboxUnread";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/unread")
|
||||
.withShortSummary("Mark own notifications unread").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsMutationResultV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipient selectors; precedence is id, ids, alertId, then all.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema("AlertsUnreadTargetV1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().markUnread(input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read visible alerts for a record.
|
||||
*******************************************************************************/
|
||||
public class RecordPanelSpec extends AlertsJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "alertsRecordPanel";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe the established method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/record-panel")
|
||||
.withShortSummary("Read visible alerts for a record").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable identifier for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AlertsRecordPanelV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing query parameters and paging semantics.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (List.of(
|
||||
new Parameter().withName("tableName").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent table name."),
|
||||
new Parameter().withName("recordId").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent record key.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke the existing service with the caller session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (api().recordPanel(input.getQueryParam("tableName"), input.getQueryParam("recordId")));
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -30,11 +30,11 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
@@ -212,7 +212,7 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
|
||||
}
|
||||
if(record.getValues().containsKey("audienceType"))
|
||||
{
|
||||
NotificationAudience audience = NotificationAudience.parse(record.getValueString("audienceType"), record.getValueString("audienceValue"));
|
||||
NotificationAudience audience = NotificationService.audienceOf(record);
|
||||
if(audience == null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Choose who the notification is for."));
|
||||
@@ -220,11 +220,11 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface
|
||||
else if(audience.getType() != NotificationAudience.Type.ALL_USERS && audience.getValues().isEmpty())
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("List the " + switch(audience.getType())
|
||||
{
|
||||
case ROLES -> "roles or groups";
|
||||
case PERMISSION -> "permission";
|
||||
default -> "user ids";
|
||||
} + " in Audience Members."));
|
||||
{
|
||||
case ROLES -> "roles or groups";
|
||||
case PERMISSION -> "permission";
|
||||
default -> "user ids";
|
||||
} + " in Audience Members."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.home.HomeHtml;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -327,7 +327,7 @@ public class DeliveryWorker
|
||||
.withTitle("Alert channel \"" + channel.getValueString("label") + "\" is failing")
|
||||
.withSummary(failures + " deliveries in a row failed.")
|
||||
.withDetails(result.error())
|
||||
.withRecordRef(com.kingsrook.qbits.alerts.spi.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
|
||||
.withRecordRef(com.kingsrook.qqq.backend.core.alerts.AlertRecordRef.of(AlertsChannel.TABLE_NAME, channelId)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-55
@@ -17,36 +17,25 @@
|
||||
package com.kingsrook.qbits.alerts.delivery.channels;
|
||||
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSigner;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN for core's WebhookSigner (QRun-IO/qqq#1109, the shared signer of
|
||||
** the qbit-webhooks assessment, QRun-IO/qqq#1105 D2). Same format: the
|
||||
** Standard Webhooks headers webhook-id, webhook-timestamp and
|
||||
** webhook-signature = "v1," + base64(HMAC-SHA256(key, id + "." + timestamp +
|
||||
** "." + body)); a "whsec_" secret is base64 after the prefix, any other text
|
||||
** is the raw key. Replace with com.kingsrook.qqq.backend.core.utils.webhooks
|
||||
** .WebhookSigner when the QQQ RC with #1109 ships, so receivers verify Alerts
|
||||
** and qbit-webhooks deliveries the same way.
|
||||
** Compatibility adapter for the qbit's existing webhook secret strings.
|
||||
** Core signs every request. Core requires at least 16 key bytes, whereas the
|
||||
** previous signer accepted shorter keys. HMAC-SHA256 zero-pads keys to its
|
||||
** 64-byte block size, so padding a short legacy key to 16 bytes preserves
|
||||
** its signature exactly. New secrets should use WebhookSecret.generate().
|
||||
*******************************************************************************/
|
||||
public final class StandardWebhookSigner
|
||||
{
|
||||
public static final String HEADER_ID = "webhook-id";
|
||||
public static final String HEADER_TIMESTAMP = "webhook-timestamp";
|
||||
public static final String HEADER_SIGNATURE = "webhook-signature";
|
||||
|
||||
private static final String PREFIX = "whsec_";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Signing only.
|
||||
*******************************************************************************/
|
||||
@@ -57,55 +46,36 @@ public final class StandardWebhookSigner
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The three signature headers.
|
||||
** The three core Standard Webhooks headers, preserving legacy short keys.
|
||||
*******************************************************************************/
|
||||
public static Map<String, String> headers(String messageId, Instant timestamp, byte[] body, String secret)
|
||||
{
|
||||
long seconds = timestamp.getEpochSecond();
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put(HEADER_ID, messageId);
|
||||
headers.put(HEADER_TIMESTAMP, Long.toString(seconds));
|
||||
headers.put(HEADER_SIGNATURE, "v1," + sign(messageId, seconds, body, secret));
|
||||
return (headers);
|
||||
return (WebhookSigner.headers(messageId, timestamp, body, List.of(parseSecret(secret))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** base64 HMAC-SHA256 over id.timestamp.body.
|
||||
** Preserve raw UTF-8 and whsec_ base64 secrets, including legacy short keys.
|
||||
*******************************************************************************/
|
||||
public static String sign(String messageId, long timestampSeconds, byte[] body, String secret)
|
||||
{
|
||||
if(messageId == null || messageId.isEmpty() || messageId.contains(".") || messageId.chars().anyMatch(Character::isWhitespace))
|
||||
{
|
||||
throw (new IllegalArgumentException("A webhook message id is required and may not contain '.' or whitespace"));
|
||||
}
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(keyBytes(secret), "HmacSHA256"));
|
||||
mac.update((messageId + "." + timestampSeconds + ".").getBytes(StandardCharsets.UTF_8));
|
||||
mac.update(body == null ? new byte[0] : body);
|
||||
return (Base64.getEncoder().encodeToString(mac.doFinal()));
|
||||
}
|
||||
catch(GeneralSecurityException e)
|
||||
{
|
||||
throw (new IllegalStateException("HMAC-SHA256 is unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The key bytes of a secret's text form.
|
||||
*******************************************************************************/
|
||||
static byte[] keyBytes(String secret)
|
||||
private static WebhookSecret parseSecret(String secret)
|
||||
{
|
||||
if(secret == null || secret.isBlank())
|
||||
{
|
||||
throw (new IllegalArgumentException("A webhook signing secret is required"));
|
||||
}
|
||||
String trimmed = secret.trim();
|
||||
return (trimmed.startsWith(PREFIX) ? Base64.getDecoder().decode(trimmed.substring(PREFIX.length())) : trimmed.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] key = trimmed.startsWith(WebhookSecret.PREFIX)
|
||||
? Base64.getDecoder().decode(trimmed.substring(WebhookSecret.PREFIX.length()))
|
||||
: trimmed.getBytes(StandardCharsets.UTF_8);
|
||||
if(key.length == 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("A webhook signing secret is required"));
|
||||
}
|
||||
if(key.length < WebhookSecret.MINIMUM_KEY_BYTES)
|
||||
{
|
||||
return (WebhookSecret.parse(WebhookSecret.PREFIX + Base64.getEncoder().encodeToString(Arrays.copyOf(key, WebhookSecret.MINIMUM_KEY_BYTES))));
|
||||
}
|
||||
return (WebhookSecret.parse(trimmed));
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -353,6 +353,8 @@ public class AlertRouter
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7): merge record followers
|
||||
// from RecordFollowersProviderInterface here, before the existing visibility/suppression checks.
|
||||
return (new ArrayList<>(destinations.values()));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
|
||||
@@ -29,11 +29,11 @@ import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
|
||||
import com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
@@ -38,10 +38,10 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
|
||||
@@ -25,13 +25,14 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
@@ -242,7 +243,7 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface
|
||||
QProcessMetaData process = QContext.getQInstance().getProcess(input.getProcessName());
|
||||
String label = process == null || process.getLabel() == null ? input.getProcessName() : process.getLabel();
|
||||
String tableName = process == null ? null : process.getTableName();
|
||||
runtime.sink().notify(new NotificationSignal(String.format(titleFormat, label))
|
||||
QAlerts.notify(new NotificationSignal(String.format(titleFormat, label))
|
||||
.withBody(body)
|
||||
.withSeverity(severity)
|
||||
.withCategory(NotificationCategory.JOB)
|
||||
|
||||
@@ -19,10 +19,10 @@ package com.kingsrook.qbits.alerts.engine;
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertResolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.sink().raise(signal);
|
||||
runtime.getSink().raise(signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.sink().resolve(resolution);
|
||||
runtime.getSink().resolve(resolution);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,14 +74,14 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.sink().heartbeat(heartbeatKey, at);
|
||||
runtime.getSink().heartbeat(heartbeatKey, at);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
** Core notification producers share the runtime's bounded delivery queue.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(NotificationSignal signal)
|
||||
@@ -89,7 +89,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface
|
||||
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.sink().notify(signal);
|
||||
runtime.getSink().notify(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,13 +45,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
@@ -28,11 +28,11 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertResolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import com.kingsrook.qbits.alerts.AlertsSecurity;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsGroup;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
|
||||
@@ -52,9 +52,9 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
|
||||
@@ -45,11 +45,11 @@ import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
|
||||
@@ -20,13 +20,13 @@ package com.kingsrook.qbits.alerts.model;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
|
||||
@@ -26,8 +26,8 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
@@ -21,30 +21,26 @@ package com.kingsrook.qbits.alerts.notifications;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ready-made notifications for the automatic sources hosts wire up
|
||||
** (docs/NOTIFICATIONS.md 6.1): security notices, shares and assignments,
|
||||
** and release announcements. Each one is a NotificationSignal sent through
|
||||
** the alerts sink (queued; it never throws into the caller). With no Alerts
|
||||
** qbit installed they do nothing.
|
||||
** core QAlerts (queued; it never throws into the caller). With no Alerts
|
||||
** qbit installed core uses its logging sink.
|
||||
**
|
||||
** Core has no sign-in or API key events yet, so a host calls the security
|
||||
** notices from its authentication customizer or API key screens.
|
||||
*******************************************************************************/
|
||||
public final class AlertsNotices
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsNotices.class);
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
@@ -114,7 +110,7 @@ public final class AlertsNotices
|
||||
*******************************************************************************/
|
||||
public static void shared(String userId, String title, AlertLink link, String sourceName)
|
||||
{
|
||||
sendSignal(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
QAlerts.notify(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
@@ -125,7 +121,7 @@ public final class AlertsNotices
|
||||
*******************************************************************************/
|
||||
public static void release(String version, String notesMarkdown, AlertLink link)
|
||||
{
|
||||
sendSignal(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS)
|
||||
QAlerts.notify(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS)
|
||||
.withCategory(NotificationCategory.RELEASE).withSourceName("release").withSourceKey("release:" + version).withLink(link)
|
||||
.withAudience(NotificationAudience.allUsers()));
|
||||
}
|
||||
@@ -141,29 +137,7 @@ public final class AlertsNotices
|
||||
{
|
||||
return;
|
||||
}
|
||||
sendSignal(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
QAlerts.notify(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Through the installed qbit's sink (nothing when it isn't installed).
|
||||
*******************************************************************************/
|
||||
private static void sendSignal(NotificationSignal signal)
|
||||
{
|
||||
try
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
AlertsRuntime runtime = AlertsRuntime.find(qInstance);
|
||||
if(runtime != null && signal.getAudience() != null)
|
||||
{
|
||||
runtime.sink().notify(signal);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not send a notification", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,9 @@ import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
@@ -222,7 +222,7 @@ public class NotificationEmailer
|
||||
*******************************************************************************/
|
||||
static boolean atLeast(String severity, String minimum)
|
||||
{
|
||||
return (NotificationSeverity.fromName(severity, NotificationSeverity.INFO).compareTo(NotificationSeverity.fromName(minimum, NotificationSeverity.WARNING)) >= 0);
|
||||
return (NotificationService.severityOf(severity, NotificationSeverity.INFO).compareTo(NotificationService.severityOf(minimum, NotificationSeverity.WARNING)) >= 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
@@ -31,14 +33,14 @@ import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -335,7 +337,7 @@ public class NotificationService
|
||||
QRecord values = new QRecord()
|
||||
.withValue("title", StringUtils.safeTruncate(alert.getValueString("title"), 200))
|
||||
.withValue("body", alert.getValueString("summary"))
|
||||
.withValue("severity", NotificationSeverity.fromName(alert.getValueString("severity"), NotificationSeverity.WARNING).name())
|
||||
.withValue("severity", severityOf(alert.getValueString("severity"), NotificationSeverity.WARNING).name())
|
||||
.withValue("category", NotificationCategory.ALERT.name())
|
||||
.withValue("sourceName", StringUtils.hasContent(alert.getValueString("sourceName")) ? alert.getValueString("sourceName") : "alerts")
|
||||
.withValue("sourceKey", sourceKey)
|
||||
@@ -402,7 +404,42 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience audienceOf(QRecord notification)
|
||||
{
|
||||
return (NotificationAudience.parse(notification.getValueString("audienceType"), notification.getValueString("audienceValue")));
|
||||
String type = notification.getValueString("audienceType");
|
||||
String value = notification.getValueString("audienceValue");
|
||||
String[] values = value == null ? new String[0] : Arrays.stream(value.split("[,\n]"))
|
||||
.map(String::trim).filter(v -> !v.isEmpty()).distinct().toArray(String[]::new);
|
||||
if(type == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (switch(type.trim().toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "ALL_USERS" -> NotificationAudience.allUsers();
|
||||
case "ROLES" -> NotificationAudience.roles(values);
|
||||
case "PERMISSION" -> NotificationAudience.permission(values.length == 0 ? null : values[0]);
|
||||
case "USERS" -> NotificationAudience.users(values);
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a stored severity without changing legacy case and fallback behavior.
|
||||
*******************************************************************************/
|
||||
public static NotificationSeverity severityOf(String name, NotificationSeverity fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationSeverity severity : NotificationSeverity.values())
|
||||
{
|
||||
if(severity.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (severity);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -483,7 +520,7 @@ public class NotificationService
|
||||
.withValue("linkUrl", linkUrl(signal.getLink()))
|
||||
.withValue("linkLabel", signal.getLink() == null ? null : signal.getLink().getLabel())
|
||||
.withValue("audienceType", signal.getAudience().getType().name())
|
||||
.withValue("audienceValue", signal.getAudience().valuesAsString())
|
||||
.withValue("audienceValue", audienceValue(signal.getAudience()))
|
||||
.withValue("publishAt", signal.getPublishAt())
|
||||
.withValue("expireAt", signal.getExpireAt())
|
||||
.withValue("sticky", Boolean.TRUE.equals(signal.getSticky())));
|
||||
@@ -496,12 +533,23 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
private static QFilterCriteria audienceValueCriteria(NotificationAudience audience)
|
||||
{
|
||||
String value = audience.valuesAsString();
|
||||
String value = audienceValue(audience);
|
||||
return (value == null ? new QFilterCriteria("audienceValue", QCriteriaOperator.IS_BLANK) : new QFilterCriteria("audienceValue", QCriteriaOperator.EQUALS, value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the qbit's stored audience format and dedup keys with core types.
|
||||
*******************************************************************************/
|
||||
private static String audienceValue(NotificationAudience audience)
|
||||
{
|
||||
List<String> values = audience.getValues().stream().map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
return (values.isEmpty() ? null : String.join(",", values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -73,7 +73,7 @@ public final class AlertsScheduledSteps
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AlertsRuntime runtime = AlertsRuntime.get();
|
||||
runtime.sink().flush();
|
||||
runtime.getSink().flush();
|
||||
runtime.getNotifications().sendDue();
|
||||
runtime.getNotifications().purge();
|
||||
new NotificationEmailer(runtime).run();
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A monitor that an alert type ships with ("seeded"). An alerts
|
||||
** implementation inserts it once; after that it belongs to the admin, who may
|
||||
** tune or disable it. Without an implementation installed it does nothing.
|
||||
**
|
||||
** The definition's keys depend on the kind (e.g. HEARTBEAT takes heartbeatKey
|
||||
** or processName, plus expectedEverySeconds and graceSeconds; TABLE_QUERY
|
||||
** takes tableName and filter). Values must be JSON-friendly.
|
||||
*******************************************************************************/
|
||||
public class AlertDefaultMonitor implements Serializable
|
||||
{
|
||||
private String name;
|
||||
private AlertMonitorKind kind;
|
||||
private Map<String, Serializable> definition = new LinkedHashMap<>();
|
||||
private Boolean perRecord = false;
|
||||
private AlertSeverity severity;
|
||||
private Integer scheduleSeconds;
|
||||
private Integer forMinutes = 0;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A heartbeat monitor: raise when the key has not beat within the expected
|
||||
** interval plus grace.
|
||||
*******************************************************************************/
|
||||
public static AlertDefaultMonitor heartbeat(String name, String heartbeatKey, int expectedEverySeconds, int graceSeconds)
|
||||
{
|
||||
return (new AlertDefaultMonitor()
|
||||
.withName(name)
|
||||
.withKind(AlertMonitorKind.HEARTBEAT)
|
||||
.withDefinitionValue("heartbeatKey", heartbeatKey)
|
||||
.withDefinitionValue("expectedEverySeconds", expectedEverySeconds)
|
||||
.withDefinitionValue("graceSeconds", graceSeconds));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kind
|
||||
*******************************************************************************/
|
||||
public AlertMonitorKind getKind()
|
||||
{
|
||||
return (this.kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for kind
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withKind(AlertMonitorKind kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for definition
|
||||
*******************************************************************************/
|
||||
public Map<String, Serializable> getDefinition()
|
||||
{
|
||||
return (this.definition);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for definition
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withDefinition(Map<String, Serializable> definition)
|
||||
{
|
||||
this.definition = definition == null ? new LinkedHashMap<>() : new LinkedHashMap<>(definition);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set one definition value.
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withDefinitionValue(String key, Serializable value)
|
||||
{
|
||||
this.definition.put(key, value);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for perRecord
|
||||
*******************************************************************************/
|
||||
public Boolean getPerRecord()
|
||||
{
|
||||
return (this.perRecord);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for perRecord (one alert per matching record, instead of one
|
||||
** aggregate alert)
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withPerRecord(Boolean perRecord)
|
||||
{
|
||||
this.perRecord = perRecord;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for severity
|
||||
*******************************************************************************/
|
||||
public AlertSeverity getSeverity()
|
||||
{
|
||||
return (this.severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for severity (default: the type's severity)
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withSeverity(AlertSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scheduleSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getScheduleSeconds()
|
||||
{
|
||||
return (this.scheduleSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scheduleSeconds (how often to evaluate)
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withScheduleSeconds(Integer scheduleSeconds)
|
||||
{
|
||||
this.scheduleSeconds = scheduleSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for forMinutes
|
||||
*******************************************************************************/
|
||||
public Integer getForMinutes()
|
||||
{
|
||||
return (this.forMinutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for forMinutes (how long a condition must hold before it fires)
|
||||
*******************************************************************************/
|
||||
public AlertDefaultMonitor withForMinutes(Integer forMinutes)
|
||||
{
|
||||
this.forMinutes = forMinutes;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link from an alert to where a person fixes it: a record, a table with a
|
||||
** filter, an app or widget path, or an absolute URL. Exactly one target is
|
||||
** expected, so construct these with the static factories.
|
||||
*******************************************************************************/
|
||||
public class AlertLink implements Serializable
|
||||
{
|
||||
private String label;
|
||||
private String tableName;
|
||||
private Serializable recordId;
|
||||
private QQueryFilter filter;
|
||||
private String path;
|
||||
private String url;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link to one record.
|
||||
*******************************************************************************/
|
||||
public static AlertLink toRecord(String tableName, Serializable recordId, String label)
|
||||
{
|
||||
AlertLink link = new AlertLink();
|
||||
link.tableName = tableName;
|
||||
link.recordId = recordId;
|
||||
link.label = label;
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link to a table, optionally filtered.
|
||||
*******************************************************************************/
|
||||
public static AlertLink toTable(String tableName, QQueryFilter filter, String label)
|
||||
{
|
||||
AlertLink link = new AlertLink();
|
||||
link.tableName = tableName;
|
||||
link.filter = filter;
|
||||
link.label = label;
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link to an app or widget path within the application (e.g. "/admin/cdn").
|
||||
*******************************************************************************/
|
||||
public static AlertLink toPath(String path, String label)
|
||||
{
|
||||
AlertLink link = new AlertLink();
|
||||
link.path = path;
|
||||
link.label = label;
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Link to an absolute URL. Implementations only follow these for types
|
||||
** that declare them, so producer data cannot inject arbitrary links.
|
||||
*******************************************************************************/
|
||||
public static AlertLink toUrl(String url, String label)
|
||||
{
|
||||
AlertLink link = new AlertLink();
|
||||
link.url = url;
|
||||
link.label = label;
|
||||
return (link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (this.tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordId
|
||||
*******************************************************************************/
|
||||
public Serializable getRecordId()
|
||||
{
|
||||
return (this.recordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filter
|
||||
*******************************************************************************/
|
||||
public QQueryFilter getFilter()
|
||||
{
|
||||
return (this.filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for path
|
||||
*******************************************************************************/
|
||||
public String getPath()
|
||||
{
|
||||
return (this.path);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for url
|
||||
*******************************************************************************/
|
||||
public String getUrl()
|
||||
{
|
||||
return (this.url);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The kinds of monitor an alerts implementation evaluates. Declared in core
|
||||
** so producers can describe a seeded (default) monitor for their alert types
|
||||
** without depending on the implementation.
|
||||
*******************************************************************************/
|
||||
public enum AlertMonitorKind
|
||||
{
|
||||
/** count of rows matching a filter passes a threshold. */
|
||||
TABLE_QUERY,
|
||||
|
||||
/** an aggregate (count, sum, avg, min, max) crosses a band. */
|
||||
AGGREGATE,
|
||||
|
||||
/** nothing was seen (a heartbeat, a process success, a newest row) within an interval. */
|
||||
HEARTBEAT,
|
||||
|
||||
/** a process run failed. */
|
||||
PROCESS_FAILED,
|
||||
|
||||
/** a matching ESB event arrived. */
|
||||
ESB_EVENT,
|
||||
|
||||
/** a code check returned the currently-failing conditions. */
|
||||
CODE,
|
||||
|
||||
/** alerts ingested from an external system (e.g. Alertmanager). */
|
||||
EXTERNAL
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A record that an alert is about. The label is internal (shown in-app to
|
||||
** users who can see the record), never sent to an external channel.
|
||||
*******************************************************************************/
|
||||
public class AlertRecordRef implements Serializable
|
||||
{
|
||||
private String tableName;
|
||||
private Serializable primaryKey;
|
||||
private String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef(String tableName, Serializable primaryKey)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
this.primaryKey = primaryKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One ref per primary key, all in the same table. Null keys are skipped.
|
||||
*******************************************************************************/
|
||||
public static List<AlertRecordRef> of(String tableName, Collection<? extends Serializable> primaryKeys)
|
||||
{
|
||||
List<AlertRecordRef> refs = new ArrayList<>();
|
||||
if(primaryKeys != null)
|
||||
{
|
||||
for(Serializable primaryKey : primaryKeys)
|
||||
{
|
||||
if(primaryKey != null)
|
||||
{
|
||||
refs.add(new AlertRecordRef(tableName, primaryKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (refs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One ref to one record.
|
||||
*******************************************************************************/
|
||||
public static AlertRecordRef of(String tableName, Serializable primaryKey)
|
||||
{
|
||||
return (new AlertRecordRef(tableName, primaryKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two refs are equal when they name the same table and the same key (keys
|
||||
** compared by their string form, so 1 and "1" match).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
if(this == o)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(!(o instanceof AlertRecordRef that))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (Objects.equals(tableName, that.tableName) && Objects.equals(String.valueOf(primaryKey), String.valueOf(that.primaryKey)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return (Objects.hash(tableName, String.valueOf(primaryKey)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (tableName + ":" + primaryKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (this.tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tableName
|
||||
*******************************************************************************/
|
||||
public void setTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for primaryKey
|
||||
*******************************************************************************/
|
||||
public Serializable getPrimaryKey()
|
||||
{
|
||||
return (this.primaryKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for primaryKey
|
||||
*******************************************************************************/
|
||||
public void setPrimaryKey(Serializable primaryKey)
|
||||
{
|
||||
this.primaryKey = primaryKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for primaryKey
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef withPrimaryKey(Serializable primaryKey)
|
||||
{
|
||||
this.primaryKey = primaryKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for label
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label (internal: shown in-app only, never sent out)
|
||||
*******************************************************************************/
|
||||
public AlertRecordRef withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A producer's "this problem is gone", passed to QAlerts.resolve. Resolving
|
||||
** a key that is not open is a no-op.
|
||||
*******************************************************************************/
|
||||
public class AlertResolution implements Serializable
|
||||
{
|
||||
private String alertTypeName;
|
||||
private String dedupKey;
|
||||
private String note;
|
||||
private Instant resolvedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertResolution()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertResolution(String alertTypeName, String dedupKey, String note)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
this.dedupKey = dedupKey;
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The dedup key to use: the one given, or the type name.
|
||||
*******************************************************************************/
|
||||
public String getEffectiveDedupKey()
|
||||
{
|
||||
return (dedupKey == null || dedupKey.isBlank() ? alertTypeName : dedupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public String getAlertTypeName()
|
||||
{
|
||||
return (this.alertTypeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public AlertResolution withAlertTypeName(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dedupKey
|
||||
*******************************************************************************/
|
||||
public String getDedupKey()
|
||||
{
|
||||
return (this.dedupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dedupKey
|
||||
*******************************************************************************/
|
||||
public AlertResolution withDedupKey(String dedupKey)
|
||||
{
|
||||
this.dedupKey = dedupKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for note
|
||||
*******************************************************************************/
|
||||
public String getNote()
|
||||
{
|
||||
return (this.note);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for note
|
||||
*******************************************************************************/
|
||||
public AlertResolution withNote(String note)
|
||||
{
|
||||
this.note = note;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resolvedAt
|
||||
*******************************************************************************/
|
||||
public Instant getResolvedAt()
|
||||
{
|
||||
return (this.resolvedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for resolvedAt (default: when QAlerts.resolve is called)
|
||||
*******************************************************************************/
|
||||
public AlertResolution withResolvedAt(Instant resolvedAt)
|
||||
{
|
||||
this.resolvedAt = resolvedAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How urgent an alert is. Ordered from least to most severe, so
|
||||
** isAtLeast / compareTo can be used for "minimum severity" checks.
|
||||
**
|
||||
** An alert means "a person should do something". INFO is for worth-a-glance
|
||||
** items that never page anyone; external channels ignore it by default.
|
||||
*******************************************************************************/
|
||||
public enum AlertSeverity
|
||||
{
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR,
|
||||
CRITICAL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when this severity is the same as, or more severe than, the other.
|
||||
** A null other is treated as INFO (so everything is at least it).
|
||||
*******************************************************************************/
|
||||
public boolean isAtLeast(AlertSeverity other)
|
||||
{
|
||||
return (other == null || this.compareTo(other) >= 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Leniently map a severity label from another system (Prometheus, a
|
||||
** producer's own enum, etc) to an AlertSeverity. Case-insensitive; accepts
|
||||
** common synonyms ("warn", "err", "fatal", "page", "info"/"informational").
|
||||
** Returns the fallback for null, blank or unknown values.
|
||||
*******************************************************************************/
|
||||
public static AlertSeverity fromLabel(String label, AlertSeverity fallback)
|
||||
{
|
||||
if(label == null || label.isBlank())
|
||||
{
|
||||
return (fallback);
|
||||
}
|
||||
|
||||
return switch(label.trim().toLowerCase(Locale.ROOT))
|
||||
{
|
||||
case "info", "informational", "notice", "low" -> INFO;
|
||||
case "warning", "warn", "medium" -> WARNING;
|
||||
case "error", "err", "high" -> ERROR;
|
||||
case "critical", "crit", "fatal", "page", "emergency" -> CRITICAL;
|
||||
default -> fallback;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One "this is a problem" signal from a producer, passed to QAlerts.raise.
|
||||
** Raising is idempotent on (alertTypeName, dedupKey): repeats refresh one open
|
||||
** alert, they never duplicate it.
|
||||
**
|
||||
** Text fields have different reach. title and summary are external-safe: a
|
||||
** producer must write them without personal data, because they may be sent
|
||||
** to email or chat. details (and record ref labels) are internal: shown
|
||||
** in-app only, to users who can see the alert.
|
||||
**
|
||||
** Bounds: at most MAX_RECORD_REFS record refs (extras are dropped, and count
|
||||
** keeps the true total) and MAX_ATTRIBUTES attributes (extras are ignored).
|
||||
*******************************************************************************/
|
||||
public class AlertSignal implements Serializable
|
||||
{
|
||||
public static final int MAX_RECORD_REFS = 100;
|
||||
public static final int MAX_ATTRIBUTES = 20;
|
||||
|
||||
private String alertTypeName;
|
||||
private String dedupKey;
|
||||
private AlertSeverity severity;
|
||||
private String title;
|
||||
private String summary;
|
||||
private String details;
|
||||
private List<AlertRecordRef> recordRefs = new ArrayList<>();
|
||||
private List<AlertLink> links = new ArrayList<>();
|
||||
private Map<String, String> attributes = new LinkedHashMap<>();
|
||||
private Instant occurredAt;
|
||||
private Integer count;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertSignal()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor
|
||||
*******************************************************************************/
|
||||
public AlertSignal(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The dedup key to use: the one given, or the type name (one alert per type).
|
||||
*******************************************************************************/
|
||||
public String getEffectiveDedupKey()
|
||||
{
|
||||
return (dedupKey == null || dedupKey.isBlank() ? alertTypeName : dedupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record count this signal describes: count when set, otherwise the
|
||||
** number of record refs (null when there are none).
|
||||
*******************************************************************************/
|
||||
public Integer getEffectiveCount()
|
||||
{
|
||||
if(count != null)
|
||||
{
|
||||
return (count);
|
||||
}
|
||||
return (recordRefs.isEmpty() ? null : recordRefs.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public String getAlertTypeName()
|
||||
{
|
||||
return (this.alertTypeName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public void setAlertTypeName(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for alertTypeName
|
||||
*******************************************************************************/
|
||||
public AlertSignal withAlertTypeName(String alertTypeName)
|
||||
{
|
||||
this.alertTypeName = alertTypeName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dedupKey
|
||||
*******************************************************************************/
|
||||
public String getDedupKey()
|
||||
{
|
||||
return (this.dedupKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for dedupKey
|
||||
*******************************************************************************/
|
||||
public void setDedupKey(String dedupKey)
|
||||
{
|
||||
this.dedupKey = dedupKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dedupKey - a stable key, so repeats update one open alert.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withDedupKey(String dedupKey)
|
||||
{
|
||||
this.dedupKey = dedupKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for severity
|
||||
*******************************************************************************/
|
||||
public AlertSeverity getSeverity()
|
||||
{
|
||||
return (this.severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for severity
|
||||
*******************************************************************************/
|
||||
public void setSeverity(AlertSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for severity (optional; defaults to the type's severity)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withSeverity(AlertSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title (external-safe: no personal data)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for summary
|
||||
*******************************************************************************/
|
||||
public String getSummary()
|
||||
{
|
||||
return (this.summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for summary
|
||||
*******************************************************************************/
|
||||
public void setSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for summary (external-safe: no personal data)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for details
|
||||
*******************************************************************************/
|
||||
public String getDetails()
|
||||
{
|
||||
return (this.details);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for details
|
||||
*******************************************************************************/
|
||||
public void setDetails(String details)
|
||||
{
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for details (internal: in-app only, never sent externally)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withDetails(String details)
|
||||
{
|
||||
this.details = details;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordRefs
|
||||
*******************************************************************************/
|
||||
public List<AlertRecordRef> getRecordRefs()
|
||||
{
|
||||
return (this.recordRefs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for recordRefs
|
||||
*******************************************************************************/
|
||||
public void setRecordRefs(List<AlertRecordRef> recordRefs)
|
||||
{
|
||||
this.recordRefs = new ArrayList<>();
|
||||
withRecordRefs(recordRefs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add record refs. Beyond MAX_RECORD_REFS they are dropped, and count is
|
||||
** set to the true total so the alert still says how many there were.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withRecordRefs(Collection<AlertRecordRef> recordRefs)
|
||||
{
|
||||
if(recordRefs == null)
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
int total = this.recordRefs.size();
|
||||
for(AlertRecordRef recordRef : recordRefs)
|
||||
{
|
||||
if(recordRef == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
total++;
|
||||
if(this.recordRefs.size() < MAX_RECORD_REFS)
|
||||
{
|
||||
this.recordRefs.add(recordRef);
|
||||
}
|
||||
}
|
||||
|
||||
if(total > MAX_RECORD_REFS && (count == null || count < total))
|
||||
{
|
||||
count = total;
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add one record ref.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withRecordRef(AlertRecordRef recordRef)
|
||||
{
|
||||
return (withRecordRefs(recordRef == null ? null : List.of(recordRef)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for links
|
||||
*******************************************************************************/
|
||||
public List<AlertLink> getLinks()
|
||||
{
|
||||
return (this.links);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for links
|
||||
*******************************************************************************/
|
||||
public void setLinks(List<AlertLink> links)
|
||||
{
|
||||
this.links = links == null ? new ArrayList<>() : new ArrayList<>(links);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a link to where the problem is fixed.
|
||||
*******************************************************************************/
|
||||
public AlertSignal withLink(AlertLink link)
|
||||
{
|
||||
if(link != null)
|
||||
{
|
||||
this.links.add(link);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for attributes
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getAttributes()
|
||||
{
|
||||
return (this.attributes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for attributes
|
||||
*******************************************************************************/
|
||||
public void setAttributes(Map<String, String> attributes)
|
||||
{
|
||||
this.attributes = new LinkedHashMap<>();
|
||||
if(attributes != null)
|
||||
{
|
||||
attributes.forEach(this::withAttribute);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a routing label (not displayed by default). At most MAX_ATTRIBUTES
|
||||
** keys; a new key beyond that is ignored (replacing an existing key is fine).
|
||||
*******************************************************************************/
|
||||
public AlertSignal withAttribute(String key, String value)
|
||||
{
|
||||
if(key != null && (attributes.containsKey(key) || attributes.size() < MAX_ATTRIBUTES))
|
||||
{
|
||||
attributes.put(key, value);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for occurredAt
|
||||
*******************************************************************************/
|
||||
public Instant getOccurredAt()
|
||||
{
|
||||
return (this.occurredAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public void setOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for occurredAt (default: when QAlerts.raise is called)
|
||||
*******************************************************************************/
|
||||
public AlertSignal withOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for count
|
||||
*******************************************************************************/
|
||||
public Integer getCount()
|
||||
{
|
||||
return (this.count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for count
|
||||
*******************************************************************************/
|
||||
public void setCount(Integer count)
|
||||
{
|
||||
this.count = count;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for count - for aggregate alerts, e.g. "37 records".
|
||||
*******************************************************************************/
|
||||
public AlertSignal withCount(Integer count)
|
||||
{
|
||||
this.count = count;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where QAlerts sends signals. The default is LoggingAlertSink; an alerts
|
||||
** implementation (such as qbit-alerts) registers its own through
|
||||
** AlertsInstanceMetaData.withSink.
|
||||
**
|
||||
** Methods are called after the caller's transaction commits (or at once, when
|
||||
** there is none), on the caller's thread, so they must be fast: queue the
|
||||
** work, don't do database writes or network calls inline. Anything they
|
||||
** throw is caught, logged and counted by QAlerts; it never reaches the
|
||||
** producer.
|
||||
**
|
||||
** A new instance is not loaded per call: QAlerts loads the sink once per
|
||||
** QInstance and reuses it, so implementations must be thread-safe.
|
||||
*******************************************************************************/
|
||||
public interface AlertSinkInterface
|
||||
{
|
||||
/***************************************************************************
|
||||
** Record (or refresh) an alert. The signal's occurredAt is always set.
|
||||
***************************************************************************/
|
||||
void raise(AlertSignal signal);
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve an open alert, if there is one. resolvedAt is always set.
|
||||
***************************************************************************/
|
||||
void resolve(AlertResolution resolution);
|
||||
|
||||
/***************************************************************************
|
||||
** Note that a dead-man's switch beat.
|
||||
***************************************************************************/
|
||||
void heartbeat(String heartbeatKey, Instant at);
|
||||
|
||||
/***************************************************************************
|
||||
** Send a notification to people (an audience). The default does nothing,
|
||||
** so sinks written before notifications keep working.
|
||||
***************************************************************************/
|
||||
default void notify(NotificationSignal signal)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////
|
||||
// noop - sinks that don't carry notifications ignore them //
|
||||
//////////////////////////////////////////////////////////////
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance-level alerts meta-data: the sink that QAlerts sends signals to
|
||||
** (default: LoggingAlertSink) and every declared QAlertTypeMetaData. It is
|
||||
** supplemental meta-data, so any module or qbit can add to it.
|
||||
*******************************************************************************/
|
||||
public class AlertsInstanceMetaData implements QSupplementalInstanceMetaData
|
||||
{
|
||||
public static final String NAME = "alertsQBitStandIn";
|
||||
|
||||
/** alert type names: lowercase, dotted, at least two segments. */
|
||||
public static final Pattern ALERT_TYPE_NAME_PATTERN = Pattern.compile("^[a-z0-9]+(\\.[a-z0-9-]+)+$");
|
||||
|
||||
private QCodeReference sink;
|
||||
private Map<String, QAlertTypeMetaData> alertTypes = new LinkedHashMap<>();
|
||||
private List<String> duplicateAlertTypes = new ArrayList<>();
|
||||
|
||||
private transient AlertSinkInterface loadedSink;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the instance's alerts meta-data, or null when nothing added it.
|
||||
*******************************************************************************/
|
||||
public static AlertsInstanceMetaData of(QInstance qInstance)
|
||||
{
|
||||
return (qInstance == null ? null : QSupplementalInstanceMetaData.of(qInstance, NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the instance's alerts meta-data, adding an empty one if it has none.
|
||||
*******************************************************************************/
|
||||
public static AlertsInstanceMetaData ofOrWithNew(QInstance qInstance)
|
||||
{
|
||||
return (QSupplementalInstanceMetaData.ofOrWithNew(qInstance, NAME, AlertsInstanceMetaData::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sink
|
||||
*******************************************************************************/
|
||||
public QCodeReference getSink()
|
||||
{
|
||||
return (this.sink);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sink - an AlertSinkInterface implementation. An
|
||||
** alerts implementation sets this; producers never do.
|
||||
*******************************************************************************/
|
||||
public AlertsInstanceMetaData withSink(QCodeReference sink)
|
||||
{
|
||||
this.sink = sink;
|
||||
this.loadedSink = null;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The sink instance QAlerts loaded for this meta-data (cached; null until
|
||||
** first use).
|
||||
*******************************************************************************/
|
||||
AlertSinkInterface getLoadedSink()
|
||||
{
|
||||
return (this.loadedSink);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cache the loaded sink instance.
|
||||
*******************************************************************************/
|
||||
void setLoadedSink(AlertSinkInterface loadedSink)
|
||||
{
|
||||
this.loadedSink = loadedSink;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for alertTypes
|
||||
*******************************************************************************/
|
||||
public Map<String, QAlertTypeMetaData> getAlertTypes()
|
||||
{
|
||||
return (this.alertTypes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find a declared type by name, or null.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData getAlertType(String name)
|
||||
{
|
||||
return (name == null ? null : alertTypes.get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare an alert type. Adding the same object again is a no-op; adding a
|
||||
** different type with a name already taken is a validation error.
|
||||
*******************************************************************************/
|
||||
public AlertsInstanceMetaData withAlertType(QAlertTypeMetaData alertType)
|
||||
{
|
||||
if(alertType == null)
|
||||
{
|
||||
return (this);
|
||||
}
|
||||
|
||||
QAlertTypeMetaData existing = alertTypes.get(alertType.getName());
|
||||
if(existing != null && existing != alertType)
|
||||
{
|
||||
duplicateAlertTypes.add(alertType.getName());
|
||||
return (this);
|
||||
}
|
||||
|
||||
alertTypes.put(alertType.getName(), alertType);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Names must be unique and well-formed, subject tables must exist, a
|
||||
** visibility permission must not be blank, heartbeat default monitors need
|
||||
** an interval, and the sink must implement AlertSinkInterface.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, QInstanceValidator validator)
|
||||
{
|
||||
if(sink != null)
|
||||
{
|
||||
validator.validateSimpleCodeReference("Alerts sink: ", sink, AlertSinkInterface.class);
|
||||
}
|
||||
|
||||
for(String duplicate : duplicateAlertTypes)
|
||||
{
|
||||
validator.assertCondition(false, "Alert type " + duplicate + " is declared more than once.");
|
||||
}
|
||||
|
||||
for(Map.Entry<String, QAlertTypeMetaData> entry : alertTypes.entrySet())
|
||||
{
|
||||
String name = entry.getKey();
|
||||
QAlertTypeMetaData alertType = entry.getValue();
|
||||
String prefix = "Alert type " + name + ": ";
|
||||
|
||||
if(!validator.assertCondition(name != null && ALERT_TYPE_NAME_PATTERN.matcher(name).matches(), prefix + "name must be lowercase and dotted (e.g. cdn.purge.dead), matching " + ALERT_TYPE_NAME_PATTERN.pattern()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
validator.assertCondition(alertType.getDefaultSeverity() != null, prefix + "defaultSeverity is required.");
|
||||
|
||||
if(alertType.getSubjectTableName() != null)
|
||||
{
|
||||
validator.assertCondition(qInstance.getTable(alertType.getSubjectTableName()) != null, prefix + "subjectTableName " + alertType.getSubjectTableName() + " is not a table in the instance.");
|
||||
}
|
||||
|
||||
if(alertType.getVisibilityPermission() != null)
|
||||
{
|
||||
validator.assertCondition(!alertType.getVisibilityPermission().isBlank(), prefix + "visibilityPermission must not be blank.");
|
||||
}
|
||||
|
||||
if(alertType.getResolveMode() == ResolveMode.QUIET_PERIOD)
|
||||
{
|
||||
validator.assertCondition(alertType.getQuietPeriod() != null && !alertType.getQuietPeriod().isNegative() && !alertType.getQuietPeriod().isZero(), prefix + "a QUIET_PERIOD resolve mode needs a positive quietPeriod.");
|
||||
}
|
||||
|
||||
for(AlertDefaultMonitor monitor : alertType.getDefaultMonitors())
|
||||
{
|
||||
validator.assertCondition(monitor.getKind() != null, prefix + "a default monitor needs a kind.");
|
||||
if(monitor.getKind() == AlertMonitorKind.HEARTBEAT)
|
||||
{
|
||||
Object every = monitor.getDefinition().get("expectedEverySeconds");
|
||||
validator.assertCondition(every instanceof Number number && number.longValue() > 0, prefix + "a heartbeat default monitor needs a positive expectedEverySeconds.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How much of an alert an external channel (email, chat, paging, webhook) may
|
||||
** include. A channel has a level; an alert type has a ceiling, and the lower
|
||||
** of the two applies.
|
||||
*******************************************************************************/
|
||||
public enum DetailLevel
|
||||
{
|
||||
/** severity, type label, group, title, age and one link back to the alert. */
|
||||
MINIMAL,
|
||||
|
||||
/** MINIMAL plus the summary and the counts. */
|
||||
STANDARD,
|
||||
|
||||
/** STANDARD plus details and record labels (admin opt-in). */
|
||||
FULL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The lower (less revealing) of two levels. Null means "no limit".
|
||||
*******************************************************************************/
|
||||
public static DetailLevel lowerOf(DetailLevel a, DetailLevel b)
|
||||
{
|
||||
if(a == null)
|
||||
{
|
||||
return (b);
|
||||
}
|
||||
if(b == null)
|
||||
{
|
||||
return (a);
|
||||
}
|
||||
return (a.compareTo(b) <= 0 ? a : b);
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Who a notification is for, resolved to users when it is sent (stand-in for
|
||||
** core's com.kingsrook.qqq.backend.core.alerts.NotificationAudience).
|
||||
*******************************************************************************/
|
||||
public class NotificationAudience implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The kinds of audience.
|
||||
*******************************************************************************/
|
||||
public enum Type
|
||||
{
|
||||
ALL_USERS, ROLES, PERMISSION, USERS
|
||||
}
|
||||
|
||||
|
||||
|
||||
private final Type type;
|
||||
private final List<String> values;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor (blank values are dropped; values are trimmed).
|
||||
*******************************************************************************/
|
||||
public NotificationAudience(Type type, List<String> values)
|
||||
{
|
||||
this.type = type == null ? Type.USERS : type;
|
||||
this.values = values == null ? List.of() : values.stream().filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every user.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience allUsers()
|
||||
{
|
||||
return (new NotificationAudience(Type.ALL_USERS, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users with any of these roles or groups.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience roles(String... roles)
|
||||
{
|
||||
return (new NotificationAudience(Type.ROLES, roles == null ? null : Arrays.asList(roles)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users whose session holds this permission.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience permission(String permission)
|
||||
{
|
||||
return (new NotificationAudience(Type.PERMISSION, permission == null ? null : List.of(permission)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** These users.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience users(String... userIds)
|
||||
{
|
||||
return (new NotificationAudience(Type.USERS, userIds == null ? null : Arrays.asList(userIds)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One user.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience user(String userId)
|
||||
{
|
||||
return (users(userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** From the stored form: a type name and a comma- or line-separated value.
|
||||
** An unknown type is null.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience parse(String type, String value)
|
||||
{
|
||||
for(Type candidate : Type.values())
|
||||
{
|
||||
if(type != null && candidate.name().equalsIgnoreCase(type.trim()))
|
||||
{
|
||||
List<String> values = new ArrayList<>();
|
||||
if(value != null)
|
||||
{
|
||||
values.addAll(Arrays.asList(value.split("[,\\n]")));
|
||||
}
|
||||
return (new NotificationAudience(candidate, values));
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored form of the values (null when there are none).
|
||||
*******************************************************************************/
|
||||
public String valuesAsString()
|
||||
{
|
||||
return (values.isEmpty() ? null : String.join(",", values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for type
|
||||
*******************************************************************************/
|
||||
public Type getType()
|
||||
{
|
||||
return (this.type);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for values
|
||||
*******************************************************************************/
|
||||
public List<String> getValues()
|
||||
{
|
||||
return (this.values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
return (o instanceof NotificationAudience other && type == other.type && values.equals(other.values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return (Objects.hash(type, values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (type + (values.isEmpty() ? "" : values.toString()));
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a notification is about (stand-in for core's
|
||||
** com.kingsrook.qqq.backend.core.alerts.NotificationCategory). Users filter
|
||||
** and mute by category; SECURITY can never be muted in-app.
|
||||
*******************************************************************************/
|
||||
public enum NotificationCategory
|
||||
{
|
||||
SYSTEM, JOB, SECURITY, RELEASE, MARKETING, SHARE, ALERT;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The category for a name (any case), or the fallback.
|
||||
*******************************************************************************/
|
||||
public static NotificationCategory fromName(String name, NotificationCategory fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationCategory category : values())
|
||||
{
|
||||
if(category.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (category);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How a notification looks to its reader: INFO and SUCCESS are good news,
|
||||
** WARNING and ERROR need a look, CRITICAL makes the badge red (stand-in for
|
||||
** core's com.kingsrook.qqq.backend.core.alerts.NotificationSeverity).
|
||||
*******************************************************************************/
|
||||
public enum NotificationSeverity
|
||||
{
|
||||
INFO, SUCCESS, WARNING, ERROR, CRITICAL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The severity for a name (any case), or the fallback.
|
||||
*******************************************************************************/
|
||||
public static NotificationSeverity fromName(String name, NotificationSeverity fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationSeverity severity : values())
|
||||
{
|
||||
if(severity.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (severity);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
}
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A notification for people (stand-in for core's
|
||||
** com.kingsrook.qqq.backend.core.alerts.NotificationSignal): what to tell
|
||||
** them, how urgent it is, what it is about, and who it is for. Send it with
|
||||
** the alerts sink's notify (core: QAlerts.notify).
|
||||
*******************************************************************************/
|
||||
public class NotificationSignal implements Serializable
|
||||
{
|
||||
private String title;
|
||||
private String body;
|
||||
private NotificationSeverity severity;
|
||||
private NotificationCategory category;
|
||||
private String sourceName;
|
||||
private String sourceKey;
|
||||
private String sourceTableName;
|
||||
private String sourceRecordId;
|
||||
private AlertLink link;
|
||||
private NotificationAudience audience;
|
||||
private Instant publishAt;
|
||||
private Instant expireAt;
|
||||
private Boolean sticky;
|
||||
private Instant occurredAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public NotificationSignal()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with the title.
|
||||
*******************************************************************************/
|
||||
public NotificationSignal(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for title: The headline (plain text, required).
|
||||
*******************************************************************************/
|
||||
public String getTitle()
|
||||
{
|
||||
return (this.title);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for title
|
||||
*******************************************************************************/
|
||||
public void setTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for title
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withTitle(String title)
|
||||
{
|
||||
this.title = title;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for body: The message (markdown).
|
||||
*******************************************************************************/
|
||||
public String getBody()
|
||||
{
|
||||
return (this.body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for body
|
||||
*******************************************************************************/
|
||||
public void setBody(String body)
|
||||
{
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for body
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withBody(String body)
|
||||
{
|
||||
this.body = body;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for severity: Default INFO.
|
||||
*******************************************************************************/
|
||||
public NotificationSeverity getSeverity()
|
||||
{
|
||||
return (this.severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for severity
|
||||
*******************************************************************************/
|
||||
public void setSeverity(NotificationSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for severity
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSeverity(NotificationSeverity severity)
|
||||
{
|
||||
this.severity = severity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for category: Default SYSTEM.
|
||||
*******************************************************************************/
|
||||
public NotificationCategory getCategory()
|
||||
{
|
||||
return (this.category);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for category
|
||||
*******************************************************************************/
|
||||
public void setCategory(NotificationCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for category
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withCategory(NotificationCategory category)
|
||||
{
|
||||
this.category = category;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceName: Who emits it (a qbit or app name).
|
||||
*******************************************************************************/
|
||||
public String getSourceName()
|
||||
{
|
||||
return (this.sourceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceName
|
||||
*******************************************************************************/
|
||||
public void setSourceName(String sourceName)
|
||||
{
|
||||
this.sourceName = sourceName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceName
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceName(String sourceName)
|
||||
{
|
||||
this.sourceName = sourceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceKey: Dedup key: a second notify with the same key and audience updates the first.
|
||||
*******************************************************************************/
|
||||
public String getSourceKey()
|
||||
{
|
||||
return (this.sourceKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceKey
|
||||
*******************************************************************************/
|
||||
public void setSourceKey(String sourceKey)
|
||||
{
|
||||
this.sourceKey = sourceKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceKey
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceKey(String sourceKey)
|
||||
{
|
||||
this.sourceKey = sourceKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceTableName: The table of the record it is about.
|
||||
*******************************************************************************/
|
||||
public String getSourceTableName()
|
||||
{
|
||||
return (this.sourceTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public void setSourceTableName(String sourceTableName)
|
||||
{
|
||||
this.sourceTableName = sourceTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceTableName
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceTableName(String sourceTableName)
|
||||
{
|
||||
this.sourceTableName = sourceTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceRecordId: The record it is about.
|
||||
*******************************************************************************/
|
||||
public String getSourceRecordId()
|
||||
{
|
||||
return (this.sourceRecordId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public void setSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceRecordId
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSourceRecordId(String sourceRecordId)
|
||||
{
|
||||
this.sourceRecordId = sourceRecordId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for link: Where it takes the reader.
|
||||
*******************************************************************************/
|
||||
public AlertLink getLink()
|
||||
{
|
||||
return (this.link);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for link
|
||||
*******************************************************************************/
|
||||
public void setLink(AlertLink link)
|
||||
{
|
||||
this.link = link;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for link
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withLink(AlertLink link)
|
||||
{
|
||||
this.link = link;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for audience: Who it is for (required).
|
||||
*******************************************************************************/
|
||||
public NotificationAudience getAudience()
|
||||
{
|
||||
return (this.audience);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for audience
|
||||
*******************************************************************************/
|
||||
public void setAudience(NotificationAudience audience)
|
||||
{
|
||||
this.audience = audience;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for audience
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withAudience(NotificationAudience audience)
|
||||
{
|
||||
this.audience = audience;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publishAt: Not shown before this (default now).
|
||||
*******************************************************************************/
|
||||
public Instant getPublishAt()
|
||||
{
|
||||
return (this.publishAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for publishAt
|
||||
*******************************************************************************/
|
||||
public void setPublishAt(Instant publishAt)
|
||||
{
|
||||
this.publishAt = publishAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publishAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withPublishAt(Instant publishAt)
|
||||
{
|
||||
this.publishAt = publishAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for expireAt: Hidden after this.
|
||||
*******************************************************************************/
|
||||
public Instant getExpireAt()
|
||||
{
|
||||
return (this.expireAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for expireAt
|
||||
*******************************************************************************/
|
||||
public void setExpireAt(Instant expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for expireAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withExpireAt(Instant expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sticky: A banner until dismissed (meant for CRITICAL).
|
||||
*******************************************************************************/
|
||||
public Boolean getSticky()
|
||||
{
|
||||
return (this.sticky);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sticky
|
||||
*******************************************************************************/
|
||||
public void setSticky(Boolean sticky)
|
||||
{
|
||||
this.sticky = sticky;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sticky
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withSticky(Boolean sticky)
|
||||
{
|
||||
this.sticky = sticky;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for occurredAt: When it happened (default now).
|
||||
*******************************************************************************/
|
||||
public Instant getOccurredAt()
|
||||
{
|
||||
return (this.occurredAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public void setOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public NotificationSignal withOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.TopLevelMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declares a kind of problem a qbit or app can report (e.g. cdn.purge.dead),
|
||||
** so the system "knows about" every alert source. An alerts implementation
|
||||
** syncs these into a table at startup, where admins can see and tune them.
|
||||
**
|
||||
** Add it to an instance like any top-level meta-data (qInstance.add(...), or
|
||||
** from a MetaDataProducer); it lands in AlertsInstanceMetaData.alertTypes.
|
||||
** Names are lowercase and dotted: ^[a-z0-9]+(\.[a-z0-9-]+)+$
|
||||
*******************************************************************************/
|
||||
public class QAlertTypeMetaData implements TopLevelMetaDataInterface, SourceQBitAware
|
||||
{
|
||||
private String name;
|
||||
private String label;
|
||||
private String sourceName;
|
||||
private String sourceQBitName;
|
||||
private String groupName;
|
||||
private AlertSeverity defaultSeverity = AlertSeverity.WARNING;
|
||||
private String description;
|
||||
private String howToFix;
|
||||
private String subjectTableName;
|
||||
private String visibilityPermission;
|
||||
private ResolveMode resolveMode = ResolveMode.PRODUCER;
|
||||
private Duration quietPeriod;
|
||||
private List<AlertDefaultMonitor> defaultMonitors = new ArrayList<>();
|
||||
private DetailLevel externalDetailCeiling = DetailLevel.STANDARD;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find a declared alert type by name, or null.
|
||||
*******************************************************************************/
|
||||
public static QAlertTypeMetaData of(QInstance qInstance, String name)
|
||||
{
|
||||
AlertsInstanceMetaData alertsInstanceMetaData = AlertsInstanceMetaData.of(qInstance);
|
||||
return (alertsInstanceMetaData == null ? null : alertsInstanceMetaData.getAlertType(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Adds this type to the instance's AlertsInstanceMetaData (creating it).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void addSelfToInstance(QInstance qInstance)
|
||||
{
|
||||
AlertsInstanceMetaData.ofOrWithNew(qInstance).withAlertType(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The source shown to people: sourceName if set, otherwise the qbit that
|
||||
** produced this meta-data.
|
||||
*******************************************************************************/
|
||||
public String getEffectiveSourceName()
|
||||
{
|
||||
return (sourceName != null ? sourceName : sourceQBitName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceName
|
||||
*******************************************************************************/
|
||||
public String getSourceName()
|
||||
{
|
||||
return (this.sourceName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceName
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withSourceName(String sourceName)
|
||||
{
|
||||
this.sourceName = sourceName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getSourceQBitName()
|
||||
{
|
||||
return (this.sourceQBitName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
this.sourceQBitName = sourceQBitName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sourceQBitName
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QAlertTypeMetaData withSourceQBitName(String sourceQBitName)
|
||||
{
|
||||
this.sourceQBitName = sourceQBitName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for groupName
|
||||
*******************************************************************************/
|
||||
public String getGroupName()
|
||||
{
|
||||
return (this.groupName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for groupName (the default group; created if missing)
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withGroupName(String groupName)
|
||||
{
|
||||
this.groupName = groupName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultSeverity
|
||||
*******************************************************************************/
|
||||
public AlertSeverity getDefaultSeverity()
|
||||
{
|
||||
return (this.defaultSeverity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultSeverity
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withDefaultSeverity(AlertSeverity defaultSeverity)
|
||||
{
|
||||
this.defaultSeverity = defaultSeverity;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (this.description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for howToFix
|
||||
*******************************************************************************/
|
||||
public String getHowToFix()
|
||||
{
|
||||
return (this.howToFix);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for howToFix
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withHowToFix(String howToFix)
|
||||
{
|
||||
this.howToFix = howToFix;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for subjectTableName
|
||||
*******************************************************************************/
|
||||
public String getSubjectTableName()
|
||||
{
|
||||
return (this.subjectTableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for subjectTableName (drives visibility and the record panel)
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withSubjectTableName(String subjectTableName)
|
||||
{
|
||||
this.subjectTableName = subjectTableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for visibilityPermission
|
||||
*******************************************************************************/
|
||||
public String getVisibilityPermission()
|
||||
{
|
||||
return (this.visibilityPermission);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for visibilityPermission (an extra gate on who may see it)
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withVisibilityPermission(String visibilityPermission)
|
||||
{
|
||||
this.visibilityPermission = visibilityPermission;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resolveMode
|
||||
*******************************************************************************/
|
||||
public ResolveMode getResolveMode()
|
||||
{
|
||||
return (this.resolveMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for resolveMode
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withResolveMode(ResolveMode resolveMode)
|
||||
{
|
||||
this.resolveMode = resolveMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve automatically after this long with no raise (sets the mode to
|
||||
** QUIET_PERIOD).
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withQuietPeriod(Duration quietPeriod)
|
||||
{
|
||||
this.quietPeriod = quietPeriod;
|
||||
this.resolveMode = ResolveMode.QUIET_PERIOD;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for quietPeriod
|
||||
*******************************************************************************/
|
||||
public Duration getQuietPeriod()
|
||||
{
|
||||
return (this.quietPeriod);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultMonitors
|
||||
*******************************************************************************/
|
||||
public List<AlertDefaultMonitor> getDefaultMonitors()
|
||||
{
|
||||
return (this.defaultMonitors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a seeded monitor for this type.
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withDefaultMonitor(AlertDefaultMonitor defaultMonitor)
|
||||
{
|
||||
if(defaultMonitor != null)
|
||||
{
|
||||
this.defaultMonitors.add(defaultMonitor);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for externalDetailCeiling
|
||||
*******************************************************************************/
|
||||
public DetailLevel getExternalDetailCeiling()
|
||||
{
|
||||
return (this.externalDetailCeiling);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for externalDetailCeiling (the most any channel may include)
|
||||
*******************************************************************************/
|
||||
public QAlertTypeMetaData withExternalDetailCeiling(DetailLevel externalDetailCeiling)
|
||||
{
|
||||
this.externalDetailCeiling = externalDetailCeiling;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How an open alert of a type is closed automatically. Any mode also allows
|
||||
** a person to resolve an alert manually, with a note.
|
||||
*******************************************************************************/
|
||||
public enum ResolveMode
|
||||
{
|
||||
/** the producer calls QAlerts.resolve when the condition clears. */
|
||||
PRODUCER,
|
||||
|
||||
/** the monitor that raised it resolves it when its next evaluation is clean. */
|
||||
MONITOR,
|
||||
|
||||
/** it resolves when no raise has been seen for the type's quiet period. */
|
||||
QUIET_PERIOD,
|
||||
|
||||
/** only a person resolves it. */
|
||||
MANUAL
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN for the QQQ core alerts producer SPI (QRun-IO/qqq#1108), which is
|
||||
** not yet in a released QQQ 4.1.0 RC. These classes are copies of
|
||||
** com.kingsrook.qqq.backend.core.alerts (AlertSignal, AlertSeverity,
|
||||
** QAlertTypeMetaData and friends) under this package, so the qbit can be
|
||||
** built and tested against 4.1.0-RC.4 now.
|
||||
**
|
||||
** Replace when the RC with qqq#1108 ships: delete this package, change the
|
||||
** imports to com.kingsrook.qqq.backend.core.alerts, and register
|
||||
** QbitAlertsSink in core's AlertsInstanceMetaData (see AlertsQBitProducer).
|
||||
** Until then, producers in a host reach the qbit through
|
||||
** AlertsRuntime.sink() (or its monitors), not through core's QAlerts.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.alerts.spi;
|
||||
@@ -0,0 +1,518 @@
|
||||
components:
|
||||
schemas:
|
||||
AlertsActionV1:
|
||||
description: "An available action: operationId targets an inbox operation with\
|
||||
\ this item id; process actions provide a complete href."
|
||||
properties:
|
||||
href:
|
||||
type: "string"
|
||||
label:
|
||||
type: "string"
|
||||
name:
|
||||
type: "string"
|
||||
operationId:
|
||||
type: "string"
|
||||
processName:
|
||||
type: "string"
|
||||
type: "object"
|
||||
AlertsErrorV1:
|
||||
description: "Framework error response."
|
||||
properties:
|
||||
error:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
AlertsFollowV1:
|
||||
description: "Capability discovery only. Next obtains the follow URL from its\
|
||||
\ collaboration module."
|
||||
properties:
|
||||
available:
|
||||
description: "True exactly when the collaboration UI API extension is registered."
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
AlertsInboxItemV1:
|
||||
description: "A notification recipient. body is included only in page items\
|
||||
\ and banners; alertId only for ALERT notifications."
|
||||
properties:
|
||||
actions:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsActionV1"
|
||||
type: "array"
|
||||
age:
|
||||
type: "string"
|
||||
alertId:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
body:
|
||||
nullable: true
|
||||
type: "string"
|
||||
category:
|
||||
nullable: true
|
||||
type: "string"
|
||||
deliveredAt:
|
||||
nullable: true
|
||||
type: "string"
|
||||
href:
|
||||
description: "Existing safe link, or null."
|
||||
nullable: true
|
||||
type: "string"
|
||||
id:
|
||||
type: "integer"
|
||||
linkLabel:
|
||||
nullable: true
|
||||
type: "string"
|
||||
notificationId:
|
||||
type: "integer"
|
||||
severity:
|
||||
nullable: true
|
||||
type: "string"
|
||||
source:
|
||||
nullable: true
|
||||
type: "string"
|
||||
sticky:
|
||||
type: "boolean"
|
||||
title:
|
||||
nullable: true
|
||||
type: "string"
|
||||
unread:
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
AlertsInboxPageV1:
|
||||
description: "Filtered notification page."
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsInboxItemV1"
|
||||
type: "array"
|
||||
limit:
|
||||
type: "integer"
|
||||
offset:
|
||||
type: "integer"
|
||||
total:
|
||||
type: "integer"
|
||||
type: "object"
|
||||
AlertsInboxSummaryV1:
|
||||
description: "Bell summary, latest ten items and up to three sticky banners."
|
||||
properties:
|
||||
alertsHref:
|
||||
type: "string"
|
||||
banners:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsInboxItemV1"
|
||||
type: "array"
|
||||
highestUnreadSeverity:
|
||||
nullable: true
|
||||
type: "string"
|
||||
inboxHref:
|
||||
description: "Canonical inbox navigation link."
|
||||
type: "string"
|
||||
items:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsInboxItemV1"
|
||||
type: "array"
|
||||
notificationsHref:
|
||||
description: "Existing notifications page link; retained for compatibility."
|
||||
type: "string"
|
||||
pollSeconds:
|
||||
type: "integer"
|
||||
unreadCount:
|
||||
type: "integer"
|
||||
type: "object"
|
||||
AlertsInboxTargetV1:
|
||||
description: "Select id, nonempty ids (first 500), legacy alertId, or all:true\
|
||||
\ in that precedence. All scans are capped at 1000 rows."
|
||||
properties:
|
||||
alertId:
|
||||
type: "integer"
|
||||
all:
|
||||
type: "boolean"
|
||||
id:
|
||||
type: "integer"
|
||||
ids:
|
||||
items:
|
||||
type: "integer"
|
||||
type: "array"
|
||||
type: "object"
|
||||
AlertsMutationResultV1:
|
||||
description: "Number of the caller own recipient rows changed."
|
||||
properties:
|
||||
updated:
|
||||
type: "integer"
|
||||
type: "object"
|
||||
AlertsPanelAlertV1:
|
||||
description: "A visible open alert with permission-aware process actions."
|
||||
properties:
|
||||
actions:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsActionV1"
|
||||
type: "array"
|
||||
age:
|
||||
type: "string"
|
||||
alertId:
|
||||
type: "integer"
|
||||
group:
|
||||
nullable: true
|
||||
type: "string"
|
||||
href:
|
||||
type: "string"
|
||||
raisedAt:
|
||||
nullable: true
|
||||
type: "string"
|
||||
severity:
|
||||
nullable: true
|
||||
type: "string"
|
||||
status:
|
||||
nullable: true
|
||||
type: "string"
|
||||
title:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
AlertsRecordPanelV1:
|
||||
description: "Open alerts, recent resolutions and collaboration capability.\
|
||||
\ watching is deprecated legacy subscription state."
|
||||
properties:
|
||||
canManage:
|
||||
type: "boolean"
|
||||
follow:
|
||||
$ref: "#/components/schemas/AlertsFollowV1"
|
||||
open:
|
||||
items:
|
||||
$ref: "#/components/schemas/AlertsPanelAlertV1"
|
||||
type: "array"
|
||||
openCount:
|
||||
type: "integer"
|
||||
resolvedLast30Days:
|
||||
type: "integer"
|
||||
viewAllHref:
|
||||
nullable: true
|
||||
type: "string"
|
||||
visible:
|
||||
type: "boolean"
|
||||
watching:
|
||||
description: "Deprecated: existing RECORD subscriptions only; does not represent\
|
||||
\ collaboration follow state."
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
AlertsUnreadTargetV1:
|
||||
description: "Select id or nonempty ids (first 500), in that precedence. Legacy\
|
||||
\ alertId is also accepted for compatibility. all is not supported."
|
||||
properties:
|
||||
alertId:
|
||||
type: "integer"
|
||||
id:
|
||||
type: "integer"
|
||||
ids:
|
||||
items:
|
||||
type: "integer"
|
||||
type: "array"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Alerts inbox and record panel"
|
||||
title: "QQQ Middleware API extension: alerts"
|
||||
version: "1.0.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/alerts/inbox/summary:
|
||||
get:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInboxSummary"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxSummaryV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Read the bell summary"
|
||||
/qqq/v1/alerts/inbox:
|
||||
get:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInbox"
|
||||
parameters:
|
||||
- description: "all or unread; other values behave as all."
|
||||
in: "query"
|
||||
name: "status"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Case-insensitive category filter."
|
||||
in: "query"
|
||||
name: "category"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Case-insensitive severity filter."
|
||||
in: "query"
|
||||
name: "severity"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Default 0; negatives clamp to 0; malformed values use the default."
|
||||
in: "query"
|
||||
name: "offset"
|
||||
required: false
|
||||
schema:
|
||||
type: "integer"
|
||||
- description: "Default 25; clamped to 1..100; malformed values use the default."
|
||||
in: "query"
|
||||
name: "limit"
|
||||
required: false
|
||||
schema:
|
||||
type: "integer"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxPageV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Page the notification inbox"
|
||||
/qqq/v1/alerts/inbox/read:
|
||||
post:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInboxRead"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxTargetV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Mark own notifications read"
|
||||
/qqq/v1/alerts/inbox/unread:
|
||||
post:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInboxUnread"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsUnreadTargetV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Mark own notifications unread"
|
||||
/qqq/v1/alerts/inbox/dismiss:
|
||||
post:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsInboxDismiss"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsInboxTargetV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsMutationResultV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Dismiss own notifications"
|
||||
/qqq/v1/alerts/record-panel:
|
||||
get:
|
||||
description: "Uses the signed-in caller's permissions and record visibility."
|
||||
operationId: "alertsRecordPanel"
|
||||
parameters:
|
||||
- description: "Parent table name."
|
||||
in: "query"
|
||||
name: "tableName"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Parent record key."
|
||||
in: "query"
|
||||
name: "recordId"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Permission denied"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsErrorV1"
|
||||
description: "Not found"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AlertsRecordPanelV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Read visible alerts for a record"
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qbits.alerts.api.AlertsUiApi;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The committed extension fragment describes exactly the six UI operations.
|
||||
*******************************************************************************/
|
||||
class AlertsApiContractTest extends BaseAlertsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Regenerate only by explicit build property, then check the deterministic fragment.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
{
|
||||
var extension = AlertsUiApi.extension();
|
||||
var version = new MiddlewareVersionV1();
|
||||
String generated = UiApiContract.generateExtensionYaml(version, extension);
|
||||
Path path = Path.of("src/main/resources/openapi/qqq-v1/alerts.yaml");
|
||||
if(Boolean.getBoolean("alerts.openapi.update"))
|
||||
{
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, generated);
|
||||
}
|
||||
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
|
||||
"mvnw.cmd -Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts"));
|
||||
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder(
|
||||
"/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read",
|
||||
"/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel");
|
||||
assertThat(extension.getSpecs()).hasSize(6);
|
||||
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "actions", "follow");
|
||||
assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production registers one extension; the existing route opt-out still works.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws Exception
|
||||
{
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).containsOnlyKeys("alerts");
|
||||
AlertsUiApi.register(qInstance);
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).hasSize(1);
|
||||
assertThat(AlertsUiApi.extension().getEffectivePathPrefix()).isEqualTo("/alerts");
|
||||
QInstance disabled = new QInstance();
|
||||
disabled.addBackend(defineH2Backend());
|
||||
new AlertsQBitProducer().withQBitConfig(new AlertsQBitConfig().withBackendName(BACKEND_NAME).withRegisterRoutes(false)).produce(disabled);
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(disabled)).isNull();
|
||||
assertThat(new AlertsQBitConfig().getLegacyAlertmanagerPath()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.alerts.api.AlertsRouteProvider;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.UiApiRouteAuditor;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import io.javalin.Javalin;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real HTTP coverage of the extension, permission boundaries and machine webhook.
|
||||
*******************************************************************************/
|
||||
class AlertsApiHttpTest extends BaseAlertsTest
|
||||
{
|
||||
private static final String TOKEN_PROPERTY = "alerts.test.http.token";
|
||||
private static final String TOKEN = "test-webhook-token";
|
||||
private QApplicationJavalinServer server;
|
||||
private int port;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Give the HTTP mock session the permissions of its test cookie identity.
|
||||
*******************************************************************************/
|
||||
public static class HttpSessionCustomizer extends TestSessionCustomizer
|
||||
{
|
||||
/***************************************************************************
|
||||
** Test identities are carried in sessionUUID cookies.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
QSession granted = sessionFor(session.getUuid());
|
||||
session.setUser(granted.getUser());
|
||||
session.setPermissions(granted.getPermissions());
|
||||
AlertsSecurity.applyToSession(instance, session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shut down even after a failed assertion or startup.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stopServer()
|
||||
{
|
||||
if(server != null)
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
System.clearProperty(TOKEN_PROPERTY);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every application server runs strict; the legacy alias is tested separately.
|
||||
*******************************************************************************/
|
||||
private void start() throws Exception
|
||||
{
|
||||
qInstance.getAuthentication().setCustomizer(new QCodeReference(HttpSessionCustomizer.class));
|
||||
server = new QApplicationJavalinServer(new AbstractQQQApplication()
|
||||
{
|
||||
/***************************************************************************
|
||||
** Use the test database and produced metadata.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (qInstance);
|
||||
}
|
||||
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
|
||||
.withMiddlewareVersionList(List.of(new MiddlewareVersionV1()));
|
||||
server.start();
|
||||
port = server.getBoundPort();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send through Javalin with an explicit user and optional bearer authorization.
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> request(String method, String path, String body, String user, String authorization) throws Exception
|
||||
{
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
|
||||
.header("Cookie", "sessionUUID=" + user);
|
||||
if(authorization != null)
|
||||
{
|
||||
request.header("Authorization", authorization);
|
||||
}
|
||||
if(body != null)
|
||||
{
|
||||
request.header("Content-Type", "application/json");
|
||||
}
|
||||
request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
|
||||
try(HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
return (client.send(request.build(), HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse success bodies while reporting any unexpected framework failure.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> json(HttpResponse<String> response) throws Exception
|
||||
{
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
return (JsonUtils.toObject(response.body(), Map.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A core producer reaches the strict HTTP inbox through the background sink.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void coreNotificationReachesInbox() throws Exception
|
||||
{
|
||||
config.withSynchronousSink(false);
|
||||
start();
|
||||
long failures = QAlerts.getProducerFailureCount();
|
||||
QAlerts.notify(new NotificationSignal("Shipment delivered").withBody("Your shipment arrived.")
|
||||
.withCategory(NotificationCategory.JOB).withSourceName("easypost-tracking")
|
||||
.withLink(AlertLink.toRecord(WIDGET, 5, "Shipment"))
|
||||
.withAudience(NotificationAudience.user("viewer-1")));
|
||||
runtime.getSink().stop();
|
||||
|
||||
String base = "/qqq/v1/alerts/inbox";
|
||||
Map<String, Object> summary = json(request("GET", base + "/summary", null, "viewer-1", null));
|
||||
assertThat(summary).containsEntry("unreadCount", 1).containsEntry("highestUnreadSeverity", "INFO");
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
|
||||
assertThat(items).singleElement().satisfies(item -> assertThat(item)
|
||||
.containsEntry("title", "Shipment delivered").containsEntry("category", "JOB")
|
||||
.containsEntry("source", "easypost-tracking").containsEntry("href", "/app/" + WIDGET + "/5"));
|
||||
Map<String, Object> page = json(request("GET", base, null, "viewer-1", null));
|
||||
assertThat((List<Map<String, Object>>) page.get("items")).singleElement()
|
||||
.satisfies(item -> assertThat(item).containsEntry("body", "Your shipment arrived."));
|
||||
assertThat(json(request("GET", base + "/summary", null, "viewer-2", null))).containsEntry("unreadCount", 0);
|
||||
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing paths, selectors, paging, null fields and additive client affordances.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void strictUiRoutes() throws Exception
|
||||
{
|
||||
new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).inboxSummary();
|
||||
Integer widget = insertWidgets("BROKEN").get(0);
|
||||
var alert = runtime.getEngine().raise(new AlertSignal("test.http.problem").withDedupKey("http")
|
||||
.withTitle("Needs attention").withSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR).withRecordRef(AlertRecordRef.of(WIDGET, widget)));
|
||||
start();
|
||||
String base = "/qqq/v1/alerts";
|
||||
Map<String, Object> summary = json(request("GET", base + "/inbox/summary", null, ADMIN, null));
|
||||
assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications");
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
|
||||
assertThat(items).hasSize(1);
|
||||
Map<String, Object> item = items.get(0);
|
||||
assertThat(item).containsKeys("href", "actions", "alertId");
|
||||
assertThat((List<Map<String, Object>>) item.get("actions")).extracting(a -> a.get("operationId")).containsExactly("alertsInboxRead", "alertsInboxDismiss");
|
||||
String target = JsonUtils.toJson(Map.of("id", item.get("id")));
|
||||
assertThat(json(request("POST", base + "/inbox/read", target, ADMIN, null))).containsEntry("updated", 1);
|
||||
assertThat(json(request("POST", base + "/inbox/unread", target, ADMIN, null))).containsEntry("updated", 1);
|
||||
assertThat(json(request("GET", base + "/inbox?status=unread&offset=bad&limit=1000", null, ADMIN, null)))
|
||||
.containsEntry("total", 1).containsEntry("offset", 0).containsEntry("limit", 100);
|
||||
Map<String, Object> panel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, ADMIN, null));
|
||||
assertThat(panel).containsEntry("follow", Map.of("available", false)).containsEntry("watching", false);
|
||||
Map<String, Object> row = ((List<Map<String, Object>>) panel.get("open")).get(0);
|
||||
assertThat((List<Map<String, Object>>) row.get("actions")).extracting(a -> a.get("processName")).containsExactly("alertsAcknowledge", "alertsSnooze");
|
||||
assertThat(((List<Map<String, Object>>) row.get("actions")).get(0).get("href"))
|
||||
.isEqualTo("/app/alertsAcknowledge?recordsParam=recordIds&recordIds=" + alert.getValue("id") + "&tableName=alerts_alert");
|
||||
Map<String, Object> viewerPanel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, VIEWER, null));
|
||||
assertThat(((List<Map<String, Object>>) viewerPanel.get("open")).get(0).get("actions")).isEqualTo(List.of());
|
||||
assertThat(json(request("POST", base + "/inbox/dismiss", target, ADMIN, null))).containsEntry("updated", 1);
|
||||
assertThat(json(request("GET", base + "/inbox/summary", null, ADMIN, null))).containsEntry("highestUnreadSeverity", null);
|
||||
assertThat(request("POST", base + "/record-panel/watch", "{}", ADMIN, null).statusCode()).isEqualTo(404);
|
||||
HttpResponse<String> invalidJson = request("POST", base + "/inbox/read", "not json", ADMIN, null);
|
||||
assertThat(invalidJson.statusCode()).isEqualTo(400);
|
||||
assertThat(invalidJson.body()).contains("The request body is not valid JSON.");
|
||||
assertThat(request("POST", base + "/inbox/read", null, ADMIN, null).statusCode()).isEqualTo(400);
|
||||
assertThat(request("GET", base + "/inbox/summary", null, "nobody", null).statusCode()).isEqualTo(403);
|
||||
assertThat(request("GET", base + "/record-panel?tableName=widget&recordId=1", null, OUTSIDER, null).statusCode()).isEqualTo(403);
|
||||
String openapi = request("GET", "/qqq/v1/openapi.yaml", null, ADMIN, null).body();
|
||||
assertThat(openapi).contains("alertsInboxSummary", "alertsRecordPanel").doesNotContain("/record-panel/watch", "/ingest/alertmanager");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The canonical webhook uses the same secret and body validation outside v1.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalWebhook() throws Exception
|
||||
{
|
||||
System.setProperty(TOKEN_PROPERTY, TOKEN);
|
||||
config.withAlertmanagerIngestTokenSecretRef("${prop." + TOKEN_PROPERTY + "}");
|
||||
start();
|
||||
assertThat(request("POST", AlertsRouteProvider.BASE_PATH + "/ingest/alertmanager", "{}", ADMIN, "Bearer " + TOKEN).statusCode()).isEqualTo(404);
|
||||
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{}", "nobody", null).statusCode()).isEqualTo(401);
|
||||
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{}", "nobody", "Bearer wrong").statusCode()).isEqualTo(401);
|
||||
HttpResponse<String> response = request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{\"alerts\":[]}", "nobody", "Bearer " + TOKEN);
|
||||
assertThat(json(response)).containsEntry("raised", 0).containsEntry("resolved", 0).containsEntry("ignored", 0);
|
||||
assertThat(response.headers().firstValue("Deprecation")).isEmpty();
|
||||
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "[]", "nobody", "Bearer " + TOKEN).statusCode()).isEqualTo(400);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test the migration-only raw provider and prove strict mode refuses its alias.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void legacyWebhook() throws Exception
|
||||
{
|
||||
System.setProperty(TOKEN_PROPERTY, TOKEN);
|
||||
config.withAlertmanagerIngestTokenSecretRef("${prop." + TOKEN_PROPERTY + "}").withLegacyAlertmanagerPath(true);
|
||||
AlertsRouteProvider provider = new AlertsRouteProvider();
|
||||
provider.setQInstance(qInstance);
|
||||
Javalin app = Javalin.create(provider::acceptJavalinConfig).start(0);
|
||||
try
|
||||
{
|
||||
port = app.port();
|
||||
String alias = AlertsRouteProvider.BASE_PATH + "/ingest/alertmanager";
|
||||
for(String path : List.of(alias, AlertsRouteProvider.WEBHOOK_PATH))
|
||||
{
|
||||
assertThat(request("POST", path, "{}", "nobody", "Bearer wrong").statusCode()).isEqualTo(401);
|
||||
HttpResponse<String> response = request("POST", path, "{\"alerts\":[]}", "nobody", "Bearer " + TOKEN);
|
||||
assertThat(json(response)).containsEntry("raised", 0);
|
||||
if(path.equals(alias))
|
||||
{
|
||||
assertThat(response.headers().firstValue("Deprecation")).contains("true");
|
||||
assertThat(response.headers().firstValue("Sunset")).contains(AlertsRouteProvider.LEGACY_SUNSET);
|
||||
}
|
||||
}
|
||||
assertThatThrownBy(() -> UiApiRouteAuditor.audit(app, List.of(new MiddlewareVersionV1()), true))
|
||||
.isInstanceOf(IllegalStateException.class).hasMessageContaining(alias);
|
||||
}
|
||||
finally
|
||||
{
|
||||
app.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ import com.kingsrook.qbits.alerts.metadata.AlertsAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
@@ -28,12 +28,12 @@ import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
@@ -201,7 +201,7 @@ class HomeAndApiTest extends BaseAlertsTest
|
||||
|
||||
/*******************************************************************************
|
||||
** The record panel: hidden with nothing to show; open alerts and recent
|
||||
** resolutions otherwise; "Watch alerts for this record".
|
||||
** resolutions otherwise; legacy watch rows and collaboration capability.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testRecordPanel() throws Exception
|
||||
@@ -225,13 +225,18 @@ class HomeAndApiTest extends BaseAlertsTest
|
||||
assertThat(other.get("visible")).isEqualTo(true);
|
||||
assertThat(other.get("openCount")).isEqualTo(0);
|
||||
|
||||
assertThat(api.watchRecord(Map.of("tableName", WIDGET, "recordId", widgets.get(0))).get("created")).isEqualTo(true);
|
||||
assertThat(api.watchRecord(Map.of("tableName", WIDGET, "recordId", widgets.get(0))).get("created")).isEqualTo(false);
|
||||
assertThat(panel.get("follow")).isEqualTo(Map.of("available", false));
|
||||
com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance)
|
||||
.withExtension(new com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0"));
|
||||
assertThat(api.recordPanel(WIDGET, String.valueOf(widgets.get(0))).get("follow")).isEqualTo(Map.of("available", true));
|
||||
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
|
||||
.withValue("subscriberType", "USER").withValue("userId", ADMIN).withValue("scopeType", "RECORD")
|
||||
.withValue("scopeValue", WIDGET).withValue("scopeRecordId", String.valueOf(widgets.get(0)))
|
||||
.withValue("enabled", true)));
|
||||
assertThat(api.recordPanel(WIDGET, String.valueOf(widgets.get(0))).get("watching")).isEqualTo(true);
|
||||
assertThat(all(AlertsSubscription.TABLE_NAME)).anyMatch(s -> "RECORD".equals(s.getValueString("scopeType")));
|
||||
|
||||
assertThatThrownBy(() -> api.recordPanel("nope", "1")).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> api.watchRecord(Map.of())).isInstanceOf(QBadRequestException.class);
|
||||
loginAs(OUTSIDER);
|
||||
assertThatThrownBy(() -> api.recordPanel(WIDGET, "1")).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
@@ -32,16 +32,16 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
|
||||
@@ -25,8 +25,8 @@ import com.kingsrook.qbits.alerts.delivery.AlertMessage;
|
||||
import com.kingsrook.qbits.alerts.delivery.AlertSecretResolver;
|
||||
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertCheckInterface;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
|
||||
@@ -26,10 +26,11 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.kingsrook.qbits.alerts.delivery.channels.ExternalChannels;
|
||||
import com.kingsrook.qbits.alerts.delivery.channels.StandardWebhookSigner;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookSecret;
|
||||
import com.kingsrook.qqq.backend.core.utils.webhooks.WebhookVerifier;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -172,8 +173,8 @@ class ExternalChannelsTest
|
||||
String timestamp = request.headers().get("Webhook-timestamp").get(0);
|
||||
String signature = request.headers().get("Webhook-signature").get(0);
|
||||
assertThat(id).startsWith("msg_");
|
||||
String expected = StandardWebhookSigner.sign(id, Long.parseLong(timestamp), request.body().getBytes(StandardCharsets.UTF_8), "whsec_c2VjcmV0LWtleS1mb3ItdGVzdHM=");
|
||||
assertThat(signature).isEqualTo("v1," + expected);
|
||||
new WebhookVerifier(List.of(WebhookSecret.parse("whsec_c2VjcmV0LWtleS1mb3ItdGVzdHM=")))
|
||||
.verify(Map.of("webhook-id", id, "webhook-timestamp", timestamp, "webhook-signature", signature), request.body().getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(request.body()).contains("\"type\":\"qqq.alert.resolved\"").contains("\"dedupKey\":\"provider:cf\"");
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
|
||||
@@ -25,7 +25,7 @@ import com.kingsrook.qbits.alerts.engine.AlertRouter;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
|
||||
import com.kingsrook.qbits.alerts.spi.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.alerts.DetailLevel;
|
||||
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.alerts.delivery;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.alerts.delivery.channels.StandardWebhookSigner;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed wire vectors generated independently with Python's hmac/hashlib.
|
||||
** Cover both secret encodings, short legacy keys and a key over one block.
|
||||
*******************************************************************************/
|
||||
class StandardWebhookSignerTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Raw and whsec_ secrets produce the exact same legacy header values.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@MethodSource("wireVectors")
|
||||
void testLegacyWireFormat(String key, String expectedSignature)
|
||||
{
|
||||
Instant timestamp = Instant.ofEpochSecond(1791806400L);
|
||||
byte[] body = "{\"event\":\"test\"}".getBytes(StandardCharsets.UTF_8);
|
||||
Map<String, String> expected = Map.of("webhook-id", "msg_test", "webhook-timestamp", "1791806400", "webhook-signature", expectedSignature);
|
||||
assertThat(StandardWebhookSigner.headers("msg_test", timestamp, body, " " + key + " ")).isEqualTo(expected);
|
||||
String encoded = "whsec_" + Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(StandardWebhookSigner.headers("msg_test", timestamp, body, encoded)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Independent expected signatures, including the 15/16-byte core boundary.
|
||||
*******************************************************************************/
|
||||
private static Stream<Arguments> wireVectors()
|
||||
{
|
||||
return (Stream.of(
|
||||
Arguments.of("short", "v1,Jz84jUiA/gQwIt0Td1DzUA/OE5soKzC5H9C5e94lliM="),
|
||||
Arguments.of("sixteen-byte-key", "v1,qep/FxanCVlGy2Zohku2Bgd3ufM4KNRcj5qT8t7blNA="),
|
||||
Arguments.of("sixteen-byte-key!", "v1,tbs9jXA3r4N06ZLpuOe6V4RasiRwYwbM2+2ZxRu8lNU="),
|
||||
Arguments.of("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "v1,K6s1XISWGs8vzyUo3Ecush1eoxy/d5CiND+gzS70I8U=")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty or malformed keys still fail before any request is sent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testInvalidSecrets()
|
||||
{
|
||||
for(String secret : new String[] { null, "", " ", "whsec_", "whsec_!" })
|
||||
{
|
||||
assertThatThrownBy(() -> StandardWebhookSigner.headers("msg_test", Instant.EPOCH, null, secret)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,19 +40,19 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.ResolveMode;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
@@ -307,29 +307,29 @@ public class AlertsDemoServer extends AbstractQQQApplication
|
||||
.withValue("finishedAt", clock.instant())).getValueInteger("id"));
|
||||
if(day == 11 || day == 23)
|
||||
{
|
||||
runtime.sink().raise(new AlertSignal("backups.job.failed").withDedupKey("nightly-db").withTitle("Nightly database backup failed")
|
||||
runtime.getSink().raise(new AlertSignal("backups.job.failed").withDedupKey("nightly-db").withTitle("Nightly database backup failed")
|
||||
.withSummary("The dump step exited with an error.").withDetails("pg_dump: error: connection to server lost")
|
||||
.withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(AlertLink.toRecord("backup_run", runId, "Open the run")));
|
||||
.withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(com.kingsrook.qqq.backend.core.alerts.AlertLink.toRecord("backup_run", runId, "Open the run")));
|
||||
}
|
||||
else
|
||||
{
|
||||
runtime.sink().resolve(new com.kingsrook.qbits.alerts.spi.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded"));
|
||||
runtime.sink().heartbeat("backups.nightly-db", clock.instant());
|
||||
runtime.getSink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded"));
|
||||
runtime.getSink().heartbeat("backups.nightly-db", clock.instant());
|
||||
}
|
||||
}
|
||||
if(day % 4 == 1 || day == 27)
|
||||
{
|
||||
runtime.sink().raise(new AlertSignal("cdn.purge.dead").withDedupKey("provider:cloudfront").withTitle((2 + day % 3) + " CDN purges failed after retries")
|
||||
runtime.getSink().raise(new AlertSignal("cdn.purge.dead").withDedupKey("provider:cloudfront").withTitle((2 + day % 3) + " CDN purges failed after retries")
|
||||
.withSummary("Content will correct itself when the edge TTL expires.").withCount(2 + day % 3));
|
||||
if(day != 27)
|
||||
{
|
||||
clock.set(clock.instant().plus(Duration.ofHours(3)));
|
||||
runtime.sink().resolve(new com.kingsrook.qbits.alerts.spi.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded"));
|
||||
runtime.getSink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded"));
|
||||
}
|
||||
}
|
||||
if(day == 25)
|
||||
{
|
||||
runtime.sink().raise(new AlertSignal("storage.quota.near").withDedupKey("bucket:media").withTitle("Media bucket is at 84% of its quota").withCount(84));
|
||||
runtime.getSink().raise(new AlertSignal("storage.quota.near").withDedupKey("bucket:media").withTitle("Media bucket is at 84% of its quota").withCount(84));
|
||||
}
|
||||
if(day % 7 == 0 || day == 29)
|
||||
{
|
||||
|
||||
@@ -35,13 +35,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertResolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
@@ -340,11 +340,11 @@ class AlertsEngineTest extends BaseAlertsTest
|
||||
@Test
|
||||
void testSink() throws Exception
|
||||
{
|
||||
runtime.sink().raise(signal("k1"));
|
||||
runtime.sink().heartbeat("sink.beat", null);
|
||||
runtime.sink().resolve(new AlertResolution(TYPE, "k1", "gone"));
|
||||
runtime.sink().raise(null);
|
||||
runtime.sink().resolve(null);
|
||||
runtime.getSink().raise(signal("k1"));
|
||||
runtime.getSink().heartbeat("sink.beat", null);
|
||||
runtime.getSink().resolve(new AlertResolution(TYPE, "k1", "gone"));
|
||||
runtime.getSink().raise(null);
|
||||
runtime.getSink().resolve(null);
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
assertThat(alerts).hasSize(1);
|
||||
assertThat(alerts.get(0).getValueString("status")).isEqualTo("RESOLVED");
|
||||
|
||||
@@ -27,13 +27,13 @@ import com.kingsrook.qbits.alerts.model.AlertsMonitor;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertDefaultMonitor;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
|
||||
@@ -29,19 +29,19 @@ import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsChannel;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsMute;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeam;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertRecordRef;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qbits.alerts.spi.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
@@ -24,9 +24,13 @@ import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsScheduledSteps;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertResolution;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertResolution;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -41,6 +45,38 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class SinkAndScheduleTest extends BaseAlertsTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A host using only core reaches the qbit through instance metadata and
|
||||
** the loadable adapter, including the asynchronous queue and persistence.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreProducerReachesInstalledSink() throws Exception
|
||||
{
|
||||
AlertsInstanceMetaData metadata = AlertsInstanceMetaData.of(qInstance);
|
||||
assertThat(metadata.getSink().getName()).isEqualTo(AlertsSinkAdapter.class.getName());
|
||||
assertThat(qInstance.getSupplementalMetaData("alertsQBitStandIn")).isNull();
|
||||
metadata.withAlertType(new QAlertTypeMetaData().withName("host.backup.failed").withDefaultSeverity(AlertSeverity.ERROR));
|
||||
long failures = QAlerts.getProducerFailureCount();
|
||||
|
||||
QAlerts.raise(new AlertSignal("host.backup.failed").withDedupKey("nightly").withTitle("Nightly backup failed"));
|
||||
QAlerts.heartbeat("host.backup.nightly");
|
||||
runtime.getSink().stop();
|
||||
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
assertThat(alerts).hasSize(1);
|
||||
assertThat(alerts.get(0).getValueString("title")).isEqualTo("Nightly backup failed");
|
||||
assertThat(alerts.get(0).getValueString("severity")).isEqualTo("ERROR");
|
||||
assertThat(alerts.get(0).getValueString("status")).isEqualTo("OPEN");
|
||||
assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1);
|
||||
|
||||
QAlerts.resolve("host.backup.failed", "nightly", "Retry succeeded");
|
||||
runtime.getSink().stop();
|
||||
assertThat(all(AlertsAlert.TABLE_NAME).get(0).getValueString("status")).isEqualTo("RESOLVED");
|
||||
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process signals on the background queue in these tests.
|
||||
*******************************************************************************/
|
||||
@@ -66,9 +102,9 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
{
|
||||
String type = "test.stop.thing" + i;
|
||||
adapter.raise(new AlertSignal(type).withTitle("Stop " + i));
|
||||
runtime.sink().stop();
|
||||
runtime.getSink().stop();
|
||||
adapter.resolve(new AlertResolution(type, null, "done"));
|
||||
runtime.sink().stop();
|
||||
runtime.getSink().stop();
|
||||
}
|
||||
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
@@ -87,7 +123,7 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
AlertsSinkAdapter adapter = new AlertsSinkAdapter();
|
||||
adapter.raise(new AlertSignal("test.queue.thing").withTitle("Queued"));
|
||||
adapter.heartbeat("queue.beat", null);
|
||||
runtime.sink().flush();
|
||||
runtime.getSink().flush();
|
||||
for(int i = 0; i < 50 && all(AlertsAlert.TABLE_NAME).isEmpty(); i++)
|
||||
{
|
||||
Thread.sleep(50);
|
||||
@@ -95,7 +131,7 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
assertThat(all(AlertsAlert.TABLE_NAME)).hasSize(1);
|
||||
|
||||
adapter.resolve(new AlertResolution("test.queue.thing", null, "done"));
|
||||
runtime.sink().stop();
|
||||
runtime.getSink().stop();
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
assertThat(alerts.get(0).getValueString("status")).isEqualTo("RESOLVED");
|
||||
assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1);
|
||||
@@ -111,10 +147,10 @@ class SinkAndScheduleTest extends BaseAlertsTest
|
||||
{
|
||||
for(int i = 0; i < 1005; i++)
|
||||
{
|
||||
runtime.sink().raise(new AlertSignal("test.flood.thing").withDedupKey("same"));
|
||||
runtime.getSink().raise(new AlertSignal("test.flood.thing").withDedupKey("same"));
|
||||
}
|
||||
assertThat(runtime.sink().getDroppedCount()).isEqualTo(5);
|
||||
runtime.sink().stop();
|
||||
assertThat(runtime.getSink().getDroppedCount()).isEqualTo(5);
|
||||
runtime.getSink().stop();
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-7
@@ -29,11 +29,12 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
@@ -147,7 +148,7 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
|
||||
{
|
||||
emailSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING");
|
||||
emailSettings("viewer-2", "JOB", "IMMEDIATE", "ERROR");
|
||||
runtime.getNotifications().notify(new NotificationSignal("Export failed").withBody("Disk <full>").withSeverity(NotificationSeverity.WARNING)
|
||||
QAlerts.notify(new NotificationSignal("Export failed").withBody("Disk <full>").withSeverity(NotificationSeverity.WARNING)
|
||||
.withCategory(NotificationCategory.JOB).withLink(AlertLink.toPath("/app/widget", "Widgets")).withAudience(NotificationAudience.users("viewer-1", "viewer-2", "manager-1")));
|
||||
|
||||
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
|
||||
@@ -175,7 +176,7 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
|
||||
clock.set(Instant.parse("2026-10-12T09:00:00Z"));
|
||||
emailSettings("viewer-1", "RELEASE", "DAILY_DIGEST", "INFO");
|
||||
AlertsNotices.release("2.4", "Dark mode everywhere.", null);
|
||||
runtime.getNotifications().notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
QAlerts.notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0);
|
||||
clock.set(Instant.parse("2026-10-12T12:05:00Z"));
|
||||
|
||||
@@ -34,16 +34,18 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
@@ -267,11 +269,11 @@ class NotificationsTest extends BaseAlertsTest
|
||||
{
|
||||
for(int i = 0; i < 4; i++)
|
||||
{
|
||||
runtime.getNotifications().notify(new NotificationSignal("Job " + i + " failed").withSeverity(i % 2 == 0 ? NotificationSeverity.ERROR : NotificationSeverity.WARNING)
|
||||
QAlerts.notify(new NotificationSignal("Job " + i + " failed").withSeverity(i % 2 == 0 ? NotificationSeverity.ERROR : NotificationSeverity.WARNING)
|
||||
.withCategory(NotificationCategory.JOB).withAudience(NotificationAudience.user("viewer-1")));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
}
|
||||
runtime.getNotifications().notify(new NotificationSignal("Sign-in from a new device").withCategory(NotificationCategory.SECURITY)
|
||||
QAlerts.notify(new NotificationSignal("Sign-in from a new device").withCategory(NotificationCategory.SECURITY)
|
||||
.withSeverity(NotificationSeverity.CRITICAL).withSticky(true).withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
loginAs("viewer-1");
|
||||
@@ -319,8 +321,12 @@ class NotificationsTest extends BaseAlertsTest
|
||||
api.markRead(Map.of("all", true));
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
|
||||
|
||||
Integer second = runtime.getNotifications().notify(signal.withTitle("Export failed again"));
|
||||
assertThat(second).isEqualTo(first);
|
||||
QAlerts.notify(signal.withTitle("Export failed again").withAudience(NotificationAudience.users(" viewer-1 ", "viewer-1")));
|
||||
assertThat(runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME))).singleElement().satisfies(notification ->
|
||||
{
|
||||
assertThat(notification.getValueInteger("id")).isEqualTo(first);
|
||||
assertThat(notification.getValueString("audienceValue")).isEqualTo("viewer-1");
|
||||
});
|
||||
Map<String, Object> summary = api.inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(1);
|
||||
assertThat(items(summary).get(0)).containsEntry("title", "Export failed again").containsEntry("href", "/app/widget/5");
|
||||
@@ -329,7 +335,7 @@ class NotificationsTest extends BaseAlertsTest
|
||||
assertThat(NotificationService.linkUrl(AlertLink.toUrl("https://example.com/x", "x"))).isEqualTo("https://example.com/x");
|
||||
assertThat(NotificationService.linkUrl(AlertLink.toPath("/app/alerts", "x"))).isEqualTo("/app/alerts");
|
||||
assertThat(NotificationService.safeLink("//evil.example.com")).isNull();
|
||||
for(String bad : List.of("/\\evil.com", "\\\\evil.com", "javascript:alert(1)", "data:text/html,x", "http://x.example.com", "https:///nohost", "/a b", "/a b", "https://"))
|
||||
for(String bad : List.of("/\\evil.com", "\\\\evil.com", "javascript:alert(1)", "data:text/html,x", "http://x.example.com", "https:///nohost", "/a b", "/a\tb", "https://"))
|
||||
{
|
||||
assertThat(NotificationService.safeLink(bad)).as(bad).isNull();
|
||||
}
|
||||
@@ -340,12 +346,91 @@ class NotificationsTest extends BaseAlertsTest
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// through the sink (the producer SPI path) //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
runtime.sink().notify(new NotificationSignal("Shared with you: Q3 notebook").withCategory(NotificationCategory.SHARE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
QAlerts.notify(new NotificationSignal("Shared with you: Q3 notebook").withCategory(NotificationCategory.SHARE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core-only notification producers deliver to the selected user's bell.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreNotificationDelivery() throws Exception
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Shipment delivered")
|
||||
.withSeverity(NotificationSeverity.INFO).withCategory(NotificationCategory.JOB)
|
||||
.withLink(AlertLink.toRecord("widget", 5, "Shipment"))
|
||||
.withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
loginAs("viewer-1");
|
||||
Map<String, Object> summary = new AlertsApi(runtime).inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(1);
|
||||
assertThat(items(summary).get(0)).containsEntry("title", "Shipment delivered").containsEntry("href", "/app/widget/5");
|
||||
loginAs("viewer-2");
|
||||
assertThat(new AlertsApi(runtime).inboxSummary().get("unreadCount")).isEqualTo(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every core audience factory resolves through the persisted notification.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreAudiences() throws Exception
|
||||
{
|
||||
config.setUserDirectory(new QCodeReference(TestDirectory.class));
|
||||
Map<NotificationAudience, List<String>> audiences = Map.of(
|
||||
NotificationAudience.user("viewer-1"), List.of("viewer-1"),
|
||||
NotificationAudience.users(" viewer-1 ", "viewer-2", "viewer-1"), List.of("viewer-1", "viewer-2"),
|
||||
NotificationAudience.roles(" staff ", "editors"), List.of("viewer-1", "viewer-2"),
|
||||
NotificationAudience.permission(" alerts.admin "), List.of("admin-1"),
|
||||
NotificationAudience.allUsers(), List.of("viewer-1", "viewer-2", "admin-1", "outsider-1"));
|
||||
for(var entry : audiences.entrySet())
|
||||
{
|
||||
String key = entry.getKey().toString();
|
||||
QAlerts.notify(new NotificationSignal("Audience test").withSourceKey(key).withAudience(entry.getKey()));
|
||||
QRecord notification = runtime.asSystem(() -> AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", key)));
|
||||
assertThat(notification).isNotNull();
|
||||
assertThat(notification.getValueString("category")).isEqualTo("SYSTEM");
|
||||
assertThat(runtime.asSystem(() -> where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notification.getValue("id")))))
|
||||
.extracting(record -> record.getValueString("userId")).containsExactlyInAnyOrderElementsOf(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core commit deferral and rollback apply before the qbit creates recipients.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreNotificationTransactions() throws Exception
|
||||
{
|
||||
QBackendTransaction committed = new QBackendTransaction();
|
||||
QAlerts.notify(new NotificationSignal("Committed").withAudience(NotificationAudience.user("viewer-1")), committed);
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).isEmpty();
|
||||
committed.commit();
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement()
|
||||
.satisfies(record -> assertThat(record.getValueString("title")).isEqualTo("Committed"));
|
||||
|
||||
QBackendTransaction rolledBack = new QBackendTransaction();
|
||||
QContext.setTransaction(rolledBack);
|
||||
try
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Rolled back").withAudience(NotificationAudience.user("viewer-1")));
|
||||
rolledBack.rollback();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setTransaction(null);
|
||||
}
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).hasSize(1);
|
||||
loginAs("viewer-1");
|
||||
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("unreadCount", 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audiences: roles from a directory, permission holders, warnings.
|
||||
*******************************************************************************/
|
||||
@@ -403,8 +488,8 @@ class NotificationsTest extends BaseAlertsTest
|
||||
// a muted category skips the user; security is never skipped //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
new InsertAction().execute(new InsertInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord().withValue("category", "MARKETING").withValue("inAppMuted", true)));
|
||||
runtime.getNotifications().notify(new NotificationSignal("Big sale").withCategory(NotificationCategory.MARKETING).withAudience(NotificationAudience.users("viewer-1", "viewer-2")));
|
||||
runtime.getNotifications().notify(new NotificationSignal("Password changed").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user("viewer-1")));
|
||||
QAlerts.notify(new NotificationSignal("Big sale").withCategory(NotificationCategory.MARKETING).withAudience(NotificationAudience.users("viewer-1", "viewer-2")));
|
||||
QAlerts.notify(new NotificationSignal("Password changed").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user("viewer-1")));
|
||||
assertThat(new AlertsApi(runtime).inboxSummary().get("unreadCount")).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user