feat(notifications): user notifications, recipients, audiences and the bell API (phase a) #9
No files matched your search
@@ -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. |
|
||||
|
||||
@@ -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&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.
|
||||
*******************************************************************************/
|
||||
|
||||
+255
@@ -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);
|
||||
}
|
||||
}
|
||||
+112
-109
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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("<").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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
Reference in new issue
Block a user