refactor(notifications): core QAlerts.notify, delete the spi stand-in (#15 part 2) #21
No files matched your search
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
+21
-13
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -197,7 +197,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
|
||||
AlertsRuntime previous = qBitConfig.getRuntime();
|
||||
if(previous != null)
|
||||
{
|
||||
previous.sink().stop();
|
||||
previous.getSink().stop();
|
||||
}
|
||||
AlertsRuntime runtime = new AlertsRuntime(qBitConfig, qInstance);
|
||||
qBitConfig.setRuntime(runtime);
|
||||
|
||||
@@ -276,11 +276,10 @@ public class AlertsRuntime
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime sink. Alert producers use core QAlerts; notification
|
||||
** producers keep calling sink().notify(spi.NotificationSignal) until RC.6.
|
||||
** STAND-IN(core=QRun-IO/qqq#1125, removeBy=4.1.0-RC.6)
|
||||
** The runtime sink for adapter delegation and queue lifecycle management.
|
||||
** Producers use core QAlerts for both alerts and notifications.
|
||||
*******************************************************************************/
|
||||
public QbitAlertsSink sink()
|
||||
public QbitAlertsSink getSink()
|
||||
{
|
||||
return (this.sink);
|
||||
}
|
||||
|
||||
@@ -45,12 +45,12 @@ import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.processes.AlertsAction;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -137,7 +137,7 @@ public class AlertsApi
|
||||
if(entry.unread())
|
||||
{
|
||||
unread++;
|
||||
NotificationSeverity severity = NotificationSeverity.fromName(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
NotificationSeverity severity = NotificationService.severityOf(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
|
||||
highest = highest == null || severity.compareTo(highest) > 0 ? severity : highest;
|
||||
}
|
||||
if(latest.size() < INBOX_ITEMS)
|
||||
|
||||
+3
-3
@@ -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."));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -45,11 +45,11 @@ import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsType;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.notifications.NotificationService;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
|
||||
@@ -20,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;
|
||||
|
||||
@@ -26,8 +26,8 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
@@ -21,30 +21,26 @@ package com.kingsrook.qbits.alerts.notifications;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ready-made notifications for the automatic sources hosts wire up
|
||||
** (docs/NOTIFICATIONS.md 6.1): security notices, shares and assignments,
|
||||
** and release announcements. Each one is a NotificationSignal sent through
|
||||
** the alerts sink (queued; it never throws into the caller). With no Alerts
|
||||
** qbit installed they do nothing.
|
||||
** core QAlerts (queued; it never throws into the caller). With no Alerts
|
||||
** qbit installed core uses its logging sink.
|
||||
**
|
||||
** Core has no sign-in or API key events yet, so a host calls the security
|
||||
** notices from its authentication customizer or API key screens.
|
||||
*******************************************************************************/
|
||||
public final class AlertsNotices
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AlertsNotices.class);
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("MMM d, yyyy").withZone(ZoneOffset.UTC);
|
||||
|
||||
|
||||
@@ -114,7 +110,7 @@ public final class AlertsNotices
|
||||
*******************************************************************************/
|
||||
public static void shared(String userId, String title, AlertLink link, String sourceName)
|
||||
{
|
||||
sendSignal(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
QAlerts.notify(new NotificationSignal(title).withCategory(NotificationCategory.SHARE).withSourceName(sourceName).withLink(link)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
@@ -125,7 +121,7 @@ public final class AlertsNotices
|
||||
*******************************************************************************/
|
||||
public static void release(String version, String notesMarkdown, AlertLink link)
|
||||
{
|
||||
sendSignal(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS)
|
||||
QAlerts.notify(new NotificationSignal("Version " + version + " is out").withBody(notesMarkdown).withSeverity(NotificationSeverity.SUCCESS)
|
||||
.withCategory(NotificationCategory.RELEASE).withSourceName("release").withSourceKey("release:" + version).withLink(link)
|
||||
.withAudience(NotificationAudience.allUsers()));
|
||||
}
|
||||
@@ -141,29 +137,7 @@ public final class AlertsNotices
|
||||
{
|
||||
return;
|
||||
}
|
||||
sendSignal(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
QAlerts.notify(signal.withCategory(NotificationCategory.SECURITY).withSourceName("security").withSourceKey(sourceKey)
|
||||
.withAudience(NotificationAudience.user(userId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Through the installed qbit's sink (nothing when it isn't installed).
|
||||
*******************************************************************************/
|
||||
private static void sendSignal(NotificationSignal signal)
|
||||
{
|
||||
try
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
AlertsRuntime runtime = AlertsRuntime.find(qInstance);
|
||||
if(runtime != null && signal.getAudience() != null)
|
||||
{
|
||||
runtime.sink().notify(signal);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not send a notification", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,9 @@ import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailStatus;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
@@ -222,7 +222,7 @@ public class NotificationEmailer
|
||||
*******************************************************************************/
|
||||
static boolean atLeast(String severity, String minimum)
|
||||
{
|
||||
return (NotificationSeverity.fromName(severity, NotificationSeverity.INFO).compareTo(NotificationSeverity.fromName(minimum, NotificationSeverity.WARNING)) >= 0);
|
||||
return (NotificationService.severityOf(severity, NotificationSeverity.INFO).compareTo(NotificationService.severityOf(minimum, NotificationSeverity.WARNING)) >= 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.alerts.AlertsRuntime;
|
||||
import com.kingsrook.qbits.alerts.engine.AlertsDb;
|
||||
@@ -31,14 +33,14 @@ import com.kingsrook.qbits.alerts.model.AlertsAlert;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -335,7 +337,7 @@ public class NotificationService
|
||||
QRecord values = new QRecord()
|
||||
.withValue("title", StringUtils.safeTruncate(alert.getValueString("title"), 200))
|
||||
.withValue("body", alert.getValueString("summary"))
|
||||
.withValue("severity", NotificationSeverity.fromName(alert.getValueString("severity"), NotificationSeverity.WARNING).name())
|
||||
.withValue("severity", severityOf(alert.getValueString("severity"), NotificationSeverity.WARNING).name())
|
||||
.withValue("category", NotificationCategory.ALERT.name())
|
||||
.withValue("sourceName", StringUtils.hasContent(alert.getValueString("sourceName")) ? alert.getValueString("sourceName") : "alerts")
|
||||
.withValue("sourceKey", sourceKey)
|
||||
@@ -402,7 +404,42 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience audienceOf(QRecord notification)
|
||||
{
|
||||
return (NotificationAudience.parse(notification.getValueString("audienceType"), notification.getValueString("audienceValue")));
|
||||
String type = notification.getValueString("audienceType");
|
||||
String value = notification.getValueString("audienceValue");
|
||||
String[] values = value == null ? new String[0] : Arrays.stream(value.split("[,\n]"))
|
||||
.map(String::trim).filter(v -> !v.isEmpty()).distinct().toArray(String[]::new);
|
||||
if(type == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (switch(type.trim().toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "ALL_USERS" -> NotificationAudience.allUsers();
|
||||
case "ROLES" -> NotificationAudience.roles(values);
|
||||
case "PERMISSION" -> NotificationAudience.permission(values.length == 0 ? null : values[0]);
|
||||
case "USERS" -> NotificationAudience.users(values);
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a stored severity without changing legacy case and fallback behavior.
|
||||
*******************************************************************************/
|
||||
public static NotificationSeverity severityOf(String name, NotificationSeverity fallback)
|
||||
{
|
||||
if(name != null)
|
||||
{
|
||||
for(NotificationSeverity severity : NotificationSeverity.values())
|
||||
{
|
||||
if(severity.name().equalsIgnoreCase(name.trim()))
|
||||
{
|
||||
return (severity);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -483,7 +520,7 @@ public class NotificationService
|
||||
.withValue("linkUrl", linkUrl(signal.getLink()))
|
||||
.withValue("linkLabel", signal.getLink() == null ? null : signal.getLink().getLabel())
|
||||
.withValue("audienceType", signal.getAudience().getType().name())
|
||||
.withValue("audienceValue", signal.getAudience().valuesAsString())
|
||||
.withValue("audienceValue", audienceValue(signal.getAudience()))
|
||||
.withValue("publishAt", signal.getPublishAt())
|
||||
.withValue("expireAt", signal.getExpireAt())
|
||||
.withValue("sticky", Boolean.TRUE.equals(signal.getSticky())));
|
||||
@@ -496,12 +533,23 @@ public class NotificationService
|
||||
*******************************************************************************/
|
||||
private static QFilterCriteria audienceValueCriteria(NotificationAudience audience)
|
||||
{
|
||||
String value = audience.valuesAsString();
|
||||
String value = audienceValue(audience);
|
||||
return (value == null ? new QFilterCriteria("audienceValue", QCriteriaOperator.IS_BLANK) : new QFilterCriteria("audienceValue", QCriteriaOperator.EQUALS, value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the qbit's stored audience format and dedup keys with core types.
|
||||
*******************************************************************************/
|
||||
private static String audienceValue(NotificationAudience audience)
|
||||
{
|
||||
List<String> values = audience.getValues().stream().map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
return (values.isEmpty() ? null : String.join(",", values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -73,7 +73,7 @@ public final class AlertsScheduledSteps
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AlertsRuntime runtime = AlertsRuntime.get();
|
||||
runtime.sink().flush();
|
||||
runtime.getSink().flush();
|
||||
runtime.getNotifications().sendDue();
|
||||
runtime.getNotifications().purge();
|
||||
new NotificationEmailer(runtime).run();
|
||||
|
||||
@@ -1,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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> values;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor (blank values are dropped; values are trimmed).
|
||||
*******************************************************************************/
|
||||
public NotificationAudience(Type type, List<String> values)
|
||||
{
|
||||
this.type = type == null ? Type.USERS : type;
|
||||
this.values = values == null ? List.of() : values.stream().filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every user.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience allUsers()
|
||||
{
|
||||
return (new NotificationAudience(Type.ALL_USERS, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users with any of these roles or groups.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience roles(String... roles)
|
||||
{
|
||||
return (new NotificationAudience(Type.ROLES, roles == null ? null : Arrays.asList(roles)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users whose session holds this permission.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience permission(String permission)
|
||||
{
|
||||
return (new NotificationAudience(Type.PERMISSION, permission == null ? null : List.of(permission)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** These users.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience users(String... userIds)
|
||||
{
|
||||
return (new NotificationAudience(Type.USERS, userIds == null ? null : Arrays.asList(userIds)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One user.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience user(String userId)
|
||||
{
|
||||
return (users(userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** From the stored form: a type name and a comma- or line-separated value.
|
||||
** An unknown type is null.
|
||||
*******************************************************************************/
|
||||
public static NotificationAudience parse(String type, String value)
|
||||
{
|
||||
for(Type candidate : Type.values())
|
||||
{
|
||||
if(type != null && candidate.name().equalsIgnoreCase(type.trim()))
|
||||
{
|
||||
List<String> values = new ArrayList<>();
|
||||
if(value != null)
|
||||
{
|
||||
values.addAll(Arrays.asList(value.split("[,\\n]")));
|
||||
}
|
||||
return (new NotificationAudience(candidate, values));
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored form of the values (null when there are none).
|
||||
*******************************************************************************/
|
||||
public String valuesAsString()
|
||||
{
|
||||
return (values.isEmpty() ? null : String.join(",", values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for type
|
||||
*******************************************************************************/
|
||||
public Type getType()
|
||||
{
|
||||
return (this.type);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for values
|
||||
*******************************************************************************/
|
||||
public List<String> getValues()
|
||||
{
|
||||
return (this.values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object o)
|
||||
{
|
||||
return (o instanceof NotificationAudience other && type == other.type && values.equals(other.values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return (Objects.hash(type, values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (type + (values.isEmpty() ? "" : values.toString()));
|
||||
}
|
||||
}
|
||||
@@ -1,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<String, Object> summary = json(request("GET", base + "/summary", null, "viewer-1", null));
|
||||
assertThat(summary).containsEntry("unreadCount", 1).containsEntry("highestUnreadSeverity", "INFO");
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
|
||||
assertThat(items).singleElement().satisfies(item -> assertThat(item)
|
||||
.containsEntry("title", "Shipment delivered").containsEntry("category", "JOB")
|
||||
.containsEntry("source", "easypost-tracking").containsEntry("href", "/app/" + WIDGET + "/5"));
|
||||
Map<String, Object> page = json(request("GET", base, null, "viewer-1", null));
|
||||
assertThat((List<Map<String, Object>>) page.get("items")).singleElement()
|
||||
.satisfies(item -> assertThat(item).containsEntry("body", "Your shipment arrived."));
|
||||
assertThat(json(request("GET", base + "/summary", null, "viewer-2", null))).containsEntry("unreadCount", 0);
|
||||
assertThat(QAlerts.getProducerFailureCount()).isEqualTo(failures);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing paths, selectors, paging, null fields and additive client affordances.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -340,11 +340,11 @@ class AlertsEngineTest extends BaseAlertsTest
|
||||
@Test
|
||||
void testSink() throws Exception
|
||||
{
|
||||
runtime.sink().raise(signal("k1"));
|
||||
runtime.sink().heartbeat("sink.beat", null);
|
||||
runtime.sink().resolve(new AlertResolution(TYPE, "k1", "gone"));
|
||||
runtime.sink().raise(null);
|
||||
runtime.sink().resolve(null);
|
||||
runtime.getSink().raise(signal("k1"));
|
||||
runtime.getSink().heartbeat("sink.beat", null);
|
||||
runtime.getSink().resolve(new AlertResolution(TYPE, "k1", "gone"));
|
||||
runtime.getSink().raise(null);
|
||||
runtime.getSink().resolve(null);
|
||||
List<QRecord> alerts = all(AlertsAlert.TABLE_NAME);
|
||||
assertThat(alerts).hasSize(1);
|
||||
assertThat(alerts.get(0).getValueString("status")).isEqualTo("RESOLVED");
|
||||
|
||||
@@ -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<QRecord> 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<QRecord> 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<QRecord> 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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-7
@@ -29,11 +29,12 @@ import com.kingsrook.qbits.alerts.model.AlertsNotification;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
|
||||
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
|
||||
import com.kingsrook.qbits.alerts.spi.AlertLink;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
|
||||
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.Content;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.messaging.SendMessageInput;
|
||||
@@ -147,7 +148,7 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
|
||||
{
|
||||
emailSettings("viewer-1", "JOB", "IMMEDIATE", "WARNING");
|
||||
emailSettings("viewer-2", "JOB", "IMMEDIATE", "ERROR");
|
||||
runtime.getNotifications().notify(new NotificationSignal("Export failed").withBody("Disk <full>").withSeverity(NotificationSeverity.WARNING)
|
||||
QAlerts.notify(new NotificationSignal("Export failed").withBody("Disk <full>").withSeverity(NotificationSeverity.WARNING)
|
||||
.withCategory(NotificationCategory.JOB).withLink(AlertLink.toPath("/app/widget", "Widgets")).withAudience(NotificationAudience.users("viewer-1", "viewer-2", "manager-1")));
|
||||
|
||||
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
|
||||
@@ -175,7 +176,7 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
|
||||
clock.set(Instant.parse("2026-10-12T09:00:00Z"));
|
||||
emailSettings("viewer-1", "RELEASE", "DAILY_DIGEST", "INFO");
|
||||
AlertsNotices.release("2.4", "Dark mode everywhere.", null);
|
||||
runtime.getNotifications().notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
QAlerts.notify(new NotificationSignal("Version 2.5 is out").withCategory(NotificationCategory.RELEASE).withAudience(NotificationAudience.user("viewer-1")));
|
||||
|
||||
assertThat(new NotificationEmailer(runtime).run().digests()).isEqualTo(0);
|
||||
clock.set(Instant.parse("2026-10-12T12:05:00Z"));
|
||||
|
||||
@@ -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<String, Object> summary = api.inboxSummary();
|
||||
assertThat(summary.get("unreadCount")).isEqualTo(1);
|
||||
assertThat(items(summary).get(0)).containsEntry("title", "Export failed again").containsEntry("href", "/app/widget/5");
|
||||
@@ -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<NotificationAudience, List<String>> audiences = Map.of(
|
||||
NotificationAudience.user("viewer-1"), List.of("viewer-1"),
|
||||
NotificationAudience.users(" viewer-1 ", "viewer-2", "viewer-1"), List.of("viewer-1", "viewer-2"),
|
||||
NotificationAudience.roles(" staff ", "editors"), List.of("viewer-1", "viewer-2"),
|
||||
NotificationAudience.permission(" alerts.admin "), List.of("admin-1"),
|
||||
NotificationAudience.allUsers(), List.of("viewer-1", "viewer-2", "admin-1", "outsider-1"));
|
||||
for(var entry : audiences.entrySet())
|
||||
{
|
||||
String key = entry.getKey().toString();
|
||||
QAlerts.notify(new NotificationSignal("Audience test").withSourceKey(key).withAudience(entry.getKey()));
|
||||
QRecord notification = runtime.asSystem(() -> AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", key)));
|
||||
assertThat(notification).isNotNull();
|
||||
assertThat(notification.getValueString("category")).isEqualTo("SYSTEM");
|
||||
assertThat(runtime.asSystem(() -> where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notification.getValue("id")))))
|
||||
.extracting(record -> record.getValueString("userId")).containsExactlyInAnyOrderElementsOf(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core commit deferral and rollback apply before the qbit creates recipients.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCoreNotificationTransactions() throws Exception
|
||||
{
|
||||
QBackendTransaction committed = new QBackendTransaction();
|
||||
QAlerts.notify(new NotificationSignal("Committed").withAudience(NotificationAudience.user("viewer-1")), committed);
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).isEmpty();
|
||||
committed.commit();
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement()
|
||||
.satisfies(record -> assertThat(record.getValueString("title")).isEqualTo("Committed"));
|
||||
|
||||
QBackendTransaction rolledBack = new QBackendTransaction();
|
||||
QContext.setTransaction(rolledBack);
|
||||
try
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("Rolled back").withAudience(NotificationAudience.user("viewer-1")));
|
||||
rolledBack.rollback();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setTransaction(null);
|
||||
}
|
||||
assertThat(all(AlertsNotification.TABLE_NAME)).hasSize(1);
|
||||
loginAs("viewer-1");
|
||||
assertThat(new AlertsApi(runtime).inboxSummary()).containsEntry("unreadCount", 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audiences: roles from a directory, permission holders, warnings.
|
||||
*******************************************************************************/
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user