From 60b68cf722ba99a4325db4e46d729b93c3009a9f Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 20:34:22 -0500 Subject: [PATCH] refactor(notifications): deliver core QAlerts.notify; delete the spi stand-in Core 4.1.0-RC.6 ships user notifications (QAlerts.notify, NotificationSignal and friends; QRun-IO/qqq#1125). The qbit's sink now implements core's notify path, so a QAlerts.notify from any qbit reaches the notification queue, inbox, channels, quiet hours and preferences. The rest of the stand-in package com.kingsrook.qbits.alerts.spi goes (NotificationSignal, NotificationAudience, NotificationCategory, NotificationSeverity, AlertLink, package-info), and so does AlertsRuntime.sink(). Breaking for anything that used com.kingsrook.qbits.alerts.spi; its only consumer, qbit-easypost-tracking, moved to core in #16 there. Closes #15 (architecture review QRun-IO/qqq#1150, F3 and F12). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- CHANGELOG.md | 3 +- README.md | 6 +- docs/DESIGN.md | 2 +- docs/NOTIFICATIONS.md | 34 +- docs/UI-API.md | 9 + .../qbits/alerts/AlertsQBitProducer.java | 2 +- .../kingsrook/qbits/alerts/AlertsRuntime.java | 7 +- .../kingsrook/qbits/alerts/api/AlertsApi.java | 6 +- .../AlertsNotificationCustomizer.java | 6 +- .../alerts/engine/AlertsProcessListener.java | 13 +- .../alerts/engine/AlertsSinkAdapter.java | 22 +- .../qbits/alerts/engine/QbitAlertsSink.java | 5 +- .../metadata/AlertsTableMetaDataHelper.java | 6 +- .../alerts/model/AlertsPossibleValues.java | 6 +- .../notifications/AlertsAudienceResolver.java | 2 +- .../alerts/notifications/AlertsNotices.java | 48 +- .../notifications/NotificationEmailer.java | 6 +- .../notifications/NotificationService.java | 66 ++- .../processes/AlertsScheduledSteps.java | 2 +- .../kingsrook/qbits/alerts/spi/AlertLink.java | 155 ------ .../alerts/spi/NotificationAudience.java | 197 ------- .../alerts/spi/NotificationCategory.java | 51 -- .../alerts/spi/NotificationSeverity.java | 51 -- .../qbits/alerts/spi/NotificationSignal.java | 502 ------------------ .../qbits/alerts/spi/package-info.java | 35 -- .../qbits/alerts/AlertsApiHttpTest.java | 37 ++ .../qbits/alerts/demo/AlertsDemoServer.java | 22 +- .../qbits/alerts/engine/AlertsEngineTest.java | 10 +- .../alerts/engine/SinkAndScheduleTest.java | 18 +- .../NotificationEmailAndNoticesTest.java | 15 +- .../notifications/NotificationsTest.java | 105 +++- 32 files changed, 306 insertions(+), 1149 deletions(-) delete mode 100644 src/main/java/com/kingsrook/qbits/alerts/spi/AlertLink.java delete mode 100644 src/main/java/com/kingsrook/qbits/alerts/spi/NotificationAudience.java delete mode 100644 src/main/java/com/kingsrook/qbits/alerts/spi/NotificationCategory.java delete mode 100644 src/main/java/com/kingsrook/qbits/alerts/spi/NotificationSeverity.java delete mode 100644 src/main/java/com/kingsrook/qbits/alerts/spi/NotificationSignal.java delete mode 100644 src/main/java/com/kingsrook/qbits/alerts/spi/package-info.java diff --git a/AGENTS.md b/AGENTS.md index 27d622e..74ef0ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance to Codex when working with code in this repository. ## Project Overview -**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the notification compatibility types in `spi/`. +**QBit Alerts**: QQQ QBit: alerts and manage-by-exception (monitors, alerts, subscriptions, channels and notifications as standard QQQ tables). Design: [QRun-IO/qqq#1090](https://github.com/QRun-IO/qqq/pull/1090). Phases P1 to P3 are implemented; see README.md for the remaining phases and the core notification integration in `notifications/`. ## Build Commands @@ -25,7 +25,7 @@ Packages: - `processes`: the record actions (`AlertsAction`, `AlertsActionSteps`) and the scheduled steps. - `home`: the Alerts home and `AlertSummaryLoader`. - `api`: the bell and record-panel routes. -- `spi`: notification types and their AlertLink dependency retained for binary compatibility until core RC.6; alerts use core RC.5. +- `notifications`: core RC.6 notification delivery, audience resolution and email; all producer types come from `com.kingsrook.qqq.backend.core.alerts`. ## Naming (owner rule) @@ -44,7 +44,7 @@ Packages: ## Dependencies - Java 21. -- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. +- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1. - Tests: JUnit 5, AssertJ. ## Release diff --git a/CHANGELOG.md b/CHANGELOG.md index ab95dea..c4a1e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ All notable changes to this project are documented here. The format follows Keep - 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. +- **Breaking:** remove the entire `com.kingsrook.qbits.alerts.spi` package (`NotificationSignal`, `NotificationAudience`, `NotificationCategory`, `NotificationSeverity`, `AlertLink`, package metadata) and `AlertsRuntime.sink().notify(...)`. Producers must use core RC.6 types and `QAlerts.notify`; rebuild existing callers. qbit-easypost-tracking#16 moved to core. +- Implement core `AlertSinkInterface.notify` in the registered adapter and runtime queue, retaining notification inbox, audience resolution, category preferences and email delivery behavior (qbit-alerts#15 part 2, F3/F12; QRun-IO/qqq#1125). Notification calls now receive core commit/rollback guarantees. No table or HTTP wire changes in this migration. - Webhook signing delegates to core `WebhookSigner`/`WebhookSecret` (qbit-alerts#15, F11). Headers, `v1,` signatures and `whsec_` encoding are unchanged. A thin adapter preserves legacy keys shorter than core's 16-byte minimum using equivalent HMAC zero-padding. ### Added diff --git a/README.md b/README.md index 65fd968..d82af2b 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The qbit also carries ordinary notifications for people (design: [docs/NOTIFICAT - **Admins** with `alerts.notification.admin` write notifications in the Notifications table (title, markdown body, severity, category, link, audience, publish and expire times, a sticky banner flag), then use the record actions Preview audience, Send now, Schedule and Retract. Edits after sending show at once; retracting hides it. - **Audiences** are resolved when sent: all users, roles or groups, holders of a permission, or named users. Each user gets one `alerts_notification_recipient` row, so read, unread and dismissed are per user. Roles need a user directory (`AlertsQBitConfig.withUserDirectory`, an `AlertsUserDirectoryInterface`); without one, "all users" means everyone who has used Alerts. -- **Producers** send with `AlertsRuntime.get(qInstance).sink().notify(new NotificationSignal(...))` (core: `QAlerts.notify`). Alerts a user subscribes to arrive in the bell as ALERT notifications. +- **Producers** send with `QAlerts.notify(new NotificationSignal(...))` using `com.kingsrook.qqq.backend.core.alerts` types (RC.6). Alerts a user subscribes to arrive in the bell as ALERT notifications. - **Ready-made notices** in `AlertsNotices`: `newSignIn`, `apiKeyCreated`, `apiKeyExpiring`, `permissionChanged` (SECURITY), `shared` (SHARE) and `release` (RELEASE, all users). Core has no sign-in or API key events yet, so hosts call these from their authentication customizer and API key screens. - **Email** is opt-in per user and category (IMMEDIATE, or DAILY_DIGEST at the digest hour in My Alert Settings), through the configured messaging provider; without one it is off. ALERT notifications are not emailed here (alert subscriptions already do), and SECURITY emails carry only the title and link. - **Failed and partial jobs** notify the person who started them (`notifyFailedJobs`, `jobRunLinkPattern`). @@ -111,7 +111,9 @@ 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)) 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. +User notifications use core `QAlerts.notify(NotificationSignal)` (RC.6, QRun-IO/qqq#1125). Core dispatches through `AlertsSinkAdapter` into the same bounded queue, notification service, audience resolution and email preferences. Calls wait for an explicit or ambient transaction to commit and are dropped on rollback. + +**Breaking Java API migration:** the entire `com.kingsrook.qbits.alerts.spi` package and `AlertsRuntime.sink()` are removed. Replace notification and `AlertLink` imports with `com.kingsrook.qqq.backend.core.alerts`, and replace `AlertsRuntime.get(qInstance).sink().notify(signal)` with `QAlerts.notify(signal)` in the producer's `QContext`. Rebuild callers; qbit-easypost-tracking#16 already moved to core. `getSink()` is for adapter delegation and queue lifecycle management, not a producer API. Notification tables and HTTP contracts are unchanged by this migration. ## Endpoints (for the Next UI) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 99c8eae..f730937 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -4,7 +4,7 @@ The design for this qbit is tracked in [QRun-IO/qqq#1090](https://github.com/QRu 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. +- **Alerts and notifications use core's RC.6 producer API**, including sink registration in core instance metadata (QRun-IO/qqq#1108 and #1125). `AlertsSinkAdapter` delegates `raise`, `resolve`, `heartbeat` and `notify` to the runtime queue. No local SPI types remain; storage-specific audience and severity conversion belongs to `NotificationService`. - **Visibility checks 1 to 3 (section 7.2)** are enforced by record security locks on session keys (`alerts_permission`, `alerts_readable_table`), not by a pre-query customizer. QQQ has no pre-query customizer, and locks also apply to counts, exports and the API. - **Heartbeats** are stored in an extra table, `alerts_heartbeat`. - **Claims** are leases, not atomic conditional updates (see README.md, Known limits). diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index ad1e72a..b608469 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -138,10 +138,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. 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: +The notification API from [QRun-IO/qqq#1125](https://github.com/QRun-IO/qqq/pull/1125) +ships in 4.1.0-RC.6. This branch targets that release and delivers core `QAlerts.notify` +calls through the installed qbit sink: ```java QAlerts.notify(new NotificationSignal("Your export failed") @@ -153,17 +152,26 @@ QAlerts.notify(new NotificationSignal("Your export failed") .withAudience(NotificationAudience.user(userId))); ``` -- `AlertSinkInterface` gets a default method, `notify(NotificationSignal)`, that logs; existing sinks keep - working. `LoggingAlertSink` logs at INFO. The qbit's sink queues the signal like an alert signal. +- `AlertSinkInterface` has a default method, `notify(NotificationSignal)`, that logs; existing sinks keep + working. `LoggingAlertSink` logs at INFO (WARN for ERROR/CRITICAL). The qbit's sink queues the signal like an alert signal. - The guarantees match `raise`: it never throws or blocks, it waits for the commit when a transaction is open, and bad input is counted. - `NotificationAudience`: `allUsers()`, `roles(...)`, `permission(p)`, `users(...)`, `user(id)`. -- Until RC.6 ships, the qbit keeps `spi.NotificationSignal`, `NotificationAudience`, - `NotificationCategory`, `NotificationSeverity` and `AlertLink`, and hosts call - `AlertsRuntime.get(qInstance).sink().notify(...)`. Their binary signatures remain unchanged for - released qbit-easypost-tracking 0.1.x. The return type of `sink()` remains `QbitAlertsSink`; - there is no local sink interface. This direct notification path queues immediately; the - transaction-aware `QAlerts.notify` guarantees above describe the planned core API. +- All producer types, including `AlertLink`, are from `com.kingsrook.qqq.backend.core.alerts`. + The qbit implements core's `notify` override in `AlertsSinkAdapter` and `QbitAlertsSink`; + signals use the existing bounded queue and `NotificationService` for audience resolution, + inbox recipients, scheduling and category/email preferences. Alert subscription channels + and their quiet-hour suppression keep their existing behavior. +- **Breaking migration:** the local `spi` package and `AlertsRuntime.sink()` are removed. + Change imports to core, call `QAlerts.notify(signal)` in the producer's `QContext`, and + rebuild. qbit-easypost-tracking#16 uses core. The runtime's `getSink()` supports adapter + delegation and queue lifecycle operations; producers use `QAlerts`. +- Stored audience types and comma/newline-separated values remain readable. The service + retains trimming, duplicate removal and null storage for empty audiences, so existing + notification dedup keys and rows need no migration. Core types do not own database parsing. +- Verification covers core producer calls through the asynchronous queue to the strict HTTP + inbox, all audience factories, commit/rollback, category mutes, immediate email and digests. + The UI fragment and all six HTTP operations are unchanged by this notification migration. ### 6.1 Automatic sources @@ -237,7 +245,7 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no |---|---| | a | Tables, the rename and retirement, admin screens and actions, audiences and recipients, the bell API, ALERT notifications | | b | Next UI: badge, popover, page, banner | -| c | Producer SPI (core PR plus the stand-in), job failures | +| c | Core RC.6 producer SPI and sink integration, job failures | | d | Security notices, shares, release and marketing producers | | e | Email: immediate and daily digest | diff --git a/docs/UI-API.md b/docs/UI-API.md index 2a62c71..ef210db 100644 --- a/docs/UI-API.md +++ b/docs/UI-API.md @@ -94,6 +94,15 @@ The alias is supported for the 0.1 minor line only and scheduled for removal in published calendar sunset is 13 April 2027. Move receivers before either retirement boundary. No automatic runtime cutoff is applied by these informational headers. +## Core notification migration (#15 part 2) + +Core `QAlerts.notify` now feeds the same inbox service through the registered sink. This +migration changes Java imports and producer entry points only: no methods, paths, +operation IDs, request fields, response fields or schemas change relative to PR #19. +The earlier client changes above still apply when adopting the full stacked branch. +The supplied Next `main` checkout still has no alerts client; the +`feature/notification-center:src/lib/api/alerts.ts` client was checked again for this work. + ## Verification Use the Maven wrapper. The committed BOM version is RC.6; while it is unreleased, verify with diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java index 83f8686..54ba329 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitProducer.java @@ -197,7 +197,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer 0 ? severity : highest; } if(latest.size() < INBOX_ITEMS) diff --git a/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java b/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java index 9365d8c..4ed286f 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/customizers/AlertsNotificationCustomizer.java @@ -30,11 +30,11 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification; import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; import com.kingsrook.qbits.alerts.notifications.NotificationService; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; @@ -212,7 +212,7 @@ public class AlertsNotificationCustomizer implements TableCustomizerInterface } if(record.getValues().containsKey("audienceType")) { - NotificationAudience audience = NotificationAudience.parse(record.getValueString("audienceType"), record.getValueString("audienceValue")); + NotificationAudience audience = NotificationService.audienceOf(record); if(audience == null) { record.addError(new BadInputStatusMessage("Choose who the notification is for.")); diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsProcessListener.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsProcessListener.java index 1972854..b6500c5 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsProcessListener.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsProcessListener.java @@ -25,13 +25,14 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig; import com.kingsrook.qbits.alerts.AlertsRuntime; import com.kingsrook.qbits.alerts.AlertsSecurity; import com.kingsrook.qbits.alerts.model.AlertsMonitor; -import com.kingsrook.qbits.alerts.spi.AlertLink; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; -import com.kingsrook.qbits.alerts.spi.NotificationSignal; import com.kingsrook.qqq.backend.core.actions.processes.listeners.ProcessLifecycleListenerInterface; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; @@ -242,7 +243,7 @@ public class AlertsProcessListener implements ProcessLifecycleListenerInterface QProcessMetaData process = QContext.getQInstance().getProcess(input.getProcessName()); String label = process == null || process.getLabel() == null ? input.getProcessName() : process.getLabel(); String tableName = process == null ? null : process.getTableName(); - runtime.sink().notify(new NotificationSignal(String.format(titleFormat, label)) + QAlerts.notify(new NotificationSignal(String.format(titleFormat, label)) .withBody(body) .withSeverity(severity) .withCategory(NotificationCategory.JOB) diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsSinkAdapter.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsSinkAdapter.java index 31044ee..99b58b0 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsSinkAdapter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsSinkAdapter.java @@ -22,6 +22,7 @@ import com.kingsrook.qbits.alerts.AlertsRuntime; import com.kingsrook.qqq.backend.core.alerts.AlertResolution; import com.kingsrook.qqq.backend.core.alerts.AlertSignal; import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; import com.kingsrook.qqq.backend.core.context.QContext; @@ -43,7 +44,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance()); if(runtime != null) { - runtime.sink().raise(signal); + runtime.getSink().raise(signal); } } @@ -58,7 +59,7 @@ public class AlertsSinkAdapter implements AlertSinkInterface AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance()); if(runtime != null) { - runtime.sink().resolve(resolution); + runtime.getSink().resolve(resolution); } } @@ -73,7 +74,22 @@ public class AlertsSinkAdapter implements AlertSinkInterface AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance()); if(runtime != null) { - runtime.sink().heartbeat(heartbeatKey, at); + runtime.getSink().heartbeat(heartbeatKey, at); + } + } + + + + /******************************************************************************* + ** Core notification producers share the runtime's bounded delivery queue. + *******************************************************************************/ + @Override + public void notify(NotificationSignal signal) + { + AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance()); + if(runtime != null) + { + runtime.getSink().notify(signal); } } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/QbitAlertsSink.java b/src/main/java/com/kingsrook/qbits/alerts/engine/QbitAlertsSink.java index a631de2..9f8b76f 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/QbitAlertsSink.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/QbitAlertsSink.java @@ -28,11 +28,11 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import com.kingsrook.qbits.alerts.AlertsRuntime; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution; -import com.kingsrook.qbits.alerts.spi.NotificationSignal; import com.kingsrook.qqq.backend.core.alerts.AlertResolution; import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; import com.kingsrook.qqq.backend.core.alerts.AlertSignal; import com.kingsrook.qqq.backend.core.alerts.AlertSinkInterface; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; import com.kingsrook.qqq.backend.core.logging.QLogger; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -134,11 +134,10 @@ public class QbitAlertsSink implements AlertSinkInterface /******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6) - ** ** Queue a notification (it is written and sent by the notification ** service on the drain thread). *******************************************************************************/ + @Override public void notify(NotificationSignal signal) { if(signal == null) diff --git a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java index 10d167f..b9d3098 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java @@ -45,11 +45,11 @@ import com.kingsrook.qbits.alerts.model.AlertsTeamMember; import com.kingsrook.qbits.alerts.model.AlertsType; import com.kingsrook.qbits.alerts.model.AlertsUserPreference; import com.kingsrook.qbits.alerts.notifications.NotificationService; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java index 747105b..89616a0 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java @@ -20,12 +20,12 @@ package com.kingsrook.qbits.alerts.model; import java.util.ArrayList; import java.util.List; import java.util.Locale; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind; import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; import com.kingsrook.qqq.backend.core.alerts.DetailLevel; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; import com.kingsrook.qqq.backend.core.alerts.ResolveMode; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsAudienceResolver.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsAudienceResolver.java index d924749..bb3133a 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsAudienceResolver.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsAudienceResolver.java @@ -26,8 +26,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import com.kingsrook.qbits.alerts.AlertsRuntime; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsNotices.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsNotices.java index 63d2b25..b6ab47f 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsNotices.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/AlertsNotices.java @@ -21,30 +21,26 @@ package com.kingsrook.qbits.alerts.notifications; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import com.kingsrook.qbits.alerts.AlertsRuntime; -import com.kingsrook.qbits.alerts.spi.AlertLink; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; -import com.kingsrook.qbits.alerts.spi.NotificationSignal; -import com.kingsrook.qqq.backend.core.context.QContext; -import com.kingsrook.qqq.backend.core.logging.QLogger; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; /******************************************************************************* ** Ready-made notifications for the automatic sources hosts wire up ** (docs/NOTIFICATIONS.md 6.1): security notices, shares and assignments, ** and release announcements. Each one is a NotificationSignal sent through - ** the alerts sink (queued; it never throws into the caller). With no Alerts - ** qbit installed they do nothing. + ** core QAlerts (queued; it never throws into the caller). With no Alerts + ** qbit installed core uses its logging sink. ** ** Core has no sign-in or API key events yet, so a host calls the security ** notices from its authentication customizer or API key screens. *******************************************************************************/ public final class AlertsNotices { - private static final QLogger LOG = QLogger.getLogger(AlertsNotices.class); private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneOffset.UTC); @@ -114,7 +110,7 @@ public final class AlertsNotices *******************************************************************************/ public static void shared(String userId, String title, AlertLink link, String sourceName) { - sendSignal(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link) + QAlerts.notify(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link) .withAudience(NotificationAudience.user(userId))); } @@ -125,7 +121,7 @@ public final class AlertsNotices *******************************************************************************/ public static void release(String version, String notesMarkdown, AlertLink link) { - sendSignal(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS) + QAlerts.notify(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS) .withCategory(NotificationCategory.RELEASE).withSourceName("release").withSourceKey("release:" + version).withLink(link) .withAudience(NotificationAudience.allUsers())); } @@ -141,29 +137,7 @@ public final class AlertsNotices { return; } - sendSignal(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey) + QAlerts.notify(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey) .withAudience(NotificationAudience.user(userId))); } - - - - /******************************************************************************* - ** Through the installed qbit's sink (nothing when it isn't installed). - *******************************************************************************/ - private static void sendSignal(NotificationSignal signal) - { - try - { - QInstance qInstance = QContext.getQInstance(); - AlertsRuntime runtime = AlertsRuntime.find(qInstance); - if(runtime != null && signal.getAudience() != null) - { - runtime.sink().notify(signal); - } - } - catch(Exception e) - { - LOG.warn("Could not send a notification", e); - } - } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java index f874d10..6bce0b8 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailer.java @@ -37,9 +37,9 @@ import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailStatus; import com.kingsrook.qbits.alerts.model.AlertsUserPreference; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; @@ -222,7 +222,7 @@ public class NotificationEmailer *******************************************************************************/ static boolean atLeast(String severity, String minimum) { - return (NotificationSeverity.fromName(severity, NotificationSeverity.INFO).compareTo(NotificationSeverity.fromName(minimum, NotificationSeverity.WARNING)) >= 0); + return (NotificationService.severityOf(severity, NotificationSeverity.INFO).compareTo(NotificationService.severityOf(minimum, NotificationSeverity.WARNING)) >= 0); } diff --git a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java index 67bc1bb..3ddff2c 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java +++ b/src/main/java/com/kingsrook/qbits/alerts/notifications/NotificationService.java @@ -22,8 +22,10 @@ import java.io.Serializable; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import com.kingsrook.qbits.alerts.AlertsRuntime; import com.kingsrook.qbits.alerts.engine.AlertsDb; @@ -31,14 +33,14 @@ import com.kingsrook.qbits.alerts.model.AlertsAlert; import com.kingsrook.qbits.alerts.model.AlertsNotification; import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; -import com.kingsrook.qbits.alerts.spi.AlertLink; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; -import com.kingsrook.qbits.alerts.spi.NotificationSignal; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.logging.QLogger; @@ -335,7 +337,7 @@ public class NotificationService QRecord values = new QRecord() .withValue("title", StringUtils.safeTruncate(alert.getValueString("title"), 200)) .withValue("body", alert.getValueString("summary")) - .withValue("severity", NotificationSeverity.fromName(alert.getValueString("severity"), NotificationSeverity.WARNING).name()) + .withValue("severity", severityOf(alert.getValueString("severity"), NotificationSeverity.WARNING).name()) .withValue("category", NotificationCategory.ALERT.name()) .withValue("sourceName", StringUtils.hasContent(alert.getValueString("sourceName")) ? alert.getValueString("sourceName") : "alerts") .withValue("sourceKey", sourceKey) @@ -402,7 +404,42 @@ public class NotificationService *******************************************************************************/ public static NotificationAudience audienceOf(QRecord notification) { - return (NotificationAudience.parse(notification.getValueString("audienceType"), notification.getValueString("audienceValue"))); + String type = notification.getValueString("audienceType"); + String value = notification.getValueString("audienceValue"); + String[] values = value == null ? new String[0] : Arrays.stream(value.split("[,\n]")) + .map(String::trim).filter(v -> !v.isEmpty()).distinct().toArray(String[]::new); + if(type == null) + { + return (null); + } + return (switch(type.trim().toUpperCase(Locale.ROOT)) + { + case "ALL_USERS" -> NotificationAudience.allUsers(); + case "ROLES" -> NotificationAudience.roles(values); + case "PERMISSION" -> NotificationAudience.permission(values.length == 0 ? null : values[0]); + case "USERS" -> NotificationAudience.users(values); + default -> null; + }); + } + + + + /******************************************************************************* + ** Read a stored severity without changing legacy case and fallback behavior. + *******************************************************************************/ + public static NotificationSeverity severityOf(String name, NotificationSeverity fallback) + { + if(name != null) + { + for(NotificationSeverity severity : NotificationSeverity.values()) + { + if(severity.name().equalsIgnoreCase(name.trim())) + { + return (severity); + } + } + } + return (fallback); } @@ -483,7 +520,7 @@ public class NotificationService .withValue("linkUrl", linkUrl(signal.getLink())) .withValue("linkLabel", signal.getLink() == null ? null : signal.getLink().getLabel()) .withValue("audienceType", signal.getAudience().getType().name()) - .withValue("audienceValue", signal.getAudience().valuesAsString()) + .withValue("audienceValue", audienceValue(signal.getAudience())) .withValue("publishAt", signal.getPublishAt()) .withValue("expireAt", signal.getExpireAt()) .withValue("sticky", Boolean.TRUE.equals(signal.getSticky()))); @@ -496,12 +533,23 @@ public class NotificationService *******************************************************************************/ private static QFilterCriteria audienceValueCriteria(NotificationAudience audience) { - String value = audience.valuesAsString(); + String value = audienceValue(audience); return (value == null ? new QFilterCriteria("audienceValue", QCriteriaOperator.IS_BLANK) : new QFilterCriteria("audienceValue", QCriteriaOperator.EQUALS, value)); } + /******************************************************************************* + ** Preserve the qbit's stored audience format and dedup keys with core types. + *******************************************************************************/ + private static String audienceValue(NotificationAudience audience) + { + List values = audience.getValues().stream().map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); + return (values.isEmpty() ? null : String.join(",", values)); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java index fe570f8..6018eb2 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java +++ b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsScheduledSteps.java @@ -73,7 +73,7 @@ public final class AlertsScheduledSteps public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException { AlertsRuntime runtime = AlertsRuntime.get(); - runtime.sink().flush(); + runtime.getSink().flush(); runtime.getNotifications().sendDue(); runtime.getNotifications().purge(); new NotificationEmailer(runtime).run(); diff --git a/src/main/java/com/kingsrook/qbits/alerts/spi/AlertLink.java b/src/main/java/com/kingsrook/qbits/alerts/spi/AlertLink.java deleted file mode 100644 index afcbe58..0000000 --- a/src/main/java/com/kingsrook/qbits/alerts/spi/AlertLink.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2026 Kingsrook, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.kingsrook.qbits.alerts.spi; - - -import java.io.Serializable; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; - - -/******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6) - ** - ** A link from an alert to where a person fixes it: a record, a table with a - ** filter, an app or widget path, or an absolute URL. Exactly one target is - ** expected, so construct these with the static factories. - *******************************************************************************/ -public class AlertLink implements Serializable -{ - private String label; - private String tableName; - private Serializable recordId; - private QQueryFilter filter; - private String path; - private String url; - - - - /******************************************************************************* - ** Link to one record. - *******************************************************************************/ - public static AlertLink toRecord(String tableName, Serializable recordId, String label) - { - AlertLink link = new AlertLink(); - link.tableName = tableName; - link.recordId = recordId; - link.label = label; - return (link); - } - - - - /******************************************************************************* - ** Link to a table, optionally filtered. - *******************************************************************************/ - public static AlertLink toTable(String tableName, QQueryFilter filter, String label) - { - AlertLink link = new AlertLink(); - link.tableName = tableName; - link.filter = filter; - link.label = label; - return (link); - } - - - - /******************************************************************************* - ** Link to an app or widget path within the application (e.g. "/admin/cdn"). - *******************************************************************************/ - public static AlertLink toPath(String path, String label) - { - AlertLink link = new AlertLink(); - link.path = path; - link.label = label; - return (link); - } - - - - /******************************************************************************* - ** Link to an absolute URL. Implementations only follow these for types - ** that declare them, so producer data cannot inject arbitrary links. - *******************************************************************************/ - public static AlertLink toUrl(String url, String label) - { - AlertLink link = new AlertLink(); - link.url = url; - link.label = label; - return (link); - } - - - - /******************************************************************************* - ** Getter for label - *******************************************************************************/ - public String getLabel() - { - return (this.label); - } - - - - /******************************************************************************* - ** Getter for tableName - *******************************************************************************/ - public String getTableName() - { - return (this.tableName); - } - - - - /******************************************************************************* - ** Getter for recordId - *******************************************************************************/ - public Serializable getRecordId() - { - return (this.recordId); - } - - - - /******************************************************************************* - ** Getter for filter - *******************************************************************************/ - public QQueryFilter getFilter() - { - return (this.filter); - } - - - - /******************************************************************************* - ** Getter for path - *******************************************************************************/ - public String getPath() - { - return (this.path); - } - - - - /******************************************************************************* - ** Getter for url - *******************************************************************************/ - public String getUrl() - { - return (this.url); - } -} diff --git a/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationAudience.java b/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationAudience.java deleted file mode 100644 index 6a1dab6..0000000 --- a/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationAudience.java +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2026 Kingsrook, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.kingsrook.qbits.alerts.spi; - - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - - -/******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6) - ** - ** Who a notification is for, resolved to users when it is sent (stand-in for - ** core's com.kingsrook.qqq.backend.core.alerts.NotificationAudience). - *******************************************************************************/ -public class NotificationAudience implements Serializable -{ - /******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6) - ** - ** The kinds of audience. - *******************************************************************************/ - public enum Type - { - ALL_USERS, ROLES, PERMISSION, USERS - } - - - - private final Type type; - private final List values; - - - - /******************************************************************************* - ** Constructor (blank values are dropped; values are trimmed). - *******************************************************************************/ - public NotificationAudience(Type type, List values) - { - this.type = type == null ? Type.USERS : type; - this.values = values == null ? List.of() : values.stream().filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList(); - } - - - - /******************************************************************************* - ** Every user. - *******************************************************************************/ - public static NotificationAudience allUsers() - { - return (new NotificationAudience(Type.ALL_USERS, null)); - } - - - - /******************************************************************************* - ** Users with any of these roles or groups. - *******************************************************************************/ - public static NotificationAudience roles(String... roles) - { - return (new NotificationAudience(Type.ROLES, roles == null ? null : Arrays.asList(roles))); - } - - - - /******************************************************************************* - ** Users whose session holds this permission. - *******************************************************************************/ - public static NotificationAudience permission(String permission) - { - return (new NotificationAudience(Type.PERMISSION, permission == null ? null : List.of(permission))); - } - - - - /******************************************************************************* - ** These users. - *******************************************************************************/ - public static NotificationAudience users(String... userIds) - { - return (new NotificationAudience(Type.USERS, userIds == null ? null : Arrays.asList(userIds))); - } - - - - /******************************************************************************* - ** One user. - *******************************************************************************/ - public static NotificationAudience user(String userId) - { - return (users(userId)); - } - - - - /******************************************************************************* - ** From the stored form: a type name and a comma- or line-separated value. - ** An unknown type is null. - *******************************************************************************/ - public static NotificationAudience parse(String type, String value) - { - for(Type candidate : Type.values()) - { - if(type != null && candidate.name().equalsIgnoreCase(type.trim())) - { - List values = new ArrayList<>(); - if(value != null) - { - values.addAll(Arrays.asList(value.split("[,\\n]"))); - } - return (new NotificationAudience(candidate, values)); - } - } - return (null); - } - - - - /******************************************************************************* - ** The stored form of the values (null when there are none). - *******************************************************************************/ - public String valuesAsString() - { - return (values.isEmpty() ? null : String.join(",", values)); - } - - - - /******************************************************************************* - ** Getter for type - *******************************************************************************/ - public Type getType() - { - return (this.type); - } - - - - /******************************************************************************* - ** Getter for values - *******************************************************************************/ - public List getValues() - { - return (this.values); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public boolean equals(Object o) - { - return (o instanceof NotificationAudience other && type == other.type && values.equals(other.values)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public int hashCode() - { - return (Objects.hash(type, values)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public String toString() - { - return (type + (values.isEmpty() ? "" : values.toString())); - } -} diff --git a/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationCategory.java b/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationCategory.java deleted file mode 100644 index 6cb8f68..0000000 --- a/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationCategory.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2026 Kingsrook, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.kingsrook.qbits.alerts.spi; - - -/******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6) - ** - ** What a notification is about (stand-in for core's - ** com.kingsrook.qqq.backend.core.alerts.NotificationCategory). Users filter - ** and mute by category; SECURITY can never be muted in-app. - *******************************************************************************/ -public enum NotificationCategory -{ - SYSTEM, JOB, SECURITY, RELEASE, MARKETING, SHARE, ALERT; - - - - /******************************************************************************* - ** The category for a name (any case), or the fallback. - *******************************************************************************/ - public static NotificationCategory fromName(String name, NotificationCategory fallback) - { - if(name != null) - { - for(NotificationCategory category : values()) - { - if(category.name().equalsIgnoreCase(name.trim())) - { - return (category); - } - } - } - return (fallback); - } -} diff --git a/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationSeverity.java b/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationSeverity.java deleted file mode 100644 index 801832f..0000000 --- a/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationSeverity.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2026 Kingsrook, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.kingsrook.qbits.alerts.spi; - - -/******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6) - ** - ** How a notification looks to its reader: INFO and SUCCESS are good news, - ** WARNING and ERROR need a look, CRITICAL makes the badge red (stand-in for - ** core's com.kingsrook.qqq.backend.core.alerts.NotificationSeverity). - *******************************************************************************/ -public enum NotificationSeverity -{ - INFO, SUCCESS, WARNING, ERROR, CRITICAL; - - - - /******************************************************************************* - ** The severity for a name (any case), or the fallback. - *******************************************************************************/ - public static NotificationSeverity fromName(String name, NotificationSeverity fallback) - { - if(name != null) - { - for(NotificationSeverity severity : values()) - { - if(severity.name().equalsIgnoreCase(name.trim())) - { - return (severity); - } - } - } - return (fallback); - } -} diff --git a/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationSignal.java b/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationSignal.java deleted file mode 100644 index 37517cf..0000000 --- a/src/main/java/com/kingsrook/qbits/alerts/spi/NotificationSignal.java +++ /dev/null @@ -1,502 +0,0 @@ -/* - * Copyright 2026 Kingsrook, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.kingsrook.qbits.alerts.spi; - - -import java.io.Serializable; -import java.time.Instant; - - -/******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6) - ** - ** A notification for people (stand-in for core's - ** com.kingsrook.qqq.backend.core.alerts.NotificationSignal): what to tell - ** them, how urgent it is, what it is about, and who it is for. Send it with - ** the alerts sink's notify (core: QAlerts.notify). - *******************************************************************************/ -public class NotificationSignal implements Serializable -{ - private String title; - private String body; - private NotificationSeverity severity; - private NotificationCategory category; - private String sourceName; - private String sourceKey; - private String sourceTableName; - private String sourceRecordId; - private AlertLink link; - private NotificationAudience audience; - private Instant publishAt; - private Instant expireAt; - private Boolean sticky; - private Instant occurredAt; - - - - /******************************************************************************* - ** Default constructor. - *******************************************************************************/ - public NotificationSignal() - { - } - - - - /******************************************************************************* - ** Constructor with the title. - *******************************************************************************/ - public NotificationSignal(String title) - { - this.title = title; - } - - - - /******************************************************************************* - ** Getter for title: The headline (plain text, required). - *******************************************************************************/ - public String getTitle() - { - return (this.title); - } - - - - /******************************************************************************* - ** Setter for title - *******************************************************************************/ - public void setTitle(String title) - { - this.title = title; - } - - - - /******************************************************************************* - ** Fluent setter for title - *******************************************************************************/ - public NotificationSignal withTitle(String title) - { - this.title = title; - return (this); - } - - - - /******************************************************************************* - ** Getter for body: The message (markdown). - *******************************************************************************/ - public String getBody() - { - return (this.body); - } - - - - /******************************************************************************* - ** Setter for body - *******************************************************************************/ - public void setBody(String body) - { - this.body = body; - } - - - - /******************************************************************************* - ** Fluent setter for body - *******************************************************************************/ - public NotificationSignal withBody(String body) - { - this.body = body; - return (this); - } - - - - /******************************************************************************* - ** Getter for severity: Default INFO. - *******************************************************************************/ - public NotificationSeverity getSeverity() - { - return (this.severity); - } - - - - /******************************************************************************* - ** Setter for severity - *******************************************************************************/ - public void setSeverity(NotificationSeverity severity) - { - this.severity = severity; - } - - - - /******************************************************************************* - ** Fluent setter for severity - *******************************************************************************/ - public NotificationSignal withSeverity(NotificationSeverity severity) - { - this.severity = severity; - return (this); - } - - - - /******************************************************************************* - ** Getter for category: Default SYSTEM. - *******************************************************************************/ - public NotificationCategory getCategory() - { - return (this.category); - } - - - - /******************************************************************************* - ** Setter for category - *******************************************************************************/ - public void setCategory(NotificationCategory category) - { - this.category = category; - } - - - - /******************************************************************************* - ** Fluent setter for category - *******************************************************************************/ - public NotificationSignal withCategory(NotificationCategory category) - { - this.category = category; - return (this); - } - - - - /******************************************************************************* - ** Getter for sourceName: Who emits it (a qbit or app name). - *******************************************************************************/ - public String getSourceName() - { - return (this.sourceName); - } - - - - /******************************************************************************* - ** Setter for sourceName - *******************************************************************************/ - public void setSourceName(String sourceName) - { - this.sourceName = sourceName; - } - - - - /******************************************************************************* - ** Fluent setter for sourceName - *******************************************************************************/ - public NotificationSignal withSourceName(String sourceName) - { - this.sourceName = sourceName; - return (this); - } - - - - /******************************************************************************* - ** Getter for sourceKey: Dedup key: a second notify with the same key and audience updates the first. - *******************************************************************************/ - public String getSourceKey() - { - return (this.sourceKey); - } - - - - /******************************************************************************* - ** Setter for sourceKey - *******************************************************************************/ - public void setSourceKey(String sourceKey) - { - this.sourceKey = sourceKey; - } - - - - /******************************************************************************* - ** Fluent setter for sourceKey - *******************************************************************************/ - public NotificationSignal withSourceKey(String sourceKey) - { - this.sourceKey = sourceKey; - return (this); - } - - - - /******************************************************************************* - ** Getter for sourceTableName: The table of the record it is about. - *******************************************************************************/ - public String getSourceTableName() - { - return (this.sourceTableName); - } - - - - /******************************************************************************* - ** Setter for sourceTableName - *******************************************************************************/ - public void setSourceTableName(String sourceTableName) - { - this.sourceTableName = sourceTableName; - } - - - - /******************************************************************************* - ** Fluent setter for sourceTableName - *******************************************************************************/ - public NotificationSignal withSourceTableName(String sourceTableName) - { - this.sourceTableName = sourceTableName; - return (this); - } - - - - /******************************************************************************* - ** Getter for sourceRecordId: The record it is about. - *******************************************************************************/ - public String getSourceRecordId() - { - return (this.sourceRecordId); - } - - - - /******************************************************************************* - ** Setter for sourceRecordId - *******************************************************************************/ - public void setSourceRecordId(String sourceRecordId) - { - this.sourceRecordId = sourceRecordId; - } - - - - /******************************************************************************* - ** Fluent setter for sourceRecordId - *******************************************************************************/ - public NotificationSignal withSourceRecordId(String sourceRecordId) - { - this.sourceRecordId = sourceRecordId; - return (this); - } - - - - /******************************************************************************* - ** Getter for link: Where it takes the reader. - *******************************************************************************/ - public AlertLink getLink() - { - return (this.link); - } - - - - /******************************************************************************* - ** Setter for link - *******************************************************************************/ - public void setLink(AlertLink link) - { - this.link = link; - } - - - - /******************************************************************************* - ** Fluent setter for link - *******************************************************************************/ - public NotificationSignal withLink(AlertLink link) - { - this.link = link; - return (this); - } - - - - /******************************************************************************* - ** Getter for audience: Who it is for (required). - *******************************************************************************/ - public NotificationAudience getAudience() - { - return (this.audience); - } - - - - /******************************************************************************* - ** Setter for audience - *******************************************************************************/ - public void setAudience(NotificationAudience audience) - { - this.audience = audience; - } - - - - /******************************************************************************* - ** Fluent setter for audience - *******************************************************************************/ - public NotificationSignal withAudience(NotificationAudience audience) - { - this.audience = audience; - return (this); - } - - - - /******************************************************************************* - ** Getter for publishAt: Not shown before this (default now). - *******************************************************************************/ - public Instant getPublishAt() - { - return (this.publishAt); - } - - - - /******************************************************************************* - ** Setter for publishAt - *******************************************************************************/ - public void setPublishAt(Instant publishAt) - { - this.publishAt = publishAt; - } - - - - /******************************************************************************* - ** Fluent setter for publishAt - *******************************************************************************/ - public NotificationSignal withPublishAt(Instant publishAt) - { - this.publishAt = publishAt; - return (this); - } - - - - /******************************************************************************* - ** Getter for expireAt: Hidden after this. - *******************************************************************************/ - public Instant getExpireAt() - { - return (this.expireAt); - } - - - - /******************************************************************************* - ** Setter for expireAt - *******************************************************************************/ - public void setExpireAt(Instant expireAt) - { - this.expireAt = expireAt; - } - - - - /******************************************************************************* - ** Fluent setter for expireAt - *******************************************************************************/ - public NotificationSignal withExpireAt(Instant expireAt) - { - this.expireAt = expireAt; - return (this); - } - - - - /******************************************************************************* - ** Getter for sticky: A banner until dismissed (meant for CRITICAL). - *******************************************************************************/ - public Boolean getSticky() - { - return (this.sticky); - } - - - - /******************************************************************************* - ** Setter for sticky - *******************************************************************************/ - public void setSticky(Boolean sticky) - { - this.sticky = sticky; - } - - - - /******************************************************************************* - ** Fluent setter for sticky - *******************************************************************************/ - public NotificationSignal withSticky(Boolean sticky) - { - this.sticky = sticky; - return (this); - } - - - - /******************************************************************************* - ** Getter for occurredAt: When it happened (default now). - *******************************************************************************/ - public Instant getOccurredAt() - { - return (this.occurredAt); - } - - - - /******************************************************************************* - ** Setter for occurredAt - *******************************************************************************/ - public void setOccurredAt(Instant occurredAt) - { - this.occurredAt = occurredAt; - } - - - - /******************************************************************************* - ** Fluent setter for occurredAt - *******************************************************************************/ - public NotificationSignal withOccurredAt(Instant occurredAt) - { - this.occurredAt = occurredAt; - return (this); - } -} diff --git a/src/main/java/com/kingsrook/qbits/alerts/spi/package-info.java b/src/main/java/com/kingsrook/qbits/alerts/spi/package-info.java deleted file mode 100644 index ee16217..0000000 --- a/src/main/java/com/kingsrook/qbits/alerts/spi/package-info.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2026 Kingsrook, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -/******************************************************************************* - ** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6) - ** - ** Only NotificationSignal, NotificationAudience, NotificationCategory, - ** NotificationSeverity and AlertLink remain here. Core 4.1.0-RC.5 ships the - ** alert SPI, but not the notification API planned in QRun-IO/qqq#1125. - ** - ** Released qbit-easypost-tracking 0.1.x calls AlertsRuntime.sink().notify - ** with these types. AlertLink remains because NotificationSignal's public - ** signatures use it. Keep their binary signatures until producers migrate - ** to core QAlerts.notify in RC.6; no local sink interface is needed because - ** AlertsRuntime.sink() still returns QbitAlertsSink. - ** - ** Alerts use com.kingsrook.qqq.backend.core.alerts. AlertsQBitProducer - ** registers AlertsSinkAdapter through core AlertsInstanceMetaData, so host - ** QAlerts.raise/resolve/heartbeat calls reach this qbit's runtime. - *******************************************************************************/ -package com.kingsrook.qbits.alerts.spi; diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java index becbabd..b1e2356 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsApiHttpTest.java @@ -24,8 +24,13 @@ import java.net.http.HttpResponse; import java.util.List; import java.util.Map; import com.kingsrook.qbits.alerts.api.AlertsRouteProvider; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef; import com.kingsrook.qqq.backend.core.alerts.AlertSignal; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; @@ -149,6 +154,38 @@ class AlertsApiHttpTest extends BaseAlertsTest + /******************************************************************************* + ** A core producer reaches the strict HTTP inbox through the background sink. + *******************************************************************************/ + @Test + @SuppressWarnings("unchecked") + void coreNotificationReachesInbox() throws Exception + { + config.withSynchronousSink(false); + start(); + long failures = QAlerts.getProducerFailureCount(); + QAlerts.notify(new NotificationSignal("Shipment delivered").withBody("Your shipment arrived.") + .withCategory(NotificationCategory.JOB).withSourceName("easypost-tracking") + .withLink(AlertLink.toRecord(WIDGET, 5, "Shipment")) + .withAudience(NotificationAudience.user("viewer-1"))); + runtime.getSink().stop(); + + String base = "/qqq/v1/alerts/inbox"; + Map summary = json(request("GET", base + "/summary", null, "viewer-1", null)); + assertThat(summary).containsEntry("unreadCount", 1).containsEntry("highestUnreadSeverity", "INFO"); + List> items = (List>) summary.get("items"); + assertThat(items).singleElement().satisfies(item -> assertThat(item) + .containsEntry("title", "Shipment delivered").containsEntry("category", "JOB") + .containsEntry("source", "easypost-tracking").containsEntry("href", "/app/" + WIDGET + "/5")); + Map page = json(request("GET", base, null, "viewer-1", null)); + assertThat((List>) page.get("items")).singleElement() + .satisfies(item -> assertThat(item).containsEntry("body", "Your shipment arrived.")); + assertThat(json(request("GET", base + "/summary", null, "viewer-2", null))).containsEntry("unreadCount", 0); + assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures); + } + + + /******************************************************************************* ** Existing paths, selectors, paging, null fields and additive client affordances. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/alerts/demo/AlertsDemoServer.java b/src/test/java/com/kingsrook/qbits/alerts/demo/AlertsDemoServer.java index 7c17eee..8d3727c 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/demo/AlertsDemoServer.java +++ b/src/test/java/com/kingsrook/qbits/alerts/demo/AlertsDemoServer.java @@ -40,17 +40,17 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription; import com.kingsrook.qbits.alerts.model.AlertsType; import com.kingsrook.qbits.alerts.model.AlertsUserPreference; import com.kingsrook.qbits.alerts.notifications.NotificationService; -import com.kingsrook.qbits.alerts.spi.AlertLink; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; -import com.kingsrook.qbits.alerts.spi.NotificationSignal; import com.kingsrook.qqq.backend.core.alerts.AlertDefaultMonitor; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; import com.kingsrook.qqq.backend.core.alerts.AlertMonitorKind; import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef; import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; import com.kingsrook.qqq.backend.core.alerts.AlertSignal; import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData; import com.kingsrook.qqq.backend.core.alerts.ResolveMode; import com.kingsrook.qqq.backend.core.context.QContext; @@ -307,29 +307,29 @@ public class AlertsDemoServer extends AbstractQQQApplication .withValue("finishedAt", clock.instant())).getValueInteger("id")); if(day == 11 || day == 23) { - runtime.sink().raise(new AlertSignal("backups.job.failed").withDedupKey("nightly-db").withTitle("Nightly database backup failed") + runtime.getSink().raise(new AlertSignal("backups.job.failed").withDedupKey("nightly-db").withTitle("Nightly database backup failed") .withSummary("The dump step exited with an error.").withDetails("pg_dump: error: connection to server lost") .withRecordRef(AlertRecordRef.of("backup_run", runId)).withLink(com.kingsrook.qqq.backend.core.alerts.AlertLink.toRecord("backup_run", runId, "Open the run"))); } else { - runtime.sink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded")); - runtime.sink().heartbeat("backups.nightly-db", clock.instant()); + runtime.getSink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("backups.job.failed", "nightly-db", "The next run succeeded")); + runtime.getSink().heartbeat("backups.nightly-db", clock.instant()); } } if(day % 4 == 1 || day == 27) { - runtime.sink().raise(new AlertSignal("cdn.purge.dead").withDedupKey("provider:cloudfront").withTitle((2 + day % 3) + " CDN purges failed after retries") + runtime.getSink().raise(new AlertSignal("cdn.purge.dead").withDedupKey("provider:cloudfront").withTitle((2 + day % 3) + " CDN purges failed after retries") .withSummary("Content will correct itself when the edge TTL expires.").withCount(2 + day % 3)); if(day != 27) { clock.set(clock.instant().plus(Duration.ofHours(3))); - runtime.sink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded")); + runtime.getSink().resolve(new com.kingsrook.qqq.backend.core.alerts.AlertResolution("cdn.purge.dead", "provider:cloudfront", "A later purge succeeded")); } } if(day == 25) { - runtime.sink().raise(new AlertSignal("storage.quota.near").withDedupKey("bucket:media").withTitle("Media bucket is at 84% of its quota").withCount(84)); + runtime.getSink().raise(new AlertSignal("storage.quota.near").withDedupKey("bucket:media").withTitle("Media bucket is at 84% of its quota").withCount(84)); } if(day % 7 == 0 || day == 29) { diff --git a/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsEngineTest.java b/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsEngineTest.java index 92ae668..a0b792d 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsEngineTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/engine/AlertsEngineTest.java @@ -340,11 +340,11 @@ class AlertsEngineTest extends BaseAlertsTest @Test void testSink() throws Exception { - runtime.sink().raise(signal("k1")); - runtime.sink().heartbeat("sink.beat", null); - runtime.sink().resolve(new AlertResolution(TYPE, "k1", "gone")); - runtime.sink().raise(null); - runtime.sink().resolve(null); + runtime.getSink().raise(signal("k1")); + runtime.getSink().heartbeat("sink.beat", null); + runtime.getSink().resolve(new AlertResolution(TYPE, "k1", "gone")); + runtime.getSink().raise(null); + runtime.getSink().resolve(null); List alerts = all(AlertsAlert.TABLE_NAME); assertThat(alerts).hasSize(1); assertThat(alerts.get(0).getValueString("status")).isEqualTo("RESOLVED"); diff --git a/src/test/java/com/kingsrook/qbits/alerts/engine/SinkAndScheduleTest.java b/src/test/java/com/kingsrook/qbits/alerts/engine/SinkAndScheduleTest.java index 5de432f..e78c270 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/engine/SinkAndScheduleTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/engine/SinkAndScheduleTest.java @@ -60,7 +60,7 @@ class SinkAndScheduleTest extends BaseAlertsTest QAlerts.raise(new AlertSignal("host.backup.failed").withDedupKey("nightly").withTitle("Nightly backup failed")); QAlerts.heartbeat("host.backup.nightly"); - runtime.sink().stop(); + runtime.getSink().stop(); List alerts = all(AlertsAlert.TABLE_NAME); assertThat(alerts).hasSize(1); @@ -70,7 +70,7 @@ class SinkAndScheduleTest extends BaseAlertsTest assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1); QAlerts.resolve("host.backup.failed", "nightly", "Retry succeeded"); - runtime.sink().stop(); + runtime.getSink().stop(); assertThat(all(AlertsAlert.TABLE_NAME).get(0).getValueString("status")).isEqualTo("RESOLVED"); assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures); } @@ -102,9 +102,9 @@ class SinkAndScheduleTest extends BaseAlertsTest { String type = "test.stop.thing" + i; adapter.raise(new AlertSignal(type).withTitle("Stop " + i)); - runtime.sink().stop(); + runtime.getSink().stop(); adapter.resolve(new AlertResolution(type, null, "done")); - runtime.sink().stop(); + runtime.getSink().stop(); } List alerts = all(AlertsAlert.TABLE_NAME); @@ -123,7 +123,7 @@ class SinkAndScheduleTest extends BaseAlertsTest AlertsSinkAdapter adapter = new AlertsSinkAdapter(); adapter.raise(new AlertSignal("test.queue.thing").withTitle("Queued")); adapter.heartbeat("queue.beat", null); - runtime.sink().flush(); + runtime.getSink().flush(); for(int i = 0; i < 50 && all(AlertsAlert.TABLE_NAME).isEmpty(); i++) { Thread.sleep(50); @@ -131,7 +131,7 @@ class SinkAndScheduleTest extends BaseAlertsTest assertThat(all(AlertsAlert.TABLE_NAME)).hasSize(1); adapter.resolve(new AlertResolution("test.queue.thing", null, "done")); - runtime.sink().stop(); + runtime.getSink().stop(); List alerts = all(AlertsAlert.TABLE_NAME); assertThat(alerts.get(0).getValueString("status")).isEqualTo("RESOLVED"); assertThat(all(AlertsHeartbeat.TABLE_NAME)).hasSize(1); @@ -147,10 +147,10 @@ class SinkAndScheduleTest extends BaseAlertsTest { for(int i = 0; i < 1005; i++) { - runtime.sink().raise(new AlertSignal("test.flood.thing").withDedupKey("same")); + runtime.getSink().raise(new AlertSignal("test.flood.thing").withDedupKey("same")); } - assertThat(runtime.sink().getDroppedCount()).isEqualTo(5); - runtime.sink().stop(); + assertThat(runtime.getSink().getDroppedCount()).isEqualTo(5); + runtime.getSink().stop(); } diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java index 1411c77..fbcc92a 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationEmailAndNoticesTest.java @@ -29,11 +29,12 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification; import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; import com.kingsrook.qbits.alerts.model.AlertsUserPreference; -import com.kingsrook.qbits.alerts.spi.AlertLink; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; -import com.kingsrook.qbits.alerts.spi.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.messaging.Content; import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput; @@ -147,7 +148,7 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest { emailSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING"); emailSettings("viewer-2", "JOB", "IMMEDIATE", "ERROR"); - runtime.getNotifications().notify(new NotificationSignal("Export failed").withBody("Disk ").withSeverity(NotificationSeverity.WARNING) + QAlerts.notify(new NotificationSignal("Export failed").withBody("Disk ").withSeverity(NotificationSeverity.WARNING) .withCategory(NotificationCategory.JOB).withLink(AlertLink.toPath("/app/widget", "Widgets")).withAudience(NotificationAudience.users("viewer-1", "viewer-2", "manager-1"))); NotificationEmailer.Result result = new NotificationEmailer(runtime).run(); @@ -175,7 +176,7 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest clock.set(Instant.parse("2026-10-12T09:00:00Z")); emailSettings("viewer-1", "RELEASE", "DAILY_DIGEST", "INFO"); AlertsNotices.release("2.4", "Dark mode everywhere.", null); - runtime.getNotifications().notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1"))); + QAlerts.notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1"))); assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0); clock.set(Instant.parse("2026-10-12T12:05:00Z")); diff --git a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java index b448309..cacae89 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/notifications/NotificationsTest.java @@ -27,25 +27,25 @@ import java.util.List; import java.util.Map; import java.util.Set; import com.kingsrook.qbits.alerts.AlertsPreferences; -import com.kingsrook.qbits.alerts.AlertsRuntime; import com.kingsrook.qbits.alerts.BaseAlertsTest; import com.kingsrook.qbits.alerts.api.AlertsApi; import com.kingsrook.qbits.alerts.engine.AlertsDb; -import com.kingsrook.qbits.alerts.engine.QbitAlertsSink; import com.kingsrook.qbits.alerts.model.AlertsNotification; import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference; import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient; import com.kingsrook.qbits.alerts.processes.AlertsAction; -import com.kingsrook.qbits.alerts.spi.AlertLink; -import com.kingsrook.qbits.alerts.spi.NotificationAudience; -import com.kingsrook.qbits.alerts.spi.NotificationCategory; -import com.kingsrook.qbits.alerts.spi.NotificationSeverity; -import com.kingsrook.qbits.alerts.spi.NotificationSignal; +import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.alerts.AlertLink; +import com.kingsrook.qqq.backend.core.alerts.NotificationAudience; +import com.kingsrook.qqq.backend.core.alerts.NotificationCategory; +import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity; +import com.kingsrook.qqq.backend.core.alerts.NotificationSignal; +import com.kingsrook.qqq.backend.core.alerts.QAlerts; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; @@ -269,11 +269,11 @@ class NotificationsTest extends BaseAlertsTest { for(int i = 0; i < 4; i++) { - runtime.getNotifications().notify(new NotificationSignal("Job " + i + " failed").withSeverity(i % 2 == 0 ? NotificationSeverity.ERROR : NotificationSeverity.WARNING) + QAlerts.notify(new NotificationSignal("Job " + i + " failed").withSeverity(i % 2 == 0 ? NotificationSeverity.ERROR : NotificationSeverity.WARNING) .withCategory(NotificationCategory.JOB).withAudience(NotificationAudience.user("viewer-1"))); clock.advance(Duration.ofMinutes(1)); } - runtime.getNotifications().notify(new NotificationSignal("Sign-in from a new device").withCategory(NotificationCategory.SECURITY) + QAlerts.notify(new NotificationSignal("Sign-in from a new device").withCategory(NotificationCategory.SECURITY) .withSeverity(NotificationSeverity.CRITICAL).withSticky(true).withAudience(NotificationAudience.user("viewer-1"))); loginAs("viewer-1"); @@ -321,8 +321,12 @@ class NotificationsTest extends BaseAlertsTest api.markRead(Map.of("all", true)); assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0); - Integer second = runtime.getNotifications().notify(signal.withTitle("Export failed again")); - assertThat(second).isEqualTo(first); + QAlerts.notify(signal.withTitle("Export failed again").withAudience(NotificationAudience.users(" viewer-1 ", "viewer-1"))); + assertThat(runtime.asSystem(() -> all(AlertsNotification.TABLE_NAME))).singleElement().satisfies(notification -> + { + assertThat(notification.getValueInteger("id")).isEqualTo(first); + assertThat(notification.getValueString("audienceValue")).isEqualTo("viewer-1"); + }); Map summary = api.inboxSummary(); assertThat(summary.get("unreadCount")).isEqualTo(1); assertThat(items(summary).get(0)).containsEntry("title", "Export failed again").containsEntry("href", "/app/widget/5"); @@ -342,28 +346,19 @@ class NotificationsTest extends BaseAlertsTest //////////////////////////////////////////////////////////////////// // through the sink (the producer SPI path) // //////////////////////////////////////////////////////////////////// - runtime.sink().notify(new NotificationSignal("Shared with you: Q3 notebook").withCategory(NotificationCategory.SHARE).withAudience(NotificationAudience.user("viewer-1"))); + QAlerts.notify(new NotificationSignal("Shared with you: Q3 notebook").withCategory(NotificationCategory.SHARE).withAudience(NotificationAudience.user("viewer-1"))); assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(2); } /******************************************************************************* - ** Preserve the method descriptors used by released notification producers, - ** including the local AlertLink, and deliver to the selected user's bell. + ** Core-only notification producers deliver to the selected user's bell. *******************************************************************************/ @Test - void testLegacyNotificationSinkSignaturesAndDelivery() throws Exception + void testCoreNotificationDelivery() throws Exception { - assertThat(AlertsRuntime.class.getMethod("sink").getReturnType()).isEqualTo(QbitAlertsSink.class); - assertThat(QbitAlertsSink.class.getMethod("notify", NotificationSignal.class).getReturnType()).isEqualTo(void.class); - assertThat(NotificationSignal.class.getMethod("withLink", AlertLink.class).getReturnType()).isEqualTo(NotificationSignal.class); - assertThat(NotificationSignal.class.getMethod("getLink").getReturnType()).isEqualTo(AlertLink.class); - assertThat(NotificationSignal.class.getMethod("withAudience", NotificationAudience.class).getReturnType()).isEqualTo(NotificationSignal.class); - assertThat(AlertLink.class.getMethod("toRecord", String.class, Serializable.class, String.class).getReturnType()).isEqualTo(AlertLink.class); - assertThat(NotificationAudience.class.getMethod("user", String.class).getReturnType()).isEqualTo(NotificationAudience.class); - - runtime.sink().notify(new NotificationSignal("Shipment delivered") + QAlerts.notify(new NotificationSignal("Shipment delivered") .withSeverity(NotificationSeverity.INFO).withCategory(NotificationCategory.JOB) .withLink(AlertLink.toRecord("widget", 5, "Shipment")) .withAudience(NotificationAudience.user("viewer-1"))); @@ -378,6 +373,64 @@ class NotificationsTest extends BaseAlertsTest + /******************************************************************************* + ** Every core audience factory resolves through the persisted notification. + *******************************************************************************/ + @Test + void testCoreAudiences() throws Exception + { + config.setUserDirectory(new QCodeReference(TestDirectory.class)); + Map> audiences = Map.of( + NotificationAudience.user("viewer-1"), List.of("viewer-1"), + NotificationAudience.users(" viewer-1 ", "viewer-2", "viewer-1"), List.of("viewer-1", "viewer-2"), + NotificationAudience.roles(" staff ", "editors"), List.of("viewer-1", "viewer-2"), + NotificationAudience.permission(" alerts.admin "), List.of("admin-1"), + NotificationAudience.allUsers(), List.of("viewer-1", "viewer-2", "admin-1", "outsider-1")); + for(var entry : audiences.entrySet()) + { + String key = entry.getKey().toString(); + QAlerts.notify(new NotificationSignal("Audience test").withSourceKey(key).withAudience(entry.getKey())); + QRecord notification = runtime.asSystem(() -> AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", key))); + assertThat(notification).isNotNull(); + assertThat(notification.getValueString("category")).isEqualTo("SYSTEM"); + assertThat(runtime.asSystem(() -> where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notification.getValue("id"))))) + .extracting(record -> record.getValueString("userId")).containsExactlyInAnyOrderElementsOf(entry.getValue()); + } + } + + + + /******************************************************************************* + ** Core commit deferral and rollback apply before the qbit creates recipients. + *******************************************************************************/ + @Test + void testCoreNotificationTransactions() throws Exception + { + QBackendTransaction committed = new QBackendTransaction(); + QAlerts.notify(new NotificationSignal("Committed").withAudience(NotificationAudience.user("viewer-1")), committed); + assertThat(all(AlertsNotification.TABLE_NAME)).isEmpty(); + committed.commit(); + assertThat(all(AlertsNotification.TABLE_NAME)).singleElement() + .satisfies(record -> assertThat(record.getValueString("title")).isEqualTo("Committed")); + + QBackendTransaction rolledBack = new QBackendTransaction(); + QContext.setTransaction(rolledBack); + try + { + QAlerts.notify(new NotificationSignal("Rolled back").withAudience(NotificationAudience.user("viewer-1"))); + rolledBack.rollback(); + } + finally + { + QContext.setTransaction(null); + } + assertThat(all(AlertsNotification.TABLE_NAME)).hasSize(1); + loginAs("viewer-1"); + assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("unreadCount", 1); + } + + + /******************************************************************************* ** Audiences: roles from a directory, permission holders, warnings. *******************************************************************************/ @@ -435,8 +488,8 @@ class NotificationsTest extends BaseAlertsTest // a muted category skips the user; security is never skipped // //////////////////////////////////////////////////////////////////// new InsertAction().execute(new InsertInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord().withValue("category", "MARKETING").withValue("inAppMuted", true))); - runtime.getNotifications().notify(new NotificationSignal("Big sale").withCategory(NotificationCategory.MARKETING).withAudience(NotificationAudience.users("viewer-1", "viewer-2"))); - runtime.getNotifications().notify(new NotificationSignal("Password changed").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user("viewer-1"))); + QAlerts.notify(new NotificationSignal("Big sale").withCategory(NotificationCategory.MARKETING).withAudience(NotificationAudience.users("viewer-1", "viewer-2"))); + QAlerts.notify(new NotificationSignal("Password changed").withCategory(NotificationCategory.SECURITY).withAudience(NotificationAudience.user("viewer-1"))); assertThat(new AlertsApi(runtime).inboxSummary().get("unreadCount")).isEqualTo(1); } -- 2.54.0