From 2692f8b1dc5975ddd16f720b927848d67ef1fe5f Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 20:18:36 -0500 Subject: [PATCH 1/2] feat(api): alerts UI routes as spec'd extension endpoints; webhook to /qqq/hooks; follow via collaboration #13: the six inbox and record-panel routes move from the raw AlertsRouteProvider onto a MiddlewareSpecExtension "alerts" (prefix /alerts). Paths are unchanged. The committed fragment openapi/qqq-v1/alerts.yaml is checked by a contract test, and the server tests run in strict mode. Additive fields let Next stop hard-coding alerts names: the summary has inboxHref, and inbox items and panel alerts have actions with process names and hrefs. #14: the Alertmanager webhook moves to POST /qqq/hooks/alerts/alertmanager. The old /qqq/v1 path is an opt-in deprecated alias (legacyAlertmanagerPath, default off; it needs non-strict mode). The alias sends Deprecation and Sunset headers and logs a warning per call. #16: POST /alerts/record-panel/watch is removed; collaboration owns following a record. The record panel reports follow.available when the collaboration extension is registered. Existing record-scoped watch subscriptions stay readable and are deprecated. Follower fan-out waits for core RecordFollowersProviderInterface (STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7)). The checkstyle header template now matches the short Apache header that the sources already use. The core baseline becomes 4.1.0-RC.6. Closes #13, closes #14, closes #16 (architecture review QRun-IO/qqq#1150, F2, F7, F12, F20). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +- README.md | 25 +- checkstyle/config.xml | 2 +- checkstyle/license.txt | 8 +- docs/DESIGN.md | 1 + docs/NOTIFICATIONS.md | 11 +- docs/UI-API.md | 108 ++++ pom.xml | 30 +- .../qbits/alerts/AlertsQBitConfig.java | 26 +- .../qbits/alerts/AlertsQBitProducer.java | 1 + .../kingsrook/qbits/alerts/api/AlertsApi.java | 70 ++- .../qbits/alerts/api/AlertsJsonSpec.java | 97 ++++ .../qbits/alerts/api/AlertsRouteProvider.java | 91 +-- .../qbits/alerts/api/AlertsSchemas.java | 306 +++++++++++ .../qbits/alerts/api/AlertsUiApi.java | 60 ++ .../qbits/alerts/api/InboxDismissSpec.java | 87 +++ .../qbits/alerts/api/InboxReadSpec.java | 87 +++ .../kingsrook/qbits/alerts/api/InboxSpec.java | 96 ++++ .../qbits/alerts/api/InboxSummarySpec.java | 88 +++ .../qbits/alerts/api/InboxUnreadSpec.java | 87 +++ .../qbits/alerts/api/RecordPanelSpec.java | 93 ++++ .../AlertsNotificationCustomizer.java | 10 +- .../qbits/alerts/engine/AlertRouter.java | 2 + .../qbits/alerts/home/AlertsHome.java | 2 +- src/main/resources/openapi/qqq-v1/alerts.yaml | 518 ++++++++++++++++++ .../qbits/alerts/AlertsApiContractTest.java | 81 +++ .../qbits/alerts/AlertsApiHttpTest.java | 254 +++++++++ .../qbits/alerts/HomeAndApiTest.java | 13 +- .../qbits/alerts/delivery/RenderingTest.java | 2 +- .../notifications/NotificationsTest.java | 2 +- 30 files changed, 2124 insertions(+), 144 deletions(-) create mode 100644 docs/UI-API.md create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/AlertsJsonSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/AlertsSchemas.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/InboxDismissSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/InboxReadSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/InboxSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/InboxSummarySpec.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/InboxUnreadSpec.java create mode 100644 src/main/java/com/kingsrook/qbits/alerts/api/RecordPanelSpec.java create mode 100644 src/main/resources/openapi/qqq-v1/alerts.yaml create mode 100644 src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java create mode 100644 src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8ca11..ab95dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ All notable changes to this project are documented here. The format follows Keep ## [0.1.0] - Unreleased ### Changed -- Build against the released QQQ 4.1.0-RC.5 BOM. Alert types and sink registration now use core's alerts SPI; host `QAlerts.raise/resolve/heartbeat` calls reach the installed qbit through core instance metadata (qbit-alerts#15, F3). +- 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). - Removed alert SPI copies. Retained `spi.NotificationSignal`, `NotificationAudience`, `NotificationCategory`, `NotificationSeverity`, `AlertLink`, and the binary-compatible `AlertsRuntime.sink().notify(...)` entry point for qbit-easypost-tracking 0.1.x until QRun-IO/qqq#1125 ships in RC.6. - Webhook signing delegates to core `WebhookSigner`/`WebhookSecret` (qbit-alerts#15, F11). Headers, `v1,` signatures and `whsec_` encoding are unchanged. A thin adapter preserves legacy keys shorter than core's 16-byte minimum using equivalent HMAC zero-padding. @@ -16,7 +22,7 @@ All notable changes to this project are documented here. The format follows Keep - a generic webhook, signed with Standard Webhooks headers through core's `WebhookSigner` (QRun-IO/qqq#1109) - PagerDuty (Events API v2: trigger, acknowledge and resolve) - an Alertmanager sink (`/api/v2/alerts`, with the qqq labels and the origin guard) -- Prometheus Alertmanager ingest at `POST /qqq/v1/alerts/ingest/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard. +- Prometheus Alertmanager ingest at `POST /qqq/hooks/alerts/alertmanager`. It takes a bearer token, creates EXTERNAL types with notifications off, and keeps a loop guard. - Tables (`alerts_*`), with the Liquibase changelog `db/liquibase/qbit-alerts/alerts.changelog.sql`: - alerts, their timeline and records - types, groups and monitors diff --git a/README.md b/README.md index ed898d3..65fd968 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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.5`). +- 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. @@ -95,7 +111,7 @@ QAlerts.raise(new AlertSignal("cdn.purge.dead") `QAlerts.resolve(...)` and `QAlerts.heartbeat(...)` reach the same sink. Declare types with core's `QAlertTypeMetaData`. Calls through `QAlerts.raise/resolve` defer to transaction commit when a transaction is present; without this qbit installed, core uses its logging sink. -User notifications remain on `AlertsRuntime.get(qInstance).sink().notify(...)`: core's notification API ([QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)) is expected in RC.6 and is not in RC.5. The four `spi.Notification*` types and `spi.AlertLink` retain their binary signatures for released qbit-easypost-tracking 0.1.x. Use core `AlertLink` for alert signals and `spi.AlertLink` for notification signals until that migration. +User notifications remain on `AlertsRuntime.get(qInstance).sink().notify(...)`: core's notification API ([QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125)) ships in RC.6; wiring the retained notification adapter to that API is a separate migration. The four `spi.Notification*` types and `spi.AlertLink` retain their binary signatures for released qbit-easypost-tracking 0.1.x. Use core `AlertLink` for alert signals and `spi.AlertLink` for notification signals until that migration. ## Endpoints (for the Next UI) @@ -107,8 +123,7 @@ User notifications remain on `AlertsRuntime.get(qInstance).sink().notify(...)`: | POST | `/qqq/v1/alerts/inbox/unread` | `{"id": n}` or `{"ids": [..]}` | | POST | `/qqq/v1/alerts/inbox/dismiss` | `{"id": n}`, `{"ids": [..]}` or `{"all": true}` | | GET | `/qqq/v1/alerts/record-panel?tableName=t&recordId=id` | The "Open alerts" panel for a record (`visible: false` when there is nothing to show) | -| POST | `/qqq/v1/alerts/record-panel/watch` | `{"tableName": t, "recordId": id}`: a RECORD subscription for the user | -| POST | `/qqq/v1/alerts/ingest/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. | +| POST | `/qqq/hooks/alerts/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. | ## External channels diff --git a/checkstyle/config.xml b/checkstyle/config.xml index 385d653..9419bf2 100644 --- a/checkstyle/config.xml +++ b/checkstyle/config.xml @@ -240,7 +240,7 @@ - + diff --git a/checkstyle/license.txt b/checkstyle/license.txt index 92332b5..4cbecc1 100644 --- a/checkstyle/license.txt +++ b/checkstyle/license.txt @@ -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, diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b46a540..99c8eae 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -3,6 +3,7 @@ The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). This repository implements it. Phases P1 to P3 are built (see README.md); P4 to P6 remain. Deviations from the design, and why: +- **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 use core's RC.5 SPI** (QRun-IO/qqq#1108), including sink registration in core instance metadata. Only notification types and their `AlertLink` signature dependency remain in `spi/`, preserving released producers until QRun-IO/qqq#1125 ships in RC.6. - **Visibility checks 1 to 3 (section 7.2)** are enforced by record security locks on session keys (`alerts_permission`, `alerts_readable_table`), not by a pre-query customizer. QQQ has no pre-query customizer, and locks also apply to counts, exports and the API. - **Heartbeats** are stored in an extra table, `alerts_heartbeat`. diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index a739ceb..ad1e72a 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -139,8 +139,9 @@ notification admins. The home stays the only Alerts dashboard. ## 6. Producer SPI The notification API below is planned in [QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125) -for 4.1.0-RC.6. The baseline is 4.1.0-RC.5, which ships the core alert SPI -(`com.kingsrook.qqq.backend.core.alerts`) but does **not** yet provide `QAlerts.notify`: +for 4.1.0-RC.6. This branch now targets 4.1.0-RC.6 for the UI extension API. The retained qbit notification +adapter still needs a separate migration to core `QAlerts.notify`; the following remains the +target integration shape: ```java QAlerts.notify(new NotificationSignal("Your export failed") @@ -250,3 +251,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. diff --git a/docs/UI-API.md b/docs/UI-API.md new file mode 100644 index 0000000..2a62c71 --- /dev/null +++ b/docs/UI-API.md @@ -0,0 +1,108 @@ +# 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. + +## 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. diff --git a/pom.xml b/pom.xml index 881be06..7572037 100644 --- a/pom.xml +++ b/pom.xml @@ -107,6 +107,32 @@ + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.6.0 + + + com.puppycrawl.tools + checkstyle + 14.3.0 + + + + checkstyle/config.xml + true + warning + + + + checkstyle + validate + + check + + + + org.jacoco jacoco-maven-plugin @@ -158,14 +184,14 @@ - + qqq-snapshot true - 4.1.0-RC.5 + 4.1.0-RC.6 diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java index bebe252..cf78b56 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java @@ -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); + } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java index 2e43e61..83f8686 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java @@ -212,6 +212,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer> 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 watchRecord(Map 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 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> 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 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). *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsJsonSpec.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsJsonSpec.java new file mode 100644 index 0000000..40c7278 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsJsonSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsRouteProvider.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsRouteProvider.java index 0aae550..76b9f17 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsRouteProvider.java +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsRouteProvider.java @@ -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 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 queryParams(Context context) - { - Map 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 body) throws Exception; - } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsSchemas.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsSchemas.java new file mode 100644 index 0000000..2bafba1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsSchemas.java @@ -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 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 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 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 items, List 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 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 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 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 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)))))); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java new file mode 100644 index 0000000..d70a255 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/AlertsUiApi.java @@ -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)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/InboxDismissSpec.java b/src/main/java/com/kingsrook/qbits/alerts/api/InboxDismissSpec.java new file mode 100644 index 0000000..c418ef1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/InboxDismissSpec.java @@ -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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/InboxReadSpec.java b/src/main/java/com/kingsrook/qbits/alerts/api/InboxReadSpec.java new file mode 100644 index 0000000..97290f1 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/InboxReadSpec.java @@ -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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/InboxSpec.java b/src/main/java/com/kingsrook/qbits/alerts/api/InboxSpec.java new file mode 100644 index 0000000..c50e658 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/InboxSpec.java @@ -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 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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/InboxSummarySpec.java b/src/main/java/com/kingsrook/qbits/alerts/api/InboxSummarySpec.java new file mode 100644 index 0000000..8538fb2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/InboxSummarySpec.java @@ -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 defineComponentSchemas() + { + return (AlertsSchemas.components()); + } + + + + /******************************************************************************* + ** Invoke the existing service with the caller session. + *******************************************************************************/ + @Override + protected Object handle(JsonInput input) throws Exception + { + return (api().inboxSummary()); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/InboxUnreadSpec.java b/src/main/java/com/kingsrook/qbits/alerts/api/InboxUnreadSpec.java new file mode 100644 index 0000000..ba38e6e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/InboxUnreadSpec.java @@ -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())); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/api/RecordPanelSpec.java b/src/main/java/com/kingsrook/qbits/alerts/api/RecordPanelSpec.java new file mode 100644 index 0000000..3fed4da --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/api/RecordPanelSpec.java @@ -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 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"))); + } +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java b/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java index e6a2219..9365d8c 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java @@ -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.")); } } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java index 76fcd8b..889228f 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java @@ -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())); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java b/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java index dcf0646..a5b4fcc 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java +++ b/src/main/java/com/kingsrook/qbits/alerts/home/AlertsHome.java @@ -44,11 +44,11 @@ import com.kingsrook.qbits.alerts.model.AlertsEvent; import com.kingsrook.qbits.alerts.model.AlertsGroup; import com.kingsrook.qbits.alerts.model.AlertsNotification; import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status; -import com.kingsrook.qbits.alerts.model.AlertsPossibleValues; import com.kingsrook.qbits.alerts.model.AlertsSubscription; import com.kingsrook.qbits.alerts.model.AlertsType; import com.kingsrook.qbits.alerts.notifications.NotificationService; diff --git a/src/main/resources/openapi/qqq-v1/alerts.yaml b/src/main/resources/openapi/qqq-v1/alerts.yaml new file mode 100644 index 0000000..5de6d48 --- /dev/null +++ b/src/main/resources/openapi/qqq-v1/alerts.yaml @@ -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" diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java new file mode 100644 index 0000000..e0b9c30 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiContractTest.java @@ -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(); + } +} diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java new file mode 100644 index 0000000..becbabd --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java @@ -0,0 +1,254 @@ +/* + * 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.AlertRecordRef; +import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +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 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 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 json(HttpResponse response) throws Exception + { + assertThat(response.statusCode()).as(response.body()).isEqualTo(200); + return (JsonUtils.toObject(response.body(), Map.class)); + } + + + + /******************************************************************************* + ** 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 summary = json(request("GET", base + "/inbox/summary", null, ADMIN, null)); + assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications"); + List> items = (List>) summary.get("items"); + assertThat(items).hasSize(1); + Map item = items.get(0); + assertThat(item).containsKeys("href", "actions", "alertId"); + assertThat((List>) 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 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 row = ((List>) panel.get("open")).get(0); + assertThat((List>) row.get("actions")).extracting(a -> a.get("processName")).containsExactly("alertsAcknowledge", "alertsSnooze"); + assertThat(((List>) row.get("actions")).get(0).get("href")) + .isEqualTo("/app/alertsAcknowledge?recordsParam=recordIds&recordIds=" + alert.getValue("id") + "&tableName=alerts_alert"); + Map viewerPanel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, VIEWER, null)); + assertThat(((List>) 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 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 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 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(); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/alerts/HomeAndApiTest.java b/src/test/java/com/kingsrook/qbits/alerts/HomeAndApiTest.java index 809f374..073442f 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/HomeAndApiTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/HomeAndApiTest.java @@ -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); } diff --git a/src/test/java/com/kingsrook/qbits/alerts/delivery/RenderingTest.java b/src/test/java/com/kingsrook/qbits/alerts/delivery/RenderingTest.java index cdd7e7c..cd5eda2 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/delivery/RenderingTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/delivery/RenderingTest.java @@ -22,9 +22,9 @@ import java.util.List; import java.util.Map; import com.kingsrook.qbits.alerts.AlertsQBitConfig; import com.kingsrook.qbits.alerts.engine.AlertRouter; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind; -import com.kingsrook.qbits.alerts.model.AlertsPossibleValues; import com.kingsrook.qqq.backend.core.alerts.DetailLevel; import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter; import com.kingsrook.qqq.backend.core.model.data.QRecord; diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java index 5df58f6..b448309 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java @@ -331,7 +331,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(); } -- 2.54.0 From a65b019ffa661e810657a5cc7e476744f5d5665d Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 07:15:35 -0500 Subject: [PATCH 2/2] ci: rebuild on the released QQQ 4.1.0-RC.6 Co-Authored-By: Claude Opus 5.5 -- 2.54.0