feat(api): alerts UI API extension, /qqq/hooks webhook, follow via collaboration (#13 #14 #16) #19

Merged
KofTwentyTwo merged 2 commits from feature/arch-ui-api-extension into develop 2026-10-14 12:20:06 +00:00
30 changed files with 2124 additions and 144 deletions

No files matched your search

+8 -2
View File
@@ -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
+20 -5
View File
@@ -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
+1 -1
View File
@@ -240,7 +240,7 @@
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
<property name="ignoreLines" value="2"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
+2 -6
View File
@@ -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,
+1
View File
@@ -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`.
+9 -2
View File
@@ -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.
+108
View File
@@ -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.
+28 -2
View File
@@ -107,6 +107,32 @@
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.6.0</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>14.3.0</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<includeTestSourceDirectory>true</includeTestSourceDirectory>
<violationSeverity>warning</violationSeverity>
</configuration>
<executions>
<execution>
<id>checkstyle</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
@@ -158,14 +184,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -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);
}
}
@@ -212,6 +212,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
boolean routes = false;
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
{
com.kingsrook.qbits.alerts.api.AlertsUiApi.register(qInstance);
com.kingsrook.qbits.alerts.api.AlertsRouteProvider.register(qInstance);
routes = true;
}
@@ -41,15 +41,14 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qbits.alerts.processes.AlertsAction;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -57,15 +56,14 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
/*******************************************************************************
@@ -158,6 +156,7 @@ public class AlertsApi
result.put("items", latest);
result.put("banners", banners);
result.put("notificationsHref", NOTIFICATIONS_HREF);
result.put("inboxHref", NOTIFICATIONS_HREF);
result.put("alertsHref", "/app/" + AlertsQBitConfig.APP_NAME);
result.put("pollSeconds", POLL_SECONDS);
return (result);
@@ -391,6 +390,11 @@ public class AlertsApi
{
row.put("alertId", parseInteger(notification.getValueString("sourceRecordId")));
}
List<Map<String, Object>> actions = new ArrayList<>();
actions.add(Map.of("name", entry.unread() ? "read" : "unread", "label", entry.unread() ? "Mark read" : "Mark unread",
"operationId", entry.unread() ? InboxReadSpec.OPERATION_ID : InboxUnreadSpec.OPERATION_ID));
actions.add(Map.of("name", "dismiss", "label", "Dismiss", "operationId", InboxDismissSpec.OPERATION_ID));
row.put("actions", actions);
return (row);
}
@@ -582,46 +586,13 @@ public class AlertsApi
result.put("viewAllHref", allIds.isEmpty() ? null : AlertsHome.listUrl(new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, dedupe(allIds))));
result.put("canManage", session.hasPermission(AlertsQBitConfig.PERMISSION_MANAGE));
result.put("watching", isWatching(tableName, recordId));
MiddlewareSpecExtensionsMetaData extensions = MiddlewareSpecExtensionsMetaData.of(runtime.getQInstance());
result.put("follow", Map.of("available", extensions != null && extensions.getExtensions().containsKey("collaboration")));
return (result);
}
/*******************************************************************************
** "Watch alerts for this record": a RECORD-scope subscription for the user.
*******************************************************************************/
public Map<String, Object> watchRecord(Map<String, Object> body) throws QException
{
String userId = requireViewer();
String tableName = body == null ? null : (String) body.get("tableName");
Object recordId = body == null ? null : body.get("recordId");
if(userId == null || !StringUtils.hasContent(tableName) || recordId == null || runtime.getQInstance().getTable(tableName) == null)
{
throw (new QBadRequestException("Send a table name and record id."));
}
if(isWatching(tableName, String.valueOf(recordId)))
{
return (Map.of("watching", true, "created", false));
}
List<QRecord> inserted = InsertAction.executeForRecords(new InsertInput(AlertsSubscription.TABLE_NAME).withRecord(new QRecord()
.withValue("subscriberType", SubscriberType.USER.name())
.withValue("userId", userId)
.withValue("scopeType", ScopeType.RECORD.name())
.withValue("scopeValue", tableName)
.withValue("scopeRecordId", String.valueOf(recordId))
.withValue("minSeverity", AlertSeverity.WARNING.name())
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
.withValue("deliveryMode", "IMMEDIATE")
.withValue("enabled", true)));
if(CollectionUtils.nullSafeHasContents(inserted.get(0).getErrors()))
{
throw (new QBadRequestException(inserted.get(0).getErrors().get(0).getMessage()));
}
return (Map.of("watching", true, "created", true, "subscriptionId", inserted.get(0).getValue("id")));
}
/*******************************************************************************
** Whether the user already has a RECORD subscription for this record.
*******************************************************************************/
@@ -654,11 +625,32 @@ public class AlertsApi
row.put("age", AlertMessageRenderer.age(alert.getValueInstant("firstRaisedAt"), now));
row.put("raisedAt", alert.getValueInstant("firstRaisedAt") == null ? null : alert.getValueInstant("firstRaisedAt").toString());
row.put("href", "/app/" + AlertsAlert.TABLE_NAME + "/" + alert.getValue("id"));
List<Map<String, Object>> actions = new ArrayList<>();
if(QContext.getQSession().hasPermission(AlertsQBitConfig.PERMISSION_MANAGE))
{
if(Status.OPEN.name().equals(alert.getValueString("status")))
{
actions.add(processAction("acknowledge", AlertsAction.ACKNOWLEDGE, alert.getValueInteger("id")));
}
actions.add(processAction("snooze", AlertsAction.SNOOZE, alert.getValueInteger("id")));
}
row.put("actions", actions);
return (row);
}
/*******************************************************************************
** Complete navigation target so clients need no process or table conventions.
*******************************************************************************/
private static Map<String, Object> processAction(String name, AlertsAction action, Integer alertId)
{
return (Map.of("name", name, "label", action.getLabel(), "processName", action.getProcessName(),
"href", "/app/" + action.getProcessName() + "?recordsParam=recordIds&recordIds=" + alertId + "&tableName=" + AlertsAlert.TABLE_NAME));
}
/*******************************************************************************
** Group labels by id (empty without read permission).
*******************************************************************************/
@@ -0,0 +1,97 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.List;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.openapi.model.Method;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
/*******************************************************************************
** Shared JSON transport; AlertsApi owns authorization and behavior.
*******************************************************************************/
abstract class AlertsJsonSpec extends AbstractJsonEndpointSpec
{
/*******************************************************************************
** The stable operation identifier exposed to clients.
*******************************************************************************/
protected abstract String operationId();
/*******************************************************************************
** Publish operation identifiers in both the fragment and served document.
*******************************************************************************/
@Override
public Method defineMethod()
{
return (super.defineMethod().withOperationId(operationId()));
}
/*******************************************************************************
** Keep the existing Content-Type requirement, including empty POST bodies.
*******************************************************************************/
@Override
public JsonInput buildInput(Context context) throws Exception
{
if(defineRequestBodySchema() != null && (context.contentType() == null || !context.contentType().toLowerCase(java.util.Locale.ROOT).startsWith("application/json")))
{
throw (new QBadRequestException("The request body must be JSON (Content-Type: application/json)."));
}
try
{
return (super.buildInput(context));
}
catch(QBadRequestException e)
{
throw (new QBadRequestException("The request body is not valid JSON."));
}
}
/*******************************************************************************
** Framework error responses retain their existing HTTP status mapping.
*******************************************************************************/
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AlertsErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "AlertsErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "AlertsErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found", "AlertsErrorV1")));
}
/*******************************************************************************
** Resolve the runtime in the authenticated caller context.
*******************************************************************************/
protected AlertsApi api() throws QException
{
return (new AlertsApi(AlertsRuntime.get()));
}
}
@@ -18,10 +18,10 @@ package com.kingsrook.qbits.alerts.api;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
@@ -30,32 +30,20 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
import io.javalin.config.JavalinConfig;
import io.javalin.http.ContentType;
import io.javalin.http.Context;
/*******************************************************************************
** Javalin routes under /qqq/v1/alerts for the Next UI bell and record panel:
** - GET /inbox/summary unread count, highest unread severity, latest 10,
** sticky banners
** - GET /inbox the notifications page (status, category,
** severity, offset, limit)
** - POST /inbox/read {"id": n}, {"ids": [..]}, {"all": true} or {"alertId": n}
** - POST /inbox/unread {"id": n} or {"ids": [..]}
** - POST /inbox/dismiss {"id": n}, {"ids": [..]} or {"all": true}
** - GET /record-panel?tableName=t&amp;recordId=id the "Open alerts" panel
** - POST /record-panel/watch {"tableName": t, "recordId": id}
** - POST /ingest/alertmanager Alertmanager's webhook (bearer token; off
** unless alertmanagerIngestTokenSecretRef is configured)
** Each request gets a session from the instance's authentication module;
** AlertsApi checks alerts.view and works in that session. Errors map
** through QJavalinUtils.handleException (400, 401, 403, 404). POST bodies
** must be JSON.
** Machine webhook routes. The UI routes are registered through AlertsUiApi.
*******************************************************************************/
public class AlertsRouteProvider implements QJavalinRouteProviderInterface
{
private static final QLogger LOG = QLogger.getLogger(AlertsRouteProvider.class);
public static final String WEBHOOK_PATH = "/qqq/hooks/alerts/alertmanager";
public static final String LEGACY_SUNSET = "Tue, 13 Apr 2027 00:00:00 GMT";
public static final String BASE_PATH = "/qqq/v1/alerts";
static final int MAX_INGEST_BYTES = 1024 * 1024;
@@ -97,14 +85,17 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
@Override
public void acceptJavalinConfig(JavalinConfig config)
{
config.routes.get(BASE_PATH + "/inbox/summary", context -> respond(context, false, (api, body) -> api.inboxSummary()));
config.routes.get(BASE_PATH + "/inbox", context -> respond(context, false, (api, body) -> api.inbox(queryParams(context))));
config.routes.post(BASE_PATH + "/inbox/read", context -> respond(context, true, AlertsApi::markRead));
config.routes.post(BASE_PATH + "/inbox/unread", context -> respond(context, true, AlertsApi::markUnread));
config.routes.post(BASE_PATH + "/inbox/dismiss", context -> respond(context, true, AlertsApi::dismiss));
config.routes.get(BASE_PATH + "/record-panel", context -> respond(context, false, (api, body) -> api.recordPanel(context.queryParam("tableName"), context.queryParam("recordId"))));
config.routes.post(BASE_PATH + "/record-panel/watch", context -> respond(context, true, AlertsApi::watchRecord));
config.routes.post(BASE_PATH + "/ingest/alertmanager", this::ingestAlertmanager);
config.routes.post(WEBHOOK_PATH, this::ingestAlertmanager);
if(Boolean.TRUE.equals(AlertsRuntime.find(qInstance).getConfig().getLegacyAlertmanagerPath()))
{
config.routes.post(BASE_PATH + "/ingest/alertmanager", context ->
{
context.header("Deprecation", "true");
context.header("Sunset", LEGACY_SUNSET);
LOG.warn("Deprecated Alertmanager webhook called; migrate to " + WEBHOOK_PATH);
ingestAlertmanager(context);
});
}
}
@@ -138,43 +129,6 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
/*******************************************************************************
** Session, body, handler, JSON - or the error's status.
*******************************************************************************/
private void respond(Context context, boolean readsBody, Handler handler)
{
try
{
ExecutorSessionUtils.setupSession(context, qInstance);
Map<String, Object> body = readsBody ? readJsonBody(context) : Map.of();
Object result = handler.handle(new AlertsApi(AlertsRuntime.get(qInstance)), body);
context.contentType(ContentType.APPLICATION_JSON);
context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS)));
}
catch(Exception e)
{
QJavalinUtils.handleException(null, context, e);
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** The first value of each query parameter.
*******************************************************************************/
private static Map<String, String> queryParams(Context context)
{
Map<String, String> params = new java.util.HashMap<>();
context.queryParamMap().forEach((name, values) -> params.put(name, values == null || values.isEmpty() ? null : values.get(0)));
return (params);
}
/*******************************************************************************
** The JSON body (an object), or 400.
*******************************************************************************/
@@ -203,15 +157,4 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
/*******************************************************************************
** One endpoint's work.
*******************************************************************************/
@FunctionalInterface
interface Handler
{
/***************************************************************************
**
***************************************************************************/
Object handle(AlertsApi api, Map<String, Object> body) throws Exception;
}
}
@@ -0,0 +1,306 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Explicit versioned wire schemas, independent of persistence entities.
*******************************************************************************/
public final class AlertsSchemas
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsSchemas()
{
}
/*******************************************************************************
** The complete component set shared by the six operations.
*******************************************************************************/
public static Map<String, Schema> components()
{
return (new TreeMap<>(Map.ofEntries(
Map.entry("AlertsActionV1", new AlertsActionV1(null, null, null, null, null).toSchema()),
Map.entry("AlertsInboxItemV1", new AlertsInboxItemV1(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsPanelAlertV1", new AlertsPanelAlertV1(null, null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsFollowV1", new AlertsFollowV1(null).toSchema()),
Map.entry("AlertsInboxSummaryV1", new AlertsInboxSummaryV1(null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsInboxPageV1", new AlertsInboxPageV1(null, null, null, null).toSchema()),
Map.entry("AlertsRecordPanelV1", new AlertsRecordPanelV1(null, null, null, null, null, null, null, null).toSchema()),
Map.entry("AlertsMutationResultV1", new AlertsMutationResultV1(null).toSchema()),
Map.entry("AlertsInboxTargetV1", new AlertsInboxTargetV1(null, null, null, null).toSchema()),
Map.entry("AlertsUnreadTargetV1", new AlertsUnreadTargetV1(null, null, null).toSchema()),
Map.entry("AlertsErrorV1", new AlertsErrorV1(null).toSchema()))));
}
/*******************************************************************************
** An available action: operationId targets an inbox operation with this item id; process actions provide a complete href.
*******************************************************************************/
public record AlertsActionV1(String name, String label, String operationId, String processName, String href) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("An available action: operationId targets an inbox operation with this item id; process actions provide a complete href.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("name", new Schema().withType(Type.STRING)),
Map.entry("label", new Schema().withType(Type.STRING)),
Map.entry("operationId", new Schema().withType(Type.STRING)),
Map.entry("processName", new Schema().withType(Type.STRING)),
Map.entry("href", new Schema().withType(Type.STRING))))));
}
}
/*******************************************************************************
** A notification recipient. body is included only in page items and banners; alertId only for ALERT notifications.
*******************************************************************************/
public record AlertsInboxItemV1(Integer id, Integer notificationId, String title, String body, String severity, String category, String source, String href, String linkLabel, String deliveredAt, String age, Boolean unread, Boolean sticky, Integer alertId, List<AlertsActionV1> actions) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("A notification recipient. body is included only in page items and banners; alertId only for ALERT notifications.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("notificationId", new Schema().withType(Type.INTEGER)),
Map.entry("title", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("body", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("category", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("source", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("href", new Schema().withType(Type.STRING).withNullable(true).withDescription("Existing safe link, or null.")),
Map.entry("linkLabel", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("deliveredAt", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("age", new Schema().withType(Type.STRING)),
Map.entry("unread", new Schema().withType(Type.BOOLEAN)),
Map.entry("sticky", new Schema().withType(Type.BOOLEAN)),
Map.entry("alertId", new Schema().withType(Type.INTEGER).withNullable(true)),
Map.entry("actions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsActionV1")))))));
}
}
/*******************************************************************************
** A visible open alert with permission-aware process actions.
*******************************************************************************/
public record AlertsPanelAlertV1(Integer alertId, String title, String severity, String status, String group, String age, String raisedAt, String href, List<AlertsActionV1> actions) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("A visible open alert with permission-aware process actions.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("alertId", new Schema().withType(Type.INTEGER)),
Map.entry("title", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("status", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("group", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("age", new Schema().withType(Type.STRING)),
Map.entry("raisedAt", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("href", new Schema().withType(Type.STRING)),
Map.entry("actions", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsActionV1")))))));
}
}
/*******************************************************************************
** Capability discovery only. Next obtains the follow URL from its collaboration module.
*******************************************************************************/
public record AlertsFollowV1(Boolean available) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Capability discovery only. Next obtains the follow URL from its collaboration module.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("available", new Schema().withType(Type.BOOLEAN).withDescription("True exactly when the collaboration UI API extension is registered."))))));
}
}
/*******************************************************************************
** Bell summary, latest ten items and up to three sticky banners.
*******************************************************************************/
public record AlertsInboxSummaryV1(Integer unreadCount, String highestUnreadSeverity, List<AlertsInboxItemV1> items, List<AlertsInboxItemV1> banners, String notificationsHref, String inboxHref, String alertsHref, Integer pollSeconds) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Bell summary, latest ten items and up to three sticky banners.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("unreadCount", new Schema().withType(Type.INTEGER)),
Map.entry("highestUnreadSeverity", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("banners", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("notificationsHref", new Schema().withType(Type.STRING).withDescription("Existing notifications page link; retained for compatibility.")),
Map.entry("inboxHref", new Schema().withType(Type.STRING).withDescription("Canonical inbox navigation link.")),
Map.entry("alertsHref", new Schema().withType(Type.STRING)),
Map.entry("pollSeconds", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Filtered notification page.
*******************************************************************************/
public record AlertsInboxPageV1(List<AlertsInboxItemV1> items, Integer total, Integer offset, Integer limit) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Filtered notification page.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("items", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsInboxItemV1"))),
Map.entry("total", new Schema().withType(Type.INTEGER)),
Map.entry("offset", new Schema().withType(Type.INTEGER)),
Map.entry("limit", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Open alerts, recent resolutions and collaboration capability. watching is deprecated legacy subscription state.
*******************************************************************************/
public record AlertsRecordPanelV1(Boolean visible, Integer openCount, List<AlertsPanelAlertV1> open, Integer resolvedLast30Days, String viewAllHref, Boolean canManage, Boolean watching, AlertsFollowV1 follow) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Open alerts, recent resolutions and collaboration capability. watching is deprecated legacy subscription state.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("visible", new Schema().withType(Type.BOOLEAN)),
Map.entry("openCount", new Schema().withType(Type.INTEGER)),
Map.entry("open", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("AlertsPanelAlertV1"))),
Map.entry("resolvedLast30Days", new Schema().withType(Type.INTEGER)),
Map.entry("viewAllHref", new Schema().withType(Type.STRING).withNullable(true)),
Map.entry("canManage", new Schema().withType(Type.BOOLEAN)),
Map.entry("watching", new Schema().withType(Type.BOOLEAN).withDescription("Deprecated: existing RECORD subscriptions only; does not represent collaboration follow state.")),
Map.entry("follow", new Schema().withRefToSchema("AlertsFollowV1"))))));
}
}
/*******************************************************************************
** Number of the caller own recipient rows changed.
*******************************************************************************/
public record AlertsMutationResultV1(Integer updated) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Number of the caller own recipient rows changed.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("updated", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Select id, nonempty ids (first 500), legacy alertId, or all:true in that precedence. All scans are capped at 1000 rows.
*******************************************************************************/
public record AlertsInboxTargetV1(Integer id, List<Integer> ids, Integer alertId, Boolean all) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Select id, nonempty ids (first 500), legacy alertId, or all:true in that precedence. All scans are capped at 1000 rows.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER))),
Map.entry("alertId", new Schema().withType(Type.INTEGER)),
Map.entry("all", new Schema().withType(Type.BOOLEAN))))));
}
}
/*******************************************************************************
** Select id or nonempty ids (first 500), in that precedence. Legacy alertId is also accepted for compatibility. all is not supported.
*******************************************************************************/
public record AlertsUnreadTargetV1(Integer id, List<Integer> ids, Integer alertId) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Select id or nonempty ids (first 500), in that precedence. Legacy alertId is also accepted for compatibility. all is not supported.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("id", new Schema().withType(Type.INTEGER)),
Map.entry("ids", new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.INTEGER))),
Map.entry("alertId", new Schema().withType(Type.INTEGER))))));
}
}
/*******************************************************************************
** Framework error response.
*******************************************************************************/
public record AlertsErrorV1(String error) implements ToSchema
{
/*******************************************************************************
** Describe the public JSON shape.
*******************************************************************************/
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Framework error response.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
/*******************************************************************************
** The alerts UI API, retaining the established /alerts namespace.
*******************************************************************************/
public final class AlertsUiApi
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsUiApi()
{
}
/*******************************************************************************
** Register once, alongside the optional Javalin webhook provider.
*******************************************************************************/
public static void register(QInstance instance)
{
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/*******************************************************************************
** The independently versioned, discoverable UI contract.
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("alerts").withPathPrefix("/alerts")
.withSemanticVersion("1.0.0").withDescription("Alerts inbox and record panel")
.withSpec(InboxSummarySpec.class).withSpec(InboxSpec.class)
.withSpec(InboxReadSpec.class).withSpec(InboxUnreadSpec.class)
.withSpec(InboxDismissSpec.class).withSpec(RecordPanelSpec.class));
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Dismiss own notifications.
*******************************************************************************/
public class InboxDismissSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxDismiss";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/dismiss")
.withShortSummary("Dismiss own notifications").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().dismiss(input.getBody()));
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Mark own notifications read.
*******************************************************************************/
public class InboxReadSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxRead";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/read")
.withShortSummary("Mark own notifications read").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsInboxTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().markRead(input.getBody()));
}
}
@@ -0,0 +1,96 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.List;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Page the notification inbox.
*******************************************************************************/
public class InboxSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInbox";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox")
.withShortSummary("Page the notification inbox").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsInboxPageV1");
}
/*******************************************************************************
** Existing query parameters and paging semantics.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(
new Parameter().withName("status").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("all or unread; other values behave as all."),
new Parameter().withName("category").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive category filter."),
new Parameter().withName("severity").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.STRING)).withDescription("Case-insensitive severity filter."),
new Parameter().withName("offset").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 0; negatives clamp to 0; malformed values use the default."),
new Parameter().withName("limit").withIn(In.QUERY).withRequired(false).withSchema(new Schema().withType(Type.INTEGER)).withDescription("Default 25; clamped to 1..100; malformed values use the default.")));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().inbox(input.getQueryParams()));
}
}
@@ -0,0 +1,88 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.Map;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Read the bell summary.
*******************************************************************************/
public class InboxSummarySpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxSummary";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/inbox/summary")
.withShortSummary("Read the bell summary").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsInboxSummaryV1");
}
/*******************************************************************************
** Publish shared components once; all six extension specs register together.
*******************************************************************************/
@Override
public Map<String, Schema> defineComponentSchemas()
{
return (AlertsSchemas.components());
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().inboxSummary());
}
}
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Mark own notifications unread.
*******************************************************************************/
public class InboxUnreadSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsInboxUnread";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath("/alerts/inbox/unread")
.withShortSummary("Mark own notifications unread").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsMutationResultV1");
}
/*******************************************************************************
** Recipient selectors; precedence is id, ids, alertId, then all.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (new Schema().withRefToSchema("AlertsUnreadTargetV1"));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().markUnread(input.getBody()));
}
}
@@ -0,0 +1,93 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.api;
import java.util.List;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Read visible alerts for a record.
*******************************************************************************/
public class RecordPanelSpec extends AlertsJsonSpec
{
public static final String OPERATION_ID = "alertsRecordPanel";
/*******************************************************************************
** Describe the established method and path.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/alerts/record-panel")
.withShortSummary("Read visible alerts for a record").withLongDescription("Uses the signed-in caller's permissions and record visibility."));
}
/*******************************************************************************
** Stable identifier for generated clients.
*******************************************************************************/
@Override
protected String operationId()
{
return (OPERATION_ID);
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("AlertsRecordPanelV1");
}
/*******************************************************************************
** Existing query parameters and paging semantics.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(
new Parameter().withName("tableName").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent table name."),
new Parameter().withName("recordId").withIn(In.QUERY).withRequired(true).withSchema(new Schema().withType(Type.STRING)).withDescription("Parent record key.")));
}
/*******************************************************************************
** Invoke the existing service with the caller session.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (api().recordPanel(input.getQueryParam("tableName"), input.getQueryParam("recordId")));
}
}
@@ -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."));
}
}
}
@@ -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()));
}
@@ -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;
@@ -0,0 +1,518 @@
components:
schemas:
AlertsActionV1:
description: "An available action: operationId targets an inbox operation with\
\ this item id; process actions provide a complete href."
properties:
href:
type: "string"
label:
type: "string"
name:
type: "string"
operationId:
type: "string"
processName:
type: "string"
type: "object"
AlertsErrorV1:
description: "Framework error response."
properties:
error:
nullable: true
type: "string"
type: "object"
AlertsFollowV1:
description: "Capability discovery only. Next obtains the follow URL from its\
\ collaboration module."
properties:
available:
description: "True exactly when the collaboration UI API extension is registered."
type: "boolean"
type: "object"
AlertsInboxItemV1:
description: "A notification recipient. body is included only in page items\
\ and banners; alertId only for ALERT notifications."
properties:
actions:
items:
$ref: "#/components/schemas/AlertsActionV1"
type: "array"
age:
type: "string"
alertId:
nullable: true
type: "integer"
body:
nullable: true
type: "string"
category:
nullable: true
type: "string"
deliveredAt:
nullable: true
type: "string"
href:
description: "Existing safe link, or null."
nullable: true
type: "string"
id:
type: "integer"
linkLabel:
nullable: true
type: "string"
notificationId:
type: "integer"
severity:
nullable: true
type: "string"
source:
nullable: true
type: "string"
sticky:
type: "boolean"
title:
nullable: true
type: "string"
unread:
type: "boolean"
type: "object"
AlertsInboxPageV1:
description: "Filtered notification page."
properties:
items:
items:
$ref: "#/components/schemas/AlertsInboxItemV1"
type: "array"
limit:
type: "integer"
offset:
type: "integer"
total:
type: "integer"
type: "object"
AlertsInboxSummaryV1:
description: "Bell summary, latest ten items and up to three sticky banners."
properties:
alertsHref:
type: "string"
banners:
items:
$ref: "#/components/schemas/AlertsInboxItemV1"
type: "array"
highestUnreadSeverity:
nullable: true
type: "string"
inboxHref:
description: "Canonical inbox navigation link."
type: "string"
items:
items:
$ref: "#/components/schemas/AlertsInboxItemV1"
type: "array"
notificationsHref:
description: "Existing notifications page link; retained for compatibility."
type: "string"
pollSeconds:
type: "integer"
unreadCount:
type: "integer"
type: "object"
AlertsInboxTargetV1:
description: "Select id, nonempty ids (first 500), legacy alertId, or all:true\
\ in that precedence. All scans are capped at 1000 rows."
properties:
alertId:
type: "integer"
all:
type: "boolean"
id:
type: "integer"
ids:
items:
type: "integer"
type: "array"
type: "object"
AlertsMutationResultV1:
description: "Number of the caller own recipient rows changed."
properties:
updated:
type: "integer"
type: "object"
AlertsPanelAlertV1:
description: "A visible open alert with permission-aware process actions."
properties:
actions:
items:
$ref: "#/components/schemas/AlertsActionV1"
type: "array"
age:
type: "string"
alertId:
type: "integer"
group:
nullable: true
type: "string"
href:
type: "string"
raisedAt:
nullable: true
type: "string"
severity:
nullable: true
type: "string"
status:
nullable: true
type: "string"
title:
nullable: true
type: "string"
type: "object"
AlertsRecordPanelV1:
description: "Open alerts, recent resolutions and collaboration capability.\
\ watching is deprecated legacy subscription state."
properties:
canManage:
type: "boolean"
follow:
$ref: "#/components/schemas/AlertsFollowV1"
open:
items:
$ref: "#/components/schemas/AlertsPanelAlertV1"
type: "array"
openCount:
type: "integer"
resolvedLast30Days:
type: "integer"
viewAllHref:
nullable: true
type: "string"
visible:
type: "boolean"
watching:
description: "Deprecated: existing RECORD subscriptions only; does not represent\
\ collaboration follow state."
type: "boolean"
type: "object"
AlertsUnreadTargetV1:
description: "Select id or nonempty ids (first 500), in that precedence. Legacy\
\ alertId is also accepted for compatibility. all is not supported."
properties:
alertId:
type: "integer"
id:
type: "integer"
ids:
items:
type: "integer"
type: "array"
type: "object"
securitySchemes:
sessionUuidCookie:
in: "cookie"
name: "sessionUUID"
type: "apiKey"
info:
contact:
email: "contact@kingsrook.com"
description: "Alerts inbox and record panel"
title: "QQQ Middleware API extension: alerts"
version: "1.0.0"
openapi: "3.0.3"
paths:
/qqq/v1/alerts/inbox/summary:
get:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInboxSummary"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsInboxSummaryV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Read the bell summary"
/qqq/v1/alerts/inbox:
get:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInbox"
parameters:
- description: "all or unread; other values behave as all."
in: "query"
name: "status"
required: false
schema:
type: "string"
- description: "Case-insensitive category filter."
in: "query"
name: "category"
required: false
schema:
type: "string"
- description: "Case-insensitive severity filter."
in: "query"
name: "severity"
required: false
schema:
type: "string"
- description: "Default 0; negatives clamp to 0; malformed values use the default."
in: "query"
name: "offset"
required: false
schema:
type: "integer"
- description: "Default 25; clamped to 1..100; malformed values use the default."
in: "query"
name: "limit"
required: false
schema:
type: "integer"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsInboxPageV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Page the notification inbox"
/qqq/v1/alerts/inbox/read:
post:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInboxRead"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsInboxTargetV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Mark own notifications read"
/qqq/v1/alerts/inbox/unread:
post:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInboxUnread"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsUnreadTargetV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Mark own notifications unread"
/qqq/v1/alerts/inbox/dismiss:
post:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsInboxDismiss"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsInboxTargetV1"
required: true
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsMutationResultV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Dismiss own notifications"
/qqq/v1/alerts/record-panel:
get:
description: "Uses the signed-in caller's permissions and record visibility."
operationId: "alertsRecordPanel"
parameters:
- description: "Parent table name."
in: "query"
name: "tableName"
required: true
schema:
type: "string"
- description: "Parent record key."
in: "query"
name: "recordId"
required: true
schema:
type: "string"
responses:
400:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Invalid input"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Permission denied"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsErrorV1"
description: "Not found"
200:
content:
application/json:
schema:
$ref: "#/components/schemas/AlertsRecordPanelV1"
description: "Successful Response"
security:
- sessionUuidCookie:
- "N/A"
summary: "Read visible alerts for a record"
@@ -0,0 +1,81 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts;
import java.nio.file.Files;
import java.nio.file.Path;
import com.kingsrook.qbits.alerts.api.AlertsUiApi;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The committed extension fragment describes exactly the six UI operations.
*******************************************************************************/
class AlertsApiContractTest extends BaseAlertsTest
{
/*******************************************************************************
** Regenerate only by explicit build property, then check the deterministic fragment.
*******************************************************************************/
@Test
void contract() throws Exception
{
var extension = AlertsUiApi.extension();
var version = new MiddlewareVersionV1();
String generated = UiApiContract.generateExtensionYaml(version, extension);
Path path = Path.of("src/main/resources/openapi/qqq-v1/alerts.yaml");
if(Boolean.getBoolean("alerts.openapi.update"))
{
Files.createDirectories(path.getParent());
Files.writeString(path, generated);
}
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -Dtest=AlertsApiContractTest -Dalerts.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Alerts"));
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder(
"/qqq/v1/alerts/inbox/summary", "/qqq/v1/alerts/inbox", "/qqq/v1/alerts/inbox/read",
"/qqq/v1/alerts/inbox/unread", "/qqq/v1/alerts/inbox/dismiss", "/qqq/v1/alerts/record-panel");
assertThat(extension.getSpecs()).hasSize(6);
assertThat(generated).contains("alertsInboxSummary", "alertsInboxRead", "alertsInboxUnread", "alertsInboxDismiss", "alertsRecordPanel", "inboxHref", "actions", "follow");
assertThat(generated).doesNotContain("/watch", "/ingest/", "/hooks/");
}
/*******************************************************************************
** Production registers one extension; the existing route opt-out still works.
*******************************************************************************/
@Test
void registration() throws Exception
{
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).containsOnlyKeys("alerts");
AlertsUiApi.register(qInstance);
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).hasSize(1);
assertThat(AlertsUiApi.extension().getEffectivePathPrefix()).isEqualTo("/alerts");
QInstance disabled = new QInstance();
disabled.addBackend(defineH2Backend());
new AlertsQBitProducer().withQBitConfig(new AlertsQBitConfig().withBackendName(BACKEND_NAME).withRegisterRoutes(false)).produce(disabled);
assertThat(MiddlewareSpecExtensionsMetaData.of(disabled)).isNull();
assertThat(new AlertsQBitConfig().getLegacyAlertmanagerPath()).isFalse();
}
}
@@ -0,0 +1,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<String, Object> context)
{
QSession granted = sessionFor(session.getUuid());
session.setUser(granted.getUser());
session.setPermissions(granted.getPermissions());
AlertsSecurity.applyToSession(instance, session);
}
}
/*******************************************************************************
** Shut down even after a failed assertion or startup.
*******************************************************************************/
@AfterEach
void stopServer()
{
if(server != null)
{
server.stop();
}
System.clearProperty(TOKEN_PROPERTY);
}
/*******************************************************************************
** Every application server runs strict; the legacy alias is tested separately.
*******************************************************************************/
private void start() throws Exception
{
qInstance.getAuthentication().setCustomizer(new QCodeReference(HttpSessionCustomizer.class));
server = new QApplicationJavalinServer(new AbstractQQQApplication()
{
/***************************************************************************
** Use the test database and produced metadata.
***************************************************************************/
@Override
public QInstance defineQInstance()
{
return (qInstance);
}
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
.withMiddlewareVersionList(List.of(new MiddlewareVersionV1()));
server.start();
port = server.getBoundPort();
}
/*******************************************************************************
** Send through Javalin with an explicit user and optional bearer authorization.
*******************************************************************************/
private HttpResponse<String> request(String method, String path, String body, String user, String authorization) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path))
.header("Cookie", "sessionUUID=" + user);
if(authorization != null)
{
request.header("Authorization", authorization);
}
if(body != null)
{
request.header("Content-Type", "application/json");
}
request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
try(HttpClient client = HttpClient.newHttpClient())
{
return (client.send(request.build(), HttpResponse.BodyHandlers.ofString()));
}
}
/*******************************************************************************
** Parse success bodies while reporting any unexpected framework failure.
*******************************************************************************/
@SuppressWarnings("unchecked")
private Map<String, Object> json(HttpResponse<String> response) throws Exception
{
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
return (JsonUtils.toObject(response.body(), Map.class));
}
/*******************************************************************************
** Existing paths, selectors, paging, null fields and additive client affordances.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void strictUiRoutes() throws Exception
{
new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).inboxSummary();
Integer widget = insertWidgets("BROKEN").get(0);
var alert = runtime.getEngine().raise(new AlertSignal("test.http.problem").withDedupKey("http")
.withTitle("Needs attention").withSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR).withRecordRef(AlertRecordRef.of(WIDGET, widget)));
start();
String base = "/qqq/v1/alerts";
Map<String, Object> summary = json(request("GET", base + "/inbox/summary", null, ADMIN, null));
assertThat(summary).containsEntry("inboxHref", "/app/notifications").containsEntry("notificationsHref", "/app/notifications");
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
assertThat(items).hasSize(1);
Map<String, Object> item = items.get(0);
assertThat(item).containsKeys("href", "actions", "alertId");
assertThat((List<Map<String, Object>>) item.get("actions")).extracting(a -> a.get("operationId")).containsExactly("alertsInboxRead", "alertsInboxDismiss");
String target = JsonUtils.toJson(Map.of("id", item.get("id")));
assertThat(json(request("POST", base + "/inbox/read", target, ADMIN, null))).containsEntry("updated", 1);
assertThat(json(request("POST", base + "/inbox/unread", target, ADMIN, null))).containsEntry("updated", 1);
assertThat(json(request("GET", base + "/inbox?status=unread&offset=bad&limit=1000", null, ADMIN, null)))
.containsEntry("total", 1).containsEntry("offset", 0).containsEntry("limit", 100);
Map<String, Object> panel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, ADMIN, null));
assertThat(panel).containsEntry("follow", Map.of("available", false)).containsEntry("watching", false);
Map<String, Object> row = ((List<Map<String, Object>>) panel.get("open")).get(0);
assertThat((List<Map<String, Object>>) row.get("actions")).extracting(a -> a.get("processName")).containsExactly("alertsAcknowledge", "alertsSnooze");
assertThat(((List<Map<String, Object>>) row.get("actions")).get(0).get("href"))
.isEqualTo("/app/alertsAcknowledge?recordsParam=recordIds&recordIds=" + alert.getValue("id") + "&tableName=alerts_alert");
Map<String, Object> viewerPanel = json(request("GET", base + "/record-panel?tableName=widget&recordId=" + widget, null, VIEWER, null));
assertThat(((List<Map<String, Object>>) viewerPanel.get("open")).get(0).get("actions")).isEqualTo(List.of());
assertThat(json(request("POST", base + "/inbox/dismiss", target, ADMIN, null))).containsEntry("updated", 1);
assertThat(json(request("GET", base + "/inbox/summary", null, ADMIN, null))).containsEntry("highestUnreadSeverity", null);
assertThat(request("POST", base + "/record-panel/watch", "{}", ADMIN, null).statusCode()).isEqualTo(404);
HttpResponse<String> invalidJson = request("POST", base + "/inbox/read", "not json", ADMIN, null);
assertThat(invalidJson.statusCode()).isEqualTo(400);
assertThat(invalidJson.body()).contains("The request body is not valid JSON.");
assertThat(request("POST", base + "/inbox/read", null, ADMIN, null).statusCode()).isEqualTo(400);
assertThat(request("GET", base + "/inbox/summary", null, "nobody", null).statusCode()).isEqualTo(403);
assertThat(request("GET", base + "/record-panel?tableName=widget&recordId=1", null, OUTSIDER, null).statusCode()).isEqualTo(403);
String openapi = request("GET", "/qqq/v1/openapi.yaml", null, ADMIN, null).body();
assertThat(openapi).contains("alertsInboxSummary", "alertsRecordPanel").doesNotContain("/record-panel/watch", "/ingest/alertmanager");
}
/*******************************************************************************
** The canonical webhook uses the same secret and body validation outside v1.
*******************************************************************************/
@Test
void canonicalWebhook() throws Exception
{
System.setProperty(TOKEN_PROPERTY, TOKEN);
config.withAlertmanagerIngestTokenSecretRef("${prop." + TOKEN_PROPERTY + "}");
start();
assertThat(request("POST", AlertsRouteProvider.BASE_PATH + "/ingest/alertmanager", "{}", ADMIN, "Bearer " + TOKEN).statusCode()).isEqualTo(404);
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{}", "nobody", null).statusCode()).isEqualTo(401);
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{}", "nobody", "Bearer wrong").statusCode()).isEqualTo(401);
HttpResponse<String> response = request("POST", AlertsRouteProvider.WEBHOOK_PATH, "{\"alerts\":[]}", "nobody", "Bearer " + TOKEN);
assertThat(json(response)).containsEntry("raised", 0).containsEntry("resolved", 0).containsEntry("ignored", 0);
assertThat(response.headers().firstValue("Deprecation")).isEmpty();
assertThat(request("POST", AlertsRouteProvider.WEBHOOK_PATH, "[]", "nobody", "Bearer " + TOKEN).statusCode()).isEqualTo(400);
}
/*******************************************************************************
** Test the migration-only raw provider and prove strict mode refuses its alias.
*******************************************************************************/
@Test
void legacyWebhook() throws Exception
{
System.setProperty(TOKEN_PROPERTY, TOKEN);
config.withAlertmanagerIngestTokenSecretRef("${prop." + TOKEN_PROPERTY + "}").withLegacyAlertmanagerPath(true);
AlertsRouteProvider provider = new AlertsRouteProvider();
provider.setQInstance(qInstance);
Javalin app = Javalin.create(provider::acceptJavalinConfig).start(0);
try
{
port = app.port();
String alias = AlertsRouteProvider.BASE_PATH + "/ingest/alertmanager";
for(String path : List.of(alias, AlertsRouteProvider.WEBHOOK_PATH))
{
assertThat(request("POST", path, "{}", "nobody", "Bearer wrong").statusCode()).isEqualTo(401);
HttpResponse<String> response = request("POST", path, "{\"alerts\":[]}", "nobody", "Bearer " + TOKEN);
assertThat(json(response)).containsEntry("raised", 0);
if(path.equals(alias))
{
assertThat(response.headers().firstValue("Deprecation")).contains("true");
assertThat(response.headers().firstValue("Sunset")).contains(AlertsRouteProvider.LEGACY_SUNSET);
}
}
assertThatThrownBy(() -> UiApiRouteAuditor.audit(app, List.of(new MiddlewareVersionV1()), true))
.isInstanceOf(IllegalStateException.class).hasMessageContaining(alias);
}
finally
{
app.stop();
}
}
}
@@ -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);
}
@@ -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;
@@ -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();
}