feat(routing): resolve ROLE and IdP Group teams; per-alert mutes (#27, part 3) #28
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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<String>` 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)):
|
||||
|
||||
@@ -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<String> resolve(String sourceType, String sourceValue) throws QException;
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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<Integer, List<String>> 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<String> userIds = new ArrayList<>();
|
||||
Set<String> 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<String> teamMembers(Integer teamId) throws QException
|
||||
{
|
||||
Set<String> 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<String> 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 + ":")));
|
||||
|
||||
@@ -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<String> recordKeys, Map<String, String> attributes)
|
||||
public record AlertTarget(Integer typeId, String typeName, Integer groupId, Integer monitorId, String subjectTableName, Set<String> recordKeys, Map<String, String> 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<String, String> 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"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Integer> 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<QRecord> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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<String> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user