feat(notifications): user notifications, recipients, audiences and the bell API (phase a) #9

Merged
KofTwentyTwo merged 2 commits from feature/notifications-a-tables into develop 2026-10-13 15:09:02 +00:00
43 changed files with 5068 additions and 609 deletions

No files matched your search

+18 -2
View File
@@ -31,6 +31,19 @@ Any qbit or app reports exceptional conditions ("a person should do something").
All data is in standard QQQ tables named `alerts_*`. People change alerts through record actions, never by editing rows.
## User notifications
The qbit also carries ordinary notifications for people (design: [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md)):
- **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.
- **People** see them in the Next UI bell and notifications page, and choose per-category settings in My Notification Settings (security notices can't be muted).
Grant `getNotificationAdminPermissionNames()` to authors. Viewers' permissions include their own recipient rows and settings.
**Upgrading from 0.1.0-RC.1:** changeset 16 renames the delivery log `alerts_notification` to `alerts_delivery`, and changeset 17 drops `alerts_inbox_item` (bell state only). Both are guarded, so a fresh schema and an RC.1 schema both end up the same.
## Requirements
- Java 21.
@@ -82,8 +95,11 @@ Once the RC ships, producers call `QAlerts.raise(...)` from core and need no dep
| Method | Path | What |
|---|---|---|
| GET | `/qqq/v1/alerts/inbox/summary` | Unread count, highest unread severity and the latest 10 items (the bell) |
| POST | `/qqq/v1/alerts/inbox/read` | `{"alertId": n}` or `{"all": true}` |
| GET | `/qqq/v1/alerts/inbox/summary` | Unread count, highest unread severity, the latest 10 notifications and sticky banners (the bell) |
| GET | `/qqq/v1/alerts/inbox` | The notifications page: `status` (all, unread), `category`, `severity`, `offset`, `limit` |
| POST | `/qqq/v1/alerts/inbox/read` | `{"id": n}`, `{"ids": [..]}`, `{"all": true}`, or `{"alertId": n}` |
| POST | `/qqq/v1/alerts/inbox/unread` | `{"id": n}` or `{"ids": [..]}` |
| POST | `/qqq/v1/alerts/inbox/dismiss` | `{"id": n}`, `{"ids": [..]}` or `{"all": true}` |
| GET | `/qqq/v1/alerts/record-panel?tableName=t&recordId=id` | The "Open alerts" panel for a record (`visible: false` when there is nothing to show) |
| POST | `/qqq/v1/alerts/record-panel/watch` | `{"tableName": t, "recordId": id}`: a RECORD subscription for the user |
| POST | `/qqq/v1/alerts/ingest/alertmanager` | Alertmanager's webhook receiver (version 4). It takes a bearer token whose reference is set by `withAlertmanagerIngestTokenSecretRef`, and is off without one. |
+1
View File
@@ -8,5 +8,6 @@ Deviations from the design, and why:
- **Heartbeats** are stored in an extra table, `alerts_heartbeat`.
- **Claims** are leases, not atomic conditional updates (see README.md, Known limits).
- **Email threading headers** wait for core messaging support.
- **The delivery log is `alerts_delivery`** (the design calls it `alerts_notification`), and **`alerts_inbox_item` is retired**: user notifications took those roles (NOTIFICATIONS.md section 2).
User notifications (announcements, job failures, shares, security notices, releases and marketing), the recipient model and the Next UI notification center are designed in [NOTIFICATIONS.md](NOTIFICATIONS.md), with the owner decisions of 2026-10-13.
@@ -21,13 +21,15 @@ import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsEvent;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
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.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
@@ -51,7 +53,9 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** see; comment; subscribe themselves; their own settings and inbox.
** - alerts.manage: acknowledge, snooze, resolve, reopen, assign, mute.
** - alerts.admin: monitors, types, groups, teams, channels, every
** subscription, the notification log, retries.
** subscription, the delivery log, retries.
** - alerts.notification.admin: write, send, schedule, retract and delete
** user notifications (docs/NOTIFICATIONS.md).
** Each table also has QQQ's standard per-table permissions
** (alerts_alert.read, ...); getViewerPermissionNames,
** getManagerPermissionNames and getAdminPermissionNames list what to grant
@@ -66,6 +70,8 @@ public class AlertsQBitConfig implements QBitConfig
public static final String PERMISSION_MANAGE = "alerts.manage";
public static final String PERMISSION_ADMIN = "alerts.admin";
public static final String PERMISSION_NOTIFICATION_ADMIN = "alerts.notification.admin";
public static final String APP_NAME = "alerts";
private String backendName;
@@ -92,6 +98,12 @@ public class AlertsQBitConfig implements QBitConfig
private Boolean registerRoutes = true;
private Boolean synchronousSink = false;
private QCodeReference userDirectory;
private Integer maxAudienceSize = 50_000;
private Integer notificationRetentionDays = 180;
private Boolean notifyFailedJobs = true;
private String jobRunLinkPattern;
private transient AlertsRuntime runtime;
@@ -114,7 +126,8 @@ public class AlertsQBitConfig implements QBitConfig
{
assertCondition(qInstance.getMessagingProvider(messagingProviderName) != null, "Alerts QBit messaging provider not found: " + messagingProviderName, errors);
}
for(Integer value : new Integer[] {reopenWindowMinutes, flapThreshold, flapWindowMinutes, maxOpenPerMonitor, monitorTimeoutSeconds, evaluateEverySeconds, deliverEverySeconds})
for(Integer value : new Integer[] {reopenWindowMinutes, flapThreshold, flapWindowMinutes, maxOpenPerMonitor, monitorTimeoutSeconds, evaluateEverySeconds, deliverEverySeconds,
maxAudienceSize, notificationRetentionDays})
{
assertCondition(value != null && value > 0, "Alerts QBit timing and limit settings must be positive", errors);
}
@@ -140,7 +153,8 @@ public class AlertsQBitConfig implements QBitConfig
{
return (List.of(AlertsAlert.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsSubscription.TABLE_NAME,
AlertsUserPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsTeamMember.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME,
AlertsNotification.TABLE_NAME, AlertsEvent.TABLE_NAME, AlertsRecordRef.TABLE_NAME, AlertsInboxItem.TABLE_NAME, AlertsHeartbeat.TABLE_NAME));
AlertsDelivery.TABLE_NAME, AlertsEvent.TABLE_NAME, AlertsRecordRef.TABLE_NAME, AlertsHeartbeat.TABLE_NAME, AlertsNotification.TABLE_NAME,
AlertsNotificationRecipient.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME));
}
@@ -160,13 +174,15 @@ public class AlertsQBitConfig implements QBitConfig
{
names.add(table + ".read");
}
for(String table : List.of(AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsInboxItem.TABLE_NAME))
for(String table : List.of(AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME))
{
names.add(table + ".read");
names.add(table + ".insert");
names.add(table + ".edit");
names.add(table + ".delete");
}
names.add(AlertsNotificationRecipient.TABLE_NAME + ".read");
names.add(AlertsNotificationRecipient.TABLE_NAME + ".edit");
return (names);
}
@@ -192,7 +208,7 @@ public class AlertsQBitConfig implements QBitConfig
*******************************************************************************/
public List<String> getAdminPermissionNames()
{
List<String> names = new ArrayList<>(List.of(PERMISSION_VIEW, PERMISSION_MANAGE, PERMISSION_ADMIN));
List<String> names = new ArrayList<>(List.of(PERMISSION_VIEW, PERMISSION_MANAGE, PERMISSION_ADMIN, PERMISSION_NOTIFICATION_ADMIN));
for(String table : allTableNames())
{
for(String suffix : List.of("read", "insert", "edit", "delete"))
@@ -205,6 +221,24 @@ public class AlertsQBitConfig implements QBitConfig
/*******************************************************************************
** What a notification author needs on top of a viewer's permissions:
** alerts.notification.admin and full access to the notifications table
** (they read every recipient row through the alerts_user all-access key).
*******************************************************************************/
public List<String> getNotificationAdminPermissionNames()
{
List<String> names = new ArrayList<>(getViewerPermissionNames());
names.add(PERMISSION_NOTIFICATION_ADMIN);
for(String suffix : List.of("read", "insert", "edit", "delete"))
{
names.add(AlertsNotification.TABLE_NAME + "." + suffix);
}
return (names);
}
/*******************************************************************************
** Getter for backendName
*******************************************************************************/
@@ -605,4 +639,158 @@ public class AlertsQBitConfig implements QBitConfig
this.runtime = runtime;
}
/*******************************************************************************
** Getter for userDirectory
*******************************************************************************/
public QCodeReference getUserDirectory()
{
return (this.userDirectory);
}
/*******************************************************************************
** Setter for userDirectory
*******************************************************************************/
public void setUserDirectory(QCodeReference userDirectory)
{
this.userDirectory = userDirectory;
}
/*******************************************************************************
** Fluent setter for userDirectory: the host's user directory (AlertsUserDirectoryInterface) for notification audiences; default: everyone who has used Alerts
*******************************************************************************/
public AlertsQBitConfig withUserDirectory(QCodeReference userDirectory)
{
this.userDirectory = userDirectory;
return (this);
}
/*******************************************************************************
** Getter for maxAudienceSize
*******************************************************************************/
public Integer getMaxAudienceSize()
{
return (this.maxAudienceSize);
}
/*******************************************************************************
** Setter for maxAudienceSize
*******************************************************************************/
public void setMaxAudienceSize(Integer maxAudienceSize)
{
this.maxAudienceSize = maxAudienceSize;
}
/*******************************************************************************
** Fluent setter for maxAudienceSize: the most users one notification reaches (default 50,000)
*******************************************************************************/
public AlertsQBitConfig withMaxAudienceSize(Integer maxAudienceSize)
{
this.maxAudienceSize = maxAudienceSize;
return (this);
}
/*******************************************************************************
** Getter for notificationRetentionDays
*******************************************************************************/
public Integer getNotificationRetentionDays()
{
return (this.notificationRetentionDays);
}
/*******************************************************************************
** Setter for notificationRetentionDays
*******************************************************************************/
public void setNotificationRetentionDays(Integer notificationRetentionDays)
{
this.notificationRetentionDays = notificationRetentionDays;
}
/*******************************************************************************
** Fluent setter for notificationRetentionDays: how long sent and retracted notifications are kept (default 180)
*******************************************************************************/
public AlertsQBitConfig withNotificationRetentionDays(Integer notificationRetentionDays)
{
this.notificationRetentionDays = notificationRetentionDays;
return (this);
}
/*******************************************************************************
** Getter for notifyFailedJobs
*******************************************************************************/
public Boolean getNotifyFailedJobs()
{
return (this.notifyFailedJobs);
}
/*******************************************************************************
** Setter for notifyFailedJobs
*******************************************************************************/
public void setNotifyFailedJobs(Boolean notifyFailedJobs)
{
this.notifyFailedJobs = notifyFailedJobs;
}
/*******************************************************************************
** Fluent setter for notifyFailedJobs: notify a user when a process they started fails or completes with errors (default true)
*******************************************************************************/
public AlertsQBitConfig withNotifyFailedJobs(Boolean notifyFailedJobs)
{
this.notifyFailedJobs = notifyFailedJobs;
return (this);
}
/*******************************************************************************
** Getter for jobRunLinkPattern
*******************************************************************************/
public String getJobRunLinkPattern()
{
return (this.jobRunLinkPattern);
}
/*******************************************************************************
** Setter for jobRunLinkPattern
*******************************************************************************/
public void setJobRunLinkPattern(String jobRunLinkPattern)
{
this.jobRunLinkPattern = jobRunLinkPattern;
}
/*******************************************************************************
** Fluent setter for jobRunLinkPattern: where a job notification links, with {processName} and {processUUID} (default: the process's table)
*******************************************************************************/
public AlertsQBitConfig withJobRunLinkPattern(String jobRunLinkPattern)
{
this.jobRunLinkPattern = jobRunLinkPattern;
return (this);
}
}
@@ -26,6 +26,7 @@ import com.kingsrook.qbits.alerts.engine.AlertTypeSync;
import com.kingsrook.qbits.alerts.engine.AlertsEngine;
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -53,6 +54,7 @@ public class AlertsRuntime
private final DeliveryWorker deliveryWorker;
private final MonitorEvaluator evaluator;
private final QbitAlertsSink sink;
private final NotificationService notifications;
@@ -84,6 +86,7 @@ public class AlertsRuntime
this.deliveryWorker = new DeliveryWorker(this);
this.evaluator = new MonitorEvaluator(this);
this.sink = new QbitAlertsSink(this);
this.notifications = new NotificationService(this);
}
@@ -222,6 +225,16 @@ public class AlertsRuntime
/*******************************************************************************
** Getter for notifications (user notifications: send, retract, the bell)
*******************************************************************************/
public NotificationService getNotifications()
{
return (this.notifications);
}
/*******************************************************************************
** Getter for router
*******************************************************************************/
@@ -40,7 +40,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** The Alerts record-security keys (design section 7).
**
** Three security key types guard the qbit's tables:
** Four security key types guard the qbit's tables:
** - alerts_user: the session's user id. Personal rows (subscriptions,
** settings, inbox items) are locked to it. alerts.admin holders get its
** all-access key.
@@ -48,7 +48,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** type sets a visibility permission is only readable with it.
** - alerts_readable_table: every table the session can read. An alert about
** a subject table is only readable by users who can read that table.
** The last two follow the logged-in user, so counts, lists, the bell and
** - alerts_recipient: the session's user id, for notification recipient
** rows. Notification authors (alerts.notification.admin) and alerts.admin
** holders get its all-access key.
** alerts_permission and alerts_readable_table follow the logged-in user, so counts, lists, the bell and
** the home are all filtered in the query. (The system user, which the
** engine runs as, holds every all-access key.)
**
@@ -66,6 +69,8 @@ public final class AlertsSecurity
public static final String KEY_PERMISSION_ALL = "alerts_permission_all_access";
public static final String KEY_READABLE_TABLE = "alerts_readable_table";
public static final String KEY_READABLE_TABLE_ALL = "alerts_readable_table_all_access";
public static final String KEY_RECIPIENT = "alerts_recipient";
public static final String KEY_RECIPIENT_ALL = "alerts_recipient_all_access";
private static final String ALERT_TABLE = "alerts_alert";
@@ -88,7 +93,8 @@ public final class AlertsSecurity
return (List.of(
new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS),
new QSecurityKeyType().withName(KEY_PERMISSION).withAllAccessKeyName(KEY_PERMISSION_ALL),
new QSecurityKeyType().withName(KEY_READABLE_TABLE).withAllAccessKeyName(KEY_READABLE_TABLE_ALL)));
new QSecurityKeyType().withName(KEY_READABLE_TABLE).withAllAccessKeyName(KEY_READABLE_TABLE_ALL),
new QSecurityKeyType().withName(KEY_RECIPIENT).withAllAccessKeyName(KEY_RECIPIENT_ALL)));
}
@@ -106,6 +112,20 @@ public final class AlertsSecurity
/*******************************************************************************
** Recipient lock: a notification recipient row is its user's (notification
** authors and alerts admins hold the all-access key).
*******************************************************************************/
public static RecordSecurityLock recipientLock(String fieldName)
{
return (new RecordSecurityLock()
.withSecurityKeyType(KEY_RECIPIENT)
.withFieldName(fieldName)
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE));
}
/*******************************************************************************
** Visibility lock on an alert (joinFromAlert null) or on a child of an
** alert (through that join): the session must hold the alert's
@@ -154,7 +174,7 @@ public final class AlertsSecurity
Map<String, List<Serializable>> keys = session.getSecurityKeyValues();
if(keys != null)
{
for(String key : List.of(KEY_USER, KEY_USER_ALL_ACCESS, KEY_PERMISSION, KEY_READABLE_TABLE))
for(String key : List.of(KEY_USER, KEY_USER_ALL_ACCESS, KEY_PERMISSION, KEY_READABLE_TABLE, KEY_RECIPIENT, KEY_RECIPIENT_ALL))
{
keys.remove(key);
}
@@ -169,6 +189,11 @@ public final class AlertsSecurity
if(StringUtils.hasContent(userId))
{
session.withSecurityKeyValue(KEY_USER, userId);
session.withSecurityKeyValue(KEY_RECIPIENT, userId);
}
if(session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN) || session.hasPermission(AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN))
{
session.withSecurityKeyValue(KEY_RECIPIENT_ALL, true);
}
if(session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN))
{
@@ -257,6 +282,7 @@ public final class AlertsSecurity
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
session.withSecurityKeyValue(KEY_PERMISSION_ALL, true);
session.withSecurityKeyValue(KEY_READABLE_TABLE_ALL, true);
session.withSecurityKeyValue(KEY_RECIPIENT_ALL, true);
return (session);
}
@@ -22,9 +22,12 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsPreferences;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
@@ -34,13 +37,17 @@ import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.home.AlertsHome;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
@@ -62,13 +69,23 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** The bell and record-panel endpoints' logic (design sections 6.3, 6.4),
** The bell, notifications page and record-panel endpoints' logic (design
** sections 6.3, 6.4; docs/NOTIFICATIONS.md 8.1),
** independent of the HTTP layer. Everything is read and written in the
** caller's session (record locks: their own inbox, alerts they can see).
** caller's session (record locks: their own recipient rows, alerts they can
** see); notification content is read for the rows they hold.
*******************************************************************************/
public class AlertsApi
{
static final int INBOX_ITEMS = 10;
static final int MAX_BANNERS = 3;
static final int SCAN_LIMIT = 1000;
static final int MAX_BATCH = 500;
static final int DEFAULT_PAGE = 25;
static final int MAX_PAGE = 100;
static final int POLL_SECONDS = 60;
public static final String NOTIFICATIONS_HREF = "/notifications";
static final int PANEL_ROWS = 5;
static final int RESOLVED_LOOKBACK = 30;
@@ -104,52 +121,34 @@ public class AlertsApi
/*******************************************************************************
** The bell: unread count, highest unread severity, the latest items.
** The bell (docs/NOTIFICATIONS.md 8.1): the unread count, the highest
** unread severity, the latest items, and sticky banners.
*******************************************************************************/
public Map<String, Object> inboxSummary() throws QException
{
String userId = requireViewer();
Instant now = runtime.now();
List<QRecord> items = userId == null ? List.of() : AlertsDb.query(AlertsInboxItem.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("archivedAt", QCriteriaOperator.IS_BLANK)
.withOrderBy(new QFilterOrderBy("deliveredAt", false))
.withLimit(200));
Map<Integer, QRecord> alerts = new HashMap<>();
List<Integer> ids = items.stream().map(item -> item.getValueInteger("alertId")).toList();
if(!ids.isEmpty())
{
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids)))
{
alerts.put(alert.getValueInteger("id"), alert);
}
}
Map<Integer, String> groups = groupLabels();
String userId = requireViewer();
Instant now = runtime.now();
List<Entry> entries = entries(userId, now);
int unread = 0;
AlertSeverity highest = null;
NotificationSeverity highest = null;
List<Map<String, Object>> latest = new ArrayList<>();
for(QRecord item : items)
List<Map<String, Object>> banners = new ArrayList<>();
for(Entry entry : entries)
{
QRecord alert = alerts.get(item.getValueInteger("alertId"));
if(alert == null)
{
continue;
}
boolean isUnread = item.getValue("readAt") == null;
if(isUnread)
if(entry.unread())
{
unread++;
AlertSeverity severity = AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING);
NotificationSeverity severity = NotificationSeverity.fromName(entry.notification().getValueString("severity"), NotificationSeverity.INFO);
highest = highest == null || severity.compareTo(highest) > 0 ? severity : highest;
}
if(latest.size() < INBOX_ITEMS)
{
Map<String, Object> row = alertRow(alert, groups, now);
row.put("unread", isUnread);
row.put("deliveredAt", item.getValueInstant("deliveredAt") == null ? null : item.getValueInstant("deliveredAt").toString());
latest.add(row);
latest.add(item(entry, now, false));
}
if(Boolean.TRUE.equals(entry.notification().getValueBoolean("sticky")) && banners.size() < MAX_BANNERS)
{
banners.add(item(entry, now, true));
}
}
@@ -157,49 +156,373 @@ public class AlertsApi
result.put("unreadCount", unread);
result.put("highestUnreadSeverity", highest == null ? null : highest.name());
result.put("items", latest);
result.put("banners", banners);
result.put("notificationsHref", NOTIFICATIONS_HREF);
result.put("alertsHref", "/app/" + AlertsQBitConfig.APP_NAME);
result.put("pollSeconds", 60);
result.put("pollSeconds", POLL_SECONDS);
return (result);
}
/*******************************************************************************
** Mark one alert's inbox item read (alertId), or all of the user's.
** The notifications page: the user's notifications (not dismissed),
** filtered by status (all or unread), category and severity, a page at a
** time, with bodies.
*******************************************************************************/
public Map<String, Object> inbox(Map<String, String> params) throws QException
{
String userId = requireViewer();
Instant now = runtime.now();
boolean onlyNew = "unread".equalsIgnoreCase(params.get("status"));
String category = StringUtils.hasContent(params.get("category")) ? params.get("category").toUpperCase(Locale.ROOT) : null;
String severity = StringUtils.hasContent(params.get("severity")) ? params.get("severity").toUpperCase(Locale.ROOT) : null;
int offset = Math.max(0, parseInt(params.get("offset"), 0));
int limit = Math.min(MAX_PAGE, Math.max(1, parseInt(params.get("limit"), DEFAULT_PAGE)));
List<Entry> matching = new ArrayList<>();
for(Entry entry : entries(userId, now))
{
if((!onlyNew || entry.unread())
&& (category == null || category.equals(entry.notification().getValueString("category")))
&& (severity == null || severity.equals(entry.notification().getValueString("severity"))))
{
matching.add(entry);
}
}
List<Map<String, Object>> items = new ArrayList<>();
for(Entry entry : matching.subList(Math.min(offset, matching.size()), Math.min(offset + limit, matching.size())))
{
items.add(item(entry, now, true));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("items", items);
result.put("total", matching.size());
result.put("offset", offset);
result.put("limit", limit);
return (result);
}
/*******************************************************************************
** Mark read: {"id": n}, {"ids": [..]}, {"all": true}, or (0.1 clients)
** {"alertId": n}.
*******************************************************************************/
public Map<String, Object> markRead(Map<String, Object> body) throws QException
{
String userId = requireViewer();
Instant now = runtime.now();
return (updateOwn(userId, target(userId, body, true), "readAt", now, record -> record.getValue("readAt") == null));
}
/*******************************************************************************
** Mark unread: {"id": n} or {"ids": [..]}.
*******************************************************************************/
public Map<String, Object> markUnread(Map<String, Object> body) throws QException
{
String userId = requireViewer();
return (updateOwn(userId, target(userId, body, false), "readAt", null, record -> record.getValue("readAt") != null));
}
/*******************************************************************************
** Dismiss (hide from the bell and the page; also marks read): {"id": n},
** {"ids": [..]} or {"all": true}.
*******************************************************************************/
public Map<String, Object> dismiss(Map<String, Object> body) throws QException
{
String userId = requireViewer();
Instant now = runtime.now();
return (updateOwn(userId, target(userId, body, true), "dismissedAt", now, record -> record.getValue("dismissedAt") == null));
}
/*******************************************************************************
** The open alerts in a user's bell (ALERT notifications not dismissed),
** for the Alerts home's "My alerts".
*******************************************************************************/
public static List<Integer> myAlertIds(String userId, int limit) throws QException
{
List<Integer> notificationIds = new ArrayList<>();
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK).withLimit(limit)))
{
notificationIds.add(recipient.getValueInteger("notificationId"));
}
List<Integer> alertIds = new ArrayList<>();
if(notificationIds.isEmpty())
{
return (alertIds);
}
for(QRecord notification : AlertsRuntime.get().asSystem(() -> AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
.withCriteria("id", QCriteriaOperator.IN, notificationIds)
.withCriteria("category", QCriteriaOperator.EQUALS, NotificationCategory.ALERT.name())
.withCriteria("status", QCriteriaOperator.EQUALS, NotificationService.STATUS_SENT))))
{
Integer alertId = parseInteger(notification.getValueString("sourceRecordId"));
if(alertId != null)
{
alertIds.add(alertId);
}
}
return (alertIds);
}
/*******************************************************************************
** One recipient row with its (live) notification.
*******************************************************************************/
record Entry(QRecord recipient, QRecord notification)
{
/***************************************************************************
**
***************************************************************************/
boolean unread()
{
return (recipient.getValue("readAt") == null);
}
}
/*******************************************************************************
** The user's bell: their recipient rows (the owner lock applies) that are
** not dismissed, joined with notifications that are SENT, published and
** not expired, newest first. ALERT notifications are kept only while the
** user can still see the alert.
*******************************************************************************/
private List<Entry> entries(String userId, Instant now) throws QException
{
if(userId == null)
{
return (List.of());
}
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK)
.withOrderBy(new QFilterOrderBy("deliveredAt", false))
.withOrderBy(new QFilterOrderBy("id", false))
.withLimit(SCAN_LIMIT));
if(recipients.isEmpty())
{
return (List.of());
}
List<Integer> ids = recipients.stream().map(recipient -> recipient.getValueInteger("notificationId")).distinct().toList();
Map<Integer, QRecord> notifications = new HashMap<>();
runtime.asSystem(() ->
{
for(QRecord notification : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
.withCriteria("id", QCriteriaOperator.IN, ids)
.withCriteria("status", QCriteriaOperator.EQUALS, NotificationService.STATUS_SENT)))
{
Instant publishAt = notification.getValueInstant("publishAt");
Instant expireAt = notification.getValueInstant("expireAt");
if((publishAt == null || !publishAt.isAfter(now)) && (expireAt == null || expireAt.isAfter(now)))
{
notifications.put(notification.getValueInteger("id"), notification);
}
}
return (null);
});
List<Integer> alertIds = new ArrayList<>();
for(QRecord notification : notifications.values())
{
if(NotificationCategory.ALERT.name().equals(notification.getValueString("category")) && parseInteger(notification.getValueString("sourceRecordId")) != null)
{
alertIds.add(parseInteger(notification.getValueString("sourceRecordId")));
}
}
Set<String> visibleAlerts = new HashSet<>();
if(!alertIds.isEmpty())
{
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, alertIds)))
{
visibleAlerts.add(String.valueOf(alert.getValue("id")));
}
}
List<Entry> entries = new ArrayList<>();
for(QRecord recipient : recipients)
{
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
if(notification != null && (!NotificationCategory.ALERT.name().equals(notification.getValueString("category")) || visibleAlerts.contains(notification.getValueString("sourceRecordId"))))
{
entries.add(new Entry(recipient, notification));
}
}
return (entries);
}
/*******************************************************************************
** One bell or page item.
*******************************************************************************/
private static Map<String, Object> item(Entry entry, Instant now, boolean withBody)
{
QRecord notification = entry.notification();
Instant deliveredAt = entry.recipient().getValueInstant("deliveredAt");
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", entry.recipient().getValueInteger("id"));
row.put("notificationId", notification.getValueInteger("id"));
row.put("title", notification.getValueString("title"));
if(withBody)
{
row.put("body", notification.getValueString("body"));
}
row.put("severity", notification.getValueString("severity"));
row.put("category", notification.getValueString("category"));
row.put("source", notification.getValueString("sourceName"));
row.put("href", NotificationService.safeLink(notification.getValueString("linkUrl")));
row.put("linkLabel", notification.getValueString("linkLabel"));
row.put("deliveredAt", deliveredAt == null ? null : deliveredAt.toString());
row.put("age", AlertMessageRenderer.age(deliveredAt, now));
row.put("unread", entry.unread());
row.put("sticky", Boolean.TRUE.equals(notification.getValueBoolean("sticky")));
if(NotificationCategory.ALERT.name().equals(notification.getValueString("category")))
{
row.put("alertId", parseInteger(notification.getValueString("sourceRecordId")));
}
return (row);
}
/*******************************************************************************
** Which of the user's recipient rows a request names (null: all of them).
*******************************************************************************/
private List<Integer> target(String userId, Map<String, Object> body, boolean allowAll) throws QException
{
if(body != null && body.get("id") != null)
{
return (List.of(toInteger(body.get("id"))));
}
if(body != null && body.get("ids") instanceof List<?> list && !list.isEmpty())
{
List<Integer> ids = new ArrayList<>();
for(Object value : list.subList(0, Math.min(list.size(), MAX_BATCH)))
{
ids.add(toInteger(value));
}
return (ids);
}
if(body != null && body.get("alertId") != null)
{
Integer alertId = toInteger(body.get("alertId"));
QRecord notification = runtime.asSystem(() -> AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", "alert:" + alertId)));
if(notification == null || userId == null)
{
return (List.of());
}
return (AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("notificationId", QCriteriaOperator.EQUALS, notification.getValue("id"))).stream().map(recipient -> recipient.getValueInteger("id")).toList());
}
if(allowAll && body != null && Boolean.TRUE.equals(body.get("all")))
{
return (null);
}
throw (new QBadRequestException(allowAll ? "Send id, ids, or all: true." : "Send id or ids."));
}
/*******************************************************************************
** Set one field on the user's own recipient rows (the named ones, or all
** not dismissed), where the row still needs it. Dismissing also marks
** read. Recounts the notifications' readers.
*******************************************************************************/
private Map<String, Object> updateOwn(String userId, List<Integer> ids, String field, Instant value, java.util.function.Predicate<QRecord> needs) throws QException
{
if(userId == null || (ids != null && ids.isEmpty()))
{
return (Map.of("updated", 0));
}
QQueryFilter filter = AlertsDb.eq("userId", userId).withCriteria("readAt", QCriteriaOperator.IS_BLANK);
Object alertId = body == null ? null : body.get("alertId");
if(alertId != null)
QQueryFilter filter = AlertsDb.eq("userId", userId).withLimit(SCAN_LIMIT);
if(ids != null)
{
filter.withCriteria("alertId", QCriteriaOperator.EQUALS, Integer.valueOf(String.valueOf(alertId)));
filter.withCriteria("id", QCriteriaOperator.IN, ids);
}
else if(body == null || !Boolean.TRUE.equals(body.get("all")))
else
{
throw (new QBadRequestException("Send alertId, or all: true."));
filter.withCriteria("dismissedAt", QCriteriaOperator.IS_BLANK);
}
Instant now = runtime.now();
List<QRecord> updates = new ArrayList<>();
for(QRecord item : AlertsDb.query(AlertsInboxItem.TABLE_NAME, filter.withLimit(500)))
List<QRecord> updates = new ArrayList<>();
Set<Integer> affected = new HashSet<>();
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, filter))
{
updates.add(new QRecord().withValue("id", item.getValue("id")).withValue("readAt", now));
if(needs.test(recipient))
{
QRecord update = new QRecord().withValue("id", recipient.getValue("id")).withValue(field, value);
if("dismissedAt".equals(field) && recipient.getValue("readAt") == null)
{
update.setValue("readAt", value);
}
updates.add(update);
affected.add(recipient.getValueInteger("notificationId"));
}
}
if(!updates.isEmpty())
{
UpdateAction.executeForRecords(new UpdateInput(AlertsInboxItem.TABLE_NAME).withRecords(updates));
UpdateAction.executeForRecords(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(updates));
runtime.getNotifications().recountRead(affected);
}
return (Map.of("updated", updates.size()));
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer toInteger(Object value) throws QBadRequestException
{
Integer parsed = value == null ? null : parseInteger(String.valueOf(value));
if(parsed == null)
{
throw (new QBadRequestException("Ids must be numbers."));
}
return (parsed);
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer parseInteger(String value)
{
try
{
return (value == null ? null : Integer.valueOf(value.trim().replaceAll("\\.0+$", "")));
}
catch(NumberFormatException e)
{
return (null);
}
}
/*******************************************************************************
**
*******************************************************************************/
private static int parseInt(String value, int fallback)
{
Integer parsed = parseInteger(value);
return (parsed == null ? fallback : parsed);
}
/*******************************************************************************
** The "Open alerts" panel for a record: visible open alerts that reference
** it (at most 5), how many were resolved in 30 days, and links. visible is
@@ -38,8 +38,13 @@ import io.javalin.http.Context;
/*******************************************************************************
** Javalin routes under /qqq/v1/alerts for the Next UI bell and record panel:
** - GET /inbox/summary unread count, highest unread severity, latest 10
** - POST /inbox/read {"alertId": n} or {"all": true}
** - GET /inbox/summary unread count, highest unread severity, latest 10,
** sticky banners
** - GET /inbox the notifications page (status, category,
** severity, offset, limit)
** - POST /inbox/read {"id": n}, {"ids": [..]}, {"all": true} or {"alertId": n}
** - POST /inbox/unread {"id": n} or {"ids": [..]}
** - POST /inbox/dismiss {"id": n}, {"ids": [..]} or {"all": true}
** - GET /record-panel?tableName=t&amp;recordId=id the "Open alerts" panel
** - POST /record-panel/watch {"tableName": t, "recordId": id}
** - POST /ingest/alertmanager Alertmanager's webhook (bearer token; off
@@ -93,7 +98,10 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
public void acceptJavalinConfig(JavalinConfig config)
{
config.routes.get(BASE_PATH + "/inbox/summary", context -> respond(context, false, (api, body) -> api.inboxSummary()));
config.routes.get(BASE_PATH + "/inbox", context -> respond(context, false, (api, body) -> api.inbox(queryParams(context))));
config.routes.post(BASE_PATH + "/inbox/read", context -> respond(context, true, AlertsApi::markRead));
config.routes.post(BASE_PATH + "/inbox/unread", context -> respond(context, true, AlertsApi::markUnread));
config.routes.post(BASE_PATH + "/inbox/dismiss", context -> respond(context, true, AlertsApi::dismiss));
config.routes.get(BASE_PATH + "/record-panel", context -> respond(context, false, (api, body) -> api.recordPanel(context.queryParam("tableName"), context.queryParam("recordId"))));
config.routes.post(BASE_PATH + "/record-panel/watch", context -> respond(context, true, AlertsApi::watchRecord));
config.routes.post(BASE_PATH + "/ingest/alertmanager", this::ingestAlertmanager);
@@ -155,6 +163,18 @@ public class AlertsRouteProvider implements QJavalinRouteProviderInterface
/*******************************************************************************
** The first value of each query parameter.
*******************************************************************************/
private static Map<String, String> queryParams(Context context)
{
Map<String, String> params = new java.util.HashMap<>();
context.queryParamMap().forEach((name, values) -> params.put(name, values == null || values.isEmpty() ? null : values.get(0)));
return (params);
}
/*******************************************************************************
** The JSON body (an object), or 400.
*******************************************************************************/
@@ -0,0 +1,255 @@
/*
* 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.customizers;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.AlertsSecurity;
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.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
import com.kingsrook.qqq.backend.core.model.statusmessages.PermissionDeniedMessage;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Rules for the notification tables, in the core actions (so the admin, the
** API and the bell all follow them):
** - Notifications: the author is the session user; new ones start as
** DRAFT (sending is a record action); status and the send bookkeeping
** can't be edited; links must be app paths or https; an audience needs
** its values. Deleting one deletes its recipient rows.
** - Recipients: people change only read and dismissed on their own rows
** (the owner lock); the qbit inserts and deletes them.
** - Notification settings: the user id is the session's; SECURITY can't be
** muted in-app.
** The system user (the qbit itself) is not restricted.
*******************************************************************************/
public class AlertsNotificationCustomizer implements TableCustomizerInterface
{
private static final Set<String> NOTIFICATION_SYSTEM_FIELDS = Set.of("status", "authorUserId", "sentAt", "retractedAt", "recipientCount", "readCount");
private static final Set<String> RECIPIENT_EDITABLE_FIELDS = Set.of("id", "readAt", "dismissedAt");
/*******************************************************************************
** Stamp the user and author before QQQ's required-field and security-lock
** checks, so they check the values this customizer sets.
*******************************************************************************/
@Override
public AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput insertInput, boolean isPreview)
{
return (AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
{
if(AlertsSecurity.isSystemUser())
{
return (records);
}
String tableName = insertInput.getTableName();
if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
{
return (AlertsWriteGuardCustomizer.deny(tableName, Capability.TABLE_INSERT, records));
}
String userId = AlertsSecurity.userId(QContext.getQSession());
for(QRecord record : records)
{
if(AlertsNotification.TABLE_NAME.equals(tableName))
{
record.setValue("authorUserId", userId);
record.setValue("status", NotificationService.STATUS_DRAFT);
for(String field : List.of("sentAt", "retractedAt", "recipientCount", "readCount"))
{
record.removeValue(field);
}
if(!StringUtils.hasContent(record.getValueString("sourceName")))
{
record.setValue("sourceName", "admin");
}
checkNotification(record);
}
else if(AlertsNotificationPreference.TABLE_NAME.equals(tableName))
{
if(!isAlertsAdmin() || record.getValue("userId") == null)
{
record.setValue("userId", userId);
}
checkPreference(record);
}
}
return (records);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
if(AlertsSecurity.isSystemUser())
{
return (records);
}
String tableName = updateInput.getTableName();
for(QRecord record : records)
{
if(AlertsNotification.TABLE_NAME.equals(tableName))
{
for(String field : NOTIFICATION_SYSTEM_FIELDS)
{
record.removeValue(field);
}
checkNotification(record);
}
else if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
{
for(String field : new ArrayList<>(record.getValues().keySet()))
{
if(!RECIPIENT_EDITABLE_FIELDS.contains(field))
{
record.removeValue(field);
}
}
}
else if(AlertsNotificationPreference.TABLE_NAME.equals(tableName))
{
record.removeValue("userId");
QRecord merged = new QRecord(record);
oldRecordList.flatMap(old -> old.stream().filter(o -> o.getValue("id") != null && o.getValue("id").equals(record.getValue("id"))).findFirst())
.ifPresent(old -> old.getValues().forEach((field, value) -> merged.getValues().putIfAbsent(field, value)));
checkPreference(merged);
merged.getErrors().forEach(record::addError);
}
}
return (records);
}
/*******************************************************************************
** People never delete recipient rows; deleting a notification deletes its
** recipients first (as the qbit).
*******************************************************************************/
@Override
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
{
String tableName = deleteInput.getTableName();
if(AlertsNotificationRecipient.TABLE_NAME.equals(tableName))
{
return (AlertsSecurity.isSystemUser() ? records : AlertsWriteGuardCustomizer.deny(tableName, Capability.TABLE_DELETE, records));
}
if(AlertsNotification.TABLE_NAME.equals(tableName) && !isPreview)
{
List<Serializable> ids = records.stream().map(record -> (Serializable) record.getValueInteger("id")).filter(id -> id != null).toList();
if(!ids.isEmpty())
{
AlertsRuntime.get().asSystem(() -> new DeleteAction().execute(new DeleteInput(AlertsNotificationRecipient.TABLE_NAME)
.withQueryFilter(new QQueryFilter(new QFilterCriteria("notificationId", QCriteriaOperator.IN, ids)))));
}
}
return (records);
}
/*******************************************************************************
** A notification's link and audience (the fields present on the record).
*******************************************************************************/
static void checkNotification(QRecord record)
{
if(record.getValues().containsKey("linkUrl") && StringUtils.hasContent(record.getValueString("linkUrl")) && NotificationService.safeLink(record.getValueString("linkUrl")) == null)
{
record.addError(new BadInputStatusMessage("The link must be an app path (starting with /) or an https URL."));
}
if(record.getValues().containsKey("audienceType"))
{
NotificationAudience audience = NotificationAudience.parse(record.getValueString("audienceType"), record.getValueString("audienceValue"));
if(audience == null)
{
record.addError(new BadInputStatusMessage("Choose who the notification is for."));
}
else if(audience.getType() != NotificationAudience.Type.ALL_USERS && audience.getValues().isEmpty())
{
record.addError(new BadInputStatusMessage("List the " + switch(audience.getType())
{
case ROLES -> "roles or groups";
case PERMISSION -> "permission";
default -> "user ids";
} + " in Audience Members."));
}
}
}
/*******************************************************************************
** SECURITY notices can't be muted in-app.
*******************************************************************************/
static void checkPreference(QRecord record)
{
if(NotificationCategory.SECURITY.name().equals(record.getValueString("category")) && Boolean.TRUE.equals(record.getValueBoolean("inAppMuted")))
{
record.addError(new PermissionDeniedMessage("Security notices can't be muted in-app; you can still choose how they are emailed."));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean isAlertsAdmin()
{
QSession session = QContext.getQSession();
return (session != null && session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN));
}
}
@@ -24,7 +24,7 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.home.HomeHtml;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationKind;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -69,7 +69,7 @@ public final class AlertMessageRenderer
/*******************************************************************************
** One message for one or more alerts.
*******************************************************************************/
public static AlertMessage render(List<Item> items, NotificationKind kind, String publicBaseUrl, Instant now)
public static AlertMessage render(List<Item> items, DeliveryKind kind, String publicBaseUrl, Instant now)
{
AlertSeverity highest = AlertSeverity.INFO;
List<Integer> alertIds = new ArrayList<>();
@@ -84,26 +84,26 @@ public final class AlertMessageRenderer
}
String base = StringUtils.hasContent(publicBaseUrl) ? publicBaseUrl.replaceAll("/+$", "") : "";
String prefix = kind == NotificationKind.TEST ? "TEST: " : "";
String prefix = kind == DeliveryKind.TEST ? "TEST: " : "";
String subject;
String link;
if(items.size() == 1)
{
QRecord alert = items.get(0).alert();
String state = kind == NotificationKind.RESOLVED ? "RESOLVED" : AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING).name();
String state = kind == DeliveryKind.RESOLVED ? "RESOLVED" : AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING).name();
subject = prefix + "[" + state + "] " + AlertRedactor.redact(alert.getValueString("title"));
link = base + "/app/" + AlertsAlert.TABLE_NAME + "/" + alert.getValue("id");
}
else
{
String group = commonGroup(items);
subject = prefix + items.size() + (kind == NotificationKind.RESOLVED ? " alerts resolved" : " new alerts") + (group == null ? "" : " in " + group);
subject = prefix + items.size() + (kind == DeliveryKind.RESOLVED ? " alerts resolved" : " new alerts") + (group == null ? "" : " in " + group);
link = base + "/app/" + AlertsAlert.TABLE_NAME;
}
StringBuilder text = new StringBuilder();
StringBuilder html = new StringBuilder("<div style=\"font-family:sans-serif;font-size:14px;line-height:1.5\">");
if(kind == NotificationKind.TEST)
if(kind == DeliveryKind.TEST)
{
text.append("This is a test notification from Alerts.\n\n");
html.append("<p><strong>This is a test notification from Alerts.</strong></p>");
@@ -156,7 +156,7 @@ public final class AlertMessageRenderer
Integer id = alert.getValueInteger("id");
entries.add(new AlertMessage.Entry(id, alert.getValueString("alertTypeName"), safe(item.typeLabel()), alert.getValueString("dedupKey"),
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING).name(),
kind == NotificationKind.RESOLVED ? "RESOLVED" : (alert.getValueString("status") == null ? "OPEN" : alert.getValueString("status")),
kind == DeliveryKind.RESOLVED ? "RESOLVED" : (alert.getValueString("status") == null ? "OPEN" : alert.getValueString("status")),
safe(alert.getValueString("title")), standard ? AlertRedactor.redact(alert.getValueString("summary")) : null, item.groupLabel(),
standard ? alert.getValueInteger("itemCount") : null, id == null ? base + "/app/" + AlertsAlert.TABLE_NAME : base + "/app/" + AlertsAlert.TABLE_NAME + "/" + id,
alert.getValueInstant("firstRaisedAt") == null ? null : alert.getValueInstant("firstRaisedAt").toString(),
@@ -29,12 +29,12 @@ import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationKind;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
@@ -100,8 +100,8 @@ public class DeliveryWorker
return (runtime.asSystem(() ->
{
Instant now = runtime.now();
List<QRecord> due = AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.PENDING.name(), NotificationStatus.RETRYING.name()))
List<QRecord> due = AlertsDb.query(AlertsDelivery.TABLE_NAME, new QQueryFilter()
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.RETRYING.name()))
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
.withOrderBy(new QFilterOrderBy("id"))
.withLimit(BATCH_LIMIT));
@@ -110,11 +110,11 @@ public class DeliveryWorker
for(QRecord row : due)
{
Instant claimedUntil = row.getValueInstant("claimedUntil");
if(claimedUntil != null && claimedUntil.isAfter(now) && NotificationStatus.SENDING.name().equals(row.getValueString("status")))
if(claimedUntil != null && claimedUntil.isAfter(now) && DeliveryStatus.SENDING.name().equals(row.getValueString("status")))
{
continue;
}
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("status", NotificationStatus.SENDING.name())
AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", row.getValue("id")).withValue("status", DeliveryStatus.SENDING.name())
.withValue("claimedUntil", now.plus(Duration.ofMinutes(2))));
groups.computeIfAbsent(row.getValue("channelId") + "|" + row.getValueString("recipientUserId") + "|" + row.getValueString("kind"), key -> new ArrayList<>()).add(row);
}
@@ -140,10 +140,10 @@ public class DeliveryWorker
int count = 0;
for(Serializable id : notificationIds)
{
QRecord row = AlertsDb.byId(AlertsNotification.TABLE_NAME, id);
if(row != null && List.of(NotificationStatus.FAILED.name(), NotificationStatus.DEAD.name()).contains(row.getValueString("status")))
QRecord row = AlertsDb.byId(AlertsDelivery.TABLE_NAME, id);
if(row != null && List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()).contains(row.getValueString("status")))
{
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", NotificationStatus.PENDING.name())
AlertsDb.update(AlertsDelivery.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", DeliveryStatus.PENDING.name())
.withValue("nextAttemptAt", runtime.now()).withValue("attempts", 0).withValue("claimedUntil", null).withValue("lastError", null));
count++;
}
@@ -171,17 +171,17 @@ public class DeliveryWorker
String channelType = channel.getValueString("channelType");
boolean personal = ChannelType.IN_APP.name().equals(channelType) || ChannelType.EMAIL.name().equals(channelType);
Instant now = runtime.now();
QRecord row = AlertsDb.insert(AlertsNotification.TABLE_NAME, new QRecord()
QRecord row = AlertsDb.insert(AlertsDelivery.TABLE_NAME, new QRecord()
.withValue("channelId", channelId)
.withValue("recipientUserId", personal ? actorUserId : null)
.withValue("recipientLabel", personal ? actorUserId : channel.getValueString("label"))
.withValue("kind", NotificationKind.TEST.name())
.withValue("status", NotificationStatus.SENDING.name())
.withValue("kind", DeliveryKind.TEST.name())
.withValue("status", DeliveryStatus.SENDING.name())
.withValue("attempts", 0)
.withValue("nextAttemptAt", now)
.withValue("claimedUntil", now.plus(Duration.ofMinutes(2))));
sendGroup(List.of(row), now);
return (AlertsDb.byId(AlertsNotification.TABLE_NAME, row.getValue("id")));
return (AlertsDb.byId(AlertsDelivery.TABLE_NAME, row.getValue("id")));
}));
}
@@ -204,7 +204,7 @@ public class DeliveryWorker
return (rows.size());
}
NotificationKind kind = NotificationKind.valueOf(first.getValueString("kind"));
DeliveryKind kind = DeliveryKind.valueOf(first.getValueString("kind"));
List<AlertMessageRenderer.Item> items = new ArrayList<>();
Map<Integer, QRecord> types = new HashMap<>();
Map<Integer, QRecord> groups = new HashMap<>();
@@ -275,22 +275,22 @@ public class DeliveryWorker
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("attempts", attempts).withValue("claimedUntil", null);
switch(result.outcome())
{
case SENT -> update.withValue("status", NotificationStatus.SENT.name()).withValue("sentAt", now).withValue("providerMessageId", result.providerMessageId()).withValue("lastError", null);
case FAILED -> update.withValue("status", NotificationStatus.FAILED.name()).withValue("lastError", truncate(result.error()));
case SENT -> update.withValue("status", DeliveryStatus.SENT.name()).withValue("sentAt", now).withValue("providerMessageId", result.providerMessageId()).withValue("lastError", null);
case FAILED -> update.withValue("status", DeliveryStatus.FAILED.name()).withValue("lastError", truncate(result.error()));
case RETRY ->
{
if(attempts > BACKOFF.size())
{
update.withValue("status", NotificationStatus.DEAD.name()).withValue("lastError", truncate(result.error()));
update.withValue("status", DeliveryStatus.DEAD.name()).withValue("lastError", truncate(result.error()));
}
else
{
update.withValue("status", NotificationStatus.RETRYING.name()).withValue("nextAttemptAt", now.plus(BACKOFF.get(attempts - 1))).withValue("lastError", truncate(result.error()));
update.withValue("status", DeliveryStatus.RETRYING.name()).withValue("nextAttemptAt", now.plus(BACKOFF.get(attempts - 1))).withValue("lastError", truncate(result.error()));
}
}
default -> throw (new IllegalStateException("Unexpected outcome " + result.outcome()));
}
AlertsDb.update(AlertsNotification.TABLE_NAME, update);
AlertsDb.update(AlertsDelivery.TABLE_NAME, update);
}
@@ -30,15 +30,14 @@ import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.engine.AlertsEngine.AlertTarget;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationKind;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SuppressedReason;
@@ -71,10 +70,10 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** 4. apply, in order: the type's notify switch, mutes, flapping, the user's
** pause, channel enabled / minimum severity / address / rate limit, and a
** 10-minute duplicate check;
** 5. write an alerts_notification row per destination (SUPPRESSED rows keep
** 5. write an alerts_delivery row per destination (SUPPRESSED rows keep
** their reason, so "why didn't I get it?" is answered by a table), and an
** inbox item per in-app recipient.
** In-app deliveries are complete when the inbox item is written; other
** ALERT notification recipient row per in-app recipient.
** In-app deliveries are complete when the recipient row is written; other
** channels are left PENDING for the delivery worker.
*******************************************************************************/
public class AlertRouter
@@ -109,13 +108,13 @@ public class AlertRouter
/*******************************************************************************
** The notification kind for a timeline event (null: not notifiable).
*******************************************************************************/
public static NotificationKind kindFor(EventType eventType)
public static DeliveryKind kindFor(EventType eventType)
{
return switch(eventType)
{
case RAISED, REOPENED, SEVERITY_CHANGED, FLAPPING_STARTED -> NotificationKind.RAISED;
case RESOLVED -> NotificationKind.RESOLVED;
case ESCALATED -> NotificationKind.ESCALATED;
case RAISED, REOPENED, SEVERITY_CHANGED, FLAPPING_STARTED -> DeliveryKind.RAISED;
case RESOLVED -> DeliveryKind.RESOLVED;
case ESCALATED -> DeliveryKind.ESCALATED;
default -> null;
};
}
@@ -128,7 +127,7 @@ public class AlertRouter
*******************************************************************************/
public void route(QRecord alert, EventType eventType, Integer eventId)
{
NotificationKind kind = kindFor(eventType);
DeliveryKind kind = kindFor(eventType);
if(alert == null || kind == null)
{
return;
@@ -231,23 +230,24 @@ public class AlertRouter
if(reason != null)
{
notification.setValue("status", NotificationStatus.SUPPRESSED.name());
notification.setValue("status", DeliveryStatus.SUPPRESSED.name());
notification.setValue("suppressedReason", reason.name());
AlertsDb.insert(AlertsNotification.TABLE_NAME, notification);
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
}
else if(inApp)
{
notification.setValue("status", NotificationStatus.SENT.name());
notification.setValue("status", DeliveryStatus.SENT.name());
notification.setValue("sentAt", now);
QRecord inserted = AlertsDb.insert(AlertsNotification.TABLE_NAME, notification);
deliverToInbox(destination.userId(), alert.getValueInteger("id"), inserted.getValueInteger("id"), now);
QRecord inserted = AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
runtime.getNotifications().deliverAlert(destination.userId(), alert, now);
LOG.debug("In-app alert delivered", logPair("deliveryId", inserted.getValue("id")));
queued++;
}
else
{
notification.setValue("status", NotificationStatus.PENDING.name());
notification.setValue("status", DeliveryStatus.PENDING.name());
notification.setValue("nextAttemptAt", now);
AlertsDb.insert(AlertsNotification.TABLE_NAME, notification);
AlertsDb.insert(AlertsDelivery.TABLE_NAME, notification);
queued++;
}
}
@@ -305,7 +305,7 @@ public class AlertRouter
** team) with their in-app and email channels (or the subscription's
** chosen channels), and subscribed channels.
*******************************************************************************/
List<Destination> destinations(AlertTarget target, AlertSeverity severity, NotificationKind kind) throws QException
List<Destination> destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) throws QException
{
Map<Integer, List<String>> groupNames = new HashMap<>();
Map<String, Destination> destinations = new LinkedHashMap<>();
@@ -463,7 +463,7 @@ public class AlertRouter
/*******************************************************************************
** Whether a subscription's events list includes this kind (empty: all).
*******************************************************************************/
private static boolean includesKind(String events, NotificationKind kind)
private static boolean includesKind(String events, DeliveryKind kind)
{
if(!StringUtils.hasContent(events))
{
@@ -486,7 +486,7 @@ public class AlertRouter
** with a host whose authentication module builds sessions with permissions
** for a user id, the alert is read through that session's record locks.
** Otherwise (empty) the user's permissions are unknown, and the caller
** allows in-app (the inbox is filtered by the same locks when it is read)
** allows in-app (the bell re-checks the alert under the reader's session)
** but sends externally only alerts with no visibility permission and no
** subject table.
*******************************************************************************/
@@ -553,8 +553,8 @@ public class AlertRouter
{
return (false);
}
int lastHour = AlertsDb.count(AlertsNotification.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.PENDING.name(), NotificationStatus.SENDING.name(), NotificationStatus.SENT.name(), NotificationStatus.RETRYING.name()))
int lastHour = AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.SENDING.name(), DeliveryStatus.SENT.name(), DeliveryStatus.RETRYING.name()))
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN, now.minus(1, ChronoUnit.HOURS)));
return (lastHour >= limit);
}
@@ -564,37 +564,18 @@ public class AlertRouter
/*******************************************************************************
** The same alert, channel, recipient and kind within 10 minutes.
*******************************************************************************/
private boolean isDuplicate(QRecord alert, Destination destination, NotificationKind kind, Instant now) throws QException
private boolean isDuplicate(QRecord alert, Destination destination, DeliveryKind kind, Instant now) throws QException
{
var filter = AlertsDb.eq("alertId", alert.getValue("id"))
.withCriteria("channelId", QCriteriaOperator.EQUALS, destination.channel().getValue("id"))
.withCriteria("kind", QCriteriaOperator.EQUALS, kind.name())
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.PENDING.name(), NotificationStatus.SENDING.name(), NotificationStatus.SENT.name(), NotificationStatus.RETRYING.name()))
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.PENDING.name(), DeliveryStatus.SENDING.name(), DeliveryStatus.SENT.name(), DeliveryStatus.RETRYING.name()))
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN, now.minus(DUPLICATE_WINDOW_MINUTES, ChronoUnit.MINUTES));
if(destination.userId() != null)
{
filter.withCriteria("recipientUserId", QCriteriaOperator.EQUALS, destination.userId());
}
return (AlertsDb.count(AlertsNotification.TABLE_NAME, filter) > 0);
}
/*******************************************************************************
** Put (or bring back to unread) the alert in the user's bell.
*******************************************************************************/
void deliverToInbox(String userId, Integer alertId, Integer notificationId, Instant now) throws QException
{
QRecord existing = AlertsDb.first(AlertsInboxItem.TABLE_NAME, AlertsDb.eq("userId", userId).withCriteria("alertId", QCriteriaOperator.EQUALS, alertId));
if(existing == null)
{
AlertsDb.insert(AlertsInboxItem.TABLE_NAME, new QRecord().withValue("userId", userId).withValue("alertId", alertId).withValue("notificationId", notificationId).withValue("deliveredAt", now));
}
else
{
AlertsDb.update(AlertsInboxItem.TABLE_NAME, new QRecord().withValue("id", existing.getValue("id")).withValue("notificationId", notificationId)
.withValue("deliveredAt", now).withValue("readAt", null).withValue("archivedAt", null));
}
return (AlertsDb.count(AlertsDelivery.TABLE_NAME, filter) > 0);
}
@@ -22,6 +22,7 @@ import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -76,4 +77,19 @@ public class AlertsSinkAdapter implements AlertSinkInterface
runtime.sink().heartbeat(heartbeatKey, at);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void notify(NotificationSignal signal)
{
AlertsRuntime runtime = AlertsRuntime.find(QContext.getQInstance());
if(runtime != null)
{
runtime.sink().notify(signal);
}
}
}
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.alerts.spi.AlertResolution;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.AlertSignal;
import com.kingsrook.qbits.alerts.spi.AlertSinkInterface;
import com.kingsrook.qbits.alerts.spi.NotificationSignal;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -132,6 +133,26 @@ public class QbitAlertsSink implements AlertSinkInterface
/*******************************************************************************
** 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)
{
return;
}
if(signal.getOccurredAt() == null)
{
signal.setOccurredAt(runtime.now());
}
submit(() -> runtime.getNotifications().notify(signal));
}
/*******************************************************************************
** Process everything queued now, on this thread (tests, shutdown).
*******************************************************************************/
@@ -34,18 +34,19 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.api.AlertsApi;
import com.kingsrook.qbits.alerts.delivery.AlertMessageRenderer;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsEvent;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Status;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsType;
@@ -160,7 +161,7 @@ public final class AlertsHome
/*******************************************************************************
** My alerts: assigned to me, or in my inbox; unacknowledged first.
** My alerts: assigned to me, or in my bell (not dismissed); unacknowledged first.
*******************************************************************************/
static QWidgetData mine(Instant now) throws QException
{
@@ -176,13 +177,9 @@ public final class AlertsHome
{
mine.put(alert.getValueInteger("id"), alert);
}
if(canRead(AlertsInboxItem.TABLE_NAME))
if(canRead(AlertsNotificationRecipient.TABLE_NAME))
{
List<Integer> ids = new ArrayList<>();
for(QRecord item : AlertsDb.query(AlertsInboxItem.TABLE_NAME, AlertsDb.eq("userId", userId).withCriteria("archivedAt", QCriteriaOperator.IS_BLANK).withLimit(SCAN_LIMIT)))
{
ids.add(item.getValueInteger("alertId"));
}
List<Integer> ids = AlertsApi.myAlertIds(userId, SCAN_LIMIT);
if(!ids.isEmpty())
{
for(QRecord alert : AlertsDb.query(AlertsAlert.TABLE_NAME, new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids).withCriteria("status", QCriteriaOperator.IN, Status.openNames())))
@@ -382,7 +379,7 @@ public final class AlertsHome
*******************************************************************************/
static QWidgetData delivery(Instant now) throws QException
{
if(!canRead(AlertsChannel.TABLE_NAME) || !canRead(AlertsNotification.TABLE_NAME))
if(!canRead(AlertsChannel.TABLE_NAME) || !canRead(AlertsDelivery.TABLE_NAME))
{
return (new RawHTML(null, HomeHtml.emptyState("Delivery health is shown to alerts admins.")));
}
@@ -391,8 +388,8 @@ public final class AlertsHome
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord channel : channels)
{
int failed = AlertsDb.count(AlertsNotification.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.FAILED.name(), NotificationStatus.DEAD.name()))
int failed = AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()))
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since));
String health = channel.getValueString("healthStatus");
if(failed == 0 && (health == null || HealthStatus.HEALTHY.name().equals(health)))
@@ -402,8 +399,8 @@ public final class AlertsHome
Map<String, Object> row = new HashMap<>();
row.put("channel", HomeHtml.link("/app/" + AlertsChannel.TABLE_NAME + "/" + channel.getValue("id"), channel.getValueString("label")));
row.put("health", HomeHtml.badge(AlertsPossibleValues.label(health == null ? "HEALTHY" : health), HealthStatus.UNHEALTHY.name().equals(health) ? HomeHtml.BAD_COLOR : HomeHtml.WARN_COLOR));
row.put("failed", HomeHtml.link(listUrl(AlertsNotification.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
.withCriteria("status", QCriteriaOperator.IN, List.of(NotificationStatus.FAILED.name(), NotificationStatus.DEAD.name()))), HomeHtml.number(failed)));
row.put("failed", HomeHtml.link(listUrl(AlertsDelivery.TABLE_NAME, AlertsDb.eq("channelId", channel.getValue("id"))
.withCriteria("status", QCriteriaOperator.IN, List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()))), HomeHtml.number(failed)));
row.put("error", HomeHtml.escape(StringUtils.hasContent(channel.getValueString("lastError")) ? StringUtils.safeTruncate(channel.getValueString("lastError"), 120, "…") : ""));
rows.add(row);
}
@@ -23,10 +23,12 @@ import com.kingsrook.qbits.alerts.AlertsPermissionChecker;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationPreference;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsType;
@@ -44,7 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The top-level Alerts app (design section 6.1). Its home is the Alerts
** dashboard; its nav lists the main tables in the design's order. The
** timeline, alert records, team members and inbox items are not in the nav:
** timeline, alert records, team members and notification recipients are not in the nav:
** they are child lists and relations on their parents, and stay standard
** tables reachable by URL and the API. Tables a user can't read are hidden
** by permission. No processes in the nav.
@@ -52,8 +54,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
public class AlertsAppMetaDataProducer extends QBitComponentMetaDataProducer<QAppMetaData, AlertsQBitConfig>
{
/** the main tables, in nav order. */
public static final List<String> MAIN_TABLES = List.of(AlertsAlert.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME,
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsNotification.TABLE_NAME);
public static final List<String> MAIN_TABLES = List.of(AlertsAlert.TABLE_NAME, AlertsNotification.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME,
AlertsSubscription.TABLE_NAME, AlertsUserPreference.TABLE_NAME, AlertsNotificationPreference.TABLE_NAME, AlertsTeam.TABLE_NAME, AlertsChannel.TABLE_NAME, AlertsMute.TABLE_NAME, AlertsDelivery.TABLE_NAME);
@@ -23,17 +23,20 @@ import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsSecurity;
import com.kingsrook.qbits.alerts.customizers.AlertsNotificationCustomizer;
import com.kingsrook.qbits.alerts.customizers.AlertsSubscriptionCustomizer;
import com.kingsrook.qbits.alerts.customizers.AlertsWriteGuardCustomizer;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsEvent;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsHeartbeat;
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
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.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsRecordRef;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
@@ -41,6 +44,10 @@ import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qbits.alerts.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.exceptions.QException;
@@ -76,27 +83,27 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
** audit, API) secured by QQQ mechanisms rather than by hiding it:
** - Permissions: QQQ's per-table read / insert / edit / delete permissions
** (AlertsQBitConfig lists them per role).
** - Record security locks (AlertsSecurity): an alert, its timeline, records
** and inbox items are visible only when the session holds the type's
** - Record security locks (AlertsSecurity): an alert, its timeline and records
** are visible only when the session holds the type's
** visibility permission (if any) and can read its subject table (if any).
** Subscriptions, settings and inbox items are the user's own (admins hold
** Subscriptions, settings and notification recipient rows are the user's own (admins hold
** the all-access key).
** - Tables the qbit writes (alerts, timeline, records, notifications, inbox
** items, heartbeats) have no insert or delete capability; people change
** - Tables the qbit writes (alerts, timeline, records, the delivery log,
** heartbeats) have no insert or delete capability; people change
** alerts through record actions, never by editing rows.
*******************************************************************************/
public final class AlertsTableMetaDataHelper
{
public static final String JOIN_ALERT_EVENT = "alerts_alert_join_alerts_event";
public static final String JOIN_ALERT_RECORD_REF = "alerts_alert_join_alerts_record_ref";
public static final String JOIN_ALERT_NOTIFICATION = "alerts_alert_join_alerts_notification";
public static final String JOIN_ALERT_INBOX_ITEM = "alerts_alert_join_alerts_inbox_item";
public static final String JOIN_ALERT_DELIVERY = "alerts_alert_join_alerts_delivery";
public static final String JOIN_TYPE_ALERT = "alerts_type_join_alerts_alert";
public static final String JOIN_TYPE_MONITOR = "alerts_type_join_alerts_monitor";
public static final String JOIN_GROUP_TYPE = "alerts_group_join_alerts_type";
public static final String JOIN_MONITOR_ALERT = "alerts_monitor_join_alerts_alert";
public static final String JOIN_TEAM_MEMBER = "alerts_team_join_alerts_team_member";
public static final String JOIN_CHANNEL_NOTIFICATION = "alerts_channel_join_alerts_notification";
public static final String JOIN_NOTIFICATION_RECIPIENT = "alerts_notification_join_alerts_notification_recipient";
public static final String JOIN_CHANNEL_DELIVERY = "alerts_channel_join_alerts_delivery";
@@ -125,7 +132,7 @@ public final class AlertsTableMetaDataHelper
"snoozedUntil", "muted", "resolvedAt", "resolvedByUserId", "resolution", "resolutionNote")))
.withSection(widgetSection(JOIN_ALERT_EVENT, "Timeline", "history"))
.withSection(widgetSection(JOIN_ALERT_RECORD_REF, "Records", "link"))
.withSection(widgetSection(JOIN_ALERT_NOTIFICATION, "Notifications", "send"))
.withSection(widgetSection(JOIN_ALERT_DELIVERY, "Notifications", "send"))
.withSection(new QFieldSection("occurrence", "Occurrences", new QIcon("repeat"), Tier.T3, List.of("firstRaisedAt", "lastRaisedAt", "occurrenceCount", "reopenCount", "flapping",
"lastNotifiedAt", "monitorId", "dedupKey", "alertTypeName", "visibilityPermission", "openDedupKey", "escalationLevel", "links", "attributes", "createDate", "modifyDate")));
return (readOnly(table));
@@ -334,7 +341,7 @@ public final class AlertsTableMetaDataHelper
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "label", "name", "channelType", "enabled", "healthStatus")))
.withSection(new QFieldSection("settings", "Settings", new QIcon("tune"), Tier.T2, List.of("settings", "secretRef", "detailLevel", "minSeverity", "rateLimitPerHour")))
.withSection(new QFieldSection("health", "Health", new QIcon("monitor_heart"), Tier.T2, List.of("lastSuccessAt", "lastFailureAt", "consecutiveFailures", "lastError")))
.withSection(widgetSection(JOIN_CHANNEL_NOTIFICATION, "Notifications", "send"))
.withSection(widgetSection(JOIN_CHANNEL_DELIVERY, "Notifications", "send"))
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
table.getField("settings").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("json")));
table.getField("enabled").setDefaultValue(true);
@@ -368,11 +375,11 @@ public final class AlertsTableMetaDataHelper
/*******************************************************************************
** alerts_notification
** alerts_delivery
*******************************************************************************/
public static QTableMetaData buildNotificationTable(AlertsQBitConfig config) throws QException
public static QTableMetaData buildDeliveryTable(AlertsQBitConfig config) throws QException
{
QTableMetaData table = base(config, AlertsNotification.TABLE_NAME, "Notification Log", "send", AlertsNotification.class)
QTableMetaData table = base(config, AlertsDelivery.TABLE_NAME, "Delivery Log", "send", AlertsDelivery.class)
.withRecordLabelFormat("%s %s")
.withRecordLabelFields("kind", "recipientLabel")
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "alertId", "kind", "status", "channelId", "recipientLabel")))
@@ -384,19 +391,92 @@ public final class AlertsTableMetaDataHelper
/*******************************************************************************
** alerts_inbox_item
** alerts_notification (user notifications, docs/NOTIFICATIONS.md 3.1)
*******************************************************************************/
public static QTableMetaData buildInboxItemTable(AlertsQBitConfig config) throws QException
public static QTableMetaData buildNotificationTable(AlertsQBitConfig config) throws QException
{
QTableMetaData table = base(config, AlertsInboxItem.TABLE_NAME, "My Alerts Inbox", "inbox", AlertsInboxItem.class)
QTableMetaData table = text(base(config, AlertsNotification.TABLE_NAME, "Notifications", "campaign", AlertsNotification.class), "body", "audienceValue")
.withRecordLabelFormat("%s")
.withRecordLabelFields("alertId")
.withRecordLabelFields("title")
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "title", "severity", "category", "status")))
.withSection(new QFieldSection("message", "Message", new QIcon("article"), Tier.T2, List.of("body", "linkUrl", "linkLabel", "sticky")))
.withSection(new QFieldSection("audience", "Audience", new QIcon("groups"), Tier.T2, List.of("audienceType", "audienceValue", "recipientCount", "readCount")))
.withSection(new QFieldSection("schedule", "Schedule", new QIcon("schedule"), Tier.T2, List.of("publishAt", "expireAt", "sentAt", "retractedAt")))
.withSection(widgetSection(JOIN_NOTIFICATION_RECIPIENT, "Recipients", "person"))
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("authorUserId", "sourceName", "sourceKey", "sourceTableName", "sourceRecordId",
"createDate", "modifyDate")));
table.getField("severity").setDefaultValue(NotificationSeverity.INFO.name());
table.getField("category").setDefaultValue(NotificationCategory.SYSTEM.name());
table.getField("audienceType").setDefaultValue(NotificationAudience.Type.ALL_USERS.name());
table.getField("status").setDefaultValue(NotificationService.STATUS_DRAFT);
table.getField("sticky").setDefaultValue(false);
table.getField("body").withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue(AdornmentType.CodeEditorValues.languageMode("markdown")));
return (notificationCustomizer(table, true));
}
/*******************************************************************************
** alerts_notification_recipient (one notification for one user). Users
** see and edit (read, dismissed) only their own rows; the qbit inserts
** and deletes them.
*******************************************************************************/
public static QTableMetaData buildNotificationRecipientTable(AlertsQBitConfig config) throws QException
{
QTableMetaData table = base(config, AlertsNotificationRecipient.TABLE_NAME, "Notification Recipients", "mark_email_read", AlertsNotificationRecipient.class)
.withRecordLabelFormat("%s %s")
.withRecordLabelFields("notificationId", "userId")
.withUniqueKey(new UniqueKey("notificationId", "userId"))
.withRecordSecurityLock(AlertsSecurity.recipientLock("userId"))
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "notificationId", "userId", "deliveredAt", "readAt", "dismissedAt")))
.withSection(new QFieldSection("email", "Email", new QIcon("mail"), Tier.T2, List.of("emailStatus", "emailedAt")))
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate")));
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_DELETE);
return (notificationCustomizer(table, true));
}
/*******************************************************************************
** alerts_notification_preference (a user's settings for one category)
*******************************************************************************/
public static QTableMetaData buildNotificationPreferenceTable(AlertsQBitConfig config) throws QException
{
QTableMetaData table = base(config, AlertsNotificationPreference.TABLE_NAME, "My Notification Settings", "tune", AlertsNotificationPreference.class)
.withRecordLabelFormat("%s %s")
.withRecordLabelFields("userId", "category")
.withUniqueKey(new UniqueKey("userId", "category"))
.withRecordSecurityLock(AlertsSecurity.ownerLock("userId"))
.withRecordSecurityLock(AlertsSecurity.visibilityLock(JOIN_ALERT_INBOX_ITEM))
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "alertId", "deliveredAt", "readAt", "archivedAt")))
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("userId", "notificationId", "createDate", "modifyDate")));
table.withoutCapabilities(Capability.TABLE_INSERT);
return (guard(table));
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "category", "inAppMuted", "emailMode", "emailMinSeverity")))
.withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("userId", "createDate", "modifyDate")));
table.getField("category").setDefaultValue(NotificationCategory.SYSTEM.name());
table.getField("inAppMuted").setDefaultValue(false);
table.getField("emailMode").setDefaultValue(AlertsPossibleValues.EmailMode.OFF.name());
table.getField("emailMinSeverity").setDefaultValue(NotificationSeverity.WARNING.name());
return (notificationCustomizer(table, false));
}
/*******************************************************************************
** The notification tables' customizer (insert, update and, optionally,
** delete).
*******************************************************************************/
private static QTableMetaData notificationCustomizer(QTableMetaData table, boolean onDelete)
{
QCodeReference reference = new QCodeReference(AlertsNotificationCustomizer.class);
List<TableCustomizers> roles = new ArrayList<>(List.of(TableCustomizers.PRE_INSERT_RECORD, TableCustomizers.PRE_UPDATE_RECORD));
if(onDelete)
{
roles.add(TableCustomizers.PRE_DELETE_RECORD);
}
for(TableCustomizers role : roles)
{
table.withCustomizer(role, reference);
}
return (table);
}
@@ -424,7 +504,8 @@ public final class AlertsTableMetaDataHelper
{
return (List.of(buildAlertTable(config), buildMonitorTable(config), buildTypeTable(config), buildGroupTable(config), buildSubscriptionTable(config),
buildUserPreferenceTable(config), buildTeamTable(config), buildTeamMemberTable(config), buildChannelTable(config), buildMuteTable(config),
buildNotificationTable(config), buildEventTable(config), buildRecordRefTable(config), buildInboxItemTable(config), buildHeartbeatTable(config)));
buildDeliveryTable(config), buildEventTable(config), buildRecordRefTable(config), buildHeartbeatTable(config), buildNotificationTable(config),
buildNotificationRecipientTable(config), buildNotificationPreferenceTable(config)));
}
@@ -436,7 +517,7 @@ public final class AlertsTableMetaDataHelper
{
List<QPossibleValueSource> sources = new ArrayList<>(AlertsPossibleValues.buildEnumSources());
for(String table : List.of(AlertsAlert.TABLE_NAME, AlertsType.TABLE_NAME, AlertsGroup.TABLE_NAME, AlertsMonitor.TABLE_NAME, AlertsTeam.TABLE_NAME,
AlertsChannel.TABLE_NAME, AlertsSubscription.TABLE_NAME))
AlertsChannel.TABLE_NAME, AlertsSubscription.TABLE_NAME, AlertsNotification.TABLE_NAME))
{
sources.add(QPossibleValueSource.newForTable(table));
}
@@ -453,14 +534,14 @@ public final class AlertsTableMetaDataHelper
List<QJoinMetaData> joins = new ArrayList<>();
joins.add(oneToMany(JOIN_ALERT_EVENT, AlertsAlert.TABLE_NAME, AlertsEvent.TABLE_NAME, "alertId"));
joins.add(oneToMany(JOIN_ALERT_RECORD_REF, AlertsAlert.TABLE_NAME, AlertsRecordRef.TABLE_NAME, "alertId"));
joins.add(oneToMany(JOIN_ALERT_NOTIFICATION, AlertsAlert.TABLE_NAME, AlertsNotification.TABLE_NAME, "alertId"));
joins.add(oneToMany(JOIN_ALERT_INBOX_ITEM, AlertsAlert.TABLE_NAME, AlertsInboxItem.TABLE_NAME, "alertId"));
joins.add(oneToMany(JOIN_ALERT_DELIVERY, AlertsAlert.TABLE_NAME, AlertsDelivery.TABLE_NAME, "alertId"));
joins.add(oneToMany(JOIN_TYPE_ALERT, AlertsType.TABLE_NAME, AlertsAlert.TABLE_NAME, "alertTypeId"));
joins.add(oneToMany(JOIN_TYPE_MONITOR, AlertsType.TABLE_NAME, AlertsMonitor.TABLE_NAME, "alertTypeId"));
joins.add(oneToMany(JOIN_GROUP_TYPE, AlertsGroup.TABLE_NAME, AlertsType.TABLE_NAME, "groupId"));
joins.add(oneToMany(JOIN_MONITOR_ALERT, AlertsMonitor.TABLE_NAME, AlertsAlert.TABLE_NAME, "monitorId"));
joins.add(oneToMany(JOIN_TEAM_MEMBER, AlertsTeam.TABLE_NAME, AlertsTeamMember.TABLE_NAME, "teamId"));
joins.add(oneToMany(JOIN_CHANNEL_NOTIFICATION, AlertsChannel.TABLE_NAME, AlertsNotification.TABLE_NAME, "channelId"));
joins.add(oneToMany(JOIN_CHANNEL_DELIVERY, AlertsChannel.TABLE_NAME, AlertsDelivery.TABLE_NAME, "channelId"));
joins.add(oneToMany(JOIN_NOTIFICATION_RECIPIENT, AlertsNotification.TABLE_NAME, AlertsNotificationRecipient.TABLE_NAME, "notificationId"));
return (joins);
}
@@ -481,14 +562,15 @@ public final class AlertsTableMetaDataHelper
List<QWidgetMetaDataInterface> widgets = new ArrayList<>();
widgets.add(childList(joins.get(JOIN_ALERT_EVENT), "Timeline", 50, new QFilterOrderBy("at", false), List.of("at", "eventType", "userId", "toValue", "note")));
widgets.add(childList(joins.get(JOIN_ALERT_RECORD_REF), "Records", 25, new QFilterOrderBy("id", true), List.of("tableName", "recordId", "label", "active")));
widgets.add(childList(joins.get(JOIN_ALERT_NOTIFICATION), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
widgets.add(childList(joins.get(JOIN_ALERT_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
widgets.add(childList(joins.get(JOIN_TYPE_ALERT), "Alerts", 10, new QFilterOrderBy("lastRaisedAt", false), List.of("title", "severity", "status", "lastRaisedAt")));
widgets.add(childList(joins.get(JOIN_TYPE_MONITOR), "Monitors", 10, new QFilterOrderBy("id", true), List.of("name", "kind", "enabled", "lastStatus")));
widgets.add(childList(joins.get(JOIN_GROUP_TYPE), "Alert Types", 50, new QFilterOrderBy("label", true), List.of("label", "defaultSeverity", "enabled", "openCount")));
widgets.add(childList(joins.get(JOIN_MONITOR_ALERT), "Alerts", 10, new QFilterOrderBy("lastRaisedAt", false), List.of("title", "severity", "status", "lastRaisedAt")));
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(joins.get(JOIN_TEAM_MEMBER)).withLabel("Members").withCanAddChildRecord(true).withMaxRows(100)
.withOnlyIncludeFieldNames(List.of("userId", "role")).getWidgetMetaData());
widgets.add(childList(joins.get(JOIN_CHANNEL_NOTIFICATION), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
widgets.add(childList(joins.get(JOIN_CHANNEL_DELIVERY), "Notifications", 25, new QFilterOrderBy("id", false), List.of("kind", "status", "recipientLabel", "sentAt")));
widgets.add(childList(joins.get(JOIN_NOTIFICATION_RECIPIENT), "Recipients", 25, new QFilterOrderBy("id", true), List.of("userId", "deliveredAt", "readAt", "dismissedAt", "emailStatus")));
return (widgets);
}
@@ -0,0 +1,669 @@
/*
* 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.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
/*******************************************************************************
** One delivery attempt: who, which channel, status, retries and error.
** Suppressed deliveries are logged too, with their reason. Written by the
** qbit; read-only.
**
** Generated from the qbit-alerts schema (one definition for the entity,
** the Liquibase changelog and the test DDL).
*******************************************************************************/
public class AlertsDelivery extends QRecordEntity
{
public static final String TABLE_NAME = "alerts_delivery";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isEditable = false, possibleValueSourceName = "alerts_alert", label = "Alert")
private Integer alertId;
@QField(isEditable = false, label = "Alert Event")
private Integer alertEventId;
@QField(isEditable = false, possibleValueSourceName = "alerts_subscription", label = "Subscription")
private Integer subscriptionId;
@QField(isEditable = false, possibleValueSourceName = "alerts_channel", label = "Channel")
private Integer channelId;
@QField(isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Recipient")
private String recipientUserId;
@QField(isEditable = false, maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Sent To")
private String recipientLabel;
@QField(isEditable = false, maxLength = 16, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_delivery_kind")
private String kind;
@QField(isEditable = false, maxLength = 16, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_delivery_status")
private String status;
@QField(isEditable = false, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_suppressed_reason")
private String suppressedReason;
@QField(isEditable = false)
private Integer attempts;
@QField(isEditable = false)
private Instant nextAttemptAt;
@QField(isEditable = false)
private Instant claimedUntil;
@QField(isEditable = false)
private Instant sentAt;
@QField(isEditable = false, maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String providerMessageId;
@QField(isEditable = false, maxLength = 2000, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String lastError;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public AlertsDelivery()
{
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
public AlertsDelivery(QRecord record)
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (this.id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AlertsDelivery withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for alertId
*******************************************************************************/
public Integer getAlertId()
{
return (this.alertId);
}
/*******************************************************************************
** Setter for alertId
*******************************************************************************/
public void setAlertId(Integer alertId)
{
this.alertId = alertId;
}
/*******************************************************************************
** Fluent setter for alertId
*******************************************************************************/
public AlertsDelivery withAlertId(Integer alertId)
{
this.alertId = alertId;
return (this);
}
/*******************************************************************************
** Getter for alertEventId
*******************************************************************************/
public Integer getAlertEventId()
{
return (this.alertEventId);
}
/*******************************************************************************
** Setter for alertEventId
*******************************************************************************/
public void setAlertEventId(Integer alertEventId)
{
this.alertEventId = alertEventId;
}
/*******************************************************************************
** Fluent setter for alertEventId
*******************************************************************************/
public AlertsDelivery withAlertEventId(Integer alertEventId)
{
this.alertEventId = alertEventId;
return (this);
}
/*******************************************************************************
** Getter for subscriptionId
*******************************************************************************/
public Integer getSubscriptionId()
{
return (this.subscriptionId);
}
/*******************************************************************************
** Setter for subscriptionId
*******************************************************************************/
public void setSubscriptionId(Integer subscriptionId)
{
this.subscriptionId = subscriptionId;
}
/*******************************************************************************
** Fluent setter for subscriptionId
*******************************************************************************/
public AlertsDelivery withSubscriptionId(Integer subscriptionId)
{
this.subscriptionId = subscriptionId;
return (this);
}
/*******************************************************************************
** Getter for channelId
*******************************************************************************/
public Integer getChannelId()
{
return (this.channelId);
}
/*******************************************************************************
** Setter for channelId
*******************************************************************************/
public void setChannelId(Integer channelId)
{
this.channelId = channelId;
}
/*******************************************************************************
** Fluent setter for channelId
*******************************************************************************/
public AlertsDelivery withChannelId(Integer channelId)
{
this.channelId = channelId;
return (this);
}
/*******************************************************************************
** Getter for recipientUserId
*******************************************************************************/
public String getRecipientUserId()
{
return (this.recipientUserId);
}
/*******************************************************************************
** Setter for recipientUserId
*******************************************************************************/
public void setRecipientUserId(String recipientUserId)
{
this.recipientUserId = recipientUserId;
}
/*******************************************************************************
** Fluent setter for recipientUserId
*******************************************************************************/
public AlertsDelivery withRecipientUserId(String recipientUserId)
{
this.recipientUserId = recipientUserId;
return (this);
}
/*******************************************************************************
** Getter for recipientLabel
*******************************************************************************/
public String getRecipientLabel()
{
return (this.recipientLabel);
}
/*******************************************************************************
** Setter for recipientLabel
*******************************************************************************/
public void setRecipientLabel(String recipientLabel)
{
this.recipientLabel = recipientLabel;
}
/*******************************************************************************
** Fluent setter for recipientLabel
*******************************************************************************/
public AlertsDelivery withRecipientLabel(String recipientLabel)
{
this.recipientLabel = recipientLabel;
return (this);
}
/*******************************************************************************
** Getter for kind
*******************************************************************************/
public String getKind()
{
return (this.kind);
}
/*******************************************************************************
** Setter for kind
*******************************************************************************/
public void setKind(String kind)
{
this.kind = kind;
}
/*******************************************************************************
** Fluent setter for kind
*******************************************************************************/
public AlertsDelivery withKind(String kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Getter for status
*******************************************************************************/
public String getStatus()
{
return (this.status);
}
/*******************************************************************************
** Setter for status
*******************************************************************************/
public void setStatus(String status)
{
this.status = status;
}
/*******************************************************************************
** Fluent setter for status
*******************************************************************************/
public AlertsDelivery withStatus(String status)
{
this.status = status;
return (this);
}
/*******************************************************************************
** Getter for suppressedReason
*******************************************************************************/
public String getSuppressedReason()
{
return (this.suppressedReason);
}
/*******************************************************************************
** Setter for suppressedReason
*******************************************************************************/
public void setSuppressedReason(String suppressedReason)
{
this.suppressedReason = suppressedReason;
}
/*******************************************************************************
** Fluent setter for suppressedReason
*******************************************************************************/
public AlertsDelivery withSuppressedReason(String suppressedReason)
{
this.suppressedReason = suppressedReason;
return (this);
}
/*******************************************************************************
** Getter for attempts
*******************************************************************************/
public Integer getAttempts()
{
return (this.attempts);
}
/*******************************************************************************
** Setter for attempts
*******************************************************************************/
public void setAttempts(Integer attempts)
{
this.attempts = attempts;
}
/*******************************************************************************
** Fluent setter for attempts
*******************************************************************************/
public AlertsDelivery withAttempts(Integer attempts)
{
this.attempts = attempts;
return (this);
}
/*******************************************************************************
** Getter for nextAttemptAt
*******************************************************************************/
public Instant getNextAttemptAt()
{
return (this.nextAttemptAt);
}
/*******************************************************************************
** Setter for nextAttemptAt
*******************************************************************************/
public void setNextAttemptAt(Instant nextAttemptAt)
{
this.nextAttemptAt = nextAttemptAt;
}
/*******************************************************************************
** Fluent setter for nextAttemptAt
*******************************************************************************/
public AlertsDelivery withNextAttemptAt(Instant nextAttemptAt)
{
this.nextAttemptAt = nextAttemptAt;
return (this);
}
/*******************************************************************************
** Getter for claimedUntil
*******************************************************************************/
public Instant getClaimedUntil()
{
return (this.claimedUntil);
}
/*******************************************************************************
** Setter for claimedUntil
*******************************************************************************/
public void setClaimedUntil(Instant claimedUntil)
{
this.claimedUntil = claimedUntil;
}
/*******************************************************************************
** Fluent setter for claimedUntil
*******************************************************************************/
public AlertsDelivery withClaimedUntil(Instant claimedUntil)
{
this.claimedUntil = claimedUntil;
return (this);
}
/*******************************************************************************
** Getter for sentAt
*******************************************************************************/
public Instant getSentAt()
{
return (this.sentAt);
}
/*******************************************************************************
** Setter for sentAt
*******************************************************************************/
public void setSentAt(Instant sentAt)
{
this.sentAt = sentAt;
}
/*******************************************************************************
** Fluent setter for sentAt
*******************************************************************************/
public AlertsDelivery withSentAt(Instant sentAt)
{
this.sentAt = sentAt;
return (this);
}
/*******************************************************************************
** Getter for providerMessageId
*******************************************************************************/
public String getProviderMessageId()
{
return (this.providerMessageId);
}
/*******************************************************************************
** Setter for providerMessageId
*******************************************************************************/
public void setProviderMessageId(String providerMessageId)
{
this.providerMessageId = providerMessageId;
}
/*******************************************************************************
** Fluent setter for providerMessageId
*******************************************************************************/
public AlertsDelivery withProviderMessageId(String providerMessageId)
{
this.providerMessageId = providerMessageId;
return (this);
}
/*******************************************************************************
** Getter for lastError
*******************************************************************************/
public String getLastError()
{
return (this.lastError);
}
/*******************************************************************************
** Setter for lastError
*******************************************************************************/
public void setLastError(String lastError)
{
this.lastError = lastError;
}
/*******************************************************************************
** Fluent setter for lastError
*******************************************************************************/
public AlertsDelivery withLastError(String lastError)
{
this.lastError = lastError;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (this.createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AlertsDelivery withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (this.modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AlertsDelivery withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -25,9 +25,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
/*******************************************************************************
** One delivery attempt: who, which channel, status, retries and error.
** Suppressed deliveries are logged too, with their reason. Written by the
** qbit; read-only.
** A message for people: an announcement an admin writes, or one a producer emits (a failed job, a share, a security notice, a release). One recipient row per user it reaches.
**
** Generated from the qbit-alerts schema (one definition for the entity,
** the Liquibase changelog and the test DDL).
@@ -39,50 +37,68 @@ public class AlertsNotification extends QRecordEntity
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isEditable = false, possibleValueSourceName = "alerts_alert", label = "Alert")
private Integer alertId;
@QField(isRequired = true, maxLength = 200, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String title;
@QField(isEditable = false, label = "Alert Event")
private Integer alertEventId;
@QField(label = "Message")
private String body;
@QField(isEditable = false, possibleValueSourceName = "alerts_subscription", label = "Subscription")
private Integer subscriptionId;
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_notification_severity")
private String severity;
@QField(isEditable = false, possibleValueSourceName = "alerts_channel", label = "Channel")
private Integer channelId;
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_notification_category")
private String category;
@QField(isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Recipient")
private String recipientUserId;
@QField(isEditable = false, maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Sent To")
private String recipientLabel;
@QField(isEditable = false, maxLength = 16, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_notification_kind")
private String kind;
@QField(isEditable = false, maxLength = 16, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_notification_status")
@QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_notification_status")
private String status;
@QField(isEditable = false, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_suppressed_reason")
private String suppressedReason;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Source")
private String sourceName;
@QField(isEditable = false)
private Integer attempts;
@QField(maxLength = 200, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String sourceKey;
@QField(isEditable = false)
private Instant nextAttemptAt;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Source Table")
private String sourceTableName;
@QField(isEditable = false)
private Instant claimedUntil;
@QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Source Record")
private String sourceRecordId;
@QField(maxLength = 1000, valueTooLongBehavior = ValueTooLongBehavior.ERROR, label = "Link")
private String linkUrl;
@QField(maxLength = 200, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String linkLabel;
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_audience_type", label = "Audience")
private String audienceType;
@QField(label = "Audience Members")
private String audienceValue;
@QField()
private Instant publishAt;
@QField()
private Instant expireAt;
@QField(label = "Sticky Banner")
private Boolean sticky;
@QField(isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Author")
private String authorUserId;
@QField(isEditable = false)
private Instant sentAt;
@QField(isEditable = false, maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String providerMessageId;
@QField(isEditable = false)
private Instant retractedAt;
@QField(isEditable = false, maxLength = 2000, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
private String lastError;
@QField(isEditable = false, label = "Recipients")
private Integer recipientCount;
@QField(isEditable = false, label = "Read")
private Integer readCount;
@QField(isEditable = false)
private Instant createDate;
@@ -100,7 +116,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
@@ -110,7 +125,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
@@ -120,7 +134,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
@@ -130,7 +143,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
@@ -141,224 +153,118 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Getter for alertId
** Getter for title
*******************************************************************************/
public Integer getAlertId()
public String getTitle()
{
return (this.alertId);
return (this.title);
}
/*******************************************************************************
** Setter for alertId
** Setter for title
*******************************************************************************/
public void setAlertId(Integer alertId)
public void setTitle(String title)
{
this.alertId = alertId;
this.title = title;
}
/*******************************************************************************
** Fluent setter for alertId
** Fluent setter for title
*******************************************************************************/
public AlertsNotification withAlertId(Integer alertId)
public AlertsNotification withTitle(String title)
{
this.alertId = alertId;
this.title = title;
return (this);
}
/*******************************************************************************
** Getter for alertEventId
** Getter for body
*******************************************************************************/
public Integer getAlertEventId()
public String getBody()
{
return (this.alertEventId);
return (this.body);
}
/*******************************************************************************
** Setter for alertEventId
** Setter for body
*******************************************************************************/
public void setAlertEventId(Integer alertEventId)
public void setBody(String body)
{
this.alertEventId = alertEventId;
this.body = body;
}
/*******************************************************************************
** Fluent setter for alertEventId
** Fluent setter for body
*******************************************************************************/
public AlertsNotification withAlertEventId(Integer alertEventId)
public AlertsNotification withBody(String body)
{
this.alertEventId = alertEventId;
this.body = body;
return (this);
}
/*******************************************************************************
** Getter for subscriptionId
** Getter for severity
*******************************************************************************/
public Integer getSubscriptionId()
public String getSeverity()
{
return (this.subscriptionId);
return (this.severity);
}
/*******************************************************************************
** Setter for subscriptionId
** Setter for severity
*******************************************************************************/
public void setSubscriptionId(Integer subscriptionId)
public void setSeverity(String severity)
{
this.subscriptionId = subscriptionId;
this.severity = severity;
}
/*******************************************************************************
** Fluent setter for subscriptionId
** Fluent setter for severity
*******************************************************************************/
public AlertsNotification withSubscriptionId(Integer subscriptionId)
public AlertsNotification withSeverity(String severity)
{
this.subscriptionId = subscriptionId;
this.severity = severity;
return (this);
}
/*******************************************************************************
** Getter for channelId
** Getter for category
*******************************************************************************/
public Integer getChannelId()
public String getCategory()
{
return (this.channelId);
return (this.category);
}
/*******************************************************************************
** Setter for channelId
** Setter for category
*******************************************************************************/
public void setChannelId(Integer channelId)
public void setCategory(String category)
{
this.channelId = channelId;
this.category = category;
}
/*******************************************************************************
** Fluent setter for channelId
** Fluent setter for category
*******************************************************************************/
public AlertsNotification withChannelId(Integer channelId)
public AlertsNotification withCategory(String category)
{
this.channelId = channelId;
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for recipientUserId
*******************************************************************************/
public String getRecipientUserId()
{
return (this.recipientUserId);
}
/*******************************************************************************
** Setter for recipientUserId
*******************************************************************************/
public void setRecipientUserId(String recipientUserId)
{
this.recipientUserId = recipientUserId;
}
/*******************************************************************************
** Fluent setter for recipientUserId
*******************************************************************************/
public AlertsNotification withRecipientUserId(String recipientUserId)
{
this.recipientUserId = recipientUserId;
return (this);
}
/*******************************************************************************
** Getter for recipientLabel
*******************************************************************************/
public String getRecipientLabel()
{
return (this.recipientLabel);
}
/*******************************************************************************
** Setter for recipientLabel
*******************************************************************************/
public void setRecipientLabel(String recipientLabel)
{
this.recipientLabel = recipientLabel;
}
/*******************************************************************************
** Fluent setter for recipientLabel
*******************************************************************************/
public AlertsNotification withRecipientLabel(String recipientLabel)
{
this.recipientLabel = recipientLabel;
return (this);
}
/*******************************************************************************
** Getter for kind
*******************************************************************************/
public String getKind()
{
return (this.kind);
}
/*******************************************************************************
** Setter for kind
*******************************************************************************/
public void setKind(String kind)
{
this.kind = kind;
}
/*******************************************************************************
** Fluent setter for kind
*******************************************************************************/
public AlertsNotification withKind(String kind)
{
this.kind = kind;
return (this);
}
/*******************************************************************************
** Getter for status
*******************************************************************************/
@@ -368,7 +274,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Setter for status
*******************************************************************************/
@@ -378,7 +283,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for status
*******************************************************************************/
@@ -389,130 +293,341 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Getter for suppressedReason
** Getter for sourceName
*******************************************************************************/
public String getSuppressedReason()
public String getSourceName()
{
return (this.suppressedReason);
return (this.sourceName);
}
/*******************************************************************************
** Setter for suppressedReason
** Setter for sourceName
*******************************************************************************/
public void setSuppressedReason(String suppressedReason)
public void setSourceName(String sourceName)
{
this.suppressedReason = suppressedReason;
this.sourceName = sourceName;
}
/*******************************************************************************
** Fluent setter for suppressedReason
** Fluent setter for sourceName
*******************************************************************************/
public AlertsNotification withSuppressedReason(String suppressedReason)
public AlertsNotification withSourceName(String sourceName)
{
this.suppressedReason = suppressedReason;
this.sourceName = sourceName;
return (this);
}
/*******************************************************************************
** Getter for attempts
** Getter for sourceKey
*******************************************************************************/
public Integer getAttempts()
public String getSourceKey()
{
return (this.attempts);
return (this.sourceKey);
}
/*******************************************************************************
** Setter for attempts
** Setter for sourceKey
*******************************************************************************/
public void setAttempts(Integer attempts)
public void setSourceKey(String sourceKey)
{
this.attempts = attempts;
this.sourceKey = sourceKey;
}
/*******************************************************************************
** Fluent setter for attempts
** Fluent setter for sourceKey
*******************************************************************************/
public AlertsNotification withAttempts(Integer attempts)
public AlertsNotification withSourceKey(String sourceKey)
{
this.attempts = attempts;
this.sourceKey = sourceKey;
return (this);
}
/*******************************************************************************
** Getter for nextAttemptAt
** Getter for sourceTableName
*******************************************************************************/
public Instant getNextAttemptAt()
public String getSourceTableName()
{
return (this.nextAttemptAt);
return (this.sourceTableName);
}
/*******************************************************************************
** Setter for nextAttemptAt
** Setter for sourceTableName
*******************************************************************************/
public void setNextAttemptAt(Instant nextAttemptAt)
public void setSourceTableName(String sourceTableName)
{
this.nextAttemptAt = nextAttemptAt;
this.sourceTableName = sourceTableName;
}
/*******************************************************************************
** Fluent setter for nextAttemptAt
** Fluent setter for sourceTableName
*******************************************************************************/
public AlertsNotification withNextAttemptAt(Instant nextAttemptAt)
public AlertsNotification withSourceTableName(String sourceTableName)
{
this.nextAttemptAt = nextAttemptAt;
this.sourceTableName = sourceTableName;
return (this);
}
/*******************************************************************************
** Getter for claimedUntil
** Getter for sourceRecordId
*******************************************************************************/
public Instant getClaimedUntil()
public String getSourceRecordId()
{
return (this.claimedUntil);
return (this.sourceRecordId);
}
/*******************************************************************************
** Setter for claimedUntil
** Setter for sourceRecordId
*******************************************************************************/
public void setClaimedUntil(Instant claimedUntil)
public void setSourceRecordId(String sourceRecordId)
{
this.claimedUntil = claimedUntil;
this.sourceRecordId = sourceRecordId;
}
/*******************************************************************************
** Fluent setter for claimedUntil
** Fluent setter for sourceRecordId
*******************************************************************************/
public AlertsNotification withClaimedUntil(Instant claimedUntil)
public AlertsNotification withSourceRecordId(String sourceRecordId)
{
this.claimedUntil = claimedUntil;
this.sourceRecordId = sourceRecordId;
return (this);
}
/*******************************************************************************
** Getter for linkUrl
*******************************************************************************/
public String getLinkUrl()
{
return (this.linkUrl);
}
/*******************************************************************************
** Setter for linkUrl
*******************************************************************************/
public void setLinkUrl(String linkUrl)
{
this.linkUrl = linkUrl;
}
/*******************************************************************************
** Fluent setter for linkUrl
*******************************************************************************/
public AlertsNotification withLinkUrl(String linkUrl)
{
this.linkUrl = linkUrl;
return (this);
}
/*******************************************************************************
** Getter for linkLabel
*******************************************************************************/
public String getLinkLabel()
{
return (this.linkLabel);
}
/*******************************************************************************
** Setter for linkLabel
*******************************************************************************/
public void setLinkLabel(String linkLabel)
{
this.linkLabel = linkLabel;
}
/*******************************************************************************
** Fluent setter for linkLabel
*******************************************************************************/
public AlertsNotification withLinkLabel(String linkLabel)
{
this.linkLabel = linkLabel;
return (this);
}
/*******************************************************************************
** Getter for audienceType
*******************************************************************************/
public String getAudienceType()
{
return (this.audienceType);
}
/*******************************************************************************
** Setter for audienceType
*******************************************************************************/
public void setAudienceType(String audienceType)
{
this.audienceType = audienceType;
}
/*******************************************************************************
** Fluent setter for audienceType
*******************************************************************************/
public AlertsNotification withAudienceType(String audienceType)
{
this.audienceType = audienceType;
return (this);
}
/*******************************************************************************
** Getter for audienceValue
*******************************************************************************/
public String getAudienceValue()
{
return (this.audienceValue);
}
/*******************************************************************************
** Setter for audienceValue
*******************************************************************************/
public void setAudienceValue(String audienceValue)
{
this.audienceValue = audienceValue;
}
/*******************************************************************************
** Fluent setter for audienceValue
*******************************************************************************/
public AlertsNotification withAudienceValue(String audienceValue)
{
this.audienceValue = audienceValue;
return (this);
}
/*******************************************************************************
** Getter for publishAt
*******************************************************************************/
public Instant getPublishAt()
{
return (this.publishAt);
}
/*******************************************************************************
** Setter for publishAt
*******************************************************************************/
public void setPublishAt(Instant publishAt)
{
this.publishAt = publishAt;
}
/*******************************************************************************
** Fluent setter for publishAt
*******************************************************************************/
public AlertsNotification withPublishAt(Instant publishAt)
{
this.publishAt = publishAt;
return (this);
}
/*******************************************************************************
** Getter for expireAt
*******************************************************************************/
public Instant getExpireAt()
{
return (this.expireAt);
}
/*******************************************************************************
** Setter for expireAt
*******************************************************************************/
public void setExpireAt(Instant expireAt)
{
this.expireAt = expireAt;
}
/*******************************************************************************
** Fluent setter for expireAt
*******************************************************************************/
public AlertsNotification withExpireAt(Instant expireAt)
{
this.expireAt = expireAt;
return (this);
}
/*******************************************************************************
** Getter for sticky
*******************************************************************************/
public Boolean getSticky()
{
return (this.sticky);
}
/*******************************************************************************
** Setter for sticky
*******************************************************************************/
public void setSticky(Boolean sticky)
{
this.sticky = sticky;
}
/*******************************************************************************
** Fluent setter for sticky
*******************************************************************************/
public AlertsNotification withSticky(Boolean sticky)
{
this.sticky = sticky;
return (this);
}
/*******************************************************************************
** Getter for authorUserId
*******************************************************************************/
public String getAuthorUserId()
{
return (this.authorUserId);
}
/*******************************************************************************
** Setter for authorUserId
*******************************************************************************/
public void setAuthorUserId(String authorUserId)
{
this.authorUserId = authorUserId;
}
/*******************************************************************************
** Fluent setter for authorUserId
*******************************************************************************/
public AlertsNotification withAuthorUserId(String authorUserId)
{
this.authorUserId = authorUserId;
return (this);
}
/*******************************************************************************
** Getter for sentAt
@@ -523,7 +638,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Setter for sentAt
*******************************************************************************/
@@ -533,7 +647,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for sentAt
*******************************************************************************/
@@ -544,68 +657,89 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Getter for providerMessageId
** Getter for retractedAt
*******************************************************************************/
public String getProviderMessageId()
public Instant getRetractedAt()
{
return (this.providerMessageId);
return (this.retractedAt);
}
/*******************************************************************************
** Setter for providerMessageId
** Setter for retractedAt
*******************************************************************************/
public void setProviderMessageId(String providerMessageId)
public void setRetractedAt(Instant retractedAt)
{
this.providerMessageId = providerMessageId;
this.retractedAt = retractedAt;
}
/*******************************************************************************
** Fluent setter for providerMessageId
** Fluent setter for retractedAt
*******************************************************************************/
public AlertsNotification withProviderMessageId(String providerMessageId)
public AlertsNotification withRetractedAt(Instant retractedAt)
{
this.providerMessageId = providerMessageId;
this.retractedAt = retractedAt;
return (this);
}
/*******************************************************************************
** Getter for lastError
** Getter for recipientCount
*******************************************************************************/
public String getLastError()
public Integer getRecipientCount()
{
return (this.lastError);
return (this.recipientCount);
}
/*******************************************************************************
** Setter for lastError
** Setter for recipientCount
*******************************************************************************/
public void setLastError(String lastError)
public void setRecipientCount(Integer recipientCount)
{
this.lastError = lastError;
this.recipientCount = recipientCount;
}
/*******************************************************************************
** Fluent setter for lastError
** Fluent setter for recipientCount
*******************************************************************************/
public AlertsNotification withLastError(String lastError)
public AlertsNotification withRecipientCount(Integer recipientCount)
{
this.lastError = lastError;
this.recipientCount = recipientCount;
return (this);
}
/*******************************************************************************
** Getter for readCount
*******************************************************************************/
public Integer getReadCount()
{
return (this.readCount);
}
/*******************************************************************************
** Setter for readCount
*******************************************************************************/
public void setReadCount(Integer readCount)
{
this.readCount = readCount;
}
/*******************************************************************************
** Fluent setter for readCount
*******************************************************************************/
public AlertsNotification withReadCount(Integer readCount)
{
this.readCount = readCount;
return (this);
}
/*******************************************************************************
** Getter for createDate
@@ -616,7 +750,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
@@ -626,7 +759,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
@@ -637,7 +769,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
@@ -647,7 +778,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
@@ -657,7 +787,6 @@ public class AlertsNotification extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
@@ -0,0 +1,302 @@
/*
* 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.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
/*******************************************************************************
** A user's settings for one notification category: muted in-app (never for security), and email off, immediate or in a daily digest, from a minimum severity.
**
** Generated from the qbit-alerts schema (one definition for the entity,
** the Liquibase changelog and the test DDL).
*******************************************************************************/
public class AlertsNotificationPreference extends QRecordEntity
{
public static final String TABLE_NAME = "alerts_notification_preference";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "User")
private String userId;
@QField(isRequired = true, maxLength = 16, possibleValueSourceName = "alerts_notification_category")
private String category;
@QField(label = "Muted In-App")
private Boolean inAppMuted;
@QField(maxLength = 16, possibleValueSourceName = "alerts_email_mode", label = "Email")
private String emailMode;
@QField(maxLength = 16, possibleValueSourceName = "alerts_notification_severity", label = "Email From Severity")
private String emailMinSeverity;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public AlertsNotificationPreference()
{
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
public AlertsNotificationPreference(QRecord record)
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (this.id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AlertsNotificationPreference withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for userId
*******************************************************************************/
public String getUserId()
{
return (this.userId);
}
/*******************************************************************************
** Setter for userId
*******************************************************************************/
public void setUserId(String userId)
{
this.userId = userId;
}
/*******************************************************************************
** Fluent setter for userId
*******************************************************************************/
public AlertsNotificationPreference withUserId(String userId)
{
this.userId = userId;
return (this);
}
/*******************************************************************************
** Getter for category
*******************************************************************************/
public String getCategory()
{
return (this.category);
}
/*******************************************************************************
** Setter for category
*******************************************************************************/
public void setCategory(String category)
{
this.category = category;
}
/*******************************************************************************
** Fluent setter for category
*******************************************************************************/
public AlertsNotificationPreference withCategory(String category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for inAppMuted
*******************************************************************************/
public Boolean getInAppMuted()
{
return (this.inAppMuted);
}
/*******************************************************************************
** Setter for inAppMuted
*******************************************************************************/
public void setInAppMuted(Boolean inAppMuted)
{
this.inAppMuted = inAppMuted;
}
/*******************************************************************************
** Fluent setter for inAppMuted
*******************************************************************************/
public AlertsNotificationPreference withInAppMuted(Boolean inAppMuted)
{
this.inAppMuted = inAppMuted;
return (this);
}
/*******************************************************************************
** Getter for emailMode
*******************************************************************************/
public String getEmailMode()
{
return (this.emailMode);
}
/*******************************************************************************
** Setter for emailMode
*******************************************************************************/
public void setEmailMode(String emailMode)
{
this.emailMode = emailMode;
}
/*******************************************************************************
** Fluent setter for emailMode
*******************************************************************************/
public AlertsNotificationPreference withEmailMode(String emailMode)
{
this.emailMode = emailMode;
return (this);
}
/*******************************************************************************
** Getter for emailMinSeverity
*******************************************************************************/
public String getEmailMinSeverity()
{
return (this.emailMinSeverity);
}
/*******************************************************************************
** Setter for emailMinSeverity
*******************************************************************************/
public void setEmailMinSeverity(String emailMinSeverity)
{
this.emailMinSeverity = emailMinSeverity;
}
/*******************************************************************************
** Fluent setter for emailMinSeverity
*******************************************************************************/
public AlertsNotificationPreference withEmailMinSeverity(String emailMinSeverity)
{
this.emailMinSeverity = emailMinSeverity;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (this.createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AlertsNotificationPreference withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (this.modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AlertsNotificationPreference withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -25,27 +25,24 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior
/*******************************************************************************
** One alert in one user's bell.
** One notification for one user: when it reached them, and whether they read it, dismissed it, or were emailed.
**
** Generated from the qbit-alerts schema (one definition for the entity,
** the Liquibase changelog and the test DDL).
*******************************************************************************/
public class AlertsInboxItem extends QRecordEntity
public class AlertsNotificationRecipient extends QRecordEntity
{
public static final String TABLE_NAME = "alerts_inbox_item";
public static final String TABLE_NAME = "alerts_notification_recipient";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, isEditable = false, possibleValueSourceName = "alerts_notification", label = "Notification")
private Integer notificationId;
@QField(isRequired = true, isEditable = false, maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "User")
private String userId;
@QField(isRequired = true, isEditable = false, possibleValueSourceName = "alerts_alert", label = "Alert")
private Integer alertId;
@QField(isEditable = false, label = "Notification")
private Integer notificationId;
@QField(isEditable = false)
private Instant deliveredAt;
@@ -53,7 +50,13 @@ public class AlertsInboxItem extends QRecordEntity
private Instant readAt;
@QField()
private Instant archivedAt;
private Instant dismissedAt;
@QField(isEditable = false)
private Instant emailedAt;
@QField(isEditable = false, maxLength = 16, possibleValueSourceName = "alerts_email_status")
private String emailStatus;
@QField(isEditable = false)
private Instant createDate;
@@ -66,22 +69,20 @@ public class AlertsInboxItem extends QRecordEntity
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public AlertsInboxItem()
public AlertsNotificationRecipient()
{
}
/*******************************************************************************
** Constructor that populates from a QRecord.
*******************************************************************************/
public AlertsInboxItem(QRecord record)
public AlertsNotificationRecipient(QRecord record)
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
@@ -91,7 +92,6 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
@@ -101,80 +101,16 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AlertsInboxItem withId(Integer id)
public AlertsNotificationRecipient withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for userId
*******************************************************************************/
public String getUserId()
{
return (this.userId);
}
/*******************************************************************************
** Setter for userId
*******************************************************************************/
public void setUserId(String userId)
{
this.userId = userId;
}
/*******************************************************************************
** Fluent setter for userId
*******************************************************************************/
public AlertsInboxItem withUserId(String userId)
{
this.userId = userId;
return (this);
}
/*******************************************************************************
** Getter for alertId
*******************************************************************************/
public Integer getAlertId()
{
return (this.alertId);
}
/*******************************************************************************
** Setter for alertId
*******************************************************************************/
public void setAlertId(Integer alertId)
{
this.alertId = alertId;
}
/*******************************************************************************
** Fluent setter for alertId
*******************************************************************************/
public AlertsInboxItem withAlertId(Integer alertId)
{
this.alertId = alertId;
return (this);
}
/*******************************************************************************
** Getter for notificationId
*******************************************************************************/
@@ -184,7 +120,6 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Setter for notificationId
*******************************************************************************/
@@ -194,17 +129,43 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for notificationId
*******************************************************************************/
public AlertsInboxItem withNotificationId(Integer notificationId)
public AlertsNotificationRecipient withNotificationId(Integer notificationId)
{
this.notificationId = notificationId;
return (this);
}
/*******************************************************************************
** Getter for userId
*******************************************************************************/
public String getUserId()
{
return (this.userId);
}
/*******************************************************************************
** Setter for userId
*******************************************************************************/
public void setUserId(String userId)
{
this.userId = userId;
}
/*******************************************************************************
** Fluent setter for userId
*******************************************************************************/
public AlertsNotificationRecipient withUserId(String userId)
{
this.userId = userId;
return (this);
}
/*******************************************************************************
** Getter for deliveredAt
@@ -215,7 +176,6 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Setter for deliveredAt
*******************************************************************************/
@@ -225,18 +185,16 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for deliveredAt
*******************************************************************************/
public AlertsInboxItem withDeliveredAt(Instant deliveredAt)
public AlertsNotificationRecipient withDeliveredAt(Instant deliveredAt)
{
this.deliveredAt = deliveredAt;
return (this);
}
/*******************************************************************************
** Getter for readAt
*******************************************************************************/
@@ -246,7 +204,6 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Setter for readAt
*******************************************************************************/
@@ -256,48 +213,99 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for readAt
*******************************************************************************/
public AlertsInboxItem withReadAt(Instant readAt)
public AlertsNotificationRecipient withReadAt(Instant readAt)
{
this.readAt = readAt;
return (this);
}
/*******************************************************************************
** Getter for archivedAt
** Getter for dismissedAt
*******************************************************************************/
public Instant getArchivedAt()
public Instant getDismissedAt()
{
return (this.archivedAt);
return (this.dismissedAt);
}
/*******************************************************************************
** Setter for archivedAt
** Setter for dismissedAt
*******************************************************************************/
public void setArchivedAt(Instant archivedAt)
public void setDismissedAt(Instant dismissedAt)
{
this.archivedAt = archivedAt;
this.dismissedAt = dismissedAt;
}
/*******************************************************************************
** Fluent setter for archivedAt
** Fluent setter for dismissedAt
*******************************************************************************/
public AlertsInboxItem withArchivedAt(Instant archivedAt)
public AlertsNotificationRecipient withDismissedAt(Instant dismissedAt)
{
this.archivedAt = archivedAt;
this.dismissedAt = dismissedAt;
return (this);
}
/*******************************************************************************
** Getter for emailedAt
*******************************************************************************/
public Instant getEmailedAt()
{
return (this.emailedAt);
}
/*******************************************************************************
** Setter for emailedAt
*******************************************************************************/
public void setEmailedAt(Instant emailedAt)
{
this.emailedAt = emailedAt;
}
/*******************************************************************************
** Fluent setter for emailedAt
*******************************************************************************/
public AlertsNotificationRecipient withEmailedAt(Instant emailedAt)
{
this.emailedAt = emailedAt;
return (this);
}
/*******************************************************************************
** Getter for emailStatus
*******************************************************************************/
public String getEmailStatus()
{
return (this.emailStatus);
}
/*******************************************************************************
** Setter for emailStatus
*******************************************************************************/
public void setEmailStatus(String emailStatus)
{
this.emailStatus = emailStatus;
}
/*******************************************************************************
** Fluent setter for emailStatus
*******************************************************************************/
public AlertsNotificationRecipient withEmailStatus(String emailStatus)
{
this.emailStatus = emailStatus;
return (this);
}
/*******************************************************************************
** Getter for createDate
@@ -308,7 +316,6 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
@@ -318,18 +325,16 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AlertsInboxItem withCreateDate(Instant createDate)
public AlertsNotificationRecipient withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
@@ -339,7 +344,6 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
@@ -349,11 +353,10 @@ public class AlertsInboxItem extends QRecordEntity
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AlertsInboxItem withModifyDate(Instant modifyDate)
public AlertsNotificationRecipient withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
@@ -23,6 +23,9 @@ import java.util.Locale;
import com.kingsrook.qbits.alerts.spi.AlertMonitorKind;
import com.kingsrook.qbits.alerts.spi.AlertSeverity;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qbits.alerts.spi.NotificationAudience;
import com.kingsrook.qbits.alerts.spi.NotificationCategory;
import com.kingsrook.qbits.alerts.spi.NotificationSeverity;
import com.kingsrook.qbits.alerts.spi.ResolveMode;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
@@ -51,9 +54,15 @@ public final class AlertsPossibleValues
public static final String CHANNEL_TYPE = "alerts_channel_type";
public static final String HEALTH_STATUS = "alerts_health_status";
public static final String MUTE_EFFECT = "alerts_mute_effect";
public static final String NOTIFICATION_KIND = "alerts_notification_kind";
public static final String NOTIFICATION_STATUS = "alerts_notification_status";
public static final String DELIVERY_KIND = "alerts_delivery_kind";
public static final String DELIVERY_STATUS = "alerts_delivery_status";
public static final String SUPPRESSED_REASON = "alerts_suppressed_reason";
public static final String NOTIFICATION_SEVERITY = "alerts_notification_severity";
public static final String NOTIFICATION_CATEGORY = "alerts_notification_category";
public static final String NOTIFICATION_STATUS = "alerts_notification_status";
public static final String AUDIENCE_TYPE = "alerts_audience_type";
public static final String EMAIL_STATUS = "alerts_email_status";
public static final String EMAIL_MODE = "alerts_email_mode";
@@ -192,7 +201,7 @@ public final class AlertsPossibleValues
/*******************************************************************************
** Why a notification was sent.
*******************************************************************************/
public enum NotificationKind
public enum DeliveryKind
{
RAISED, ESCALATED, REMINDER, RESOLVED, DIGEST, TEST
}
@@ -202,7 +211,7 @@ public final class AlertsPossibleValues
/*******************************************************************************
** A notification's delivery state.
*******************************************************************************/
public enum NotificationStatus
public enum DeliveryStatus
{
PENDING, SENDING, SENT, RETRYING, FAILED, DEAD, SUPPRESSED
}
@@ -251,9 +260,45 @@ public final class AlertsPossibleValues
enumSource(CHANNEL_TYPE, ChannelType.values()),
enumSource(HEALTH_STATUS, HealthStatus.values()),
enumSource(MUTE_EFFECT, MuteEffect.values()),
enumSource(NOTIFICATION_KIND, NotificationKind.values()),
enumSource(DELIVERY_KIND, DeliveryKind.values()),
enumSource(DELIVERY_STATUS, DeliveryStatus.values()),
enumSource(SUPPRESSED_REASON, SuppressedReason.values()),
enumSource(NOTIFICATION_SEVERITY, NotificationSeverity.values()),
enumSource(NOTIFICATION_CATEGORY, NotificationCategory.values()),
enumSource(NOTIFICATION_STATUS, NotificationStatus.values()),
enumSource(SUPPRESSED_REASON, SuppressedReason.values())));
enumSource(AUDIENCE_TYPE, NotificationAudience.Type.values()),
enumSource(EMAIL_STATUS, EmailStatus.values()),
enumSource(EMAIL_MODE, EmailMode.values())));
}
/*******************************************************************************
** A user notification's state.
*******************************************************************************/
public enum NotificationStatus
{
DRAFT, SCHEDULED, SENT, RETRACTED
}
/*******************************************************************************
** Whether (and how) a recipient was emailed.
*******************************************************************************/
public enum EmailStatus
{
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
}
/*******************************************************************************
** How a user wants a category by email.
*******************************************************************************/
public enum EmailMode
{
OFF, IMMEDIATE, DAILY_DIGEST
}
@@ -0,0 +1,242 @@
/*
* 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.notifications;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Resolves a notification audience to user ids at send time
** (docs/NOTIFICATIONS.md section 4): ALL_USERS and ROLES from the user
** directory, PERMISSION by building each directory user's session, USERS as
** given. The result is capped at maxAudienceSize. Call it as the system user.
*******************************************************************************/
public class AlertsAudienceResolver
{
private static final QLogger LOG = QLogger.getLogger(AlertsAudienceResolver.class);
private final AlertsRuntime runtime;
/*******************************************************************************
** The users an audience reached: ids in order, labels where known, and why
** some could not be resolved.
*******************************************************************************/
public record Resolved(List<String> userIds, Map<String, String> labels, List<String> warnings)
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public AlertsAudienceResolver(AlertsRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** Resolve an audience to users.
*******************************************************************************/
public Resolved resolve(NotificationAudience audience) throws QException
{
Map<String, String> users = new LinkedHashMap<>();
List<String> warnings = new ArrayList<>();
if(audience == null)
{
warnings.add("No audience is set.");
return (new Resolved(List.of(), Map.of(), warnings));
}
switch(audience.getType())
{
case USERS ->
{
Map<String, String> labels = labelsIfKnown();
for(String userId : audience.getValues())
{
users.put(userId, labels.getOrDefault(userId, userId));
}
if(audience.getValues().isEmpty())
{
warnings.add("Name at least one user id.");
}
}
case ALL_USERS ->
{
for(AlertsUserDirectoryInterface.DirectoryUser user : directory().listUsers())
{
users.put(user.userId(), label(user));
}
}
case ROLES ->
{
Set<String> wanted = audience.getValues().stream().map(role -> role.toLowerCase(Locale.ROOT)).collect(Collectors.toSet());
boolean anyRoles = false;
for(AlertsUserDirectoryInterface.DirectoryUser user : directory().listUsers())
{
Set<String> roles = user.roles() == null ? Set.of() : user.roles();
anyRoles |= !roles.isEmpty();
if(roles.stream().anyMatch(role -> wanted.contains(role.toLowerCase(Locale.ROOT))))
{
users.put(user.userId(), label(user));
}
}
if(!anyRoles)
{
warnings.add("The user directory lists no roles, so a roles audience reaches nobody. Configure a user directory (AlertsQBitConfig.withUserDirectory).");
}
}
case PERMISSION ->
{
String permission = audience.getValues().isEmpty() ? null : audience.getValues().get(0);
boolean anySession = false;
for(AlertsUserDirectoryInterface.DirectoryUser user : permission == null ? List.<AlertsUserDirectoryInterface.DirectoryUser>of() : directory().listUsers())
{
QSession session = automatedSession(user.userId());
anySession |= session != null;
if(session != null && session.hasPermission(permission))
{
users.put(user.userId(), label(user));
}
}
if(permission == null)
{
warnings.add("Name the permission.");
}
else if(!anySession)
{
warnings.add("The authentication module could not build sessions for users, so their permissions are unknown and a permission audience reaches nobody.");
}
}
default -> warnings.add("Unknown audience.");
}
List<String> ids = new ArrayList<>(users.keySet());
int max = runtime.getConfig().getMaxAudienceSize();
if(ids.size() > max)
{
warnings.add("The audience has " + ids.size() + " users; only the first " + max + " (maxAudienceSize) are reached.");
ids = new ArrayList<>(ids.subList(0, max));
}
Map<String, String> labels = new LinkedHashMap<>();
for(String id : ids)
{
labels.put(id, users.get(id));
}
return (new Resolved(ids, labels, warnings));
}
/*******************************************************************************
** The configured directory, or the known-users default.
*******************************************************************************/
public AlertsUserDirectoryInterface directory()
{
QCodeReference reference = runtime.getConfig().getUserDirectory();
if(reference != null)
{
AlertsUserDirectoryInterface directory = QCodeLoader.getAdHoc(AlertsUserDirectoryInterface.class, reference);
if(directory != null)
{
return (directory);
}
}
return (new KnownUsersDirectory());
}
/*******************************************************************************
** Labels for the directory's users (empty when it can't be read).
*******************************************************************************/
private Map<String, String> labelsIfKnown()
{
Map<String, String> labels = new LinkedHashMap<>();
try
{
for(AlertsUserDirectoryInterface.DirectoryUser user : directory().listUsers())
{
labels.put(user.userId(), label(user));
}
}
catch(Exception e)
{
LOG.debug("Could not read the user directory for labels", e);
}
return (labels);
}
/*******************************************************************************
**
*******************************************************************************/
private static String label(AlertsUserDirectoryInterface.DirectoryUser user)
{
return (user.label() == null || user.label().isBlank() ? user.userId() : user.label());
}
/*******************************************************************************
** A session for a user from the instance's authentication module (null
** when it can't make one).
*******************************************************************************/
private QSession automatedSession(String userId)
{
try
{
QInstance qInstance = runtime.getQInstance();
if(qInstance.getAuthentication() == null)
{
return (null);
}
QAuthenticationModuleInterface module = new QAuthenticationModuleDispatcher().getQModule(qInstance.getAuthentication());
return (module.createAutomatedSessionForUser(qInstance, userId));
}
catch(Exception e)
{
LOG.debug("Could not build a session for a user", e, logPair("userId", userId));
return (null);
}
}
}
@@ -0,0 +1,47 @@
/*
* 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.notifications;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** The host's list of people, for notification audiences (docs/NOTIFICATIONS.md
** section 4). Set it with AlertsQBitConfig.withUserDirectory. Without one,
** the directory is everyone who has used Alerts (KnownUsersDirectory).
*******************************************************************************/
public interface AlertsUserDirectoryInterface
{
/*******************************************************************************
** Every active user, with their roles or groups (for ROLES audiences).
*******************************************************************************/
List<DirectoryUser> listUsers() throws QException;
/*******************************************************************************
** One person: their QQQ user id (the session's user id reference), a
** display label, an email address (may be null), and their roles or groups.
*******************************************************************************/
record DirectoryUser(String userId, String label, String emailAddress, Set<String> roles)
{
}
}
@@ -0,0 +1,56 @@
/*
* 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.notifications;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** The default user directory: everyone who has used Alerts, that is, who has
** a row in My Alert Settings (created the first time a user opens the bell,
** the Alerts home or a record panel). It knows no roles. Called as the
** system user.
*******************************************************************************/
public class KnownUsersDirectory implements AlertsUserDirectoryInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<DirectoryUser> listUsers() throws QException
{
List<DirectoryUser> users = new ArrayList<>();
for(QRecord preference : AlertsDb.query(AlertsUserPreference.TABLE_NAME, new QQueryFilter()))
{
String userId = preference.getValueString("userId");
if(userId != null)
{
users.add(new DirectoryUser(userId, preference.getValueString("userLabel"), preference.getValueString("emailAddress"), Set.of()));
}
}
return (users);
}
}
@@ -0,0 +1,569 @@
/*
* 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.notifications;
import java.io.Serializable;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** User notifications (docs/NOTIFICATIONS.md): writes notifications from
** producer signals and alerts, resolves audiences into one recipient row per
** user, retracts, sends scheduled ones when they are due, and purges old
** ones. Everything runs as the system user; what each person sees is
** filtered by their own recipient rows (owner record lock).
*******************************************************************************/
public class NotificationService
{
private static final QLogger LOG = QLogger.getLogger(NotificationService.class);
public static final String STATUS_DRAFT = "DRAFT";
public static final String STATUS_SCHEDULED = "SCHEDULED";
public static final String STATUS_SENT = "SENT";
public static final String STATUS_RETRACTED = "RETRACTED";
static final int BATCH = 500;
private final AlertsRuntime runtime;
private final AlertsAudienceResolver resolver;
/*******************************************************************************
** What a send did.
*******************************************************************************/
public record SendResult(int added, int total, List<String> warnings)
{
}
/*******************************************************************************
** Constructor
*******************************************************************************/
public NotificationService(AlertsRuntime runtime)
{
this.runtime = runtime;
this.resolver = new AlertsAudienceResolver(runtime);
}
/*******************************************************************************
** A producer's notification: written, then sent now (or scheduled for its
** publishAt). A signal whose sourceKey and audience match a live
** notification updates it and brings it back to unread for its readers.
** Returns the notification id.
*******************************************************************************/
public Integer notify(NotificationSignal signal) throws QException
{
if(signal == null || !StringUtils.hasContent(signal.getTitle()) || signal.getAudience() == null)
{
throw (new QUserFacingException("A notification needs a title and an audience."));
}
Instant now = runtime.now();
return (runtime.asSystem(() ->
{
QRecord values = fromSignal(signal);
QRecord existing = null;
if(StringUtils.hasContent(signal.getSourceKey()))
{
existing = AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", signal.getSourceKey())
.withCriteria("audienceType", QCriteriaOperator.EQUALS, signal.getAudience().getType().name())
.withCriteria("status", QCriteriaOperator.IN, List.of(STATUS_SENT, STATUS_SCHEDULED))
.withCriteria(audienceValueCriteria(signal.getAudience())));
}
boolean scheduled = signal.getPublishAt() != null && signal.getPublishAt().isAfter(now);
Integer id;
if(existing != null)
{
id = existing.getValueInteger("id");
AlertsDb.update(AlertsNotification.TABLE_NAME, values.withValue("id", id));
if(STATUS_SENT.equals(existing.getValueString("status")))
{
markAllUnread(id, now);
}
}
else
{
values.setValue("status", scheduled ? STATUS_SCHEDULED : STATUS_DRAFT);
id = AlertsDb.insert(AlertsNotification.TABLE_NAME, values).getValueInteger("id");
}
if(!scheduled)
{
send(id);
}
return (id);
}));
}
/*******************************************************************************
** Send (or re-send) a notification: resolve its audience and add a
** recipient row for each user who doesn't have one (users who muted the
** category in-app are skipped, except for SECURITY). Marks it SENT.
*******************************************************************************/
public SendResult send(Integer notificationId) throws QException
{
Instant now = runtime.now();
return (runtime.asSystem(() ->
{
QRecord notification = AlertsDb.byId(AlertsNotification.TABLE_NAME, notificationId);
if(notification == null)
{
throw (new QUserFacingException("The notification was not found."));
}
if(STATUS_RETRACTED.equals(notification.getValueString("status")))
{
throw (new QUserFacingException("A retracted notification can't be sent again; copy it instead."));
}
AlertsAudienceResolver.Resolved resolved = resolver.resolve(audienceOf(notification));
Set<String> existing = recipientUserIds(notificationId);
Set<String> muted = mutedUsers(notification.getValueString("category"));
List<QRecord> inserts = new ArrayList<>();
for(String userId : resolved.userIds())
{
if(!existing.contains(userId) && !muted.contains(userId))
{
inserts.add(newRecipient(notificationId, userId, now));
}
}
insertRecipients(inserts);
QRecord update = new QRecord().withValue("id", notificationId)
.withValue("status", STATUS_SENT)
.withValue("recipientCount", existing.size() + inserts.size());
if(notification.getValue("sentAt") == null)
{
update.setValue("sentAt", now);
}
if(notification.getValue("publishAt") == null)
{
update.setValue("publishAt", now);
}
AlertsDb.update(AlertsNotification.TABLE_NAME, update);
LOG.info("Sent a notification", logPair("notificationId", notificationId), logPair("added", inserts.size()), logPair("total", existing.size() + inserts.size()));
return (new SendResult(inserts.size(), existing.size() + inserts.size(), resolved.warnings()));
}));
}
/*******************************************************************************
** Who a notification would reach now (Preview audience).
*******************************************************************************/
public AlertsAudienceResolver.Resolved preview(QRecord notification) throws QException
{
return (runtime.asSystem(() -> resolver.resolve(audienceOf(notification))));
}
/*******************************************************************************
** Schedule a draft for its publishAt (which must be in the future).
*******************************************************************************/
public void schedule(Integer notificationId) throws QException
{
Instant now = runtime.now();
runtime.asSystem(() ->
{
QRecord notification = AlertsDb.byId(AlertsNotification.TABLE_NAME, notificationId);
Instant publishAt = notification == null ? null : notification.getValueInstant("publishAt");
if(publishAt == null || !publishAt.isAfter(now))
{
throw (new QUserFacingException("Set Publish At to a future time first."));
}
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", notificationId).withValue("status", STATUS_SCHEDULED));
return (null);
});
}
/*******************************************************************************
** Retract a notification: it disappears from every bell. Recipient rows
** are kept (for the record).
*******************************************************************************/
public void retract(Integer notificationId) throws QException
{
Instant now = runtime.now();
runtime.asSystem(() -> AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", notificationId)
.withValue("status", STATUS_RETRACTED).withValue("retractedAt", now)));
}
/*******************************************************************************
** Send every SCHEDULED notification whose publishAt has passed. Returns
** how many were sent.
*******************************************************************************/
public int sendDue() throws QException
{
Instant now = runtime.now();
List<QRecord> due = runtime.asSystem(() -> AlertsDb.query(AlertsNotification.TABLE_NAME, AlertsDb.eq("status", STATUS_SCHEDULED)
.withCriteria("publishAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now).withLimit(50)));
int sent = 0;
for(QRecord notification : due)
{
try
{
send(notification.getValueInteger("id"));
sent++;
}
catch(Exception e)
{
LOG.warn("Could not send a scheduled notification", e, logPair("notificationId", notification.getValue("id")));
}
}
return (sent);
}
/*******************************************************************************
** Delete notifications (and their recipients) a week past their expireAt,
** and SENT or RETRACTED ones older than notificationRetentionDays. At most
** 500 per call. Returns how many were deleted.
*******************************************************************************/
public int purge() throws QException
{
Instant now = runtime.now();
Instant oldest = now.minus(runtime.getConfig().getNotificationRetentionDays(), ChronoUnit.DAYS);
return (runtime.asSystem(() ->
{
Set<Serializable> ids = new HashSet<>();
for(QRecord row : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
.withCriteria("expireAt", QCriteriaOperator.LESS_THAN, now.minus(7, ChronoUnit.DAYS)).withLimit(BATCH)))
{
ids.add(row.getValueInteger("id"));
}
for(QRecord row : AlertsDb.query(AlertsNotification.TABLE_NAME, new QQueryFilter()
.withCriteria("status", QCriteriaOperator.IN, List.of(STATUS_SENT, STATUS_RETRACTED))
.withCriteria("createDate", QCriteriaOperator.LESS_THAN, oldest).withLimit(BATCH)))
{
ids.add(row.getValueInteger("id"));
}
deleteWithRecipients(new ArrayList<>(ids));
return (ids.size());
}));
}
/*******************************************************************************
** Delete notifications and their recipient rows (as the system user).
*******************************************************************************/
public void deleteWithRecipients(List<Serializable> notificationIds) throws QException
{
if(CollectionUtils.nullSafeIsEmpty(notificationIds))
{
return;
}
runtime.asSystem(() ->
{
new DeleteAction().execute(new DeleteInput(AlertsNotificationRecipient.TABLE_NAME)
.withQueryFilter(new QQueryFilter(new QFilterCriteria("notificationId", QCriteriaOperator.IN, notificationIds))));
new DeleteAction().execute(new DeleteInput(AlertsNotification.TABLE_NAME).withPrimaryKeys(notificationIds));
return (null);
});
}
/*******************************************************************************
** Put an alert in a user's bell (the in-app channel): one ALERT
** notification per alert (sourceKey alert:id), kept in step with the
** alert's title and severity, and a recipient row for the user that comes
** back to unread on every delivery. Call as the system user.
*******************************************************************************/
public void deliverAlert(String userId, QRecord alert, Instant now) throws QException
{
Integer alertId = alert.getValueInteger("id");
String sourceKey = "alert:" + alertId;
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("category", NotificationCategory.ALERT.name())
.withValue("sourceName", StringUtils.hasContent(alert.getValueString("sourceName")) ? alert.getValueString("sourceName") : "alerts")
.withValue("sourceKey", sourceKey)
.withValue("sourceTableName", AlertsAlert.TABLE_NAME)
.withValue("sourceRecordId", String.valueOf(alertId))
.withValue("linkUrl", "/app/" + AlertsAlert.TABLE_NAME + "/" + alertId)
.withValue("linkLabel", "Open alert");
QRecord notification = AlertsDb.first(AlertsNotification.TABLE_NAME, AlertsDb.eq("sourceKey", sourceKey)
.withCriteria("category", QCriteriaOperator.EQUALS, NotificationCategory.ALERT.name()));
Integer notificationId;
if(notification == null)
{
notificationId = AlertsDb.insert(AlertsNotification.TABLE_NAME, values
.withValue("audienceType", NotificationAudience.Type.USERS.name())
.withValue("status", STATUS_SENT)
.withValue("publishAt", now)
.withValue("sentAt", now)
.withValue("recipientCount", 0)).getValueInteger("id");
}
else
{
notificationId = notification.getValueInteger("id");
AlertsDb.update(AlertsNotification.TABLE_NAME, values.withValue("id", notificationId).withValue("status", STATUS_SENT));
}
QRecord recipient = AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId)
.withCriteria("userId", QCriteriaOperator.EQUALS, userId));
if(recipient == null)
{
AlertsDb.insert(AlertsNotificationRecipient.TABLE_NAME, newRecipient(notificationId, userId, now));
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", notificationId)
.withValue("recipientCount", AlertsDb.count(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId))));
}
else
{
AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME, new QRecord().withValue("id", recipient.getValue("id"))
.withValue("deliveredAt", now).withValue("readAt", null).withValue("dismissedAt", null));
}
}
/*******************************************************************************
** Recount a notification's readers (after people mark it read or unread).
*******************************************************************************/
public void recountRead(Set<Integer> notificationIds) throws QException
{
runtime.asSystem(() ->
{
for(Integer id : notificationIds)
{
int read = AlertsDb.count(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", id).withCriteria("readAt", QCriteriaOperator.IS_NOT_BLANK));
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", id).withValue("readCount", read));
}
return (null);
});
}
/*******************************************************************************
** A notification's audience, from its stored fields (null when unknown).
*******************************************************************************/
public static NotificationAudience audienceOf(QRecord notification)
{
return (NotificationAudience.parse(notification.getValueString("audienceType"), notification.getValueString("audienceValue")));
}
/*******************************************************************************
** A link as a URL the UI can follow: a record or table path, an app path,
** or an https URL (anything else is dropped).
*******************************************************************************/
public static String linkUrl(AlertLink link)
{
if(link == null)
{
return (null);
}
if(link.getTableName() != null && link.getRecordId() != null)
{
return ("/app/" + link.getTableName() + "/" + link.getRecordId());
}
if(link.getTableName() != null)
{
return ("/app/" + link.getTableName());
}
return (safeLink(link.getPath() != null ? link.getPath() : link.getUrl()));
}
/*******************************************************************************
** The link if it is a relative app path or an https URL, else null.
*******************************************************************************/
public static String safeLink(String url)
{
if(!StringUtils.hasContent(url))
{
return (null);
}
String trimmed = url.trim();
boolean relative = trimmed.startsWith("/") && !trimmed.startsWith("//");
return (relative || trimmed.toLowerCase().startsWith("https://") ? trimmed : null);
}
/*******************************************************************************
** The notification fields from a signal.
*******************************************************************************/
private QRecord fromSignal(NotificationSignal signal)
{
return (new QRecord()
.withValue("title", StringUtils.safeTruncate(signal.getTitle(), 200))
.withValue("body", signal.getBody())
.withValue("severity", (signal.getSeverity() == null ? NotificationSeverity.INFO : signal.getSeverity()).name())
.withValue("category", (signal.getCategory() == null ? NotificationCategory.SYSTEM : signal.getCategory()).name())
.withValue("sourceName", StringUtils.safeTruncate(signal.getSourceName(), 100))
.withValue("sourceKey", StringUtils.safeTruncate(signal.getSourceKey(), 200))
.withValue("sourceTableName", signal.getSourceTableName())
.withValue("sourceRecordId", signal.getSourceRecordId())
.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("publishAt", signal.getPublishAt())
.withValue("expireAt", signal.getExpireAt())
.withValue("sticky", Boolean.TRUE.equals(signal.getSticky())));
}
/*******************************************************************************
** Match the stored audience value (null when there are no values).
*******************************************************************************/
private static QFilterCriteria audienceValueCriteria(NotificationAudience audience)
{
String value = audience.valuesAsString();
return (value == null ? new QFilterCriteria("audienceValue", QCriteriaOperator.IS_BLANK) : new QFilterCriteria("audienceValue", QCriteriaOperator.EQUALS, value));
}
/*******************************************************************************
**
*******************************************************************************/
private static QRecord newRecipient(Integer notificationId, String userId, Instant now)
{
return (new QRecord().withValue("notificationId", notificationId).withValue("userId", userId).withValue("deliveredAt", now).withValue("emailStatus", "NONE"));
}
/*******************************************************************************
** The users who already have this notification.
*******************************************************************************/
private Set<String> recipientUserIds(Integer notificationId) throws QException
{
Set<String> userIds = new HashSet<>();
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId)))
{
userIds.add(recipient.getValueString("userId"));
}
return (userIds);
}
/*******************************************************************************
** Users who muted a category in-app (never for SECURITY).
*******************************************************************************/
private Set<String> mutedUsers(String category) throws QException
{
Set<String> userIds = new HashSet<>();
if(category == null || NotificationCategory.SECURITY.name().equals(category))
{
return (userIds);
}
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, AlertsDb.eq("category", category).withCriteria("inAppMuted", QCriteriaOperator.EQUALS, true)))
{
userIds.add(preference.getValueString("userId"));
}
return (userIds);
}
/*******************************************************************************
** Insert recipient rows in batches.
*******************************************************************************/
private void insertRecipients(List<QRecord> recipients) throws QException
{
for(int start = 0; start < recipients.size(); start += BATCH)
{
List<QRecord> batch = recipients.subList(start, Math.min(recipients.size(), start + BATCH));
for(QRecord inserted : new InsertAction().execute(new InsertInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(new ArrayList<>(batch))).getRecords())
{
if(CollectionUtils.nullSafeHasContents(inserted.getErrors()))
{
LOG.warn("Could not add a notification recipient", logPair("error", inserted.getErrors().get(0).getMessage()));
}
}
}
}
/*******************************************************************************
** Bring a notification back to unread (and undismissed) for everyone who
** has it (a producer repeated it).
*******************************************************************************/
private void markAllUnread(Integer notificationId, Instant now) throws QException
{
List<QRecord> updates = new ArrayList<>();
for(QRecord recipient : AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", notificationId)))
{
updates.add(new QRecord().withValue("id", recipient.getValue("id")).withValue("deliveredAt", now).withValue("readAt", null).withValue("dismissedAt", null));
}
for(int start = 0; start < updates.size(); start += BATCH)
{
new UpdateAction().execute(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecords(new ArrayList<>(updates.subList(start, Math.min(updates.size(), start + BATCH)))));
}
if(!updates.isEmpty())
{
AlertsDb.update(AlertsNotification.TABLE_NAME, new QRecord().withValue("id", notificationId).withValue("readCount", 0));
}
}
}
@@ -21,6 +21,7 @@ import java.util.List;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
@@ -56,7 +57,11 @@ public enum AlertsAction
ENABLE_MONITOR("alertsEnableMonitor", "Enable monitor", "toggle_on", AlertsMonitor.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, null),
DISABLE_MONITOR("alertsDisableMonitor", "Disable monitor", "toggle_off", AlertsMonitor.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, null),
SEND_TEST("alertsSendTestNotification", "Send test notification", "send", AlertsChannel.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, 1),
RETRY_DELIVERY("alertsRetryDelivery", "Retry delivery", "replay", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, null);
RETRY_DELIVERY("alertsRetryDelivery", "Retry delivery", "replay", AlertsDelivery.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, null),
PREVIEW_AUDIENCE("alertsPreviewAudience", "Preview audience", "groups", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN, 1),
SEND_NOTIFICATION("alertsSendNotification", "Send now", "send", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN, null),
SCHEDULE_NOTIFICATION("alertsScheduleNotification", "Schedule", "schedule_send", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN, null),
RETRACT_NOTIFICATION("alertsRetractNotification", "Retract", "unpublished", AlertsNotification.TABLE_NAME, AlertsQBitConfig.PERMISSION_NOTIFICATION_ADMIN, null);
public static final String FIELD_NOTE = "alertsNote";
public static final String FIELD_SNOOZE_HOURS = "alertsSnoozeHours";
@@ -29,14 +29,16 @@ import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MonitorStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationStatus;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.notifications.AlertsAudienceResolver;
import com.kingsrook.qbits.alerts.notifications.NotificationService;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -66,6 +68,8 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
*******************************************************************************/
public final class AlertsActionSteps
{
static final int AUDIENCE_SAMPLE = 10;
/*******************************************************************************
** Steps only.
@@ -165,7 +169,11 @@ public final class AlertsActionSteps
*******************************************************************************/
private void review(AlertsAction action, QRecord record) throws QException
{
if(action == AlertsAction.RUN_CHECK)
if(action == AlertsAction.PREVIEW_AUDIENCE || action == AlertsAction.SEND_NOTIFICATION)
{
extra.addAll(audienceLines(runtime().getNotifications().preview(record), action == AlertsAction.PREVIEW_AUDIENCE));
}
else if(action == AlertsAction.RUN_CHECK)
{
MonitorEvaluator.Plan plan = runtime().getEvaluator().preview(record.getValueInteger("id"));
extra.addAll(planLines(plan, false));
@@ -311,7 +319,7 @@ public final class AlertsActionSteps
{
QRecord notification = runtime.getDeliveryWorker().sendTest(id, actor);
String status = notification.getValueString("status");
if(!NotificationStatus.SENT.name().equals(status))
if(!DeliveryStatus.SENT.name().equals(status))
{
extra.add(new ProcessSummaryLine(Status.ERROR, null, "The test did not send: " + Objects.toString(notification.getValueString("lastError"), status)));
yield (false);
@@ -320,6 +328,28 @@ public final class AlertsActionSteps
}
case RETRY_DELIVERY -> runtime.getDeliveryWorker().retry(List.of(id)) > 0;
case CREATE_MONITOR -> createMonitor(record);
case PREVIEW_AUDIENCE ->
{
extra.addAll(audienceLines(runtime.getNotifications().preview(record), true));
yield (true);
}
case SEND_NOTIFICATION ->
{
NotificationService.SendResult result = runtime.getNotifications().send(id);
extra.add(new ProcessSummaryLine(Status.INFO, null, "\"" + record.getValueString("title") + "\" reached " + count(result.added(), "new recipient")
+ " (" + count(result.total(), "recipient") + " in all)."));
yield (true);
}
case SCHEDULE_NOTIFICATION ->
{
runtime.getNotifications().schedule(id);
yield (true);
}
case RETRACT_NOTIFICATION ->
{
runtime.getNotifications().retract(id);
yield (true);
}
};
}
}
@@ -339,7 +369,10 @@ public final class AlertsActionSteps
case REOPEN -> AlertsPossibleValues.Status.RESOLVED.name().equals(status) ? null : "Only resolved alerts can be reopened.";
case ENABLE_MONITOR -> Boolean.TRUE.equals(record.getValueBoolean("enabled")) ? "Already enabled." : null;
case DISABLE_MONITOR -> Boolean.FALSE.equals(record.getValueBoolean("enabled")) ? "Already disabled." : null;
case RETRY_DELIVERY -> List.of(NotificationStatus.FAILED.name(), NotificationStatus.DEAD.name()).contains(status) ? null : "Only failed or dead notifications can be retried.";
case RETRY_DELIVERY -> List.of(DeliveryStatus.FAILED.name(), DeliveryStatus.DEAD.name()).contains(status) ? null : "Only failed or dead notifications can be retried.";
case PREVIEW_AUDIENCE, SEND_NOTIFICATION -> NotificationService.STATUS_RETRACTED.equals(status) ? "Retracted notifications can't be sent." : null;
case SCHEDULE_NOTIFICATION -> NotificationService.STATUS_DRAFT.equals(status) ? null : "Only drafts can be scheduled.";
case RETRACT_NOTIFICATION -> NotificationService.STATUS_SENT.equals(status) || NotificationService.STATUS_SCHEDULED.equals(status) ? null : "Only sent or scheduled notifications can be retracted.";
case SUBSCRIBE_TYPE -> alreadySubscribed(ScopeType.ALERT_TYPE, record.getValueString("name"));
case SUBSCRIBE_GROUP -> alreadySubscribed(ScopeType.GROUP, record.getValueString("name"));
case SUBSCRIBE_MONITOR -> alreadySubscribed(ScopeType.MONITOR, String.valueOf(record.getValue("id")));
@@ -472,6 +505,28 @@ public final class AlertsActionSteps
/*******************************************************************************
** Review lines for an audience: who it reaches, a sample, and warnings.
*******************************************************************************/
static List<ProcessSummaryLine> audienceLines(AlertsAudienceResolver.Resolved resolved, boolean preview)
{
List<ProcessSummaryLine> lines = new ArrayList<>();
int size = resolved.userIds().size();
lines.add(new ProcessSummaryLine(size == 0 ? Status.WARNING : Status.INFO, null, (preview ? "Would reach " : "Will reach up to ") + count(size, "user") + "."));
if(size > 0)
{
List<String> sample = resolved.userIds().stream().limit(AUDIENCE_SAMPLE).map(userId -> resolved.labels().getOrDefault(userId, userId)).toList();
lines.add(new ProcessSummaryLine(Status.INFO, null, "For example: " + String.join(", ", sample) + (size > sample.size() ? ", and " + (size - sample.size()) + " more." : ".")));
}
for(String warning : resolved.warnings())
{
lines.add(new ProcessSummaryLine(Status.WARNING, null, warning));
}
return (lines);
}
/*******************************************************************************
** The action this process run is.
*******************************************************************************/
@@ -509,6 +564,7 @@ public final class AlertsActionSteps
case "alerts_group" -> "group";
case "alerts_monitor" -> "monitor";
case "alerts_channel" -> "channel";
case "alerts_delivery" -> "delivery";
case "alerts_notification" -> "notification";
default -> "record";
};
@@ -537,6 +593,10 @@ public final class AlertsActionSteps
case SEND_TEST -> "tested";
case RETRY_DELIVERY -> "queued again";
case CREATE_MONITOR -> "given a new (disabled) monitor";
case PREVIEW_AUDIENCE -> "previewed";
case SEND_NOTIFICATION -> "sent";
case SCHEDULE_NOTIFICATION -> "scheduled";
case RETRACT_NOTIFICATION -> "retracted";
};
}
@@ -73,6 +73,8 @@ public final class AlertsScheduledSteps
{
AlertsRuntime runtime = AlertsRuntime.get();
runtime.sink().flush();
runtime.getNotifications().sendDue();
runtime.getNotifications().purge();
output.addValue(OUTPUT_COUNT, runtime.getDeliveryWorker().deliverDue());
}
}
@@ -52,4 +52,15 @@ public interface AlertSinkInterface
***************************************************************************/
void heartbeat(String heartbeatKey, Instant at);
/***************************************************************************
** Send a notification to people (an audience). The default does nothing,
** so sinks written before notifications keep working.
***************************************************************************/
default void notify(NotificationSignal signal)
{
//////////////////////////////////////////////////////////////
// noop - sinks that don't carry notifications ignore them //
//////////////////////////////////////////////////////////////
}
}
@@ -0,0 +1,193 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/*******************************************************************************
** Who a notification is for, resolved to users when it is sent (stand-in for
** core's com.kingsrook.qqq.backend.core.alerts.NotificationAudience).
*******************************************************************************/
public class NotificationAudience implements Serializable
{
/*******************************************************************************
** The kinds of audience.
*******************************************************************************/
public enum Type
{
ALL_USERS, ROLES, PERMISSION, USERS
}
private final Type type;
private final List<String> values;
/*******************************************************************************
** Constructor (blank values are dropped; values are trimmed).
*******************************************************************************/
public NotificationAudience(Type type, List<String> values)
{
this.type = type == null ? Type.USERS : type;
this.values = values == null ? List.of() : values.stream().filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
}
/*******************************************************************************
** Every user.
*******************************************************************************/
public static NotificationAudience allUsers()
{
return (new NotificationAudience(Type.ALL_USERS, null));
}
/*******************************************************************************
** Users with any of these roles or groups.
*******************************************************************************/
public static NotificationAudience roles(String... roles)
{
return (new NotificationAudience(Type.ROLES, roles == null ? null : Arrays.asList(roles)));
}
/*******************************************************************************
** Users whose session holds this permission.
*******************************************************************************/
public static NotificationAudience permission(String permission)
{
return (new NotificationAudience(Type.PERMISSION, permission == null ? null : List.of(permission)));
}
/*******************************************************************************
** These users.
*******************************************************************************/
public static NotificationAudience users(String... userIds)
{
return (new NotificationAudience(Type.USERS, userIds == null ? null : Arrays.asList(userIds)));
}
/*******************************************************************************
** One user.
*******************************************************************************/
public static NotificationAudience user(String userId)
{
return (users(userId));
}
/*******************************************************************************
** From the stored form: a type name and a comma- or line-separated value.
** An unknown type is null.
*******************************************************************************/
public static NotificationAudience parse(String type, String value)
{
for(Type candidate : Type.values())
{
if(type != null && candidate.name().equalsIgnoreCase(type.trim()))
{
List<String> values = new ArrayList<>();
if(value != null)
{
values.addAll(Arrays.asList(value.split("[,\\n]")));
}
return (new NotificationAudience(candidate, values));
}
}
return (null);
}
/*******************************************************************************
** The stored form of the values (null when there are none).
*******************************************************************************/
public String valuesAsString()
{
return (values.isEmpty() ? null : String.join(",", values));
}
/*******************************************************************************
** Getter for type
*******************************************************************************/
public Type getType()
{
return (this.type);
}
/*******************************************************************************
** Getter for values
*******************************************************************************/
public List<String> getValues()
{
return (this.values);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean equals(Object o)
{
return (o instanceof NotificationAudience other && type == other.type && values.equals(other.values));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public int hashCode()
{
return (Objects.hash(type, values));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String toString()
{
return (type + (values.isEmpty() ? "" : values.toString()));
}
}
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
/*******************************************************************************
** What a notification is about (stand-in for core's
** com.kingsrook.qqq.backend.core.alerts.NotificationCategory). Users filter
** and mute by category; SECURITY can never be muted in-app.
*******************************************************************************/
public enum NotificationCategory
{
SYSTEM, JOB, SECURITY, RELEASE, MARKETING, SHARE, ALERT;
/*******************************************************************************
** The category for a name (any case), or the fallback.
*******************************************************************************/
public static NotificationCategory fromName(String name, NotificationCategory fallback)
{
if(name != null)
{
for(NotificationCategory category : values())
{
if(category.name().equalsIgnoreCase(name.trim()))
{
return (category);
}
}
}
return (fallback);
}
}
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
/*******************************************************************************
** How a notification looks to its reader: INFO and SUCCESS are good news,
** WARNING and ERROR need a look, CRITICAL makes the badge red (stand-in for
** core's com.kingsrook.qqq.backend.core.alerts.NotificationSeverity).
*******************************************************************************/
public enum NotificationSeverity
{
INFO, SUCCESS, WARNING, ERROR, CRITICAL;
/*******************************************************************************
** The severity for a name (any case), or the fallback.
*******************************************************************************/
public static NotificationSeverity fromName(String name, NotificationSeverity fallback)
{
if(name != null)
{
for(NotificationSeverity severity : values())
{
if(severity.name().equalsIgnoreCase(name.trim()))
{
return (severity);
}
}
}
return (fallback);
}
}
@@ -0,0 +1,500 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.spi;
import java.io.Serializable;
import java.time.Instant;
/*******************************************************************************
** A notification for people (stand-in for core's
** com.kingsrook.qqq.backend.core.alerts.NotificationSignal): what to tell
** them, how urgent it is, what it is about, and who it is for. Send it with
** the alerts sink's notify (core: QAlerts.notify).
*******************************************************************************/
public class NotificationSignal implements Serializable
{
private String title;
private String body;
private NotificationSeverity severity;
private NotificationCategory category;
private String sourceName;
private String sourceKey;
private String sourceTableName;
private String sourceRecordId;
private AlertLink link;
private NotificationAudience audience;
private Instant publishAt;
private Instant expireAt;
private Boolean sticky;
private Instant occurredAt;
/*******************************************************************************
** Default constructor.
*******************************************************************************/
public NotificationSignal()
{
}
/*******************************************************************************
** Constructor with the title.
*******************************************************************************/
public NotificationSignal(String title)
{
this.title = title;
}
/*******************************************************************************
** Getter for title: The headline (plain text, required).
*******************************************************************************/
public String getTitle()
{
return (this.title);
}
/*******************************************************************************
** Setter for title
*******************************************************************************/
public void setTitle(String title)
{
this.title = title;
}
/*******************************************************************************
** Fluent setter for title
*******************************************************************************/
public NotificationSignal withTitle(String title)
{
this.title = title;
return (this);
}
/*******************************************************************************
** Getter for body: The message (markdown).
*******************************************************************************/
public String getBody()
{
return (this.body);
}
/*******************************************************************************
** Setter for body
*******************************************************************************/
public void setBody(String body)
{
this.body = body;
}
/*******************************************************************************
** Fluent setter for body
*******************************************************************************/
public NotificationSignal withBody(String body)
{
this.body = body;
return (this);
}
/*******************************************************************************
** Getter for severity: Default INFO.
*******************************************************************************/
public NotificationSeverity getSeverity()
{
return (this.severity);
}
/*******************************************************************************
** Setter for severity
*******************************************************************************/
public void setSeverity(NotificationSeverity severity)
{
this.severity = severity;
}
/*******************************************************************************
** Fluent setter for severity
*******************************************************************************/
public NotificationSignal withSeverity(NotificationSeverity severity)
{
this.severity = severity;
return (this);
}
/*******************************************************************************
** Getter for category: Default SYSTEM.
*******************************************************************************/
public NotificationCategory getCategory()
{
return (this.category);
}
/*******************************************************************************
** Setter for category
*******************************************************************************/
public void setCategory(NotificationCategory category)
{
this.category = category;
}
/*******************************************************************************
** Fluent setter for category
*******************************************************************************/
public NotificationSignal withCategory(NotificationCategory category)
{
this.category = category;
return (this);
}
/*******************************************************************************
** Getter for sourceName: Who emits it (a qbit or app name).
*******************************************************************************/
public String getSourceName()
{
return (this.sourceName);
}
/*******************************************************************************
** Setter for sourceName
*******************************************************************************/
public void setSourceName(String sourceName)
{
this.sourceName = sourceName;
}
/*******************************************************************************
** Fluent setter for sourceName
*******************************************************************************/
public NotificationSignal withSourceName(String sourceName)
{
this.sourceName = sourceName;
return (this);
}
/*******************************************************************************
** Getter for sourceKey: Dedup key: a second notify with the same key and audience updates the first.
*******************************************************************************/
public String getSourceKey()
{
return (this.sourceKey);
}
/*******************************************************************************
** Setter for sourceKey
*******************************************************************************/
public void setSourceKey(String sourceKey)
{
this.sourceKey = sourceKey;
}
/*******************************************************************************
** Fluent setter for sourceKey
*******************************************************************************/
public NotificationSignal withSourceKey(String sourceKey)
{
this.sourceKey = sourceKey;
return (this);
}
/*******************************************************************************
** Getter for sourceTableName: The table of the record it is about.
*******************************************************************************/
public String getSourceTableName()
{
return (this.sourceTableName);
}
/*******************************************************************************
** Setter for sourceTableName
*******************************************************************************/
public void setSourceTableName(String sourceTableName)
{
this.sourceTableName = sourceTableName;
}
/*******************************************************************************
** Fluent setter for sourceTableName
*******************************************************************************/
public NotificationSignal withSourceTableName(String sourceTableName)
{
this.sourceTableName = sourceTableName;
return (this);
}
/*******************************************************************************
** Getter for sourceRecordId: The record it is about.
*******************************************************************************/
public String getSourceRecordId()
{
return (this.sourceRecordId);
}
/*******************************************************************************
** Setter for sourceRecordId
*******************************************************************************/
public void setSourceRecordId(String sourceRecordId)
{
this.sourceRecordId = sourceRecordId;
}
/*******************************************************************************
** Fluent setter for sourceRecordId
*******************************************************************************/
public NotificationSignal withSourceRecordId(String sourceRecordId)
{
this.sourceRecordId = sourceRecordId;
return (this);
}
/*******************************************************************************
** Getter for link: Where it takes the reader.
*******************************************************************************/
public AlertLink getLink()
{
return (this.link);
}
/*******************************************************************************
** Setter for link
*******************************************************************************/
public void setLink(AlertLink link)
{
this.link = link;
}
/*******************************************************************************
** Fluent setter for link
*******************************************************************************/
public NotificationSignal withLink(AlertLink link)
{
this.link = link;
return (this);
}
/*******************************************************************************
** Getter for audience: Who it is for (required).
*******************************************************************************/
public NotificationAudience getAudience()
{
return (this.audience);
}
/*******************************************************************************
** Setter for audience
*******************************************************************************/
public void setAudience(NotificationAudience audience)
{
this.audience = audience;
}
/*******************************************************************************
** Fluent setter for audience
*******************************************************************************/
public NotificationSignal withAudience(NotificationAudience audience)
{
this.audience = audience;
return (this);
}
/*******************************************************************************
** Getter for publishAt: Not shown before this (default now).
*******************************************************************************/
public Instant getPublishAt()
{
return (this.publishAt);
}
/*******************************************************************************
** Setter for publishAt
*******************************************************************************/
public void setPublishAt(Instant publishAt)
{
this.publishAt = publishAt;
}
/*******************************************************************************
** Fluent setter for publishAt
*******************************************************************************/
public NotificationSignal withPublishAt(Instant publishAt)
{
this.publishAt = publishAt;
return (this);
}
/*******************************************************************************
** Getter for expireAt: Hidden after this.
*******************************************************************************/
public Instant getExpireAt()
{
return (this.expireAt);
}
/*******************************************************************************
** Setter for expireAt
*******************************************************************************/
public void setExpireAt(Instant expireAt)
{
this.expireAt = expireAt;
}
/*******************************************************************************
** Fluent setter for expireAt
*******************************************************************************/
public NotificationSignal withExpireAt(Instant expireAt)
{
this.expireAt = expireAt;
return (this);
}
/*******************************************************************************
** Getter for sticky: A banner until dismissed (meant for CRITICAL).
*******************************************************************************/
public Boolean getSticky()
{
return (this.sticky);
}
/*******************************************************************************
** Setter for sticky
*******************************************************************************/
public void setSticky(Boolean sticky)
{
this.sticky = sticky;
}
/*******************************************************************************
** Fluent setter for sticky
*******************************************************************************/
public NotificationSignal withSticky(Boolean sticky)
{
this.sticky = sticky;
return (this);
}
/*******************************************************************************
** Getter for occurredAt: When it happened (default now).
*******************************************************************************/
public Instant getOccurredAt()
{
return (this.occurredAt);
}
/*******************************************************************************
** Setter for occurredAt
*******************************************************************************/
public void setOccurredAt(Instant occurredAt)
{
this.occurredAt = occurredAt;
}
/*******************************************************************************
** Fluent setter for occurredAt
*******************************************************************************/
public NotificationSignal withOccurredAt(Instant occurredAt)
{
this.occurredAt = occurredAt;
return (this);
}
}
@@ -6,8 +6,8 @@
-- - include: { file: db/liquibase/qbit-alerts/alerts.changelog.sql }
--
-- Rules for this file: the host runs it, the qbit never runs DDL; additive only
-- (nothing is ever dropped or rewritten); every statement is idempotent
-- (IF NOT EXISTS). Column names are the snake_case form of the field names.
-- (nothing is ever dropped or rewritten, except the one pre-GA rename in changesets 16-17);
-- every statement is idempotent (IF NOT EXISTS, or a precondition). Column names are the snake_case form of the field names.
-- changeset qbit-alerts:1
-- comment: A category of alerts for people ("Backups", "CDN"), optionally nested. The Alerts home groups by these, subscriptions target them, and every alert type belongs to one.
@@ -314,7 +314,7 @@ CREATE TABLE IF NOT EXISTS alerts_notification (
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_notification PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_status_next_attempt_at ON alerts_notification (status, next_attempt_at);
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_status_next_attempt_at ON alerts_notification (status, next_attempt_at);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_alert_id ON alerts_notification (alert_id);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_channel_id_status ON alerts_notification (channel_id, status);
@@ -347,3 +347,83 @@ CREATE TABLE IF NOT EXISTS alerts_heartbeat (
CONSTRAINT pk_alerts_heartbeat PRIMARY KEY (id),
CONSTRAINT uq_alerts_heartbeat UNIQUE (heartbeat_key)
);
-- changeset qbit-alerts:16
-- comment: Pre-GA rename (docs/NOTIFICATIONS.md section 2): the delivery log becomes alerts_delivery, so alerts_notification can hold user notifications. Runs only on a schema that still has the 0.1.0-RC.1 delivery log.
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE LOWER(table_name) = 'alerts_notification' AND LOWER(column_name) = 'alert_event_id'
ALTER TABLE alerts_notification RENAME TO alerts_delivery;
ALTER TABLE alerts_delivery RENAME CONSTRAINT pk_alerts_notification TO pk_alerts_delivery;
ALTER INDEX ix_alerts_notification_alert_id RENAME TO ix_alerts_delivery_alert_id;
ALTER INDEX ix_alerts_notification_channel_id_status RENAME TO ix_alerts_delivery_channel_id_status;
-- changeset qbit-alerts:17
-- comment: Pre-GA retirement (docs/NOTIFICATIONS.md section 2): alert bell items are now notification recipients. alerts_inbox_item held only bell read state.
DROP TABLE IF EXISTS alerts_inbox_item;
-- changeset qbit-alerts:18
-- comment: A message for people: an announcement an admin writes, or one a producer emits (a failed job, a share, a security notice, a release). One recipient row per user it reaches.
CREATE TABLE IF NOT EXISTS alerts_notification (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
title VARCHAR(200) NOT NULL,
body TEXT,
severity VARCHAR(16) NOT NULL,
category VARCHAR(16) NOT NULL,
status VARCHAR(16),
source_name VARCHAR(100),
source_key VARCHAR(200),
source_table_name VARCHAR(100),
source_record_id VARCHAR(100),
link_url VARCHAR(1000),
link_label VARCHAR(200),
audience_type VARCHAR(16) NOT NULL,
audience_value TEXT,
publish_at TIMESTAMP,
expire_at TIMESTAMP,
sticky BOOLEAN,
author_user_id VARCHAR(100),
sent_at TIMESTAMP,
retracted_at TIMESTAMP,
recipient_count INTEGER,
read_count INTEGER,
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_notification PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_status_publish_at ON alerts_notification (status, publish_at);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_source_key ON alerts_notification (source_key);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_expire_at ON alerts_notification (expire_at);
-- changeset qbit-alerts:19
-- comment: One notification for one user: when it reached them, and whether they read it, dismissed it, or were emailed.
CREATE TABLE IF NOT EXISTS alerts_notification_recipient (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
notification_id INTEGER NOT NULL,
user_id VARCHAR(100) NOT NULL,
delivered_at TIMESTAMP,
read_at TIMESTAMP,
dismissed_at TIMESTAMP,
emailed_at TIMESTAMP,
email_status VARCHAR(16),
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_notification_recipient PRIMARY KEY (id),
CONSTRAINT uq_alerts_notification_recipient UNIQUE (notification_id, user_id)
);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_user_id_read_at ON alerts_notification_recipient (user_id, read_at);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_email_status ON alerts_notification_recipient (email_status);
-- changeset qbit-alerts:20
-- comment: A user's settings for one notification category: muted in-app (never for security), and email off, immediate or in a daily digest, from a minimum severity.
CREATE TABLE IF NOT EXISTS alerts_notification_preference (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
user_id VARCHAR(100) NOT NULL,
category VARCHAR(16) NOT NULL,
in_app_muted BOOLEAN,
email_mode VARCHAR(16),
email_min_severity VARCHAR(16),
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id),
CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category)
);
@@ -171,7 +171,13 @@ class AlertsQBitProducerTest extends BaseAlertsTest
}
for(String tableName : AlertsQBitConfig.allTableNames())
{
int start = changelog.indexOf("CREATE TABLE IF NOT EXISTS " + tableName + " (");
///////////////////////////////////////////////////////////////////////////////
// alerts_delivery was created as alerts_notification (changeset 13) and //
// renamed (16); the user-notification table is the later alerts_notification //
///////////////////////////////////////////////////////////////////////////////
String created = "alerts_delivery".equals(tableName) ? "alerts_notification" : tableName;
int start = "alerts_notification".equals(tableName) ? changelog.lastIndexOf("CREATE TABLE IF NOT EXISTS alerts_notification (")
: changelog.indexOf("CREATE TABLE IF NOT EXISTS " + created + " (");
String body = changelog.substring(start, changelog.indexOf(");", start));
QTableMetaData table = qInstance.getTable(tableName);
for(QFieldMetaData field : table.getFields().values())
@@ -193,7 +199,7 @@ class AlertsQBitProducerTest extends BaseAlertsTest
assertThat(available).contains(AlertsQBitConfig.PERMISSION_VIEW, AlertsQBitConfig.PERMISSION_MANAGE, AlertsQBitConfig.PERMISSION_ADMIN, "alerts_alert.read");
assertThat(config.getManagerPermissionNames()).containsAll(config.getViewerPermissionNames()).contains(AlertsQBitConfig.PERMISSION_MANAGE);
assertThat(config.getAdminPermissionNames()).containsAll(config.getManagerPermissionNames());
assertThat(config.getViewerPermissionNames()).doesNotContain(AlertsQBitConfig.PERMISSION_ADMIN, "alerts_channel.read", "alerts_notification.read");
assertThat(config.getViewerPermissionNames()).doesNotContain(AlertsQBitConfig.PERMISSION_ADMIN, "alerts_channel.read", "alerts_delivery.read");
}
@@ -361,7 +361,7 @@ public class BaseAlertsTest
public void customizeAutomatedSessionForUser(QInstance qInstance, QSession automatedSessionForUser, java.io.Serializable userId)
{
String id = String.valueOf(userId);
if(id.startsWith("admin") || id.startsWith("manager") || id.startsWith("viewer") || id.startsWith("outsider"))
if(id.startsWith("admin") || id.startsWith("author") || id.startsWith("manager") || id.startsWith("viewer") || id.startsWith("outsider"))
{
automatedSessionForUser.setPermissions(sessionFor(id).getPermissions());
}
@@ -385,6 +385,10 @@ public class BaseAlertsTest
{
session.getPermissions().addAll(defaults.getAdminPermissionNames());
}
else if(userId.startsWith("author"))
{
session.getPermissions().addAll(defaults.getNotificationAdminPermissionNames());
}
else if(userId.startsWith("manager"))
{
session.getPermissions().addAll(defaults.getManagerPermissionNames());
@@ -185,7 +185,7 @@ class HomeAndApiTest extends BaseAlertsTest
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) summary.get("items");
assertThat(items).hasSize(2);
assertThat(items.get(0)).containsKeys("title", "href", "group", "age", "unread");
assertThat(items.get(0)).containsKeys("id", "title", "href", "source", "category", "age", "unread", "alertId");
api.markRead(Map.of("alertId", items.get(0).get("alertId")));
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(1);
@@ -24,10 +24,10 @@ import java.util.Map;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsEvent;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
@@ -312,9 +312,9 @@ class SecurityAndActionsTest extends BaseAlertsTest
QRecord email = runtime.asSystem(() -> AlertsDb.first(AlertsChannel.TABLE_NAME, AlertsDb.eq("name", "email")));
List<String> test = run(AlertsAction.SEND_TEST, List.of(email.getValueInteger("id")), Map.of());
assertThat(test).anyMatch(line -> line.startsWith("ERROR"));
QRecord failed = all(AlertsNotification.TABLE_NAME).stream().filter(n -> "TEST".equals(n.getValueString("kind"))).findFirst().orElseThrow();
QRecord failed = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> "TEST".equals(n.getValueString("kind"))).findFirst().orElseThrow();
assertThat(failed.getValueString("status")).isEqualTo("FAILED");
assertThat(run(AlertsAction.RETRY_DELIVERY, List.of(failed.getValueInteger("id")), Map.of())).anyMatch(line -> line.startsWith("OK: 1"));
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsNotification.TABLE_NAME, failed.getValue("id"))).getValueString("status")).isEqualTo("PENDING");
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsDelivery.TABLE_NAME, failed.getValue("id"))).getValueString("status")).isEqualTo("PENDING");
}
}
@@ -24,7 +24,7 @@ import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.engine.AlertRouter;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.NotificationKind;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind;
import com.kingsrook.qbits.alerts.spi.DetailLevel;
import com.kingsrook.qqq.backend.core.instances.QMetaDataVariableInterpreter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -86,21 +86,21 @@ class RenderingTest
.withValue("summary", "The nightly job stopped.").withValue("details", "password=hunter2 in the log <script>x</script>").withValue("itemCount", 3)
.withValue("firstRaisedAt", now.minusSeconds(3 * 3600));
AlertMessage minimal = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.MINIMAL)), NotificationKind.RAISED, "https://x.example/", now);
AlertMessage minimal = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.MINIMAL)), DeliveryKind.RAISED, "https://x.example/", now);
assertThat(minimal.subject()).isEqualTo("[ERROR] Backups failed for [redacted]");
assertThat(minimal.text()).contains("3 h").doesNotContain("nightly job").doesNotContain("hunter2");
assertThat(minimal.link()).isEqualTo("https://x.example/app/alerts_alert/7");
AlertMessage standard = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.STANDARD)), NotificationKind.RAISED, null, now);
AlertMessage standard = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.STANDARD)), DeliveryKind.RAISED, null, now);
assertThat(standard.text()).contains("nightly job").contains("3 records").doesNotContain("hunter2");
AlertMessage full = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.FULL)), NotificationKind.RESOLVED, null, now);
AlertMessage full = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.FULL)), DeliveryKind.RESOLVED, null, now);
assertThat(full.subject()).startsWith("[RESOLVED]");
assertThat(full.text()).contains("[redacted] in the log").doesNotContain("hunter2");
assertThat(full.html()).contains("&lt;").doesNotContain("<script");
AlertMessage test = AlertMessageRenderer.render(List.of(new AlertMessageRenderer.Item(alert, "Backup failed", null, DetailLevel.MINIMAL),
new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.MINIMAL)), NotificationKind.TEST, null, now);
new AlertMessageRenderer.Item(alert, "Backup failed", "Backups", DetailLevel.MINIMAL)), DeliveryKind.TEST, null, now);
assertThat(test.subject()).isEqualTo("TEST: 2 new alerts");
assertThat(DetailLevel.lowerOf(DetailLevel.FULL, DetailLevel.MINIMAL)).isEqualTo(DetailLevel.MINIMAL);
assertThat(AlertMessageRenderer.age(null, now)).isEqualTo("now");
@@ -160,6 +160,6 @@ class RenderingTest
assertThat(AlertsPossibleValues.label("IDP_GROUP")).isEqualTo("IdP Group");
assertThat(AlertsPossibleValues.label("IN_APP")).isEqualTo("In App");
assertThat(AlertsPossibleValues.label(null)).isNull();
assertThat(AlertsPossibleValues.buildEnumSources()).hasSize(19);
assertThat(AlertsPossibleValues.buildEnumSources()).hasSize(25);
}
}
@@ -28,9 +28,10 @@ import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
import com.kingsrook.qbits.alerts.delivery.DeliveryWorker;
import com.kingsrook.qbits.alerts.model.AlertsAlert;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsInboxItem;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsDelivery;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
import com.kingsrook.qbits.alerts.model.AlertsMute;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
@@ -128,7 +129,7 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
*******************************************************************************/
private List<QRecord> notifications() throws QException
{
return (all(AlertsNotification.TABLE_NAME));
return (all(AlertsDelivery.TABLE_NAME));
}
@@ -155,7 +156,8 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
assertThat(sent).hasSize(1);
assertThat(sent.get(0).getValueString("status")).isEqualTo("SENT");
assertThat(sent.get(0).getValueString("recipientLabel")).isEqualTo("Name of admin-1 (in app)");
assertThat(all(AlertsInboxItem.TABLE_NAME)).hasSize(1);
assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).hasSize(1);
assertThat(all(AlertsNotification.TABLE_NAME)).singleElement().matches(n -> "ALERT".equals(n.getValueString("category")));
////////////////////////////////////////////////////////////////
// a viewer gets settings but no bootstrap subscription //
@@ -312,7 +314,7 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
List<QRecord> rows = notifications();
assertThat(rows).anyMatch(r -> "viewer-2".equals(r.getValueString("recipientUserId")) && "SENT".equals(r.getValueString("status")));
assertThat(rows).anyMatch(r -> "outsider-2".equals(r.getValueString("recipientUserId")) && "NOT_VISIBLE".equals(r.getValueString("suppressedReason")));
assertThat(where(AlertsInboxItem.TABLE_NAME, AlertsDb.eq("userId", "outsider-2"))).isEmpty();
assertThat(where(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "outsider-2"))).isEmpty();
}
@@ -350,7 +352,7 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
clock.advance(wait);
runtime.getDeliveryWorker().deliverDue();
}
QRecord dead = runtime.asSystem(() -> AlertsDb.byId(AlertsNotification.TABLE_NAME, retrying.getValue("id")));
QRecord dead = runtime.asSystem(() -> AlertsDb.byId(AlertsDelivery.TABLE_NAME, retrying.getValue("id")));
assertThat(dead.getValueString("status")).isEqualTo("DEAD");
QRecord unhealthy = runtime.asSystem(() -> AlertsDb.byId(AlertsChannel.TABLE_NAME, channel.getValue("id")));
assertThat(unhealthy.getValueString("healthStatus")).isEqualTo("UNHEALTHY");
@@ -358,7 +360,7 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
QRecord failing = runtime.asSystem(() -> AlertsDb.first(AlertsAlert.TABLE_NAME, AlertsDb.eq("alertTypeName", DeliveryWorker.CHANNEL_FAILING_TYPE)));
assertThat(failing).isNotNull();
assertThat(failing.getValueString("status")).isEqualTo("OPEN");
assertThat(where(AlertsNotification.TABLE_NAME, AlertsDb.eq("alertId", failing.getValue("id")))).allMatch(r -> r.getValue("recipientUserId") != null);
assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("alertId", failing.getValue("id")))).allMatch(r -> r.getValue("recipientUserId") != null);
/////////////////////////////////////////////////////
// retry, and a success heals the channel //
@@ -35,7 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class EntitiesTest
{
static final List<Class<? extends QRecordEntity>> ENTITIES = List.of(AlertsAlert.class, AlertsChannel.class, AlertsEvent.class, AlertsGroup.class, AlertsHeartbeat.class,
AlertsInboxItem.class, AlertsMonitor.class, AlertsMute.class, AlertsNotification.class, AlertsRecordRef.class, AlertsSubscription.class, AlertsTeam.class,
AlertsMonitor.class, AlertsNotification.class, AlertsNotificationRecipient.class, AlertsNotificationPreference.class, AlertsMute.class, AlertsDelivery.class, AlertsRecordRef.class, AlertsSubscription.class, AlertsTeam.class,
AlertsTeamMember.class, AlertsType.class, AlertsUserPreference.class);
@@ -0,0 +1,446 @@
/*
* 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.notifications;
import java.io.Serializable;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.AlertsPreferences;
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.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.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.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** User notifications (docs/NOTIFICATIONS.md): authoring, audiences,
** recipients, the bell and page API, actions, producers, retention.
*******************************************************************************/
class NotificationsTest extends BaseAlertsTest
{
static final String AUTHOR = "author-1";
/*******************************************************************************
** A directory with roles, for ROLES audiences.
*******************************************************************************/
public static class TestDirectory implements AlertsUserDirectoryInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public List<DirectoryUser> listUsers()
{
return (List.of(
new DirectoryUser("viewer-1", "Vera Viewer", "vera@example.com", Set.of("staff")),
new DirectoryUser("viewer-2", "Victor Viewer", null, Set.of("staff", "editors")),
new DirectoryUser("admin-1", "Ada Admin", null, Set.of("admins")),
new DirectoryUser("outsider-1", "Otto Outsider", null, Set.of())));
}
}
/*******************************************************************************
** Users who have opened the bell (the default directory).
*******************************************************************************/
private void knownUsers(String... userIds) throws QException
{
for(String userId : userIds)
{
AlertsPreferences.ensure(runtime, loginAs(userId));
}
}
/*******************************************************************************
** Insert a notification as the current user; returns the record.
*******************************************************************************/
private QRecord author(QRecord notification) throws QException
{
return (new InsertAction().execute(new InsertInput(AlertsNotification.TABLE_NAME).withRecord(notification)).getRecords().get(0));
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> items(Map<String, Object> result)
{
return ((List<Map<String, Object>>) result.get("items"));
}
/*******************************************************************************
** Run an action as the current user; the Result lines.
*******************************************************************************/
private List<String> run(AlertsAction action, List<Serializable> ids) throws QException
{
RunProcessInput input = new RunProcessInput()
.withProcessName(action.getProcessName())
.withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)
.withCallback(new QProcessCallback()
{
@Override
public QQueryFilter getQueryFilter()
{
return (new QQueryFilter().withCriteria("id", QCriteriaOperator.IN, ids));
}
@Override
public Map<String, Serializable> getFieldValues(List<QFieldMetaData> fields)
{
return (new HashMap<>());
}
});
RunProcessOutput output = new RunProcessAction().execute(input);
if(output.getException().isPresent())
{
throw (new QException(output.getException().get()));
}
@SuppressWarnings("unchecked")
List<ProcessSummaryLineInterface> lines = (List<ProcessSummaryLineInterface>) output.getValue(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
List<String> text = new ArrayList<>();
for(ProcessSummaryLineInterface line : lines == null ? List.<ProcessSummaryLineInterface>of() : lines)
{
text.add(line.getStatus() + ": " + (line instanceof ProcessSummaryLine summary ? summary.getCount() + " " + summary.getMessage() : String.valueOf(line)));
}
return (text);
}
/*******************************************************************************
** An author writes, previews, sends, edits and retracts an announcement;
** viewers read, unread and dismiss it in the bell.
*******************************************************************************/
@Test
void testAuthorSendReadRetract() throws Exception
{
knownUsers("viewer-1", "viewer-2");
loginAs(AUTHOR);
QRecord draft = author(new QRecord().withValue("title", "Version 2.0 is out").withValue("body", "**New:** dark mode.")
.withValue("severity", "SUCCESS").withValue("category", "RELEASE").withValue("audienceType", "ALL_USERS").withValue("linkUrl", "/app/releases"));
assertThat(draft.getErrors()).isEmpty();
Integer id = draft.getValueInteger("id");
QRecord stored = AlertsDb.byId(AlertsNotification.TABLE_NAME, id);
assertThat(stored.getValueString("status")).isEqualTo("DRAFT");
assertThat(stored.getValueString("authorUserId")).isEqualTo(AUTHOR);
assertThat(stored.getValueString("sourceName")).isEqualTo("admin");
List<String> preview = run(AlertsAction.PREVIEW_AUDIENCE, List.of(id));
assertThat(String.join("\n", preview)).contains("Would reach 2 users").contains("Name of viewer-1");
assertThat(runtime.asSystem(() -> all(AlertsNotificationRecipient.TABLE_NAME))).isEmpty();
List<String> sent = run(AlertsAction.SEND_NOTIFICATION, List.of(id));
assertThat(String.join("\n", sent)).contains("notification was sent").contains("reached 2 new recipients");
assertThat(AlertsDb.byId(AlertsNotification.TABLE_NAME, id).getValueString("status")).isEqualTo("SENT");
assertThat(AlertsDb.byId(AlertsNotification.TABLE_NAME, id).getValueInteger("recipientCount")).isEqualTo(2);
loginAs("viewer-1");
AlertsApi api = new AlertsApi(runtime);
Map<String, Object> summary = api.inboxSummary();
assertThat(summary.get("unreadCount")).isEqualTo(1);
assertThat(summary.get("highestUnreadSeverity")).isEqualTo("SUCCESS");
Map<String, Object> item = items(summary).get(0);
assertThat(item).containsEntry("title", "Version 2.0 is out").containsEntry("category", "RELEASE").containsEntry("href", "/app/releases").containsEntry("unread", true);
assertThat(item).doesNotContainKey("body");
Integer mine = (Integer) item.get("id");
////////////////////////////////////////////////////////////////////
// another user's row is invisible to me, and can't be changed //
////////////////////////////////////////////////////////////////////
Integer theirs = runtime.asSystem(() -> AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("userId", "viewer-2"))).getValueInteger("id");
assertThat(AlertsDb.byId(AlertsNotificationRecipient.TABLE_NAME, theirs)).isNull();
assertThat(api.markRead(Map.of("id", theirs)).get("updated")).isEqualTo(0);
assertThat(api.markRead(Map.of("id", mine)).get("updated")).isEqualTo(1);
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
assertThat(runtime.asSystem(() -> AlertsDb.byId(AlertsNotification.TABLE_NAME, id)).getValueInteger("readCount")).isEqualTo(1);
assertThat(api.markUnread(Map.of("ids", List.of(mine))).get("updated")).isEqualTo(1);
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(1);
assertThatThrownBy(() -> api.markUnread(Map.of("all", true))).hasMessageContaining("Send id or ids");
////////////////////////////////////////////////////////////////////
// the page has the body; editing after send changes what I see //
////////////////////////////////////////////////////////////////////
assertThat(items(api.inbox(Map.of())).get(0).get("body")).isEqualTo("**New:** dark mode.");
loginAs(AUTHOR);
new UpdateAction().execute(new UpdateInput(AlertsNotification.TABLE_NAME).withRecord(new QRecord().withValue("id", id).withValue("title", "Version 2.0.1 is out")
.withValue("status", "DRAFT").withValue("recipientCount", 99)));
assertThat(AlertsDb.byId(AlertsNotification.TABLE_NAME, id).getValueString("status")).isEqualTo("SENT");
loginAs("viewer-1");
assertThat(items(api.inboxSummary()).get(0).get("title")).isEqualTo("Version 2.0.1 is out");
////////////////////////////////////////////////////////////////////
// a user can change only read and dismissed on their row //
////////////////////////////////////////////////////////////////////
QRecord moved = new UpdateAction().execute(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecord(new QRecord().withValue("id", mine)
.withValue("userId", "viewer-2"))).getRecords().get(0);
assertThat(moved.getErrors()).isNotEmpty();
new UpdateAction().execute(new UpdateInput(AlertsNotificationRecipient.TABLE_NAME).withRecord(new QRecord().withValue("id", mine)
.withValue("emailStatus", "SENT").withValue("readAt", Instant.now())));
QRecord row = AlertsDb.byId(AlertsNotificationRecipient.TABLE_NAME, mine);
assertThat(row.getValueString("userId")).isEqualTo("viewer-1");
assertThat(row.getValueString("emailStatus")).isEqualTo("NONE");
assertThat(row.getValue("readAt")).isNotNull();
assertThat(new DeleteAction().execute(new DeleteInput(AlertsNotificationRecipient.TABLE_NAME).withPrimaryKeys(List.of(mine))).getDeletedRecordCount()).isEqualTo(0);
////////////////////////////////////////////////////////////////////
// retracting hides it everywhere //
////////////////////////////////////////////////////////////////////
loginAs(AUTHOR);
assertThat(String.join("\n", run(AlertsAction.RETRACT_NOTIFICATION, List.of(id)))).contains("notification was retracted");
assertThat(String.join("\n", run(AlertsAction.SEND_NOTIFICATION, List.of(id)))).contains("can't be sent");
loginAs("viewer-2");
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
assertThat(items(api.inbox(Map.of()))).isEmpty();
}
/*******************************************************************************
** Dismiss, all, the alertId form, and page filters and paging.
*******************************************************************************/
@Test
void testDismissAndPage() throws Exception
{
for(int i = 0; i < 4; i++)
{
runtime.getNotifications().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)
.withSeverity(NotificationSeverity.CRITICAL).withSticky(true).withAudience(NotificationAudience.user("viewer-1")));
loginAs("viewer-1");
AlertsApi api = new AlertsApi(runtime);
Map<String, Object> summary = api.inboxSummary();
assertThat(summary.get("unreadCount")).isEqualTo(5);
assertThat(summary.get("highestUnreadSeverity")).isEqualTo("CRITICAL");
@SuppressWarnings("unchecked")
List<Map<String, Object>> banners = (List<Map<String, Object>>) summary.get("banners");
assertThat(banners).singleElement().satisfies(banner -> assertThat(banner).containsEntry("title", "Sign-in from a new device").containsKey("body"));
assertThat(items(summary).get(0).get("title")).isEqualTo("Sign-in from a new device");
assertThat(api.inbox(Map.of("category", "job")).get("total")).isEqualTo(4);
assertThat(api.inbox(Map.of("severity", "ERROR")).get("total")).isEqualTo(2);
Map<String, Object> page = api.inbox(Map.of("offset", "1", "limit", "2"));
assertThat(page).containsEntry("total", 5).containsEntry("offset", 1).containsEntry("limit", 2);
assertThat(items(page)).extracting(i -> i.get("title")).containsExactly("Job 3 failed", "Job 2 failed");
assertThat(api.dismiss(Map.of("id", banners.get(0).get("id"))).get("updated")).isEqualTo(1);
summary = api.inboxSummary();
assertThat(summary.get("unreadCount")).isEqualTo(4);
assertThat((List<?>) summary.get("banners")).isEmpty();
assertThat(api.markRead(Map.of("all", true)).get("updated")).isEqualTo(4);
assertThat(api.inbox(Map.of("status", "unread")).get("total")).isEqualTo(0);
assertThat(api.dismiss(Map.of("all", true)).get("updated")).isEqualTo(4);
assertThat(api.inbox(Map.of()).get("total")).isEqualTo(0);
assertThatThrownBy(() -> api.dismiss(Map.of())).hasMessageContaining("Send id");
assertThatThrownBy(() -> api.dismiss(Map.of("id", "x"))).hasMessageContaining("numbers");
}
/*******************************************************************************
** Producers: a repeated sourceKey updates one notification and brings it
** back to unread; links are made safe; bad signals are refused.
*******************************************************************************/
@Test
void testProducerDedupAndLinks() throws Exception
{
NotificationSignal signal = new NotificationSignal("Export failed").withSourceKey("process:abc").withCategory(NotificationCategory.JOB)
.withLink(AlertLink.toRecord("widget", 5, "Widget")).withAudience(NotificationAudience.user("viewer-1"));
Integer first = runtime.getNotifications().notify(signal);
loginAs("viewer-1");
AlertsApi api = new AlertsApi(runtime);
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);
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");
assertThat(NotificationService.linkUrl(AlertLink.toUrl("javascript:alert(1)", "x"))).isNull();
assertThat(NotificationService.linkUrl(AlertLink.toUrl("https://example.com/x", "x"))).isEqualTo("https://example.com/x");
assertThat(NotificationService.linkUrl(AlertLink.toPath("/app/alerts", "x"))).isEqualTo("/app/alerts");
assertThat(NotificationService.safeLink("//evil.example.com")).isNull();
assertThatThrownBy(() -> runtime.getNotifications().notify(new NotificationSignal("No audience"))).hasMessageContaining("audience");
////////////////////////////////////////////////////////////////////
// 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")));
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(2);
}
/*******************************************************************************
** Audiences: roles from a directory, permission holders, warnings.
*******************************************************************************/
@Test
void testAudiences() throws Exception
{
knownUsers("viewer-1");
NotificationService service = runtime.getNotifications();
QRecord roles = new QRecord().withValue("audienceType", "ROLES").withValue("audienceValue", "editors");
assertThat(service.preview(roles).userIds()).isEmpty();
assertThat(service.preview(roles).warnings()).anyMatch(w -> w.contains("no roles"));
config.setUserDirectory(new QCodeReference(TestDirectory.class));
assertThat(service.preview(roles).userIds()).containsExactly("viewer-2");
assertThat(service.preview(new QRecord().withValue("audienceType", "ROLES").withValue("audienceValue", "STAFF, admins")).userIds())
.containsExactlyInAnyOrder("viewer-1", "viewer-2", "admin-1");
assertThat(service.preview(new QRecord().withValue("audienceType", "PERMISSION").withValue("audienceValue", "alerts.admin")).userIds()).containsExactly("admin-1");
assertThat(service.preview(new QRecord().withValue("audienceType", "USERS").withValue("audienceValue", "viewer-1\nnew-person")).labels())
.containsEntry("viewer-1", "Vera Viewer").containsEntry("new-person", "new-person");
assertThat(service.preview(new QRecord().withValue("audienceType", "nonsense")).warnings()).isNotEmpty();
config.setMaxAudienceSize(2);
AlertsAudienceResolver.Resolved capped = service.preview(new QRecord().withValue("audienceType", "ALL_USERS"));
assertThat(capped.userIds()).hasSize(2);
assertThat(capped.warnings()).anyMatch(w -> w.contains("maxAudienceSize"));
}
/*******************************************************************************
** Validation on the tables: links, audiences, security mutes.
*******************************************************************************/
@Test
void testValidation() throws Exception
{
loginAs(AUTHOR);
assertThat(author(new QRecord().withValue("title", "x").withValue("severity", "INFO").withValue("category", "SYSTEM").withValue("audienceType", "ALL_USERS")
.withValue("linkUrl", "javascript:alert(1)")).getErrors()).anyMatch(e -> e.getMessage().contains("app path"));
assertThat(author(new QRecord().withValue("title", "x").withValue("severity", "INFO").withValue("category", "SYSTEM").withValue("audienceType", "USERS"))
.getErrors()).anyMatch(e -> e.getMessage().contains("user ids"));
loginAs("viewer-1");
QRecord muted = new InsertAction().execute(new InsertInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord()
.withValue("category", "SECURITY").withValue("inAppMuted", true))).getRecords().get(0);
assertThat(muted.getErrors()).anyMatch(e -> e.getMessage().contains("can't be muted"));
QRecord security = new InsertAction().execute(new InsertInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord()
.withValue("category", "SECURITY").withValue("emailMode", "IMMEDIATE"))).getRecords().get(0);
assertThat(security.getErrors()).isEmpty();
assertThat(AlertsDb.byId(AlertsNotificationPreference.TABLE_NAME, security.getValue("id")).getValueString("userId")).isEqualTo("viewer-1");
QRecord update = new UpdateAction().execute(new UpdateInput(AlertsNotificationPreference.TABLE_NAME).withRecord(new QRecord()
.withValue("id", security.getValue("id")).withValue("inAppMuted", true))).getRecords().get(0);
assertThat(update.getErrors()).anyMatch(e -> e.getMessage().contains("can't be muted"));
////////////////////////////////////////////////////////////////////
// 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")));
assertThat(new AlertsApi(runtime).inboxSummary().get("unreadCount")).isEqualTo(1);
}
/*******************************************************************************
** Scheduling, expiry, purge, and deleting with recipients.
*******************************************************************************/
@Test
void testScheduleExpireAndPurge() throws Exception
{
knownUsers("viewer-1");
loginAs(AUTHOR);
QRecord draft = author(new QRecord().withValue("title", "Maintenance tonight").withValue("severity", "WARNING").withValue("category", "SYSTEM")
.withValue("audienceType", "USERS").withValue("audienceValue", "viewer-1"));
Integer id = draft.getValueInteger("id");
assertThat(String.join("\n", run(AlertsAction.SCHEDULE_NOTIFICATION, List.of(id)))).contains("future");
new UpdateAction().execute(new UpdateInput(AlertsNotification.TABLE_NAME).withRecord(new QRecord().withValue("id", id)
.withValue("publishAt", clock.instant().plus(Duration.ofHours(1))).withValue("expireAt", clock.instant().plus(Duration.ofHours(3)))));
assertThat(String.join("\n", run(AlertsAction.SCHEDULE_NOTIFICATION, List.of(id)))).contains("notification was scheduled");
assertThat(runtime.getNotifications().sendDue()).isEqualTo(0);
clock.advance(Duration.ofMinutes(61));
assertThat(runtime.getNotifications().sendDue()).isEqualTo(1);
loginAs("viewer-1");
AlertsApi api = new AlertsApi(runtime);
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(1);
clock.advance(Duration.ofHours(3));
assertThat(api.inboxSummary().get("unreadCount")).isEqualTo(0);
assertThat(runtime.getNotifications().purge()).isEqualTo(0);
clock.advance(Duration.ofDays(8));
assertThat(runtime.getNotifications().purge()).isEqualTo(1);
assertThat(runtime.asSystem(() -> all(AlertsNotificationRecipient.TABLE_NAME))).isEmpty();
////////////////////////////////////////////////////////////////////
// deleting a notification deletes its recipients //
////////////////////////////////////////////////////////////////////
Integer other = runtime.getNotifications().notify(new NotificationSignal("Hello").withAudience(NotificationAudience.user("viewer-1")));
loginAs(AUTHOR);
new DeleteAction().execute(new DeleteInput(AlertsNotification.TABLE_NAME).withPrimaryKeys(List.of(other)));
assertThat(runtime.asSystem(() -> all(AlertsNotificationRecipient.TABLE_NAME))).isEmpty();
}
}
+69 -20
View File
@@ -2,7 +2,10 @@
DROP TABLE IF EXISTS alerts_heartbeat;
DROP TABLE IF EXISTS alerts_inbox_item;
DROP TABLE IF EXISTS alerts_notification_preference;
DROP TABLE IF EXISTS alerts_notification_recipient;
DROP TABLE IF EXISTS alerts_notification;
DROP TABLE IF EXISTS alerts_delivery;
DROP TABLE IF EXISTS alerts_mute;
DROP TABLE IF EXISTS alerts_channel;
DROP TABLE IF EXISTS alerts_team_member;
@@ -274,7 +277,7 @@ CREATE TABLE IF NOT EXISTS alerts_mute (
);
CREATE INDEX IF NOT EXISTS ix_alerts_mute_starts_at_ends_at ON alerts_mute (starts_at, ends_at);
CREATE TABLE IF NOT EXISTS alerts_notification (
CREATE TABLE IF NOT EXISTS alerts_delivery (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
alert_id INTEGER,
alert_event_id INTEGER,
@@ -293,26 +296,11 @@ CREATE TABLE IF NOT EXISTS alerts_notification (
last_error VARCHAR(2000),
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_notification PRIMARY KEY (id)
CONSTRAINT pk_alerts_delivery PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_status_next_attempt_at ON alerts_notification (status, next_attempt_at);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_alert_id ON alerts_notification (alert_id);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_channel_id_status ON alerts_notification (channel_id, status);
CREATE TABLE IF NOT EXISTS alerts_inbox_item (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
user_id VARCHAR(100) NOT NULL,
alert_id INTEGER NOT NULL,
notification_id INTEGER,
delivered_at TIMESTAMP,
read_at TIMESTAMP,
archived_at TIMESTAMP,
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_inbox_item PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_alerts_inbox_item_user_id_read_at ON alerts_inbox_item (user_id, read_at);
CREATE INDEX IF NOT EXISTS ix_alerts_inbox_item_alert_id ON alerts_inbox_item (alert_id);
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_status_next_attempt_at ON alerts_delivery (status, next_attempt_at);
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_alert_id ON alerts_delivery (alert_id);
CREATE INDEX IF NOT EXISTS ix_alerts_delivery_channel_id_status ON alerts_delivery (channel_id, status);
CREATE TABLE IF NOT EXISTS alerts_heartbeat (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
@@ -324,3 +312,64 @@ CREATE TABLE IF NOT EXISTS alerts_heartbeat (
CONSTRAINT pk_alerts_heartbeat PRIMARY KEY (id),
CONSTRAINT uq_alerts_heartbeat UNIQUE (heartbeat_key)
);
CREATE TABLE IF NOT EXISTS alerts_notification (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
title VARCHAR(200) NOT NULL,
body TEXT,
severity VARCHAR(16) NOT NULL,
category VARCHAR(16) NOT NULL,
status VARCHAR(16),
source_name VARCHAR(100),
source_key VARCHAR(200),
source_table_name VARCHAR(100),
source_record_id VARCHAR(100),
link_url VARCHAR(1000),
link_label VARCHAR(200),
audience_type VARCHAR(16) NOT NULL,
audience_value TEXT,
publish_at TIMESTAMP,
expire_at TIMESTAMP,
sticky BOOLEAN,
author_user_id VARCHAR(100),
sent_at TIMESTAMP,
retracted_at TIMESTAMP,
recipient_count INTEGER,
read_count INTEGER,
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_notification PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_status_publish_at ON alerts_notification (status, publish_at);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_source_key ON alerts_notification (source_key);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_expire_at ON alerts_notification (expire_at);
CREATE TABLE IF NOT EXISTS alerts_notification_recipient (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
notification_id INTEGER NOT NULL,
user_id VARCHAR(100) NOT NULL,
delivered_at TIMESTAMP,
read_at TIMESTAMP,
dismissed_at TIMESTAMP,
emailed_at TIMESTAMP,
email_status VARCHAR(16),
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_notification_recipient PRIMARY KEY (id),
CONSTRAINT uq_alerts_notification_recipient UNIQUE (notification_id, user_id)
);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_user_id_read_at ON alerts_notification_recipient (user_id, read_at);
CREATE INDEX IF NOT EXISTS ix_alerts_notification_recipient_email_status ON alerts_notification_recipient (email_status);
CREATE TABLE IF NOT EXISTS alerts_notification_preference (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL,
user_id VARCHAR(100) NOT NULL,
category VARCHAR(16) NOT NULL,
in_app_muted BOOLEAN,
email_mode VARCHAR(16),
email_min_severity VARCHAR(16),
create_date TIMESTAMP,
modify_date TIMESTAMP,
CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id),
CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category)
);