From ea5f67933a61775dc704c60cfe7a3032abc63858 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 10:46:38 -0500 Subject: [PATCH] feat(routing): resolve ROLE and IdP Group teams; per-alert mutes Team member sources ROLE and IDP_GROUP were stored but never resolved, so only manual members were notified. A host can now register an AlertAudienceResolver (AlertsQBitConfig.withAudienceResolver); team expansion merges its users with manual members, de-duplicated, bounded by maxAudienceSize, and every user still passes the visibility check. Without a resolver those sources resolve to nobody, the router logs it and the team screen says so. The per-alert Mute action muted the whole alert type, and RECORD-scope mutes never matched because no record id reached the matcher. Mute now mutes only that alert (new ALERT scope on type + dedup key), RECORD mutes compare the stored scopeRecordId, and "Mute this alert type" is a separate, clearly labelled action. Changesets 21-22 add alerts_mute.scope_record_id and widen scope_value. Refs #27 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 34 +++++ .../qbits/alerts/AlertAudienceResolver.java | 37 ++++++ .../qbits/alerts/AlertsQBitConfig.java | 23 ++++ .../qbits/alerts/engine/AlertRouter.java | 74 ++++++++++- .../qbits/alerts/engine/AlertsEngine.java | 10 +- .../qbits/alerts/engine/MonitorEvaluator.java | 2 +- .../metadata/AlertsTableMetaDataHelper.java | 11 +- .../qbits/alerts/model/AlertsMute.java | 36 +++++- .../alerts/model/AlertsPossibleValues.java | 2 +- .../qbits/alerts/processes/AlertsAction.java | 5 +- .../alerts/processes/AlertsActionSteps.java | 34 ++++- .../qbit-alerts/alerts.changelog.sql | 12 ++ .../qbits/alerts/AlertsQBitProducerTest.java | 80 +++++++++++- .../qbits/alerts/SecurityAndActionsTest.java | 94 ++++++++++++++ .../alerts/engine/RoutingAndDeliveryTest.java | 119 ++++++++++++++++++ src/test/resources/alerts-h2.sql | 3 +- 17 files changed, 558 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/alerts/AlertAudienceResolver.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f336bd..177961a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to this project are documented here. The format follows Keep ## [0.1.0] - Unreleased ### Changed +- Team subscriptions now combine explicit members with ROLE and IDP_GROUP users from the optional public `AlertAudienceResolver` configured through `withAudienceResolver`. Recipients are deduplicated and retain per-user visibility checks. Missing resolvers resolve to nobody, log the reason, and show configuration-aware help on team/member screens. +- Fix the alert Mute action to target its subject record, or its type and dedup key when no subject exists. Add the separate "Mute this alert type" action. RECORD mutes now compare table and record ID. Guarded changesets 21–22 add `scope_record_id` and allow full ALERT keys; existing type-wide mutes retain their scope. - Switch the optional qbit-email integration to its published `com.kingsrook.qbits.email.api` package in `0.1.0-RC.2`, preserving templates, categories, idempotency keys, linked records and outcome mapping. Narrow the architecture allowance to that API package while retaining all guardrails. - Email goes through qbit-email when it is produced in the instance (optional dependency, `qbit-email.version` 0.1.0-RC.2): alert channel emails (template `alerts_alert`), notification emails (`alerts_notification`) and digests (`alerts_digest`), all NOTIFICATION, one recipient per message, with idempotency keys and the alert or notification as the linked record. `messagingProviderName` stays as the fallback when qbit-email is absent, and is not required when it is present. - Target released QQQ `4.1.0-RC.6`; verify normally with `./mvnw -B verify` (no version override). diff --git a/README.md b/README.md index 202063f..e69aede 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,40 @@ Any qbit or app reports exceptional conditions ("a person should do something"). All data is in standard QQQ tables named `alerts_*`. People change alerts through record actions, never by editing rows. +## Team audiences and mutes + +Teams keep their source in `alerts_team.memberSource` and `memberSourceValue`. +Rows in `alerts_team_member` are explicit user memberships. A team's ROLE or +IDP_GROUP source adds users to those explicit members at routing time. Register +`com.kingsrook.qbits.alerts.AlertAudienceResolver` before producing the qbit: + +```java +config.withAudienceResolver((sourceType, sourceValue) -> hostDirectory.resolveUserIds(sourceType, sourceValue)); +``` + +The host implements that directory lookup through its role or identity-provider +public API; this qbit has no dependency on either implementation. Return a +`Set` of user IDs (an empty set for an unknown source/value), bounded by +`maxAudienceSize` (50,000 by default). Users are deduplicated across explicit +members, resolved members and subscriptions, and each still passes the router's +visibility checks. Missing or failing resolvers contribute no users and log a +clear reason; explicit members still receive alerts. Team and member screens +show the missing-resolver help text only when no resolver was configured. + +The alert's **Mute** record action silences its subject table and record ID when +there is one. Without a subject record, it silences only that alert's type and +dedup key, including later occurrences of the same problem. **Mute this alert +type** is a separate record action on alerts and alert types. Both use the usual +Review and Result summaries and require a reason and a duration. Mutes remain +shared rules, not personal notification preferences. + +RECORD mutes store the table in `scopeValue` and its record ID in `scopeRecordId`. +ALERT mutes store the engine's full `type|dedupKey` in `scopeValue`. Apply guarded +Liquibase changesets 21–22 before running this version: they add the record ID +column and widen mute scope values to 520 characters. Existing type-wide mutes +remain type-wide; historical RECORD mutes need their intended record ID filled +in because previous versions did not store it. + ## User notifications The qbit also carries ordinary notifications for people (design: [docs/NOTIFICATIONS.md](docs/NOTIFICATIONS.md)): diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertAudienceResolver.java b/src/main/java/com/kingsrook/qbits/alerts/AlertAudienceResolver.java new file mode 100644 index 0000000..3bd773e --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertAudienceResolver.java @@ -0,0 +1,37 @@ +/* + * 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.util.Set; +import com.kingsrook.qqq.backend.core.exceptions.QException; + + +/******************************************************************************* + ** Host SPI for ROLE and IDP_GROUP team membership. Implementations use the + ** host's public APIs and return user IDs, never display names. Return an empty + ** set for an unknown source or value. Results must be bounded by the configured + ** maxAudienceSize; routing still checks every user's alert visibility. + *******************************************************************************/ +@FunctionalInterface +public interface AlertAudienceResolver +{ + /******************************************************************************* + ** Resolve the team's memberSource and memberSourceValue to user IDs. + *******************************************************************************/ + Set resolve(String sourceType, String sourceValue) throws QException; +} diff --git a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java index cf78b56..458a818 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/alerts/AlertsQBitConfig.java @@ -106,10 +106,33 @@ public class AlertsQBitConfig implements QBitConfig private Boolean notifyFailedJobs = true; private String jobRunLinkPattern; + private transient AlertAudienceResolver audienceResolver; + private transient AlertsRuntime runtime; + /******************************************************************************* + ** Optional host resolver for ROLE and IDP_GROUP team members. + *******************************************************************************/ + public AlertAudienceResolver getAudienceResolver() + { + return (audienceResolver); + } + + + + /******************************************************************************* + ** Register the host resolver before producing the qbit's metadata. + *******************************************************************************/ + public AlertsQBitConfig withAudienceResolver(AlertAudienceResolver audienceResolver) + { + this.audienceResolver = audienceResolver; + return (this); + } + + + /******************************************************************************* ** The backend must be named and exist; numbers must be positive. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java index 36b3f91..46a7f80 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java @@ -22,9 +22,12 @@ import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import com.kingsrook.qbits.alerts.AlertAudienceResolver; import com.kingsrook.qbits.alerts.AlertsRuntime; import com.kingsrook.qbits.alerts.AlertsSecurity; import com.kingsrook.qbits.alerts.engine.AlertsEngine.AlertTarget; @@ -37,11 +40,13 @@ import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ChannelType; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryKind; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.DeliveryStatus; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType; +import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MemberSource; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.ScopeType; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SubscriberType; import com.kingsrook.qbits.alerts.model.AlertsPossibleValues.SuppressedReason; 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.AlertsType; import com.kingsrook.qbits.alerts.model.AlertsUserPreference; @@ -297,7 +302,7 @@ public class AlertRouter Map> groupNames = new HashMap<>(); for(QRecord mute : mutes) { - if(scopeMatches(mute.getValueString("scopeType"), mute.getValueString("scopeValue"), null, target, groupNames)) + if(scopeMatches(mute.getValueString("scopeType"), mute.getValueString("scopeValue"), mute.getValueString("scopeRecordId"), target, groupNames)) { return (true); } @@ -338,13 +343,10 @@ public class AlertRouter continue; } - List userIds = new ArrayList<>(); + Set userIds = new LinkedHashSet<>(); if(SubscriberType.TEAM.name().equals(subscriberType)) { - for(QRecord member : AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", subscription.getValue("teamId")))) - { - userIds.add(member.getValueString("userId")); - } + userIds.addAll(teamMembers(subscription.getValueInteger("teamId"))); } else if(StringUtils.hasContent(subscription.getValueString("userId"))) { @@ -353,6 +355,10 @@ public class AlertRouter for(String userId : userIds) { + if(!StringUtils.hasContent(userId)) + { + continue; + } QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId)); for(QRecord channel : userChannels(subscription, preference)) { @@ -383,6 +389,61 @@ public class AlertRouter + /******************************************************************************* + ** Explicit members plus the team's optional host-managed source. Missing + ** or failed resolvers contribute nobody and leave an actionable log entry. + *******************************************************************************/ + private Set teamMembers(Integer teamId) throws QException + { + Set userIds = new LinkedHashSet<>(); + QRecord team = AlertsDb.byId(AlertsTeam.TABLE_NAME, teamId); + if(team == null) + { + return (userIds); + } + for(QRecord member : AlertsDb.query(AlertsTeamMember.TABLE_NAME, AlertsDb.eq("teamId", teamId).withLimit(Math.addExact(runtime.getConfig().getMaxAudienceSize(), 1)))) + { + if(userIds.size() >= runtime.getConfig().getMaxAudienceSize()) + { + LOG.warn("Team audience exceeds maxAudienceSize; extra members are not notified", logPair("teamId", teamId)); + break; + } + userIds.add(member.getValueString("userId")); + } + String source = team.getValueString("memberSource"); + if(MemberSource.ROLE.name().equals(source) || MemberSource.IDP_GROUP.name().equals(source)) + { + AlertAudienceResolver resolver = runtime.getConfig().getAudienceResolver(); + if(resolver == null) + { + LOG.warn("Team source resolves to nobody: no host audience resolver is configured", logPair("teamId", teamId), logPair("source", source)); + } + else + { + try + { + Set resolved = resolver.resolve(source, team.getValueString("memberSourceValue")); + if(resolved != null) + { + if(resolved.size() > runtime.getConfig().getMaxAudienceSize() + || resolved.stream().filter(userId -> !userIds.contains(userId)).count() > runtime.getConfig().getMaxAudienceSize() - userIds.size()) + { + throw (new QException("Team audience exceeds maxAudienceSize")); + } + userIds.addAll(resolved); + } + } + catch(Exception e) + { + LOG.warn("Team source resolves to nobody: host audience resolver failed", logPair("teamId", teamId), logPair("source", source)); + } + } + } + return (userIds); + } + + + /******************************************************************************* ** A user's channels for a subscription: the ones it names, else in-app ** (unless turned off) and email (when enabled in their settings). @@ -429,6 +490,7 @@ public class AlertRouter return switch(type) { case ALL -> true; + case ALERT -> scopeValue != null && scopeValue.equals(target.alertKey()); case ALERT_TYPE -> scopeValue != null && scopeValue.equals(target.typeName()); case MONITOR -> scopeValue != null && target.monitorId() != null && scopeValue.equals(String.valueOf(target.monitorId())); case TABLE -> scopeValue != null && (scopeValue.equals(target.subjectTableName()) || target.recordKeys().stream().anyMatch(key -> key.startsWith(scopeValue + ":"))); diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsEngine.java b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsEngine.java index b3d6104..b2f0223 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsEngine.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertsEngine.java @@ -824,7 +824,7 @@ public class AlertsEngine /******************************************************************************* ** What a subscription or mute scope is matched against. *******************************************************************************/ - public record AlertTarget(Integer typeId, String typeName, Integer groupId, Integer monitorId, String subjectTableName, Set recordKeys, Map attributes) + public record AlertTarget(Integer typeId, String typeName, Integer groupId, Integer monitorId, String subjectTableName, Set recordKeys, Map attributes, String alertKey) { /*************************************************************************** ** From a type row and a signal (before the alert exists). @@ -837,7 +837,7 @@ public class AlertsEngine keys.add(ref.getTableName() + ":" + ref.getPrimaryKey()); } return (new AlertTarget(type.getValueInteger("id"), signal.getAlertTypeName(), type.getValueInteger("groupId"), monitorId, - type.getValueString("subjectTableName"), keys, signal.getAttributes())); + type.getValueString("subjectTableName"), keys, signal.getAttributes(), openKey(signal.getAlertTypeName(), signal.getEffectiveDedupKey()))); } @@ -852,6 +852,10 @@ public class AlertsEngine { keys.add(ref.getValueString("tableName") + ":" + ref.getValueString("recordId")); } + if(StringUtils.hasContent(alert.getValueString("subjectTableName")) && StringUtils.hasContent(alert.getValueString("subjectRecordId"))) + { + keys.add(alert.getValueString("subjectTableName") + ":" + alert.getValueString("subjectRecordId")); + } Map attributes = new HashMap<>(); String json = alert.getValueString("attributes"); if(StringUtils.hasContent(json)) @@ -867,7 +871,7 @@ public class AlertsEngine } } return (new AlertTarget(alert.getValueInteger("alertTypeId"), alert.getValueString("alertTypeName"), alert.getValueInteger("groupId"), alert.getValueInteger("monitorId"), - alert.getValueString("subjectTableName"), keys, attributes)); + alert.getValueString("subjectTableName"), keys, attributes, openKey(alert.getValueString("alertTypeName"), alert.getValueString("dedupKey")))); } } } diff --git a/src/main/java/com/kingsrook/qbits/alerts/engine/MonitorEvaluator.java b/src/main/java/com/kingsrook/qbits/alerts/engine/MonitorEvaluator.java index 7104eb6..2bd9d4b 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/engine/MonitorEvaluator.java +++ b/src/main/java/com/kingsrook/qbits/alerts/engine/MonitorEvaluator.java @@ -216,7 +216,7 @@ public class MonitorEvaluator long started = System.currentTimeMillis(); String typeName = type.getValueString("name"); - AlertTarget target = new AlertTarget(type.getValueInteger("id"), typeName, type.getValueInteger("groupId"), monitorId, type.getValueString("subjectTableName"), Set.of(), Map.of()); + AlertTarget target = new AlertTarget(type.getValueInteger("id"), typeName, type.getValueInteger("groupId"), monitorId, type.getValueString("subjectTableName"), Set.of(), Map.of(), null); if(runtime.getRouter().isSuppressedByMaintenance(target, now)) { Plan plan = new Plan(MonitorStatus.SKIPPED_MUTED, "skipped: maintenance window", List.of(), List.of(), List.of(), List.of(), null); diff --git a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java index b9d3098..284a5dd 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java +++ b/src/main/java/com/kingsrook/qbits/alerts/metadata/AlertsTableMetaDataHelper.java @@ -62,6 +62,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn; import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType; import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; @@ -304,6 +305,10 @@ public final class AlertsTableMetaDataHelper .withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, List.of("description"))) .withSection(widgetSection(JOIN_TEAM_MEMBER, "Members", "person")) .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate"))); + if(config.getAudienceResolver() == null) + { + table.getField("memberSource").withHelpContent(new QHelpContent("ROLE and IdP Group members need a host resolver; none is configured")); + } table.getField("memberSource").setDefaultValue(AlertsPossibleValues.MemberSource.MANUAL.name()); return (table); } @@ -322,6 +327,10 @@ public final class AlertsTableMetaDataHelper .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "teamId", "userId", "role"))) .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createDate", "modifyDate"))); + if(config.getAudienceResolver() == null) + { + table.getField("teamId").withHelpContent(new QHelpContent("ROLE and IdP Group members need a host resolver; none is configured")); + } table.getField("role").setDefaultValue(AlertsPossibleValues.TeamRole.MEMBER.name()); return (table); } @@ -364,7 +373,7 @@ public final class AlertsTableMetaDataHelper .withRecordLabelFields("name") .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) .withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "name", "enabled", "effect"))) - .withSection(new QFieldSection("scope", "What and When", new QIcon("filter_alt"), Tier.T2, List.of("scopeType", "scopeValue", "startsAt", "endsAt", "reason"))) + .withSection(new QFieldSection("scope", "What and When", new QIcon("filter_alt"), Tier.T2, List.of("scopeType", "scopeValue", "scopeRecordId", "startsAt", "endsAt", "reason"))) .withSection(new QFieldSection("system", new QIcon("calendar_month"), Tier.T3, List.of("createdByUserId", "createDate", "modifyDate"))); table.getField("effect").setDefaultValue(AlertsPossibleValues.MuteEffect.SILENCE_NOTIFICATIONS.name()); table.getField("enabled").setDefaultValue(true); diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsMute.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsMute.java index d01808c..8e1d6f6 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsMute.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsMute.java @@ -47,9 +47,12 @@ public class AlertsMute extends QRecordEntity @QField(isRequired = true, maxLength = 20, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, possibleValueSourceName = "alerts_scope_type") private String scopeType; - @QField(maxLength = 300, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS) + @QField(maxLength = 520, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS) private String scopeValue; + @QField(maxLength = 100, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS, label = "Scope Record") + private String scopeRecordId; + @QField(isRequired = true) private Instant startsAt; @@ -247,6 +250,37 @@ public class AlertsMute extends QRecordEntity + /******************************************************************************* + ** Getter for scopeRecordId + *******************************************************************************/ + public String getScopeRecordId() + { + return (this.scopeRecordId); + } + + + + /******************************************************************************* + ** Setter for scopeRecordId + *******************************************************************************/ + public void setScopeRecordId(String scopeRecordId) + { + this.scopeRecordId = scopeRecordId; + } + + + + /******************************************************************************* + ** Fluent setter for scopeRecordId + *******************************************************************************/ + public AlertsMute withScopeRecordId(String scopeRecordId) + { + this.scopeRecordId = scopeRecordId; + return (this); + } + + + /******************************************************************************* ** Getter for startsAt *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java index 89616a0..912f293 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java +++ b/src/main/java/com/kingsrook/qbits/alerts/model/AlertsPossibleValues.java @@ -132,7 +132,7 @@ public final class AlertsPossibleValues *******************************************************************************/ public enum ScopeType { - ALL, GROUP, ALERT_TYPE, MONITOR, TABLE, RECORD, ATTRIBUTE + ALL, GROUP, ALERT_TYPE, MONITOR, TABLE, RECORD, ATTRIBUTE, ALERT } diff --git a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsAction.java b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsAction.java index 3a57146..a7beec3 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsAction.java +++ b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsAction.java @@ -46,8 +46,9 @@ public enum AlertsAction ASSIGN("alertsAssign", "Assign", "assignment_ind", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, null), COMMENT("alertsAddComment", "Add comment", "comment", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1), MUTE_ALERT("alertsMuteAlert", "Mute", "notifications_off", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1), + MUTE_ALERT_TYPE("alertsMuteAlertType", "Mute this alert type", "notifications_off", AlertsAlert.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1), SUBSCRIBE_TYPE("alertsSubscribeToType", "Subscribe me", "notifications", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1), - MUTE_TYPE("alertsMuteType", "Mute", "notifications_off", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1), + MUTE_TYPE("alertsMuteType", "Mute this alert type", "notifications_off", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1), CREATE_MONITOR("alertsCreateMonitorFromType", "Create monitor", "add_alert", AlertsType.TABLE_NAME, AlertsQBitConfig.PERMISSION_ADMIN, 1), SUBSCRIBE_GROUP("alertsSubscribeToGroup", "Subscribe me", "notifications", AlertsGroup.TABLE_NAME, AlertsQBitConfig.PERMISSION_VIEW, 1), MUTE_GROUP("alertsMuteGroup", "Mute", "notifications_off", AlertsGroup.TABLE_NAME, AlertsQBitConfig.PERMISSION_MANAGE, 1), @@ -131,7 +132,7 @@ public enum AlertsAction .withPossibleValueSourceName(AlertsPossibleValues.SEVERITY), new QFieldMetaData(FIELD_DELIVERY_MODE, QFieldType.STRING).withLabel("Delivery").withIsRequired(true).withDefaultValue("IMMEDIATE") .withPossibleValueSourceName(AlertsPossibleValues.DELIVERY_MODE)); - case MUTE_ALERT, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> List.of( + case MUTE_ALERT, MUTE_ALERT_TYPE, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> List.of( new QFieldMetaData(FIELD_MUTE_MINUTES, QFieldType.INTEGER).withLabel("Mute for (minutes)").withIsRequired(true).withDefaultValue(60), new QFieldMetaData(FIELD_NOTE, QFieldType.TEXT).withLabel("Reason").withIsRequired(true)); default -> List.of(); diff --git a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsActionSteps.java b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsActionSteps.java index 69e3f89..f556beb 100644 --- a/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsActionSteps.java +++ b/src/main/java/com/kingsrook/qbits/alerts/processes/AlertsActionSteps.java @@ -26,6 +26,7 @@ import java.util.Objects; import com.kingsrook.qbits.alerts.AlertsRuntime; import com.kingsrook.qbits.alerts.AlertsSecurity; import com.kingsrook.qbits.alerts.engine.AlertsDb; +import com.kingsrook.qbits.alerts.engine.AlertsEngine; import com.kingsrook.qbits.alerts.engine.MonitorEvaluator; import com.kingsrook.qbits.alerts.model.AlertsMonitor; import com.kingsrook.qbits.alerts.model.AlertsMute; @@ -302,7 +303,8 @@ public final class AlertsActionSteps case MUTE_TYPE -> mute(ScopeType.ALERT_TYPE, record.getValueString("name"), "type " + record.getValueString("label"), input, actor); case MUTE_GROUP -> mute(ScopeType.GROUP, record.getValueString("name"), "group " + record.getValueString("label"), input, actor); case MUTE_MONITOR -> mute(ScopeType.MONITOR, String.valueOf(id), "monitor " + record.getValueString("name"), input, actor); - case MUTE_ALERT -> mute(ScopeType.ALERT_TYPE, record.getValueString("alertTypeName"), "alert type " + record.getValueString("alertTypeName"), input, actor); + case MUTE_ALERT -> muteAlert(record, input, actor); + case MUTE_ALERT_TYPE -> mute(ScopeType.ALERT_TYPE, record.getValueString("alertTypeName"), "alert type " + record.getValueString("alertTypeName"), input, actor); case RUN_CHECK -> { MonitorEvaluator.Plan plan = runtime.getEvaluator().runNow(id); @@ -421,9 +423,34 @@ public final class AlertsActionSteps /******************************************************************************* - ** Create a silence-notifications mute from now (through the table). + ** Mute the subject record when present, otherwise this type and dedup key. + *******************************************************************************/ + private static boolean muteAlert(QRecord alert, RunBackendStepInput input, String actor) throws QException + { + if(StringUtils.hasContent(alert.getValueString("subjectTableName")) && StringUtils.hasContent(alert.getValueString("subjectRecordId"))) + { + return (mute(ScopeType.RECORD, alert.getValueString("subjectTableName"), alert.getValueString("subjectRecordId"), "alert " + alert.getValue("id"), input, actor)); + } + return (mute(ScopeType.ALERT, AlertsEngine.openKey(alert.getValueString("alertTypeName"), alert.getValueString("dedupKey")), null, + "alert " + alert.getValue("id"), input, actor)); + } + + + + /******************************************************************************* + ** Create a mute without a subject record ID. *******************************************************************************/ private static boolean mute(ScopeType scopeType, String scopeValue, String what, RunBackendStepInput input, String actor) throws QException + { + return (mute(scopeType, scopeValue, null, what, input, actor)); + } + + + + /******************************************************************************* + ** Create a silence-notifications mute from now (through the table). + *******************************************************************************/ + private static boolean mute(ScopeType scopeType, String scopeValue, String scopeRecordId, String what, RunBackendStepInput input, String actor) throws QException { Integer minutes = input.getValueInteger(AlertsAction.FIELD_MUTE_MINUTES); String reason = input.getValueString(AlertsAction.FIELD_NOTE); @@ -437,6 +464,7 @@ public final class AlertsActionSteps .withValue("reason", reason) .withValue("scopeType", scopeType.name()) .withValue("scopeValue", scopeValue) + .withValue("scopeRecordId", scopeRecordId) .withValue("startsAt", now) .withValue("endsAt", now.plus(minutes == null || minutes < 1 ? 60 : minutes, ChronoUnit.MINUTES)) .withValue("effect", MuteEffect.SILENCE_NOTIFICATIONS.name()) @@ -586,7 +614,7 @@ public final class AlertsActionSteps case ASSIGN -> "assigned"; case COMMENT -> "commented on"; case SUBSCRIBE_TYPE, SUBSCRIBE_GROUP, SUBSCRIBE_MONITOR -> "subscribed to"; - case MUTE_ALERT, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> "muted"; + case MUTE_ALERT, MUTE_ALERT_TYPE, MUTE_TYPE, MUTE_GROUP, MUTE_MONITOR -> "muted"; case RUN_CHECK -> "checked"; case ENABLE_MONITOR -> "enabled"; case DISABLE_MONITOR -> "disabled"; diff --git a/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql b/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql index 9dfaec9..6cf5b40 100644 --- a/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql +++ b/src/main/resources/db/liquibase/qbit-alerts/alerts.changelog.sql @@ -427,3 +427,15 @@ CREATE TABLE IF NOT EXISTS alerts_notification_preference ( CONSTRAINT pk_alerts_notification_preference PRIMARY KEY (id), CONSTRAINT uq_alerts_notification_preference UNIQUE (user_id, category) ); + +-- changeset qbit-alerts:21 +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME) = 'alerts_mute' AND LOWER(COLUMN_NAME) = 'scope_record_id' +-- comment: Record mutes store the subject record ID separately from its table name. +ALTER TABLE alerts_mute ADD COLUMN scope_record_id VARCHAR(100); + +-- changeset qbit-alerts:22 +-- preconditions onFail:MARK_RAN onError:HALT +-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME) = 'alerts_mute' AND LOWER(COLUMN_NAME) = 'scope_value' AND CHARACTER_MAXIMUM_LENGTH < 520 +-- comment: ALERT mutes hold the complete type and dedup key, including long keys. +ALTER TABLE alerts_mute ALTER COLUMN scope_value SET DATA TYPE VARCHAR(520); diff --git a/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java index eea5685..fbc7e59 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/AlertsQBitProducerTest.java @@ -26,6 +26,7 @@ import com.kingsrook.qbits.alerts.api.AlertsRouteProvider; import com.kingsrook.qbits.alerts.metadata.AlertsAppMetaDataProducer; import com.kingsrook.qbits.alerts.metadata.AlertsHomeWidgetsMetaDataProducer; import com.kingsrook.qbits.alerts.metadata.AlertsProcessesMetaDataProducer; +import com.kingsrook.qbits.alerts.metadata.AlertsTableMetaDataHelper; import com.kingsrook.qbits.alerts.processes.AlertsAction; import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData; @@ -69,6 +70,23 @@ class AlertsQBitProducerTest extends BaseAlertsTest + /******************************************************************************* + ** Team and member screens explain missing host setup only when needed. + *******************************************************************************/ + @Test + void testTeamResolverHelp() throws Exception + { + assertThat(AlertsTableMetaDataHelper.buildTeamTable(config).getField("memberSource").getHelpContents()) + .anyMatch(help -> help.getContent().contains("ROLE and IdP Group members need a host resolver; none is configured")); + assertThat(AlertsTableMetaDataHelper.buildTeamMemberTable(config).getField("teamId").getHelpContents()) + .anyMatch(help -> help.getContent().contains("none is configured")); + config.withAudienceResolver((source, value) -> Set.of()); + assertThat(AlertsTableMetaDataHelper.buildTeamTable(config).getField("memberSource").getHelpContents()).isNullOrEmpty(); + assertThat(AlertsTableMetaDataHelper.buildTeamMemberTable(config).getField("teamId").getHelpContents()).isNullOrEmpty(); + } + + + /******************************************************************************* ** Without a config the producer is disabled; bad configs fail. *******************************************************************************/ @@ -182,7 +200,67 @@ class AlertsQBitProducerTest extends BaseAlertsTest QTableMetaData table = qInstance.getTable(tableName); for(QFieldMetaData field : table.getFields().values()) { - assertThat(body).as(tableName + "." + field.getName()).contains(" " + QInstanceEnricher.inferBackendName(field.getName()) + " "); + String column = QInstanceEnricher.inferBackendName(field.getName()); + assertThat(body.contains(" " + column + " ") || changelog.contains("ALTER TABLE " + created + " ADD COLUMN " + column + " ")) + .as(tableName + "." + field.getName()).isTrue(); + } + } + } + + + + /******************************************************************************* + ** Upgrade the old mute table using the shipped SQL and guards twice; + ** existing rules survive and the new fields accept full alert keys. + *******************************************************************************/ + @Test + void testMuteSchemaUpgradeIsRepeatable() throws Exception + { + String changelog; + try(InputStream in = getClass().getClassLoader().getResourceAsStream("db/liquibase/qbit-alerts/alerts.changelog.sql")) + { + changelog = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + int start = changelog.indexOf("CREATE TABLE IF NOT EXISTS alerts_mute ("); + try(java.sql.Connection connection = com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.getConnection(defineH2Backend()); + java.sql.Statement statement = connection.createStatement()) + { + statement.execute("DROP TABLE alerts_mute"); + statement.execute(changelog.substring(start, changelog.indexOf(");", start) + 2)); + statement.execute("INSERT INTO alerts_mute (name, scope_type, scope_value, starts_at, ends_at) VALUES ('Existing', 'ALERT_TYPE', 'existing.type', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"); + for(int pass = 0; pass < 2; pass++) + { + for(String number : List.of("21", "22")) + { + String block = changelog.substring(changelog.indexOf("-- changeset qbit-alerts:" + number)); + String guard = block.lines().filter(line -> line.startsWith("-- precondition-sql-check")).findFirst().orElseThrow(); + String sql = block.lines().filter(line -> line.startsWith("ALTER TABLE")).findFirst().orElseThrow(); + int expected = Integer.parseInt(guard.substring(guard.indexOf("expectedResult:") + 15, guard.indexOf(" SELECT"))); + try(java.sql.ResultSet result = statement.executeQuery(guard.substring(guard.indexOf("SELECT")))) + { + assertThat(result.next()).isTrue(); + if(result.getInt(1) != expected) + { + continue; + } + } + statement.execute(sql); + } + } + try(java.sql.ResultSet result = statement.executeQuery("SELECT scope_type, scope_value, scope_record_id FROM alerts_mute")) + { + assertThat(result.next()).isTrue(); + assertThat(result.getString(1)).isEqualTo("ALERT_TYPE"); + assertThat(result.getString(2)).isEqualTo("existing.type"); + assertThat(result.getString(3)).isNull(); + } + String key = "k".repeat(520); + statement.execute("UPDATE alerts_mute SET scope_value = '" + key + "', scope_record_id = '123'"); + try(java.sql.ResultSet result = statement.executeQuery("SELECT scope_value, scope_record_id FROM alerts_mute")) + { + assertThat(result.next()).isTrue(); + assertThat(result.getString(1)).isEqualTo(key); + assertThat(result.getString(2)).isEqualTo("123"); } } } diff --git a/src/test/java/com/kingsrook/qbits/alerts/SecurityAndActionsTest.java b/src/test/java/com/kingsrook/qbits/alerts/SecurityAndActionsTest.java index 19fb91d..8b36b27 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/SecurityAndActionsTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/SecurityAndActionsTest.java @@ -271,6 +271,100 @@ class SecurityAndActionsTest extends BaseAlertsTest + /******************************************************************************* + ** The record action mutes its subject, leaving other records of the same + ** alert type deliverable. The separate type action silences those too. + *******************************************************************************/ + @Test + void testRecordAndTypeMuteActions() throws Exception + { + List widgets = insertWidgets("BROKEN", "BROKEN"); + QRecord one = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("one").withRecordRef(AlertRecordRef.of(WIDGET, widgets.get(0)))); + QRecord two = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("two").withRecordRef(AlertRecordRef.of(WIDGET, widgets.get(1)))); + subscribeForMuteTest(); + loginAs(MANAGER); + assertThat(run(AlertsAction.MUTE_ALERT, List.of(one.getValueInteger("id")), Map.of(AlertsAction.FIELD_NOTE, "Working on this record"))) + .anyMatch(line -> line.startsWith("OK: 1")); + QRecord mute = all(AlertsMute.TABLE_NAME).get(0); + assertThat(mute.getValueString("scopeType")).isEqualTo("RECORD"); + assertThat(mute.getValueString("scopeValue")).isEqualTo(WIDGET); + assertThat(mute.getValueString("scopeRecordId")).isEqualTo(String.valueOf(widgets.get(0))); + routeForMuteTest(one); + routeForMuteTest(two); + assertThat(deliveriesFor(one)).singleElement().matches(d -> "MUTED".equals(d.getValueString("suppressedReason"))); + assertThat(deliveriesFor(two)).singleElement().matches(d -> "SENT".equals(d.getValueString("status"))); + + run(AlertsAction.MUTE_ALERT_TYPE, List.of(one.getValueInteger("id")), Map.of(AlertsAction.FIELD_NOTE, "Working on the type")); + routeForMuteTest(two); + assertThat(deliveriesFor(two)).anyMatch(d -> "MUTED".equals(d.getValueString("suppressedReason"))); + assertThat(AlertsAction.MUTE_ALERT_TYPE.getLabel()).isEqualTo("Mute this alert type"); + assertThat(AlertsAction.MUTE_TYPE.getLabel()).isEqualTo("Mute this alert type"); + } + + + + /******************************************************************************* + ** Subjectless alerts mute by full type and dedup key, never by type alone. + *******************************************************************************/ + @Test + void testSubjectlessMuteAction() throws Exception + { + String type = "test.system." + "t".repeat(170); + String key = "k".repeat(300); + QRecord one = runtime.getEngine().raise(new AlertSignal(type).withDedupKey(key)); + QRecord two = runtime.getEngine().raise(new AlertSignal(type).withDedupKey("other")); + QRecord otherType = runtime.getEngine().raise(new AlertSignal("test.system.other").withDedupKey(key)); + subscribeForMuteTest(); + loginAs(MANAGER); + run(AlertsAction.MUTE_ALERT, List.of(one.getValueInteger("id")), Map.of(AlertsAction.FIELD_NOTE, "Working on this alert")); + QRecord mute = all(AlertsMute.TABLE_NAME).get(0); + assertThat(mute.getValueString("scopeType")).isEqualTo("ALERT"); + assertThat(mute.getValueString("scopeValue")).isEqualTo(type + "|" + key); + routeForMuteTest(one); + routeForMuteTest(two); + routeForMuteTest(otherType); + assertThat(deliveriesFor(one)).singleElement().matches(d -> "MUTED".equals(d.getValueString("suppressedReason"))); + assertThat(deliveriesFor(two)).singleElement().matches(d -> "SENT".equals(d.getValueString("status"))); + assertThat(deliveriesFor(otherType)).singleElement().matches(d -> "SENT".equals(d.getValueString("status"))); + } + + + + /******************************************************************************* + ** Subscribe after raising so initial delivery does not mask mute assertions. + *******************************************************************************/ + private void subscribeForMuteTest() throws QException + { + runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord().withValue("subscriberType", "USER") + .withValue("userId", VIEWER).withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("enabled", true))); + } + + + + /******************************************************************************* + ** Route a fresh notification event against the current mute rules. + *******************************************************************************/ + private void routeForMuteTest(QRecord alert) throws QException + { + runtime.asSystem(() -> + { + new com.kingsrook.qbits.alerts.engine.AlertRouter(runtime).route(alert, com.kingsrook.qbits.alerts.model.AlertsPossibleValues.EventType.RAISED, null); + return (null); + }); + } + + + + /******************************************************************************* + ** Read delivery outcomes for one alert. + *******************************************************************************/ + private List deliveriesFor(QRecord alert) throws QException + { + return (where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("alertId", alert.getValue("id")))); + } + + + /******************************************************************************* ** Type, group and monitor actions; channel and notification actions. *******************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/alerts/engine/RoutingAndDeliveryTest.java b/src/test/java/com/kingsrook/qbits/alerts/engine/RoutingAndDeliveryTest.java index 05b36d3..bf6e463 100644 --- a/src/test/java/com/kingsrook/qbits/alerts/engine/RoutingAndDeliveryTest.java +++ b/src/test/java/com/kingsrook/qbits/alerts/engine/RoutingAndDeliveryTest.java @@ -19,6 +19,7 @@ package com.kingsrook.qbits.alerts.engine; import java.time.Duration; import java.util.List; +import java.util.Set; import com.kingsrook.qbits.alerts.AlertsPreferences; import com.kingsrook.qbits.alerts.AlertsQBitConfig; import com.kingsrook.qbits.alerts.BaseAlertsTest; @@ -200,6 +201,124 @@ class RoutingAndDeliveryTest extends BaseAlertsTest + /******************************************************************************* + ** Both host sources combine with explicit members and other subscriptions; + ** duplicate users receive once and invisible users remain suppressed. + *******************************************************************************/ + @Test + void testHostTeamSources() throws Exception + { + List calls = new java.util.ArrayList<>(); + config.withMaxAudienceSize(3).withAudienceResolver((source, value) -> + { + calls.add(source + ":" + value); + return (Set.of(VIEWER, MANAGER, OUTSIDER)); + }); + for(String source : List.of("ROLE", "IDP_GROUP")) + { + Integer teamId = sourcedTeam(source); + runtime.asSystem(() -> AlertsDb.insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", teamId).withValue("userId", VIEWER))); + } + subscribe("USER", VIEWER, null, null, "ALL", null, null, "ERROR"); + runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("source-members")); + + assertThat(calls).containsExactly("ROLE:source-ROLE", "IDP_GROUP:source-IDP_GROUP"); + assertThat(notifications()).hasSize(3); + assertThat(notifications()).filteredOn(n -> "SENT".equals(n.getValueString("status"))) + .extracting(n -> n.getValueString("recipientUserId")).containsExactlyInAnyOrder(VIEWER, MANAGER); + assertThat(notifications()).filteredOn(n -> OUTSIDER.equals(n.getValueString("recipientUserId"))) + .singleElement().matches(n -> "NOT_VISIBLE".equals(n.getValueString("suppressedReason"))); + } + + + + /******************************************************************************* + ** Unconfigured sources resolve to nobody; explicit members still work. + *******************************************************************************/ + @Test + void testMissingTeamResolver() throws Exception + { + sourcedTeam("ROLE"); + sourcedTeam("IDP_GROUP"); + runtime.getEngine().raise(new AlertSignal(SYSTEM).withDedupKey("no-resolver")); + assertThat(notifications()).isEmpty(); + + Integer manualTeam = sourcedTeam("MANUAL"); + runtime.asSystem(() -> AlertsDb.insert(AlertsTeamMember.TABLE_NAME, new QRecord().withValue("teamId", manualTeam).withValue("userId", VIEWER))); + config.withAudienceResolver((source, value) -> + { + throw (new QException("Unavailable")); + }); + runtime.getEngine().raise(new AlertSignal(SYSTEM).withDedupKey("failed-resolver")); + assertThat(notifications()).singleElement().matches(n -> VIEWER.equals(n.getValueString("recipientUserId")) && "SENT".equals(n.getValueString("status"))); + } + + + + /******************************************************************************* + ** Oversized resolver results fail closed without truncating the audience. + *******************************************************************************/ + @Test + void testTeamAudienceLimit() throws Exception + { + config.withMaxAudienceSize(1).withAudienceResolver((source, value) -> Set.of(VIEWER, MANAGER)); + sourcedTeam("ROLE"); + runtime.getEngine().raise(new AlertSignal(SYSTEM).withDedupKey("too-many")); + assertThat(notifications()).isEmpty(); + } + + + + /******************************************************************************* + ** Create a team subscription using a host-managed or manual source. + *******************************************************************************/ + private Integer sourcedTeam(String source) throws QException + { + Integer teamId = runtime.asSystem(() -> AlertsDb.insert(AlertsTeam.TABLE_NAME, new QRecord().withValue("name", source).withValue("label", source) + .withValue("memberSource", source).withValue("memberSourceValue", "source-" + source)).getValueInteger("id")); + subscribe("TEAM", null, teamId, null, "ALL", null, null, "ERROR"); + return (teamId); + } + + + + /******************************************************************************* + ** RECORD mutes require both table and ID, including an alert whose subject + ** is stored directly without an active record-reference row. + *******************************************************************************/ + @Test + void testRecordMuteMatching() throws Exception + { + runtime.asSystem(() -> AlertsDb.insert(AlertsMute.TABLE_NAME, new QRecord().withValue("name", "Record mute") + .withValue("scopeType", "RECORD").withValue("scopeValue", WIDGET).withValue("scopeRecordId", "123") + .withValue("startsAt", clock.instant()).withValue("endsAt", clock.instant().plusSeconds(60)) + .withValue("effect", "SILENCE_NOTIFICATIONS").withValue("enabled", true))); + QRecord alert = runtime.getEngine().raise(new AlertSignal(TYPE).withDedupKey("subject-only")); + alert.setValue("subjectRecordId", "123"); + assertThat(recordMuted(alert)).isTrue(); + alert.setValue("subjectRecordId", "124"); + assertThat(recordMuted(alert)).isFalse(); + alert.setValue("subjectRecordId", "123"); + alert.setValue("subjectTableName", "another_table"); + assertThat(recordMuted(alert)).isFalse(); + alert.setValue("subjectTableName", WIDGET); + clock.advance(Duration.ofMinutes(1)); + assertThat(recordMuted(alert)).isFalse(); + } + + + + /******************************************************************************* + ** Match a mute as the router does under the system session. + *******************************************************************************/ + private boolean recordMuted(QRecord alert) throws QException + { + return (runtime.asSystem(() -> new AlertRouter(runtime).isMuted(AlertsEngine.AlertTarget.of(alert), clock.instant(), + com.kingsrook.qbits.alerts.model.AlertsPossibleValues.MuteEffect.SILENCE_NOTIFICATIONS))); + } + + + /******************************************************************************* ** Suppressions are logged with their reason. *******************************************************************************/ diff --git a/src/test/resources/alerts-h2.sql b/src/test/resources/alerts-h2.sql index b4f6813..ce9cb93 100644 --- a/src/test/resources/alerts-h2.sql +++ b/src/test/resources/alerts-h2.sql @@ -265,7 +265,8 @@ CREATE TABLE IF NOT EXISTS alerts_mute ( name VARCHAR(200) NOT NULL, reason VARCHAR(2000), scope_type VARCHAR(20) NOT NULL, - scope_value VARCHAR(300), + scope_value VARCHAR(520), + scope_record_id VARCHAR(100), starts_at TIMESTAMP NOT NULL, ends_at TIMESTAMP NOT NULL, effect VARCHAR(30), -- 2.54.0