Every Alerts table used, seeded or explained; setup card and help #36

Merged
KofTwentyTwo merged 4 commits from feature/tables-populated-defaults into develop 2026-10-14 22:31:48 +00:00
17 changed files with 1688 additions and 14 deletions

No files matched your search

+11
View File
@@ -7,6 +7,17 @@ All notable changes to this project are documented here. The format follows Keep
### Fixed
- 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
- 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.
- 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.
### Fixed
- A group's `ownerTeamId` was stored but never read. An alert that no subscription matches (WARNING and above) now goes to the owner team of its group, or of the nearest parent group that has one.
- A user's `defaultChannelIds` was stored but never read. It now adds the chosen external channels to the bell (unless In-App is off) for subscriptions that name none.
- Alert types, groups and default monitors also sync on the first bell request, so those tables are not empty on a running install.
### Changed
- Keep reminders and escalations immediate for digest subscribers while applying pause and quiet hours to their push channels. Preserve team audience resolution, mute scopes, both scheduled jobs and changesets 21-27.
- Apply user pause and local quiet hours to personal push channels (email, email lists and other non-IN_APP channels), including team members, with logged suppression reasons and a configurable quiet-hours severity bypass. The in-app bell remains unaffected, including digest delivery. Channel subscriptions are unaffected.
+14
View File
@@ -140,6 +140,20 @@ new AlertsQBitProducer()
4. **Optional: ESB and Quick Search.** `AlertsIntegrations` names the main tables and searchable fields for the host's ESB and Quick Search configuration.
## Starter defaults and setup
After the schema, include the starter defaults. Include order: the schema first, then the defaults. They are insert-only (a row is added only when its name is absent, so edits survive and nothing is ever updated), hold no secrets, and are guarded so a host without the tables skips them:
```yaml
- include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
```
They create the `in-app` and `email` channels and the `alerts` and `other` groups, exactly the rows the runtime would otherwise create on first use. Everything else starts empty for a reason the table's own help gives: alert types and default monitors are declared in code by the installed qbits and synced on the first visit; teams, escalation policies and subscriptions name your people; the rest is runtime data and user content.
Every table has help that says what it is for and **how its rows get created**, shown on the table screen and when it is empty (`help/qbit-alerts.yaml`). Alerts admins also get a **Setup** card first on the home that lists each configuration table, whether it is set up, and the next step.
Routing details worth knowing: an alert that no subscription matches goes to the owner team of its group (or the nearest parent group that has one), at WARNING and above; a person's **Default Channels** (My Alert Settings) replace the built-in email choice for subscriptions that name no channels (the bell stays unless turned off). Follow-ups go to the owner team only if it was told about the raise.
## Raising alerts
Use the producer SPI from `com.kingsrook.qqq.backend.core.alerts`, released in QQQ 4.1.0-RC.5 ([QRun-IO/qqq#1108](https://github.com/QRun-IO/qqq/pull/1108)). `AlertsQBitProducer` registers the qbit's sink through core's `AlertsInstanceMetaData`, so host producers need only core:
+16
View File
@@ -0,0 +1,16 @@
# Upgrading
## Unreleased: starter defaults, setup card, table help
1. **Add the defaults changelog** after `alerts.changelog.sql` in the host's master changelog:
```yaml
- include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
```
It is insert-only and guarded (row exists by name: no change; table missing: skipped), so it is safe on an installed host. It creates the `in-app` and `email` channels and the `alerts` and `other` groups only, the same rows the runtime creates on first use.
2. **Behavior changes to know about.**
- A group's **Owner Team** is now used: an alert at WARNING or above that no subscription covers (by scope and severity) is sent to that team's members; follow-ups (resolved, reminders) go only to members who were told about the raise. Hosts that never set an owner team see no change.
- **Default Channels** on My Alert Settings is now used for subscriptions that name no channels. The bell stays whenever In-App is not turned off; the list replaces the built-in email choice with the channels you chose (unknown ids are ignored). Hosts that never set it see no change.
- Alert types, groups and default monitors now also sync on the first request of the bell, not only on the home or the first signal.
3. Nothing else is required. The help bundle and the Setup card need no configuration.
+7
View File
@@ -105,6 +105,13 @@
<scope>test</scope>
</dependency>
<!-- AlertsDefaultsChangelogTest runs the shipped seed changelog through real Liquibase on H2. -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.33.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -24,6 +24,7 @@ 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;
@@ -173,6 +174,7 @@ public class AlertsQBitProducer implements QBitMetaDataProducer<AlertsQBitConfig
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(qInstance);
output.addSelfToInstance(qInstance);
AlertsHelp.apply(qInstance);
return (new MetaDataProducerMultiOutput());
}
@@ -55,6 +55,7 @@ import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -75,6 +76,8 @@ import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExten
*******************************************************************************/
public class AlertsApi
{
private static final QLogger LOG = QLogger.getLogger(AlertsApi.class);
static final int INBOX_ITEMS = 10;
static final int MAX_BANNERS = 3;
static final int SCAN_LIMIT = 1000;
@@ -102,7 +105,9 @@ public class AlertsApi
/*******************************************************************************
** Check alerts.view and set up the session (keys, settings).
** Check alerts.view and set up the session (keys, settings, and the first-visit
** sync of alert types, groups and default monitors, so those tables are never
** empty on a running install).
*******************************************************************************/
public String requireViewer() throws QException
{
@@ -113,6 +118,17 @@ public class AlertsApi
}
AlertsSecurity.ensureSessionKeys(runtime.getQInstance(), session);
AlertsPreferences.ensure(runtime, session);
try
{
runtime.getTypeSync().syncIfNeeded();
}
catch(Exception e)
{
////////////////////////////////////////////////////////////////////////
// best effort: the bell must work even if the sync fails; next call retries //
////////////////////////////////////////////////////////////////////////
LOG.warn("Alert type sync failed on the first visit; will retry on the next request", e);
}
return (AlertsSecurity.userId(session));
}
@@ -148,7 +148,7 @@ public class AlertRouter
try
{
deliver(alert, eventType, eventId, kind, destinations(AlertTarget.of(alert),
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), kind));
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), kind, alert.getValue("id")));
}
catch(Exception e)
{
@@ -164,7 +164,7 @@ public class AlertRouter
public int remind(QRecord alert) throws QException
{
return (deliver(alert, null, null, DeliveryKind.REMINDER, destinations(AlertTarget.of(alert),
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), DeliveryKind.REMINDER)));
AlertSeverity.fromLabel(alert.getValueString("severity"), AlertSeverity.WARNING), DeliveryKind.REMINDER, alert.getValue("id"))));
}
@@ -415,20 +415,32 @@ public class AlertRouter
** Every destination for an alert: subscribed users (directly or through a
** team) with their in-app and email channels (or the subscription's
** chosen channels), subscribed channels, and readable record followers.
** An alert that no subscription matches (at WARNING or above) goes to the
** members of its group's owner team, so an unowned problem is not silent.
*******************************************************************************/
List<Destination> destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) throws QException
List<Destination> destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind, Object alertId) throws QException
{
Map<Integer, List<String>> groupNames = new HashMap<>();
Map<String, Destination> destinations = new LinkedHashMap<>();
boolean subscribed = false;
for(QRecord subscription : AlertsDb.query(AlertsSubscription.TABLE_NAME, AlertsDb.eq("enabled", true)))
{
AlertSeverity min = AlertSeverity.fromLabel(subscription.getValueString("minSeverity"), AlertSeverity.WARNING);
if(!severity.isAtLeast(min) || !includesKind(subscription.getValueString("events"), kind)
if(!severity.isAtLeast(min)
|| !scopeMatches(subscription.getValueString("scopeType"), subscription.getValueString("scopeValue"), subscription.getValueString("scopeRecordId"), target, groupNames))
{
continue;
}
//////////////////////////////////////////////////////////////////////////////
// covered by scope and severity alone: a subscription that leaves out an //
// event kind (events=RAISED) still means someone owns this alert //
//////////////////////////////////////////////////////////////////////////////
subscribed = true;
if(!includesKind(subscription.getValueString("events"), kind))
{
continue;
}
Integer subscriptionId = subscription.getValueInteger("id");
String minName = subscription.getValueString("minSeverity");
String subscriberType = subscription.getValueString("subscriberType");
@@ -465,6 +477,10 @@ public class AlertRouter
}
}
}
if(!subscribed && severity.isAtLeast(AlertSeverity.WARNING))
{
addOwnerTeam(destinations, target, kind, alertId);
}
for(String recordKey : kind == DeliveryKind.REMINDER ? java.util.Set.<String>of() : target.recordKeys())
{
String[] parts = recordKey.split(":", 2);
@@ -488,6 +504,79 @@ public class AlertRouter
/*******************************************************************************
** The fallback for an alert nobody subscribed to: the members of the owner
** team of its group (or of the nearest parent group that has one), through
** their normal channels and the usual suppression checks.
*******************************************************************************/
private void addOwnerTeam(Map<String, Destination> destinations, AlertTarget target, DeliveryKind kind, Object alertId) throws QException
{
Integer teamId = ownerTeamId(target.groupId());
if(teamId == null)
{
return;
}
for(String userId : teamMembers(teamId))
{
if(!StringUtils.hasContent(userId))
{
continue;
}
if((kind == DeliveryKind.RESOLVED || kind == DeliveryKind.REMINDER) && !wasToldOfRaise(alertId, userId))
{
///////////////////////////////////////////////////////////////////////////
// a follow-up only goes to owners who were told about the alert itself //
///////////////////////////////////////////////////////////////////////////
continue;
}
QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
for(QRecord channel : userChannels(null, preference))
{
destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference, "IMMEDIATE"));
}
}
}
/*******************************************************************************
** Whether the user was sent (not suppressed) the RAISED notice for this alert.
*******************************************************************************/
private boolean wasToldOfRaise(Object alertId, String userId) throws QException
{
return (alertId != null && AlertsDb.count(AlertsDelivery.TABLE_NAME, AlertsDb.eq("alertId", (java.io.Serializable) alertId)
.withCriteria("recipientUserId", QCriteriaOperator.EQUALS, userId).withCriteria("kind", QCriteriaOperator.EQUALS, DeliveryKind.RAISED.name())
.withCriteria("status", QCriteriaOperator.NOT_EQUALS, DeliveryStatus.SUPPRESSED.name())) > 0);
}
/*******************************************************************************
** The owner team of a group, or of the nearest parent that has one (null:
** none).
*******************************************************************************/
Integer ownerTeamId(Integer groupId) throws QException
{
Integer current = groupId;
int depth = 0;
while(current != null && depth++ < 10)
{
QRecord group = AlertsDb.byId(AlertsGroup.TABLE_NAME, current);
if(group == null)
{
return (null);
}
if(group.getValueInteger("ownerTeamId") != null)
{
return (group.getValueInteger("ownerTeamId"));
}
current = group.getValueInteger("parentGroupId");
}
return (null);
}
/*******************************************************************************
** Explicit members plus the team's optional host-managed source. Missing
** or failed resolvers contribute nobody and leave an actionable log entry.
@@ -545,7 +634,9 @@ public class AlertRouter
/*******************************************************************************
** A user's channels for a subscription: the ones it names, else in-app
** (unless turned off) and email (when enabled in their settings).
** (unless turned off) plus the user's own default channels (My Alert
** Settings) in place of the built-in email, else in-app and email (when
** enabled in their settings).
*******************************************************************************/
private List<QRecord> userChannels(QRecord subscription, QRecord preference) throws QException
{
@@ -553,18 +644,32 @@ public class AlertRouter
String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds");
if(StringUtils.hasContent(overrides))
{
for(String id : overrides.split("[,\\s]+"))
return (channelsById(overrides));
}
boolean inApp = preference == null || !Boolean.FALSE.equals(preference.getValueBoolean("inAppEnabled"));
String defaults = preference == null ? null : preference.getValueString("defaultChannelIds");
if(StringUtils.hasContent(defaults))
{
List<QRecord> chosen = channelsById(defaults);
if(!chosen.isEmpty())
{
if(!id.isBlank())
/////////////////////////////////////////////////////////////////////////
// the bell stays (unless turned off); the list replaces external ones //
/////////////////////////////////////////////////////////////////////////
if(inApp)
{
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, Integer.valueOf(id.trim()));
if(channel != null)
channels.add(runtime.getChannels().defaultChannel(ChannelType.IN_APP));
}
for(QRecord channel : chosen)
{
if(!ChannelType.IN_APP.name().equals(channel.getValueString("channelType")))
{
channels.add(channel);
}
}
return (channels);
}
return (channels);
}
if(preference == null || !Boolean.FALSE.equals(preference.getValueBoolean("inAppEnabled")))
@@ -580,6 +685,37 @@ public class AlertRouter
/*******************************************************************************
** The channel rows for a list of ids ("3, 5"); ids that are not numbers or
** no longer exist are skipped.
*******************************************************************************/
private List<QRecord> channelsById(String ids) throws QException
{
List<QRecord> channels = new ArrayList<>();
for(String id : ids.split("[,\\s]+"))
{
if(id.isBlank())
{
continue;
}
try
{
QRecord channel = AlertsDb.byId(AlertsChannel.TABLE_NAME, Integer.valueOf(id.trim()));
if(channel != null)
{
channels.add(channel);
}
}
catch(NumberFormatException e)
{
LOG.debug("Ignoring a channel id that is not a number", logPair("id", id));
}
}
return (channels);
}
/*******************************************************************************
** Whether a subscription or mute scope covers the target.
*******************************************************************************/
@@ -80,6 +80,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
*******************************************************************************/
public final class AlertsHome
{
public static final String KIND_SETUP = "setup";
public static final String KIND_SEVERITY = "severity";
public static final String KIND_MINE = "mine";
public static final String KIND_ACTION = "needsAction";
@@ -121,6 +122,7 @@ public final class AlertsHome
Instant now = runtime.now();
return switch(kind == null ? "" : kind)
{
case KIND_SETUP -> setup(runtime, now);
case KIND_SEVERITY -> severity(runtime, now);
case KIND_MINE -> mine(now);
case KIND_ACTION -> needsAction(now);
@@ -136,6 +138,34 @@ public final class AlertsHome
/*******************************************************************************
** Setup (alerts admins): each configuration table, whether it is set up, and
** the next step, linked to its table.
*******************************************************************************/
static QWidgetData setup(AlertsRuntime runtime, Instant now) throws QException
{
List<AlertsSetupStatus.Item> items = AlertsSetupStatus.load(runtime, now);
long todo = items.stream().filter(item -> item.state() == AlertsSetupStatus.State.TODO).count();
StringBuilder html = new StringBuilder(HomeHtml.note(todo == 0 ? "Everything Alerts needs is set up." : HomeHtml.plural(todo, "item") + (todo == 1 ? " needs" : " need") + " attention."));
for(AlertsSetupStatus.Item item : items)
{
String badge = switch(item.state())
{
case OK -> HomeHtml.badge("Set up", HomeHtml.OK_COLOR);
case TODO -> HomeHtml.badge("Needs setup", HomeHtml.BAD_COLOR);
case DEFAULT -> HomeHtml.badge("Using defaults", HomeHtml.WARN_COLOR);
case OPTIONAL -> HomeHtml.badge("Optional", HomeHtml.OFF_COLOR);
};
String label = item.tableName() == null ? HomeHtml.escape(item.label()) : HomeHtml.link("/app/" + item.tableName(), item.label());
html.append("<div style=\"padding:8px 0;border-top:1px solid rgba(127,127,127,.2)\">")
.append(HomeHtml.card("<strong>" + label + "</strong> " + badge, HomeHtml.escape(item.detail()), item.nextStep() == null ? null : "Next: " + HomeHtml.escape(item.nextStep())))
.append("</div>");
}
return (new RawHTML(null, html.toString()));
}
/*******************************************************************************
** Open by severity: four tiles, each with the unacknowledged count and the
** oldest age, linking to the filtered list.
@@ -749,6 +779,6 @@ public final class AlertsHome
*******************************************************************************/
static Set<String> kinds()
{
return (Set.of(KIND_SEVERITY, KIND_MINE, KIND_ACTION, KIND_BY_GROUP, KIND_TREND, KIND_NOISY, KIND_DELIVERY, KIND_RESOLVED));
return (Set.of(KIND_SETUP, KIND_SEVERITY, KIND_MINE, KIND_ACTION, KIND_BY_GROUP, KIND_TREND, KIND_NOISY, KIND_DELIVERY, KIND_RESOLVED));
}
}
@@ -0,0 +1,179 @@
/*
* 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.home;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.alerts.AlertsRuntime;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsEscalationPolicy;
import com.kingsrook.qbits.alerts.model.AlertsEscalationStep;
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.AlertsPossibleValues.ChannelType;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.HealthStatus;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.data.QRecord;
/*******************************************************************************
** The Setup card on the Alerts home: each configuration table, whether it is
** set up, and the next step. Reads only the qbit's own small configuration
** tables, in the signed-in administrator's session.
*******************************************************************************/
public final class AlertsSetupStatus
{
/*******************************************************************************
** OK: working. TODO: needs attention. DEFAULT: working on built-in defaults.
** OPTIONAL: a feature that is off until set up.
*******************************************************************************/
public enum State
{
OK, TODO, DEFAULT, OPTIONAL
}
/*******************************************************************************
** One line of the card. tableName is null for an item with no table.
*******************************************************************************/
public record Item(String label, String tableName, State state, String detail, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AlertsSetupStatus()
{
}
/*******************************************************************************
** Build the items.
*******************************************************************************/
public static List<Item> load(AlertsRuntime runtime, Instant now) throws QException
{
List<Item> items = new ArrayList<>();
/////////////
// channels //
/////////////
List<QRecord> channels = AlertsDb.query(AlertsChannel.TABLE_NAME, new QQueryFilter());
long external = channels.stream().filter(c -> !List.of(ChannelType.IN_APP.name(), ChannelType.EMAIL.name()).contains(c.getValueString("channelType"))).count();
long unhealthy = channels.stream().filter(c -> HealthStatus.UNHEALTHY.name().equals(c.getValueString("healthStatus"))).count();
if(channels.isEmpty())
{
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.DEFAULT, "No channel rows yet: the bell and email channels are created when the first alert is routed.",
"Include alerts-defaults.changelog.sql to create them now, then add Slack, Teams or a webhook if you want them."));
}
else if(unhealthy > 0)
{
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.TODO, unhealthy + (unhealthy == 1 ? " channel is" : " channels are") + " failing.",
"Open it, check its settings and secret reference, then Send test notification."));
}
else
{
items.add(new Item("Channels", AlertsChannel.TABLE_NAME, State.OK, plural(channels.size(), "channel") + (external == 0 ? ": the bell and email only." : ", " + external + " of them outside the bell and email."),
external == 0 ? "Optional: add Slack, Teams, a webhook or PagerDuty." : null));
}
///////////////////////
// types and monitors //
///////////////////////
int types = AlertsDb.count(AlertsType.TABLE_NAME, AlertsDb.eq("installed", true));
items.add(types == 0
? new Item("Alert types", AlertsType.TABLE_NAME, State.TODO, "No installed alert type.", "Types are declared in code by the installed qbits (QAlertTypeMetaData); open the Alerts home once so they sync.")
: new Item("Alert types", AlertsType.TABLE_NAME, State.OK, plural(types, "type") + " declared by the installed qbits.", null));
int monitors = AlertsDb.count(AlertsMonitor.TABLE_NAME, AlertsDb.eq("enabled", true));
items.add(monitors == 0
? new Item("Monitors", AlertsMonitor.TABLE_NAME, State.OPTIONAL, "No monitor is turned on: alerts come only from code that raises them.", "On an alert type, choose Create monitor, or add one with New.")
: new Item("Monitors", AlertsMonitor.TABLE_NAME, State.OK, plural(monitors, "monitor") + " turned on.", null));
/////////////////////////////////
// groups, teams and owner teams //
/////////////////////////////////
List<QRecord> groups = AlertsDb.query(AlertsGroup.TABLE_NAME, new QQueryFilter());
long ownedGroups = groups.stream().filter(g -> g.getValueInteger("ownerTeamId") != null).count();
items.add(ownedGroups == 0
? new Item("Groups", AlertsGroup.TABLE_NAME, State.OPTIONAL, plural(groups.size(), "group") + ", none with an owner team.", "Give a group an Owner Team so alerts that nobody subscribed to still reach someone.")
: new Item("Groups", AlertsGroup.TABLE_NAME, State.OK, plural(groups.size(), "group") + ", " + ownedGroups + " with an owner team.", null));
int teams = AlertsDb.count(AlertsTeam.TABLE_NAME, new QQueryFilter());
items.add(teams == 0
? new Item("Teams", AlertsTeam.TABLE_NAME, State.OPTIONAL, "No team.", "Add a team and its members to subscribe people together or to own a group.")
: new Item("Teams", AlertsTeam.TABLE_NAME, State.OK, plural(teams, "team") + ".", null));
/////////////////////////////////////////////////////
// subscriptions: nobody hears anything without one //
/////////////////////////////////////////////////////
int subscriptions = AlertsDb.count(AlertsSubscription.TABLE_NAME, AlertsDb.eq("enabled", true));
if(subscriptions > 0)
{
items.add(new Item("Subscriptions", AlertsSubscription.TABLE_NAME, State.OK, plural(subscriptions, "subscription") + " turned on.", null));
}
else
{
items.add(new Item("Subscriptions", AlertsSubscription.TABLE_NAME, ownedGroups > 0 ? State.DEFAULT : State.TODO, "No subscription: " + (ownedGroups > 0 ? "alerts reach only the owner teams of their groups." : "alerts reach nobody."),
"Use Subscribe me on a type, group or monitor, or add one with New. An alerts admin gets an in-app subscription to errors on first visit."));
}
///////////////////////////////////////
// escalation, maintenance, scheduler //
///////////////////////////////////////
int policies = AlertsDb.count(AlertsEscalationPolicy.TABLE_NAME, AlertsDb.eq("enabled", true));
int steps = AlertsDb.count(AlertsEscalationStep.TABLE_NAME, new QQueryFilter());
items.add(policies == 0
? new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.OPTIONAL, "None: alerts are reminded, not escalated.", "Add a policy and its steps, then pick it on a type or group.")
: steps == 0
? new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.TODO, plural(policies, "policy") + ", but no steps, so nothing escalates.", "Add steps to the policy.")
: new Item("Escalation policies", AlertsEscalationPolicy.TABLE_NAME, State.OK, plural(policies, "policy") + ", " + plural(steps, "step") + ".", null));
int mutes = AlertsDb.count(AlertsMute.TABLE_NAME, AlertsDb.eq("enabled", true).withCriteria("endsAt", QCriteriaOperator.GREATER_THAN, now));
items.add(new Item("Mutes and maintenance", AlertsMute.TABLE_NAME, State.OPTIONAL, mutes == 0 ? "Nothing is muted." : plural(mutes, "mute") + " active or scheduled.",
mutes == 0 ? "Add a window before planned maintenance." : null));
items.add(runtime.getConfig().getSchedulerName() == null
? new Item("Scheduler", null, State.TODO, "No scheduler is configured, so monitors, delivery, digests and escalation only run when started by hand.", "Set schedulerName in the Alerts config.")
: new Item("Scheduler", null, State.OK, "Monitors, delivery, digests and escalation run on the " + runtime.getConfig().getSchedulerName() + " scheduler.", null));
return (items);
}
/*******************************************************************************
** "1 channel", "2 channels".
*******************************************************************************/
private static String plural(long count, String noun)
{
return (count + " " + noun + (count == 1 ? "" : "s"));
}
}
@@ -0,0 +1,127 @@
/*
* 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()));
}
}
@@ -33,11 +33,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaData
/*******************************************************************************
** The Alerts home dashboard (design section 6.2), in page order, all full
** width: open by severity, my alerts, needs action, by group, trend, noisy
** width: setup (admins), open by severity, my alerts, needs action, by group, trend, noisy
** types, delivery health (admins) and recently resolved.
*******************************************************************************/
public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, AlertsQBitConfig>
{
public static final String SETUP = "alertsHomeSetup";
public static final String SEVERITY = "alertsHomeOpenBySeverity";
public static final String MINE = "alertsHomeMyAlerts";
public static final String ACTION = "alertsHomeNeedsAction";
@@ -55,7 +56,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
*******************************************************************************/
public static List<String> homeWidgetNames()
{
return (List.of(SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED));
return (List.of(SETUP, SEVERITY, MINE, ACTION, BY_GROUP, TREND, NOISY, DELIVERY, NOTICES, RESOLVED));
}
@@ -70,6 +71,7 @@ public class AlertsHomeWidgetsMetaDataProducer extends QBitComponentMetaDataProd
String table = WidgetType.TABLE.getType();
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
output.add(widget(SETUP, "Setup", html, AlertsHome.KIND_SETUP, AlertsQBitConfig.PERMISSION_ADMIN));
output.add(widget(SEVERITY, "Open Alerts", html, AlertsHome.KIND_SEVERITY, AlertsQBitConfig.PERMISSION_VIEW));
output.add(widget(MINE, "My Alerts", table, AlertsHome.KIND_MINE, AlertsQBitConfig.PERMISSION_VIEW));
output.add(widget(ACTION, "Needs Action", table, AlertsHome.KIND_ACTION, AlertsQBitConfig.PERMISSION_VIEW));
@@ -0,0 +1,59 @@
-- liquibase formatted sql
-- qbit-alerts starter defaults: the two channels every install needs, and the
-- two groups the qbit itself files alerts under, so Delivery health and the
-- group lists are not empty before the first alert.
-- Include it from the host's master changelog AFTER alerts.changelog.sql:
--
-- - include: { file: db/liquibase/qbit-alerts/alerts-defaults.changelog.sql }
--
-- Rollback is empty on purpose: a name-based delete would also remove rows the host already had.
-- Rules for this file:
-- * insert-only: every row is inserted only when its natural key (the name) is
-- absent, so an administrator's edits always survive and nothing is updated;
-- * no secrets: the channels hold settings only, never a secret;
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
-- * every changeset has a precondition on its table, so a host that has not
-- created a table yet simply skips it (MARK_RAN).
-- The runtime creates the same rows when they are missing (AlertChannels.defaultChannel,
-- AlertTypeSync.ensureGroup); this file only makes them exist from the first deploy.
-- Alert types, monitors, escalation policies, teams and subscriptions are NOT seeded
-- here: types and default monitors are declared by the installed qbits and synced at
-- startup of the Alerts home, and the rest is the host's own people and policy.
-- AlertsDefaultsChangelogTest runs this file twice against a fresh schema.
-- changeset qbit-alerts:defaults-1
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_channel WHERE name = 'in-app'
-- comment: The in-app channel (the bell): every user has it, full detail, no rate limit.
INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date)
SELECT 'in-app', 'In-App (Bell)', 'IN_APP', TRUE, 'FULL', 'INFO', 0, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'in-app');
-- rollback empty
-- changeset qbit-alerts:defaults-2
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_channel WHERE name = 'email'
-- comment: The personal email channel: a user's own address (My Alert Settings), minimal detail, 60 an hour.
INSERT INTO alerts_channel (name, label, channel_type, enabled, detail_level, min_severity, rate_limit_per_hour, health_status, consecutive_failures, create_date, modify_date)
SELECT 'email', 'Email (Personal Address)', 'EMAIL', TRUE, 'MINIMAL', 'INFO', 60, 'HEALTHY', 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM alerts_channel WHERE name = 'email');
-- rollback empty
-- changeset qbit-alerts:defaults-3
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_group WHERE name = 'alerts'
-- comment: The group the qbit files its own alerts under (a monitor that cannot run, a failing channel, dropped signals).
INSERT INTO alerts_group (name, label, create_date, modify_date)
SELECT 'alerts', 'Alerts', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'alerts');
-- rollback empty
-- changeset qbit-alerts:defaults-4
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM alerts_group WHERE name = 'other'
-- comment: The catch-all group for alert types that name no group (and the unregistered type).
INSERT INTO alerts_group (name, label, create_date, modify_date)
SELECT 'other', 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM alerts_group WHERE name = 'other');
-- rollback empty
+108
View File
@@ -0,0 +1,108 @@
bundle: qbit-alerts
version: 1
layer: QBIT
# Help for the Alerts app and every alerts_* table, in the QQQ help-bundle format
# (qqq#1198). Each table entry says what the table is for and how its rows get
# created, so an empty table explains itself. AlertsHelp applies it until the core
# HelpBundles registry ships in a QQQ release (a stand-in until core #1198 ships).
# Summaries stay under 120 characters (AlertsTablesGuardrailTest).
entries:
- key: app:alerts;slot:help
summary: "Manage by exception: alerts, who hears about them, how they are delivered, and the checks that raise them."
content: |
The Setup card on the home (alerts admins) lists every part of Alerts with its status and the next step. A new installation starts with the in-app and email channels and two groups; alert types and their default monitors appear as soon as the first person opens Alerts, because the installed qbits declare them. Nobody is notified until someone subscribes (the first alerts admin gets an in-app subscription to errors and worse automatically) or a group has an owner team.
- key: table:alerts_alert;slot:help
summary: "One problem, open or closed, identified by its type and dedup key. Repeats update it; they never duplicate it."
content: |
**How rows get created:** never by hand. A producer (QAlerts.raise), a monitor that finds its condition true, a failed process, or an Alertmanager webhook raises the alert; the same condition updates the same row. People change its state with the actions Acknowledge, Snooze, Resolve, Reopen, Assign, Comment and Mute. Empty means nothing is wrong right now, or nothing is reporting yet: check Monitors and Alert Types.
- key: table:alerts_type;slot:help
summary: "A kind of problem a source can report (such as cdn.purge.dead): its severity, group, quiet period and escalation."
content: |
**How rows get created:** from the alert types that the installed qbits and the host declare in code (QAlertTypeMetaData). They are synced the first time someone opens Alerts after a start, and when the first alert of a type is raised. You tune a row here (turn it off, override its severity, move it to a group, set a reminder interval or escalation policy); you do not add types by hand. Empty means the first sync has not run yet: open the Alerts home.
- key: table:alerts_group;slot:help
summary: "A category of alerts (Backups, CDN), optionally nested: used to organize the home, subscriptions and owners."
content: |
**How rows get created:** the qbit seeds Alerts and Other, and every alert type that names a group creates it on first sync. Add your own to organize types, and nest one inside another. Set an Owner Team on a group: an alert in that group that no subscription matches goes to the owner team's members, so a problem with an owner is never silent. Set an escalation policy on a group to apply it to every type in it that has none.
- key: table:alerts_monitor;slot:help
summary: "A configured check that raises alerts of a type: a table query, a threshold, a heartbeat or a failed process."
content: |
**How rows get created:** qbits ship default monitors for their alert types; they are added on first sync and can be tuned or turned off, not deleted. To add your own, use Create monitor on an alert type, or New here. Run check now tests a monitor on demand. If a monitor cannot run three times in a row, an alert says so. Empty means no installed qbit ships a default monitor; alerts then come only from code that raises them.
- key: table:alerts_record_ref;slot:help
summary: "A record an alert is about, so the alert shows on that record and is hidden from people who cannot see it."
content: |
**How rows get created:** written by the engine when a signal names records (AlertRecordRef) or a table-query monitor finds rows. Never edited by hand. Empty means no alert has been about a specific record yet.
- key: table:alerts_event;slot:help
summary: "The alert timeline: raised, repeated, acknowledged, snoozed, resolved, reopened, notified, commented."
content: |
**How rows get created:** written by the engine and the record actions whenever an alert changes. Read-only history. Empty means no alert exists yet.
- key: table:alerts_subscription;slot:help
summary: "Who hears about what: a user, team or channel, scoped to everything, a group, type, monitor, table or record."
content: |
**How rows get created:** use Subscribe me on an alert type, group or monitor, or New here (pick who, the scope, the minimum severity, which events and how to deliver). The first alerts admin who opens Alerts gets one in-app subscription to errors and worse. Without a subscription an alert reaches nobody, unless its group has an owner team. A subscription can name channels; otherwise the person's default channels apply.
- key: table:alerts_user_preference;slot:help
summary: "Your alert settings: email address, quiet hours, digest hour, time zone, default channels and a vacation pause."
content: |
**How rows get created:** one row per person, created automatically with defaults the first time they open Alerts (in-app on, email on once an address is added). Edit yours here. Quiet hours and the pause hold email and other push channels, never the bell; an alert at or above your bypass severity (CRITICAL by default) still gets through. Default Channels, when set, replace the built-in email choice for subscriptions that name no channels; the bell stays unless you turn In-App off.
- key: table:alerts_team;slot:help
summary: "A named set of people who can be subscribed together, or own a group of alerts."
content: |
**How rows get created:** an administrator adds teams with New, then adds Team Members, or sets a member source (a role or IdP group, which needs the host's audience resolver). Subscribe a team to a scope, or make it the Owner Team of an alert group so unsubscribed alerts reach it. Empty is normal until you decide who answers for what; the Setup card says what is missing.
- key: table:alerts_team_member;slot:help
summary: "One person in a team, with an optional role. Teams combine these with any role or IdP-group source."
content: |
**How rows get created:** add members from a team's Members list, or New here. A team with no members and no member source notifies nobody. Empty means no team has members yet.
- key: table:alerts_channel;slot:help
summary: "A configured destination for alerts: the bell, email, an email list, Slack, Teams, a webhook, PagerDuty."
content: |
**How rows get created:** the qbit seeds In-App (the bell) and Email (a person's own address) so alerts can be delivered from the first alert. Add Slack, Teams, a webhook or PagerDuty with New: its settings go in the row and its secret stays in your secret store, referenced by name (Secret Ref), never typed here. Use Send test notification to check a channel; Health shows whether recent deliveries worked.
- key: table:alerts_mute;slot:help
summary: "A maintenance window or mute that silences notifications, or suppresses alerts, for a scope and a time."
content: |
**How rows get created:** use Mute on an alert, an alert type, a group or a monitor, or New here for a planned maintenance window (scope, start, end, effect). Silence notifications keeps recording alerts; suppress alerts stops raising them. Empty means nothing is muted, which is normal.
- key: table:alerts_delivery;slot:help
summary: "The delivery log: one attempt per person and channel, with status, retries and, when held back, the reason."
content: |
**How rows get created:** written by the router when an alert is sent, and updated by the delivery worker (retries, failures). Suppressed deliveries are logged too, with why (muted, quiet hours, not visible, no address). Retry delivery re-queues a failed one. Empty means no alert has been routed to anyone yet: check Subscriptions.
- key: table:alerts_notification;slot:help
summary: "A message for people: an announcement an admin writes, or one a producer sends (failed job, share, release)."
content: |
**How rows get created:** an administrator writes one with New, previews the audience, and sends or schedules it; producers create them with QAlerts.notify (failed jobs, shares, security notices, releases). Each reaches people through the bell and, by their settings, email or Slack. Empty means nothing has been announced and no producer has sent one.
- key: table:alerts_notification_recipient;slot:help
summary: "One notification for one person: when it reached them and whether they read, dismissed or were emailed it."
content: |
**How rows get created:** written when a notification is sent, one per person it reaches. Read-only; people change read and dismissed from the bell. Empty means no notification has been sent.
- key: table:alerts_notification_preference;slot:help
summary: "Your settings for one notification category: mute it in-app, and email or Slack off, immediate or daily digest."
content: |
**How rows get created:** each person adds a row per category (job, share, security, release...) from My Notification Settings when they want to change the default. Without a row the defaults apply: in-app on, email and Slack off. Security notices cannot be muted in-app. Empty means everyone is using the defaults.
- key: table:alerts_heartbeat;slot:help
summary: "The last time each dead-man's switch beat, watched by heartbeat monitors."
content: |
**How rows get created:** written when code calls QAlerts.heartbeat(key) or a watched process completes. A Heartbeat monitor raises an alert when a key stops beating. Empty means nothing has beaten yet, which is fine until you add a heartbeat monitor.
- key: table:alerts_escalation_policy;slot:help
summary: "A reusable escalation plan: how long to wait, who to tell next, and how often to repeat."
content: |
**How rows get created:** an administrator adds a policy with New, adds ordered Escalation Steps, then picks it on an alert type or group (a type's policy overrides its group's). Policies name real people, teams and channels, so the qbit cannot seed them. Empty means alerts are not escalated, only reminded.
- key: table:alerts_escalation_step;slot:help
summary: "One step of an escalation policy: after how many minutes, tell this user, team or channel."
content: |
**How rows get created:** add steps from a policy's Steps list. Each has an order, a delay in minutes and one target (a user, a team or a channel). Acknowledging, snoozing or resolving an alert stops the steps. Empty means no policy has steps yet.
@@ -0,0 +1,352 @@
/*
* 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;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The shipped starter defaults (db/liquibase/qbit-alerts/alerts-defaults.
** changelog.sql), run through real Liquibase on H2 (PostgreSQL mode) against
** the qbit's test schema: the rows appear, a second update runs nothing, an
** edited row survives, a host without the tables marks the changesets ran,
** and the seeded rows are the ones the runtime would create itself.
*******************************************************************************/
class AlertsDefaultsChangelogTest extends BaseAlertsTest
{
static final String CHANGELOG = "db/liquibase/qbit-alerts/alerts-defaults.changelog.sql";
/** the tables the changelog seeds. */
static final Set<String> SEEDED_TABLES = Set.of("alerts_channel", "alerts_group");
/*******************************************************************************
** Fresh schema, update twice: same rows; an edit survives.
*******************************************************************************/
@Test
void seedsAFreshSchemaInsertOnly() throws Exception
{
try(Connection connection = freshDatabase(true))
{
update(connection);
assertThat(names(connection, "alerts_channel")).containsExactlyInAnyOrder("in-app", "email");
assertThat(names(connection, "alerts_group")).containsExactlyInAnyOrder("alerts", "other");
try(Statement statement = connection.createStatement())
{
statement.execute("UPDATE alerts_channel SET label = 'Bell', enabled = FALSE WHERE name = 'in-app'");
statement.execute("DELETE FROM alerts_group WHERE name = 'other'");
}
connection.commit();
update(connection);
assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'in-app'")).isEqualTo("Bell");
assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel")).isEqualTo("2");
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'EXECUTED'")).isEqualTo("4");
}
}
/*******************************************************************************
** A host that already has a row by that name (its own settings) keeps it
** untouched, and no second one is added.
*******************************************************************************/
@Test
void keepsAHostsExistingRows() throws Exception
{
try(Connection connection = freshDatabase(true); Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO alerts_channel (name, label, channel_type, enabled, rate_limit_per_hour) VALUES ('email', 'Ops mail', 'EMAIL', FALSE, 5)");
update(connection);
assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'email'")).isEqualTo("Ops mail");
assertThat(scalar(connection, "SELECT rate_limit_per_hour FROM alerts_channel WHERE name = 'email'")).isEqualTo("5");
assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel WHERE name = 'email'")).isEqualTo("1");
}
}
/*******************************************************************************
** Rolling the seeds back (Liquibase also rolls back changesets it only marked
** as ran) never deletes rows the host already had.
*******************************************************************************/
@Test
void rollbackKeepsHostOwnedRows() throws Exception
{
try(Connection connection = freshDatabase(true))
{
try(Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO alerts_channel (name, label, channel_type) VALUES ('in-app', 'Mine', 'IN_APP')");
statement.execute("INSERT INTO alerts_group (name, label) VALUES ('other', 'Mine')");
}
connection.commit();
update(connection);
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).rollback(4, "");
connection.commit();
assertThat(scalar(connection, "SELECT label FROM alerts_channel WHERE name = 'in-app'")).isEqualTo("Mine");
assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'other'")).isEqualTo("Mine");
assertThat(scalar(connection, "SELECT COUNT(*) FROM alerts_channel")).isEqualTo("2");
}
}
/*******************************************************************************
** A host that has not created the tables yet skips every changeset (marked
** ran), rather than failing its deploy.
*******************************************************************************/
@Test
void aMissingTableMarksTheChangesetsRan() throws Exception
{
try(Connection connection = freshDatabase(false))
{
update(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM databasechangelog WHERE exectype = 'MARK_RAN'")).isEqualTo("4");
}
}
/*******************************************************************************
** Every changeset guards itself and uses only precondition forms that parse
** in Liquibase 4 and 5.
*******************************************************************************/
@Test
void everyChangesetIsGuarded() throws Exception
{
String text = changelogText();
assertThat(text).doesNotContain("precondition-table-exists");
String[] changesets = text.split("(?m)^(?=-- changeset )");
List<String> parts = new ArrayList<>();
for(String part : changesets)
{
if(part.startsWith("-- changeset "))
{
parts.add(part);
}
}
assertThat(parts).hasSize(4);
for(String part : parts)
{
assertThat(part).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("precondition-sql-check expectedResult:0").contains("-- rollback ").contains("NOT EXISTS");
}
for(String table : SEEDED_TABLES)
{
assertThat(text).contains("INSERT INTO " + table + " ");
}
}
/*******************************************************************************
** The seeded rows equal what the runtime creates by itself when they are
** missing, so a deploy never changes behavior.
*******************************************************************************/
@Test
void seededRowsEqualTheRuntimeDefaults() throws Exception
{
QRecordPair in = runtimeChannel(ChannelType.IN_APP);
QRecordPair email = runtimeChannel(ChannelType.EMAIL);
Integer alertsGroup = runtime.asSystem(() -> runtime.getTypeSync().ensureGroup("alerts"));
Integer otherGroup = runtime.asSystem(() -> runtime.getTypeSync().ensureGroup(com.kingsrook.qbits.alerts.engine.AlertTypeSync.DEFAULT_GROUP));
try(Connection connection = freshDatabase(true))
{
update(connection);
for(QRecordPair channel : List.of(in, email))
{
for(Map.Entry<String, String> column : channel.columns.entrySet())
{
assertThat(scalar(connection, "SELECT " + column.getKey() + " FROM alerts_channel WHERE name = '" + channel.name + "'")).as(channel.name + " " + column.getKey()).isEqualTo(column.getValue());
}
}
assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'alerts'")).isEqualTo(groupLabel(alertsGroup));
assertThat(scalar(connection, "SELECT label FROM alerts_group WHERE name = 'other'")).isEqualTo(groupLabel(otherGroup));
}
}
/*******************************************************************************
** The channel the runtime makes when missing, as comparable column values.
*******************************************************************************/
private QRecordPair runtimeChannel(ChannelType type) throws Exception
{
return (runtime.asSystem(() ->
{
var row = runtime.getChannels().defaultChannel(type);
QRecordPair pair = new QRecordPair(row.getValueString("name"));
pair.columns.put("label", row.getValueString("label"));
pair.columns.put("channel_type", row.getValueString("channelType"));
pair.columns.put("detail_level", row.getValueString("detailLevel"));
pair.columns.put("min_severity", row.getValueString("minSeverity"));
pair.columns.put("rate_limit_per_hour", String.valueOf(row.getValueInteger("rateLimitPerHour")));
pair.columns.put("health_status", row.getValueString("healthStatus"));
return (pair);
}));
}
/*******************************************************************************
** The label of a group the runtime created.
*******************************************************************************/
private String groupLabel(Integer id) throws Exception
{
return (runtime.asSystem(() -> com.kingsrook.qbits.alerts.engine.AlertsDb.byId("alerts_group", id).getValueString("label")));
}
/*******************************************************************************
** A name and its expected column values.
*******************************************************************************/
private static final class QRecordPair
{
private final String name;
private final Map<String, String> columns = new java.util.LinkedHashMap<>();
/*******************************************************************************
**
*******************************************************************************/
private QRecordPair(String name)
{
this.name = name;
}
}
/*******************************************************************************
** A fresh in-memory H2 (PostgreSQL mode), with or without the qbit's tables.
*******************************************************************************/
static Connection freshDatabase(boolean withTables) throws Exception
{
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
if(withTables)
{
String script;
try(InputStream in = AlertsDefaultsChangelogTest.class.getClassLoader().getResourceAsStream("alerts-h2.sql"))
{
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
try(Statement statement = connection.createStatement())
{
StringBuilder current = new StringBuilder();
for(String line : script.split("\n"))
{
if(line.trim().startsWith("--"))
{
continue;
}
current.append(line).append('\n');
if(line.trim().endsWith(";"))
{
statement.execute(current.toString().trim().replaceAll(";$", ""));
current.setLength(0);
}
}
}
}
return (connection);
}
/*******************************************************************************
** Run the changelog with real Liquibase.
*******************************************************************************/
static void update(Connection connection) throws Exception
{
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
//////////////////////////////////////////////////////////////////
// not closed: closing Liquibase would close the test connection //
//////////////////////////////////////////////////////////////////
new Liquibase(CHANGELOG, new ClassLoaderResourceAccessor(), database).update("");
connection.commit();
}
/*******************************************************************************
**
*******************************************************************************/
static String changelogText() throws Exception
{
try(InputStream stream = AlertsDefaultsChangelogTest.class.getClassLoader().getResourceAsStream(CHANGELOG))
{
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
}
}
/*******************************************************************************
**
*******************************************************************************/
private static List<String> names(Connection connection, String table) throws Exception
{
List<String> names = new ArrayList<>();
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery("SELECT name FROM " + table))
{
while(rs.next())
{
names.add(rs.getString(1));
}
}
return (names);
}
/*******************************************************************************
**
*******************************************************************************/
private static String scalar(Connection connection, String sql) throws Exception
{
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
{
rs.next();
return (rs.getString(1));
}
}
}
@@ -0,0 +1,204 @@
/*
* 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;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
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.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Guardrail (owner requirement 2026-10-14: "all tables should be populated
** and the qbit should be fully using them"). For every alerts_* table:
** - runtime code (not just its model, metadata, config or home screen) reads
** or writes it, so no table exists only on paper;
** - it is either seeded by the shipped defaults changelog, or listed here
** with why it starts empty;
** - it has bundle help that says what it is for and how its rows get created.
** A new table fails this test until it is wired, seeded or explained.
*******************************************************************************/
class AlertsTablesGuardrailTest extends BaseAlertsTest
{
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "alerts");
/*******************************************************************************
** Tables that legitimately start empty, and where their rows come from.
*******************************************************************************/
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
Map.entry("alerts_type", "synced from the alert types the installed qbits declare in code, on the first visit (AlertTypeSync)"),
Map.entry("alerts_monitor", "default monitors are shipped by the declaring qbits and seeded by AlertTypeSync; admins add the rest"),
Map.entry("alerts_alert", "runtime: a producer or monitor raises an alert"),
Map.entry("alerts_record_ref", "runtime: written with an alert that names records"),
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_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"),
Map.entry("alerts_notification", "user content and producer messages"),
Map.entry("alerts_notification_recipient", "runtime: one per person a notification reaches"),
Map.entry("alerts_notification_preference", "user content: a person's per-category choice; defaults apply without a row"),
Map.entry("alerts_heartbeat", "runtime: written by QAlerts.heartbeat and completed processes"),
Map.entry("alerts_escalation_policy", "host-specific: names real people, teams and channels"),
Map.entry("alerts_escalation_step", "host-specific: names real people, teams and channels"));
/*******************************************************************************
** Every table is used by runtime code.
*******************************************************************************/
@Test
void everyTableIsUsedAtRuntime() throws Exception
{
Map<String, String> entityByTable = entityByTable();
List<String> runtimeSource;
try(Stream<Path> files = Files.walk(MAIN))
{
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("home")) && !p.endsWith("AlertsQBitConfig.java"))
.map(AlertsTablesGuardrailTest::read).toList();
}
for(String table : alertsTables())
{
String entity = entityByTable.get(table);
assertThat(entity).as("entity class for " + table).isNotNull();
Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
}
}
/*******************************************************************************
** Every table is seeded or explained.
*******************************************************************************/
@Test
void everyTableIsSeededOrExplained() throws Exception
{
String changelog = AlertsDefaultsChangelogTest.changelogText();
for(String table : alertsTables())
{
boolean seeded = AlertsDefaultsChangelogTest.SEEDED_TABLES.contains(table);
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
}
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance.getTable(table) != null, "STARTS_EMPTY names only real tables");
}
/*******************************************************************************
** Every table and the app have bundle help that says how rows get created.
*******************************************************************************/
@Test
void everyTableHasEmptyStateHelp() throws Exception
{
assertThat(AlertsHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
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(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.
*******************************************************************************/
@Test
void hostHelpIsNotOverridden() throws Exception
{
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()));
}
/*******************************************************************************
**
*******************************************************************************/
private List<String> alertsTables()
{
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("alerts_")).sorted().toList();
assertThat(tables).hasSizeGreaterThan(15);
return (tables);
}
/*******************************************************************************
** model/X.java declares TABLE_NAME = "x".
*******************************************************************************/
private static Map<String, String> entityByTable() throws Exception
{
Map<String, String> rs = new HashMap<>();
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
try(Stream<Path> files = Files.list(MAIN.resolve("model")))
{
for(Path file : files.toList())
{
Matcher m = p.matcher(read(file));
if(m.find())
{
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
}
}
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private static String read(Path path)
{
try
{
return (Files.readString(path, StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
@@ -0,0 +1,306 @@
/*
* 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.engine;
import java.util.List;
import com.kingsrook.qbits.alerts.AlertsQBitConfig;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.TestSteps;
import com.kingsrook.qbits.alerts.delivery.DeliveryResult;
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.AlertsPossibleValues.Resolution;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Two settings that used to be stored but never read, now enforced by the
** router: a group's owner team (the fallback audience for an alert nobody
** subscribed to) and a user's default channels.
*******************************************************************************/
class AlertsWiringTest extends BaseAlertsTest
{
static final String TYPE = "test.system.down";
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeConfig(AlertsQBitConfig config)
{
config.withChannelType(new QCodeReference(TestSteps.RecordingChannel.class));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void customizeInstance(QInstance qInstance)
{
AlertsInstanceMetaData.ofOrWithNew(qInstance)
.withAlertType(new QAlertTypeMetaData().withName(TYPE).withLabel("System down").withGroupName("infrastructure").withDefaultSeverity(AlertSeverity.CRITICAL));
}
/*******************************************************************************
**
*******************************************************************************/
@BeforeEach
void reset()
{
TestSteps.SENT.clear();
TestSteps.nextOutcome = DeliveryResult.Outcome.SENT;
}
/*******************************************************************************
** A team with these members; returns its id.
*******************************************************************************/
private Integer team(String name, String... members) throws QException
{
Integer teamId = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", name).withValue("label", name)).getValueInteger("id"));
for(String member : members)
{
runtime.asSystem(() -> AlertsDb.insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", teamId).withValue("userId", member)));
}
return (teamId);
}
/*******************************************************************************
** The group row for the test type (synced on first use).
*******************************************************************************/
private QRecord group(String name) throws QException
{
runtime.getTypeSync().syncIfNeeded();
return (runtime.asSystem(() -> AlertsDb.first(AlertsGroup.TABLE_NAME, AlertsDb.eq("name", name))));
}
/*******************************************************************************
** The users who were sent an alert, sorted.
*******************************************************************************/
private List<String> recipients() throws QException
{
return (all(AlertsDelivery.TABLE_NAME).stream().map(n -> n.getValueString("recipientUserId")).distinct().sorted().toList());
}
/*******************************************************************************
** A user subscription to everything at WARNING and worse.
*******************************************************************************/
private void subscribe(String userId) throws QException
{
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", userId)
.withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("events", "RAISED").withValue("deliveryMode", "IMMEDIATE").withValue("enabled", true)));
}
/*******************************************************************************
** An alert nobody subscribed to goes to its group's owner team; once a
** subscription matches, the owner team is not added.
*******************************************************************************/
@Test
void ownerTeamIsTheFallbackAudience() throws Exception
{
Integer teamId = team("infra", "owner-1", "owner-2");
QRecord group = group("infrastructure");
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId)));
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("a").withTitle("Down"));
assertThat(recipients()).containsExactly("owner-1", "owner-2");
assertThat(all(AlertsDelivery.TABLE_NAME)).allMatch(n -> "SENT".equals(n.getValueString("status")));
List<java.io.Serializable> before = all(AlertsDelivery.TABLE_NAME).stream().map(n -> n.getValue("id")).toList();
subscribe("subscriber");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("b").withTitle("Down again"));
List<QRecord> second = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !before.contains(n.getValue("id"))).toList();
assertThat(second).extracting(n -> n.getValueString("recipientUserId")).containsExactly("subscriber");
}
/*******************************************************************************
** The nearest parent group with an owner team is used; a group with no
** owner anywhere leaves the alert unrouted, as before.
*******************************************************************************/
@Test
void ownerTeamComesFromTheNearestParentGroup() throws Exception
{
QRecord group = group("infrastructure");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("none").withTitle("Down"));
assertThat(recipients()).as("no owner team anywhere: nobody is notified").isEmpty();
Integer teamId = team("platform", "parent-owner");
Integer parent = runtime.asSystem(() -> AlertsDb.insert(AlertsGroup.TABLE_NAME, new QRecord().withValue("name", "platform").withValue("label", "Platform")
.withValue("ownerTeamId", teamId)).getValueInteger("id"));
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("parentGroupId", parent)));
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("parent").withTitle("Down"));
assertThat(recipients()).containsExactly("parent-owner");
}
/*******************************************************************************
** The owner fallback is for alerts that matter: an INFO alert is not sent
** to the owner team.
*******************************************************************************/
@Test
void ownerTeamIsNotToldAboutInfo() throws Exception
{
Integer teamId = team("infra", "owner-1");
QRecord group = group("infrastructure");
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId)));
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("info").withSeverity(AlertSeverity.INFO).withTitle("FYI"));
assertThat(recipients()).isEmpty();
}
/*******************************************************************************
** A user's default channels replace the built-in email choice, but the bell
** stays unless the user turned it off.
*******************************************************************************/
@Test
void defaultChannelsDecideWhereAUserIsReached() throws Exception
{
QRecord chat = runtime.asSystem(() -> AlertsDb.insert(AlertsChannel.TABLE_NAME, new QRecord().withValue("name", "chat").withValue("label", "Chat")
.withValue("channelType", "SLACK").withValue("enabled", true).withValue("detailLevel", "STANDARD").withValue("rateLimitPerHour", 60)
.withValue("healthStatus", "HEALTHY").withValue("consecutiveFailures", 0)));
String chatId = String.valueOf(chat.getValue("id"));
subscribe("u1");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("plain").withTitle("Down"));
List<QRecord> plain = all(AlertsDelivery.TABLE_NAME);
assertThat(plain).singleElement().matches(n -> !chatId.equals(String.valueOf(n.getValue("channelId"))), "went to the built-in in-app channel");
QRecord preference = runtime.asSystem(() -> AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "u1").withValue("userLabel", "One")
.withValue("inAppEnabled", true).withValue("emailEnabled", false).withValue("defaultChannelIds", "not-a-number, " + chatId + ", 99999")));
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("chosen").withTitle("Down again"));
List<QRecord> chosen = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !n.getValue("id").equals(plain.get(0).getValue("id"))).toList();
assertThat(chosen).hasSize(2);
assertThat(chosen).anyMatch(n -> chatId.equals(String.valueOf(n.getValue("channelId"))));
assertThat(chosen).anyMatch(n -> !chatId.equals(String.valueOf(n.getValue("channelId"))));
runtime.asSystem(() -> AlertsDb.update(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("id", preference.getValue("id")).withValue("inAppEnabled", false)));
List<Object> before = all(AlertsDelivery.TABLE_NAME).stream().map(n -> (Object) n.getValue("id")).toList();
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("no-bell").withTitle("Down a third time"));
List<QRecord> third = all(AlertsDelivery.TABLE_NAME).stream().filter(n -> !before.contains(n.getValue("id"))).toList();
assertThat(third).singleElement().matches(n -> chatId.equals(String.valueOf(n.getValue("channelId"))), "only the chosen channel when the bell is off");
}
/*******************************************************************************
** A subscription that covers the alert by scope and severity, but only for
** some events, still counts as ownership: RESOLVED does not fall through to
** the owner team.
*******************************************************************************/
@Test
void aSubscriptionWithFewerEventsStillCoversTheAlert() throws Exception
{
Integer teamId = team("infra", "owner-1");
QRecord group = group("infrastructure");
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId)));
subscribe("subscriber");
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("a").withTitle("Down"));
runtime.getEngine().resolve(TYPE, "a", Resolution.PRODUCER, null, "fixed");
assertThat(recipients()).containsExactly("subscriber");
}
/*******************************************************************************
** Follow-ups (resolved, reminders) go to the owner team only when it was
** told about the raise.
*******************************************************************************/
@Test
void ownerTeamGetsFollowUpsOnlyAfterTheRaise() throws Exception
{
Integer teamId = team("infra", "owner-1");
QRecord group = group("infrastructure");
/////////////////////////////////////////////////////////////////
// raised with no owner team: nobody is told, so nobody is told //
// about the resolution even though a team is named afterwards //
/////////////////////////////////////////////////////////////////
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("early").withTitle("Down"));
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", teamId)));
runtime.getEngine().resolve(TYPE, "early", Resolution.PRODUCER, null, "fixed");
assertThat(recipients()).isEmpty();
runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("late").withTitle("Down"));
assertThat(recipients()).containsExactly("owner-1");
runtime.getEngine().resolve(TYPE, "late", Resolution.PRODUCER, null, "fixed");
assertThat(all(AlertsDelivery.TABLE_NAME).stream().filter(n -> "RESOLVED".equals(n.getValueString("kind")))).hasSize(1);
}
/*******************************************************************************
** A failing type sync (a unique-key race between replicas, say) never breaks
** the bell; the next request retries.
*******************************************************************************/
@Test
void aFailingSyncDoesNotBreakTheBell() throws Exception
{
try(java.sql.Connection connection = com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.getConnection((com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) qInstance.getBackend(BACKEND_NAME));
java.sql.Statement statement = connection.createStatement())
{
statement.execute("ALTER TABLE alerts_type RENAME TO alerts_type_gone");
try
{
assertThat(new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).requireViewer()).isEqualTo(ADMIN);
}
finally
{
statement.execute("ALTER TABLE alerts_type_gone RENAME TO alerts_type");
}
}
assertThat(new com.kingsrook.qbits.alerts.api.AlertsApi(runtime).requireViewer()).isEqualTo(ADMIN);
assertThat(all("alerts_type")).isNotEmpty();
}
}
@@ -0,0 +1,105 @@
/*
* 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.home;
import java.util.List;
import com.kingsrook.qbits.alerts.BaseAlertsTest;
import com.kingsrook.qbits.alerts.engine.AlertsDb;
import com.kingsrook.qbits.alerts.home.AlertsSetupStatus.Item;
import com.kingsrook.qbits.alerts.home.AlertsSetupStatus.State;
import com.kingsrook.qbits.alerts.model.AlertsChannel;
import com.kingsrook.qbits.alerts.model.AlertsGroup;
import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeam;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The Setup card: states follow the real configuration, and the home widget
** renders for admins only.
*******************************************************************************/
class AlertsSetupTest extends BaseAlertsTest
{
/*******************************************************************************
**
*******************************************************************************/
private Item item(String label) throws Exception
{
return (AlertsSetupStatus.load(runtime, clock.instant()).stream().filter(i -> i.label().equals(label)).findFirst().orElseThrow());
}
/*******************************************************************************
** A fresh install: defaults channels, nobody subscribed, no scheduler.
*******************************************************************************/
@Test
void freshInstallNeedsSubscriptionsAndAScheduler() throws Exception
{
runtime.getTypeSync().syncIfNeeded();
assertThat(item("Channels").state()).isEqualTo(State.DEFAULT);
assertThat(item("Alert types").state()).isEqualTo(State.OK);
assertThat(item("Subscriptions").state()).isEqualTo(State.TODO);
assertThat(item("Scheduler").state()).isEqualTo(State.TODO);
assertThat(item("Teams").state()).isEqualTo(State.OPTIONAL);
assertThat(item("Groups").state()).isEqualTo(State.OPTIONAL);
assertThat(AlertsSetupStatus.load(runtime, clock.instant())).allMatch(i -> i.tableName() == null || i.tableName().startsWith("alerts_"));
}
/*******************************************************************************
** Configuring a part moves its line.
*******************************************************************************/
@Test
void configuringMovesTheState() throws Exception
{
runtime.getTypeSync().syncIfNeeded();
runtime.getChannels().ensureDefaults();
assertThat(item("Channels").state()).isEqualTo(State.OK);
runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER").withValue("userId", "u")
.withValue("scopeType", "ALL").withValue("minSeverity", "ERROR").withValue("enabled", true)));
assertThat(item("Subscriptions").state()).isEqualTo(State.OK);
Integer team = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", "t").withValue("label", "T")).getValueInteger("id"));
QRecord group = runtime.asSystem(() -> AlertsDb.first(AlertsGroup.TABLE_NAME, new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter()));
runtime.asSystem(() -> AlertsDb.update(AlertsGroup.TABLE_NAME, new QRecord().withValue("id", group.getValue("id")).withValue("ownerTeamId", team)));
assertThat(item("Groups").state()).isEqualTo(State.OK);
assertThat(item("Teams").state()).isEqualTo(State.OK);
runtime.asSystem(() -> AlertsDb.update(AlertsChannel.TABLE_NAME, new QRecord().withValue("id", 1).withValue("healthStatus", "UNHEALTHY")));
assertThat(item("Channels").state()).isEqualTo(State.TODO);
}
/*******************************************************************************
** The widget renders the card for an admin, with links to the tables.
*******************************************************************************/
@Test
void widgetRendersForAdmins() throws Exception
{
runtime.getTypeSync().syncIfNeeded();
String html = ((com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML) AlertsHome.load(AlertsHome.KIND_SETUP, runtime)).getHtml();
assertThat(html).contains("Needs setup").contains("href=\"/app/alerts_subscription\"").contains("Next: ").contains("attention");
}
}