Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 dc04474689 test(help): compare the help bundle version with the artifact version, not a hardcoded SNAPSHOT
The bundle version is now Maven-filtered, so a literal SNAPSHOT in the test
fails every release build. Surefire passes the artifact version in
qbit.artifactVersion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:07:02 -05:00
james.maes 912b83c2a4 Merge pull request #39 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:37:20 -05:00
james.maesandClaude Opus 5.5 e5d31991eb fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:02:04 -05:00
james.maes fc04d0a4fb Merge pull request #38 from QRun-IO/feature/help-bundle
feat(help): Alerts help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:29:38 -05:00
james.maesandClaude Opus 5.5 50e1b52a4d feat(help): ship the Alerts help bundle through QQQ help bundles (419 entries, coverage gate)
Host-neutral text (kof22-specific facts removed), registered with
HelpBundles in the producer, gated by HelpBundleAssertions. Replaces the
temporary AlertsHelp loader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:01:53 -05:00
james.maes 2dcd6cd3e4 Merge pull request #37 from QRun-IO/feature/notify-email-requested-v2
feat(notifications): email REQUESTED recipients immediately (per-job opt-in), v2
2026-10-14 18:51:03 -05:00
james.maesandClaude Opus 5.5 04104ad4aa feat(notifications): email REQUESTED recipients immediately (per-job opt-in), v2
Merges feature/notify-email-requested (#22) onto current develop and
reconciles it with the paginated emailer, user pause, quiet hours and the
notification preferences. REQUESTED email honours the category email
minimum severity, pause (SECURITY bypasses) and quiet hours. No schema
change, so no new changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:48:28 -05:00
james.maes c37d5d3f2e Merge pull request #35 from QRun-IO/feature/foreign-keys
feat(schema): foreign keys for every alerts relation (changesets 30-53, after #32)
2026-10-14 18:00:42 -05:00
james.maes 0143caf4c9 Merge pull request #32 from QRun-IO/feature/alerts-join-indexes
fix(schema): index the join child columns monitor_id, alert_type_id and group_id
2026-10-14 18:00:34 -05:00
james.maes 8a4791508c Merge pull request #33 from QRun-IO/feature/bootstrap-subscription-once
fix(preferences): create the bootstrap subscription once per user
2026-10-14 18:00:27 -05:00
james.maesandClaude Opus 5.5 1ff75ffcda Merge origin/develop into feature/bootstrap-subscription-once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:49:11 -05:00
james.maesandClaude Opus 5.5 c9c1efca0a fix(preferences): create the bootstrap subscription once per user
AlertsPreferences.ensure was an unlocked check-then-insert, and a page
load fires the home widgets and the bell together. On kof22 this left
two identical "alerts (bootstrap rule)" subscriptions for the owner, so
every alert notified them twice. First calls are now serialized in the
JVM. Across replicas, each first call keeps the oldest bootstrap
subscription and deletes the rest. Subscriptions a person made are never
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:22:24 -05:00
james.maesandClaude Opus 5.5 2202037c00 feat(notifications): email REQUESTED recipients immediately (per-job opt-in)
A recipient with emailStatus REQUESTED is emailed at once, without a
standing category preference or minimum severity, and only once even when
an IMMEDIATE preference also applies. No address: SKIPPED. ALERT
notifications are still never emailed here. This is the delivery half of
the durable-jobs "Email me when done" opt-in (QRun-IO/qqq durable jobs spec
2.11); marking recipients REQUESTED from core's NotificationSignal
attribute emailRequested lands with the RC.6 core notify() adoption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:14:23 -05:00
12 changed files with 1977 additions and 274 deletions

No files matched your search

+3 -1
View File
@@ -8,8 +8,9 @@ All notable changes to this project are documented here. The format follows Keep
- Record-view child lists (e.g. a notification's Recipients) failed with "Error in JSON Serialization": the child table's meta-data includes the qbit config, and `AlertsQBitConfig.getRuntime()` points at a runtime that points back at the config, so Jackson recursed to its 1000-level nesting limit. `getRuntime()` and `getAudienceResolver()` are now `@JsonIgnore`, as in the other qbits' configs. A regression test renders and serializes every child-list widget.
### Added
- Ship the full host-neutral Alerts help bundle through QQQ 4.1.0-RC.7 core registration, replacing the temporary loader. Cover owned tables, fields, actions, jobs, widgets and possible-value sources with a bundle coverage gate; support HOST overrides and hosts without help tables.
- Starter defaults changelog `alerts-defaults.changelog.sql` (insert-only, guarded): the `in-app` and `email` channels and the `alerts` and `other` groups, equal to what the runtime creates on first use. Tested through real Liquibase on H2.
- Help for the Alerts app and every `alerts_*` table (`help/qbit-alerts.yaml`, STAND-IN core#1198): what the table is for and how its rows get created, shown when it is empty.
- Help for the Alerts app and every `alerts_*` table (`help/qbit-alerts.yaml`): what the table is for and how its rows get created, shown when it is empty.
- A Setup card first on the Alerts home (alerts admins): each configuration table, its state and the next step.
- `AlertsTablesGuardrailTest`: every table is used by runtime code, seeded or listed as starting empty with a reason, and has help.
@@ -47,6 +48,7 @@ All notable changes to this project are documented here. The format follows Keep
- Apply reminders and escalation (#27, part 2): a minute-based hidden job sends due reminders, advances ordered USER/TEAM/CHANNEL escalation steps, and caps final-step repeats. Acknowledgement, snooze, resolution and mutes stop both; reopening restarts escalation. Delivery, events and progress commit together, with a persisted same-minute checkpoint.
- Add admin escalation policy and step tables, child step lists, and type/group policy links in guarded Liquibase changesets 23–26. Type policies override group policies. CRITICAL reminders default to 60 minutes; explicit zero turns them off.
- Keep EXPIRED reserved and remove it from selectable resolution values because the design specifies no expiration rule.
- Per-notification email opt-in via recipient email status `REQUESTED`: immediate delivery without a standing category preference. It still honours the category email minimum severity, the user pause (SECURITY bypasses) and quiet hours (held until they end, for up to a day), is skipped when no address is known, and is never emailed for ALERT notifications.
- Optional external channels:
- Slack (an Incoming Webhook, Block Kit)
- Microsoft Teams (a Workflows webhook, an Adaptive Card)
+17
View File
@@ -280,6 +280,23 @@ assigned automatically. The Resolve action continues to record MANUAL.
- **Alertmanager sink:** open alerts are sent with an `endsAt` a week out, not re-posted.
- **Not built yet:** Micrometer metrics, mirrored lock columns and a configurable table namespace. These are the rest of design phases P4 to P6.
## In-app help
The producer registers [`help/qbit-alerts.yaml`](src/main/resources/help/qbit-alerts.yaml)
with QQQ's help-bundle registry. It covers the Alerts app, tables and fields,
actions, scheduled jobs, widgets and possible-value sources. QQQ syncs the bundle
when the host installs the help-content tables; registration is harmless without them.
Hosts can override individual entries by registering their own bundle with
`layer: HOST`, using the same entry `key` and `role` (default `ALL_SCREENS`).
Register it with `HelpBundles.register(qInstance, "help/my-host.yaml")`. Host
bundles take precedence over this `QBIT` bundle, and edits made by users remain protected.
`AlertsTablesGuardrailTest` runs `HelpBundleAssertions` against the produced instance
to check owned-object coverage, summary length and live references. It also requires
widget and possible-value-source help and preserves the table empty-state guidance.
When releasing, keep the bundle's literal version aligned with the POM's revision.
## Build
```bash
+28 -1
View File
@@ -153,7 +153,34 @@
</dependencies>
<build>
<resources>
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
</excludes>
</resource>
</resources>
<plugins>
<!-- the artifact version, for the help bundle version test -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables combine.children="append">
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
@@ -231,7 +258,7 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
@@ -17,6 +17,8 @@
package com.kingsrook.qbits.alerts;
import java.io.Serializable;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
@@ -26,7 +28,12 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
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.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -42,6 +49,9 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
*******************************************************************************/
public final class AlertsPreferences
{
/** marks the bootstrap rule's subscription (createdByUserId). */
static final String BOOTSTRAP_CREATOR = "alerts (bootstrap rule)";
private static final Set<String> ENSURED = ConcurrentHashMap.newKeySet();
@@ -69,36 +79,72 @@ public final class AlertsPreferences
boolean admin = session.hasPermission(AlertsQBitConfig.PERMISSION_ADMIN);
String fullName = session.getUser() == null ? null : session.getUser().getFullName();
runtime.asSystem(() ->
{
if(AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
{
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord()
.withValue("userId", userId)
.withValue("userLabel", fullName == null ? userId : fullName)
.withValue("inAppEnabled", true)
.withValue("emailEnabled", true)
.withValue("quietBypassSeverity", "CRITICAL")
.withValue("digestHour", 8));
}
if(admin && runtime.getConfig().getBootstrapMinSeverity() != null && AlertsDb.first(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
////////////////////////////////////////////////////////////////////////////
// A page load fires the home widgets and the bell together, so first //
// calls for one user race. Serialize them in this JVM; across replicas, //
// the clean-up below keeps one bootstrap subscription (the lowest id). //
////////////////////////////////////////////////////////////////////////////
synchronized(AlertsPreferences.class)
{
if(ENSURED.contains(userId))
{
QRecord inApp = runtime.getChannels().defaultChannel(ChannelType.IN_APP);
AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
.withValue("subscriberType", SubscriberType.USER.name())
.withValue("userId", userId)
.withValue("scopeType", ScopeType.ALL.name())
.withValue("minSeverity", runtime.getConfig().getBootstrapMinSeverity().name())
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
.withValue("deliveryMode", DeliveryMode.IMMEDIATE.name())
.withValue("channelOverrideIds", String.valueOf(inApp.getValue("id")))
.withValue("enabled", true)
.withValue("createdByUserId", "alerts (bootstrap rule)"));
return;
}
return (null);
});
ENSURED.add(userId);
runtime.asSystem(() ->
{
if(AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
{
AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord()
.withValue("userId", userId)
.withValue("userLabel", fullName == null ? userId : fullName)
.withValue("inAppEnabled", true)
.withValue("emailEnabled", true)
.withValue("quietBypassSeverity", "CRITICAL")
.withValue("digestHour", 8));
}
if(admin && runtime.getConfig().getBootstrapMinSeverity() != null && AlertsDb.first(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)) == null)
{
QRecord inApp = runtime.getChannels().defaultChannel(ChannelType.IN_APP);
AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
.withValue("subscriberType", SubscriberType.USER.name())
.withValue("userId", userId)
.withValue("scopeType", ScopeType.ALL.name())
.withValue("minSeverity", runtime.getConfig().getBootstrapMinSeverity().name())
.withValue("events", "RAISED,ESCALATED,REMINDER,RESOLVED")
.withValue("deliveryMode", DeliveryMode.IMMEDIATE.name())
.withValue("channelOverrideIds", String.valueOf(inApp.getValue("id")))
.withValue("enabled", true)
.withValue("createdByUserId", BOOTSTRAP_CREATOR));
}
removeDuplicateBootstrapSubscriptions(userId);
return (null);
});
ENSURED.add(userId);
}
}
/*******************************************************************************
** Keep only the first bootstrap subscription of a user. Two replicas can
** each see no subscription and insert one; every later first call (and the
** losers' own calls) delete the extra copies, so the user is not notified
** twice per alert. Subscriptions a person made are never touched.
*******************************************************************************/
static void removeDuplicateBootstrapSubscriptions(String userId) throws QException
{
List<QRecord> bootstrap = AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("userId", userId)
.withCriteria("createdByUserId", QCriteriaOperator.EQUALS, BOOTSTRAP_CREATOR)
.withOrderBy(new QFilterOrderBy("id")));
if(bootstrap.size() < 2)
{
return;
}
List<Serializable> extra = bootstrap.subList(1, bootstrap.size()).stream().map(r -> (Serializable) r.getValue("id")).toList();
new DeleteAction().execute(new DeleteInput(AlertsSubscription.TABLE_NAME).withPrimaryKeys(extra).withTransaction(QContext.getQBackendTransaction()));
}
@@ -24,7 +24,6 @@ import com.kingsrook.qbits.alerts.engine.AlertsProcessListener;
import com.kingsrook.qbits.alerts.engine.AlertsSinkAdapter;
import com.kingsrook.qbits.alerts.engine.MonitorEvaluator;
import com.kingsrook.qbits.alerts.engine.QbitAlertsSink;
import com.kingsrook.qbits.alerts.metadata.AlertsHelp;
import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsMonitor;
@@ -38,6 +37,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
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.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -174,7 +174,8 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
output.addSelfToInstance(qInstance);
AlertsHelp.apply(qInstance);
// Registration is metadata-only; core guards startup sync when helpContent is absent.
HelpBundles.register(qInstance, "help/qbit-alerts.yaml");
return (new MetaDataProducerMultiOutput());
}
@@ -1,127 +0,0 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.alerts.metadata;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-alerts.yaml (the QQQ help-bundle format) to the
** Alerts app and its alerts_* tables: what each table is for and how
** its rows get created, shown on the table screen and when it is empty.
** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
*******************************************************************************/
public final class AlertsHelp
{
public static final String RESOURCE = "/help/qbit-alerts.yaml";
public static final String SLOT = "help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = AlertsHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-alerts".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every alerts_* table in the instance.
** A host's own help on a table is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : qInstance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry));
}
}
QAppMetaData app = qInstance.getApp(AlertsQBitConfig.APP_NAME);
JsonNode appEntry = entries.get("app:" + AlertsQBitConfig.APP_NAME + ";slot:" + SLOT);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -290,7 +290,7 @@ public final class AlertsPossibleValues
*******************************************************************************/
public enum EmailStatus
{
NONE, PENDING, SENT, DIGESTED, SKIPPED, FAILED
NONE, PENDING, REQUESTED, SENT, DIGESTED, SKIPPED, FAILED
}
@@ -29,11 +29,14 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.delivery.AlertRedactor;
import com.kingsrook.qbits.alerts.email.AlertsEmail;
import com.kingsrook.qbits.alerts.email.AlertsEmailSender;
import com.kingsrook.qbits.alerts.engine.AlertTiming;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qbits.alerts.model.AlertsNotification;
@@ -43,6 +46,7 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailMode;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EmailStatus;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.actions.messaging.SendMessageAction;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.NotificationCategory;
import com.kingsrook.qqq.backend.core.alerts.NotificationSeverity;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -67,6 +71,11 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** - IMMEDIATE: one email per notification at or above the minimum severity;
** - DAILY_DIGEST: one email at the user's digest hour (in their time zone,
** from My Alert Settings) listing the day's unread notifications.
** A recipient with email status REQUESTED (a per-notification opt-in, e.g. by
** a job) is emailed immediately without a standing category preference. It
** still honours the category's email minimum severity when one is set, the
** user's pause (SECURITY bypasses) and quiet hours (held, not dropped), is
** skipped without a known address, and is only eligible for a day.
** Sent through qbit-email when it is produced in the instance (one message
** per recipient; templates alerts_notification and alerts_digest,
** NOTIFICATION), else through core SendMessageAction. Inert without either
@@ -137,12 +146,19 @@ public class NotificationEmailer
{
preferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
}
if(preferences.isEmpty())
QQueryFilter eligible = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withSubFilter(new QQueryFilter()
.withCriteria("emailStatus", QCriteriaOperator.EQUALS, EmailStatus.REQUESTED.name())
.withCriteria("deliveredAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)));
if(!preferences.isEmpty())
{
return (new Result(0, 0, 0, 0));
eligible.withSubFilter(new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name())));
}
Map<String, QRecord> settings = userSettings(preferences.keySet());
Map<String, QRecord> settings = new HashMap<>();
int sent = 0;
int skipped = 0;
int failed = 0;
@@ -151,8 +167,7 @@ public class NotificationEmailer
while(true)
{
List<QRecord> recipients = AlertsDb.query(AlertsNotificationRecipient.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(preferences.keySet()))
.withCriteria("emailStatus", QCriteriaOperator.IN, List.of(EmailStatus.NONE.name(), EmailStatus.PENDING.name()))
.withSubFilter(eligible)
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId)
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(SCAN));
if(recipients.isEmpty())
@@ -161,25 +176,43 @@ public class NotificationEmailer
}
afterId = recipients.get(recipients.size() - 1).getValueInteger("id");
Map<Integer, QRecord> notifications = notifications(recipients, now);
Set<String> pageUsers = recipients.stream().map(recipient -> recipient.getValueString("userId")).collect(Collectors.toSet());
settings.putAll(userSettings(pageUsers));
Map<String, Map<String, QRecord>> requestedPreferences = new HashMap<>();
for(QRecord preference : AlertsDb.query(AlertsNotificationPreference.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.IN, new ArrayList<>(pageUsers))))
{
requestedPreferences.computeIfAbsent(preference.getValueString("userId"), k -> new HashMap<>()).put(preference.getValueString("category"), preference);
}
for(QRecord recipient : recipients)
{
QRecord notification = notifications.get(recipient.getValueInteger("notificationId"));
String userId = recipient.getValueString("userId");
QRecord preference = notification == null ? null : preferences.get(userId).get(notification.getValueString("category"));
if(notification == null || preference == null || NotificationCategory.ALERT.name().equals(notification.getValueString("category"))
|| !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))
boolean requested = EmailStatus.REQUESTED.name().equals(recipient.getValueString("emailStatus"));
String category = notification == null ? null : notification.getValueString("category");
QRecord preference = category == null ? null : (requested ? requestedPreferences : preferences).getOrDefault(userId, Map.of()).get(category);
QRecord setting = settings.get(userId);
if(notification == null || NotificationCategory.ALERT.name().equals(category)
|| (requested ? (!StringUtils.hasContent(address(setting)) || (preference != null && StringUtils.hasContent(preference.getValueString("emailMinSeverity"))
&& !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity"))))
: (preference == null || !atLeast(notification.getValueString("severity"), preference.getValueString("emailMinSeverity")))))
{
mark(recipient, EmailStatus.SKIPPED, null);
skipped++;
}
else if(!NotificationCategory.SECURITY.name().equals(notification.getValueString("category"))
&& settings.get(userId) != null && settings.get(userId).getValueInstant("pausedUntil") != null
&& settings.get(userId).getValueInstant("pausedUntil").isAfter(now))
else if(!NotificationCategory.SECURITY.name().equals(category)
&& setting != null && setting.getValueInstant("pausedUntil") != null
&& setting.getValueInstant("pausedUntil").isAfter(now))
{
// Leave the recipient pending so a later run can deliver it.
skipped++;
}
else if(EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
else if(requested && AlertTiming.isQuiet(setting, AlertSeverity.fromLabel(notification.getValueString("severity"), AlertSeverity.WARNING), now))
{
// Quiet hours hold a requested email; it stays REQUESTED until the window ends or it ages out.
skipped++;
}
else if(requested || EmailMode.IMMEDIATE.name().equals(preference.getValueString("emailMode")))
{
EmailStatus status = sendImmediate(address(settings.get(userId)), notification);
mark(recipient, status, status == EmailStatus.SENT ? now : null);
File diff suppressed because it is too large. Load diff
@@ -26,9 +26,10 @@ import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.kingsrook.qbits.alerts.metadata.AlertsHelp;
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -58,7 +59,7 @@ class AlertsTablesGuardrailTest extends BaseAlertsTest
Map.entry("alerts_event", "runtime: the alert timeline"),
Map.entry("alerts_subscription", "people's own choices; the first alerts admin gets one by the bootstrap rule (AlertsPreferences)"),
Map.entry("alerts_user_preference", "user content: created per person on their first visit (AlertsPreferences)"),
Map.entry("alerts_team", "the host's own people (kof22 seeds its team)"),
Map.entry("alerts_team", "the host's own people"),
Map.entry("alerts_team_member", "the host's own people"),
Map.entry("alerts_mute", "user content: maintenance windows and mutes people create"),
Map.entry("alerts_delivery", "runtime log: every delivery attempt"),
@@ -122,31 +123,44 @@ class AlertsTablesGuardrailTest extends BaseAlertsTest
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
assertThat(AlertsHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
// Core adds bulk CRUD processes during validation; the qbit owns its declared actions and jobs.
var processes = AlertsProcessesMetaDataProducer.buildProcesses(config).stream().map(process -> process.getName()).toList();
HelpBundleAssertions.forInstance(qInstance)
.bundles("help/qbit-alerts.yaml")
.owning(target -> "process".equals(target.kind()) ? processes.contains(target.name()) : target.name().startsWith("alerts"))
.assertComplete();
var entries = HelpBundles.of(qInstance).getBundles().get(0).getEntries();
// Core's gate does not yet require widget and possible-value-source help.
var keys = entries.stream().map(entry -> entry.getKey()).toList();
qInstance.getWidgets().keySet().stream().filter(name -> name.startsWith("alerts"))
.forEach(name -> assertThat(keys).contains("widget:" + name + ";slot:help"));
qInstance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("alerts"))
.forEach(name -> assertThat(keys).contains("possibleValueSource:" + name + ";slot:help"));
for(String table : alertsTables())
{
List<QHelpContent> help = qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(AlertsHelp.SLOT);
assertThat(help).as(table + " table help").isNotEmpty();
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
assertThat(entries).anySatisfy(entry ->
{
assertThat(entry.getKey()).isEqualTo("table:" + table + ";slot:help");
assertThat(entry.getContent()).contains("How rows get created");
});
}
assertThat(qInstance.getApp(AlertsQBitConfig.APP_NAME).getHelpContent()).containsKey(AlertsHelp.SLOT);
assertThat(AlertsHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null,
"help names only real tables");
}
/*******************************************************************************
** A host's own help on a table is kept; the bundle fills only empty slots.
** Bundle registration needs no help table; core safely skips startup sync.
*******************************************************************************/
@Test
void hostHelpIsNotOverridden() throws Exception
void hostWithoutHelpTableIsSupported()
{
QTableMetaData table = qInstance.getTable("alerts_channel");
table.getHelpContent().remove(AlertsHelp.SLOT);
table.withHelpContent(AlertsHelp.SLOT, new QHelpContent().withContentAsMarkdown("host text"));
AlertsHelp.apply(qInstance);
assertThat(table.getHelpContent().get(AlertsHelp.SLOT)).singleElement().matches(c -> "host text".equals(c.getContent()));
assertThat(qInstance.getTable("helpContent")).isNull();
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-alerts");
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion"));
});
HelpBundles.syncOnStartup(qInstance);
}
@@ -135,6 +135,76 @@ class RoutingAndDeliveryTest extends BaseAlertsTest
/*******************************************************************************
** First calls for one user race (the home widgets and the bell load
** together): only one bootstrap subscription is created.
*******************************************************************************/
@Test
void testConcurrentFirstCallsBootstrapOnce() throws Exception
{
com.kingsrook.qqq.backend.core.context.CapturedContext context = QContext.capture();
java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(4);
try
{
java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1);
List<java.util.concurrent.Future<Object>> futures = new java.util.ArrayList<>();
for(int i = 0; i < 4; i++)
{
futures.add(pool.submit(() ->
{
QContext.init(context);
start.await();
AlertsPreferences.ensure(runtime, QContext.getQSession());
return (null);
}));
}
start.countDown();
for(java.util.concurrent.Future<Object> future : futures)
{
future.get();
}
}
finally
{
pool.shutdownNow();
}
assertThat(all(AlertsSubscription.TABLE_NAME)).hasSize(1);
assertThat(all(AlertsUserPreference.TABLE_NAME)).hasSize(1);
}
/*******************************************************************************
** Another replica raced and left a second bootstrap subscription: the next
** first call keeps the oldest and leaves a person's own subscriptions alone.
*******************************************************************************/
@Test
void testDuplicateBootstrapSubscriptionsAreRemoved() throws Exception
{
for(String creator : List.of("alerts (bootstrap rule)", "alerts (bootstrap rule)", ADMIN))
{
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
.withValue("subscriberType", "USER")
.withValue("userId", ADMIN)
.withValue("scopeType", "ALL")
.withValue("minSeverity", "ERROR")
.withValue("events", "RAISED")
.withValue("deliveryMode", "IMMEDIATE")
.withValue("enabled", true)
.withValue("createdByUserId", creator)));
}
List<QRecord> before = all(AlertsSubscription.TABLE_NAME);
assertThat(before).hasSize(3);
AlertsPreferences.ensure(runtime, QContext.getQSession());
List<QRecord> after = all(AlertsSubscription.TABLE_NAME);
assertThat(after).hasSize(2);
assertThat(after).extracting(r -> r.getValueInteger("id")).containsExactlyInAnyOrder(before.get(0).getValueInteger("id"), before.get(2).getValueInteger("id"));
}
/*******************************************************************************
** No subscriptions: nobody is notified. The bootstrap rule gives admins an
** in-app subscription to ERROR and worse on first visit.
@@ -172,6 +172,232 @@ class NotificationEmailAndNoticesTest extends BaseAlertsTest
/*******************************************************************************
** A requested email does not require a standing category preference.
*******************************************************************************/
@Test
void testRequestedWithoutPreference() throws Exception
{
addressOnly();
requested("JOB");
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** An explicit request and an immediate preference still send only once.
*******************************************************************************/
@Test
void testRequestedWithImmediatePreference() throws Exception
{
emailSettings("viewer-1", "JOB", "IMMEDIATE", "INFO");
requested("JOB");
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** A request overrides digest timing (not the category's minimum severity).
*******************************************************************************/
@Test
void testRequestedWithDigestPreference() throws Exception
{
emailSettings("viewer-1", "JOB", "DAILY_DIGEST", "INFO");
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.sent()).isEqualTo(1);
assertThat(result.digests()).isZero();
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** Knowing an address alone does not opt a notification into email.
*******************************************************************************/
@Test
void testNotRequestedWithoutPreference() throws Exception
{
addressOnly();
runtime.getNotifications().notify(new NotificationSignal("Done").withCategory(NotificationCategory.JOB)
.withAudience(NotificationAudience.user("viewer-1")));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("NONE");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Missing addresses are skipped rather than counted as delivery failures.
*******************************************************************************/
@Test
void testRequestedWithoutAddress() throws Exception
{
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.skipped()).isEqualTo(1);
assertThat(result.failed()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Alert subscriptions own alert emails, even with an explicit request.
*******************************************************************************/
@Test
void testRequestedAlertNeverEmailed() throws Exception
{
emailSettings("viewer-1", "ALERT", "IMMEDIATE", "INFO");
requested("ALERT");
assertThat(new NotificationEmailer(runtime).run().skipped()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Requests older than the delivery window are not emailed.
*******************************************************************************/
@Test
void testRequestedOutsideDeliveryWindow() throws Exception
{
addressOnly();
requested("JOB");
clock.set(clock.instant().plusSeconds(86401));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** Provider failures remain terminal and do not cause repeated sends.
*******************************************************************************/
@Test
void testRequestedDeliveryFailure() throws Exception
{
addressOnly();
requested("JOB");
runtime.getConfig().withMessagingProviderName("missing-provider");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.failed()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("FAILED");
assertThat(new NotificationEmailer(runtime).run().failed()).isZero();
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** A request still honours the category's email minimum severity.
*******************************************************************************/
@Test
void testRequestedBelowMinSeverity() throws Exception
{
emailSettings("viewer-1", "JOB", "OFF", "ERROR");
requested("JOB");
NotificationEmailer.Result result = new NotificationEmailer(runtime).run();
assertThat(result.skipped()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SKIPPED");
assertThat(SENT).isEmpty();
}
/*******************************************************************************
** A user's pause or quiet hours hold a requested email until they end.
*******************************************************************************/
@Test
void testRequestedHeldByPauseAndQuietHours() throws Exception
{
clock.set(Instant.parse("2026-10-12T12:00:00Z"));
addressOnly();
requested("JOB");
setUserPreference(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(3600)));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
clock.set(Instant.parse("2026-10-12T13:30:00Z"));
setUserPreference(new QRecord().withValue("timeZone", "UTC").withValue("quietHoursStart", "13:00").withValue("quietHoursEnd", "14:00"));
assertThat(new NotificationEmailer(runtime).run().sent()).isZero();
assertThat(emailStatus("viewer-1")).isEqualTo("REQUESTED");
clock.set(Instant.parse("2026-10-12T14:00:00Z"));
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
assertThat(emailStatus("viewer-1")).isEqualTo("SENT");
assertThat(SENT).hasSize(1);
}
/*******************************************************************************
** A pause does not hold a requested SECURITY email.
*******************************************************************************/
@Test
void testRequestedSecurityBypassesPause() throws Exception
{
addressOnly();
requested("SECURITY");
setUserPreference(new QRecord().withValue("pausedUntil", clock.instant().plusSeconds(3600)));
assertThat(new NotificationEmailer(runtime).run().sent()).isEqualTo(1);
}
/*******************************************************************************
** Update the user's alert settings row.
*******************************************************************************/
private void setUserPreference(QRecord values) throws QException
{
runtime.asSystem(() ->
{
QRecord setting = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", "viewer-1"));
return (AlertsDb.update(AlertsUserPreference.TABLE_NAME, values.withValue("id", setting.getValue("id"))));
});
}
/*******************************************************************************
** Store an address without opting into any category.
*******************************************************************************/
private void addressOnly() throws QException
{
runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME,
new QRecord().withValue("userId", "viewer-1").withValue("emailAddress", "viewer-1@example.com")));
}
/*******************************************************************************
** Set up a recipient explicitly requesting email for worker tests.
*******************************************************************************/
private void requested(String category) throws QException
{
Integer id = runtime.getNotifications().notify(new NotificationSignal("Done").withCategory(NotificationCategory.valueOf(category))
.withAudience(NotificationAudience.user("viewer-1")));
runtime.asSystem(() ->
{
QRecord recipient = AlertsDb.first(AlertsNotificationRecipient.TABLE_NAME, AlertsDb.eq("notificationId", id));
return (AlertsDb.update(AlertsNotificationRecipient.TABLE_NAME,
new QRecord().withValue("id", recipient.getValue("id")).withValue("emailStatus", "REQUESTED")));
});
}
/*******************************************************************************
** The daily digest waits for the user's hour, then lists the unread ones.
*******************************************************************************/