diff --git a/AGENTS.md b/AGENTS.md
index 74ef0ad..5ac9274 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,7 +44,7 @@ Packages:
## Dependencies
- Java 21.
-- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
+- Parent `com.kingsrook:qbit-build-parent:2.1.0` (qqq 4.0.0), with architecture guardrails enforced and strict UI API route tests enabled. The default-active `qqq-snapshot` profile imports released `qqq-bom-pom:4.1.0-RC.6` ahead of it, as in qbit-quick-search (ADR-0007). Build normally without a version override. Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4a1e51..5b2305d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,12 @@ All notable changes to this project are documented here. The format follows Keep
## [0.1.0] - Unreleased
### Changed
-- Target QQQ `4.1.0-RC.6` for the UI extension API; local verification uses a command-line version override only.
+- Target released QQQ `4.1.0-RC.6`; verify normally with `./mvnw -B verify` (no version override).
+- Adopt `qbit-build-parent:2.1.0` with architecture guardrail enforcement and strict UI API routes enabled for tests. Dependency, extension contract and stand-in checks run in the normal build.
+- Add record followers to alert routing through core `RecordFollowers.get(instance, ref, FollowEvent.ALERT)` (#16, QRun-IO/qqq#1164). Deduplicate by user/channel with subscriptions and other record refs; require source-record read access under the follower's session, including record locks. Followers use the existing alert visibility, preferences and suppression pipeline without a dependency on qbit-collaboration. Missing/disabled/failing providers preserve subscription routing.
- Register six JSON UI specs as extension `alerts` at the established `/alerts` prefix, preserving methods, paths and existing payload fields. Publish the OpenAPI fragment with a regeneration/contract test and strict server coverage (#13, F2).
- Add summary `inboxHref`, notification operation actions, alert process actions with complete hrefs, and record-panel `follow.available` (#13, F20). Follow discovery reads core extension metadata without a collaboration dependency or URL.
-- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration. Follower fan-out waits for core #1164 (#16, F12).
+- Remove the record-panel watch route and handler. Existing RECORD subscriptions and the deprecated `watching` response remain readable without a migration; new panel follows belong to collaboration (#16, F12).
- Move Alertmanager ingest to `/qqq/hooks/alerts/alertmanager`. The old path is an opt-in alias (`legacyAlertmanagerPath`, default false) for one minor release, incompatible with strict raw-route mode. Each alias call carries deprecation/sunset headers and logs a warning; authentication is shared (#14, F7).
- Run Checkstyle 14.3.0 in Maven validate (including tests); align the license template with this repository's existing headers and correct pre-existing switch/import formatting so the style check passes.
- Alert types and sink registration use core's alerts SPI (introduced in QQQ 4.1.0-RC.5); host `QAlerts.raise/resolve/heartbeat` calls reach the installed qbit through core instance metadata (qbit-alerts#15, F3).
diff --git a/README.md b/README.md
index d82af2b..24560c2 100644
--- a/README.md
+++ b/README.md
@@ -56,8 +56,11 @@ hrefs, `notificationsHref`, `alertsHref`, `canManage` and deprecated `watching`
Record following belongs to collaboration: `POST /alerts/record-panel/watch` is removed. Old
RECORD subscriptions remain readable without migration. The panel creates no subscriptions;
-Next uses its collaboration module when `follow.available` is true. Follower fan-out waits
-for core QRun-IO/qqq#1164.
+Next uses its collaboration module when `follow.available` is true. Alerts with active record
+refs also reach followers supplied by core's instance-level followers provider (`FollowEvent.ALERT`),
+when FOLLOW is enabled and the follower can read the source record. Followers and subscribers
+share recipient deduplication, channel preferences and suppression. No collaboration qbit dependency
+is required; without a provider, subscription behavior is unchanged. See [follower routing](docs/UI-API.md#record-follower-routing).
The old Alertmanager path is available for one minor release only via
`withLegacyAlertmanagerPath(true)` (default false). It requires non-strict UI route mode,
@@ -67,12 +70,17 @@ a warning. Both paths use the same bearer-token check. Strict hosts use the cano
## Requirements
- Java 21.
-- QQQ 4.1 (targets `4.1.0-RC.6`; release pending).
+- QQQ `4.1.0-RC.6` (released on Maven Central).
- An RDBMS backend.
- A scheduler, for monitors and email delivery.
- A messaging provider, for email.
- `qqq-middleware-javalin`, for the `/qqq/v1/alerts` routes (optional).
+The build uses `qbit-build-parent:2.1.0` with architecture guardrails and strict UI API route
+tests enabled. Run `./mvnw -B verify` (`.\mvnw.cmd -B verify` on Windows), without a snapshot
+override. The default-active `qqq-snapshot` profile retains its historical name but selects
+the released RC.6 BOM ahead of the parent's QQQ 4.0 BOM.
+
## Install
```java
diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md
index b608469..9182f20 100644
--- a/docs/NOTIFICATIONS.md
+++ b/docs/NOTIFICATIONS.md
@@ -161,7 +161,7 @@ QAlerts.notify(new NotificationSignal("Your export failed")
The qbit implements core's `notify` override in `AlertsSinkAdapter` and `QbitAlertsSink`;
signals use the existing bounded queue and `NotificationService` for audience resolution,
inbox recipients, scheduling and category/email preferences. Alert subscription channels
- and their quiet-hour suppression keep their existing behavior.
+ keep their existing behavior. Quiet-hour scheduling for alerts remains unimplemented (see README).
- **Breaking migration:** the local `spi` package and `AlertsRuntime.sink()` are removed.
Change imports to core, call `QAlerts.notify(signal)` in the producer's `QContext`, and
rebuild. qbit-easypost-tracking#16 uses core. The runtime's `getSink()` supports adapter
@@ -265,3 +265,5 @@ recipients) whose `expireAt` is more than 7 days past, plus SENT or RETRACTED no
The implemented UI routes and additive action/follow metadata are specified in [UI-API.md](UI-API.md),
with the generated fragment at `src/main/resources/openapi/qqq-v1/alerts.yaml`. Record following
belongs to collaboration; the old alerts panel watch route is removed.
+Record-linked alerts fan out through core's RC.6 followers provider with source-record access
+checks and the ordinary alert preference/suppression pipeline; see [record follower routing](UI-API.md#record-follower-routing).
diff --git a/docs/UI-API.md b/docs/UI-API.md
index ef210db..5c637b8 100644
--- a/docs/UI-API.md
+++ b/docs/UI-API.md
@@ -58,9 +58,26 @@ and scope subscriptions; existing record-scoped watch rows are deprecated and re
`watching` boolean is retained for compatibility and reports only those legacy rows; it must
not be used as collaboration follow state.
-There is no follower fan-out yet. `AlertRouter.destinations` contains
-`STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7)` at the point where follower destinations
-will join the existing recipient visibility and suppression pipeline.
+## Record follower routing
+
+For every active record ref on an alert, `AlertRouter.destinations` calls RC.6's
+`RecordFollowers.get(instance, ref, FollowEvent.ALERT)`. Register a core
+`RecordFollowersProviderInterface` through instance `RecordFeaturesMetaData.withFollowersProvider`
+and enable `RecordFeature.FOLLOW` for the source table (or by instance default). The provider
+selects followers for ALERT; core recommends treating ALERT like CHANGE unless the provider
+has a finer preference. The alerts qbit does not depend on qbit-collaboration.
+
+Each follower must have a known automated recipient session and be able to query the source
+record through its table permissions and record security locks. Missing records and unknown
+or denied access do not add follower destinations. The query uses the source table's configured
+primary key. Eligible followers join the existing destination map, deduplicated by user and
+channel across subscriptions and record refs. Existing subscription destinations take precedence.
+
+Followers use ordinary in-app/email settings, vacation pause for external delivery, alert
+visibility checks, type notification switches, mutes, flapping, channel limits and duplicate
+suppression. Quiet-hour scheduling remains unimplemented for all alert recipients, as listed
+in the README; follower routing does not introduce a separate delivery policy. With no provider,
+FOLLOW disabled, or a provider failure, existing subscription routing is unchanged.
## Alertmanager machine webhook
@@ -70,6 +87,10 @@ JSON request and `{raised, resolved, ignored}` response. No QQQ user session is
The previous **POST `/qqq/v1/alerts/ingest/alertmanager`** is an opt-in raw alias:
+The architecture guardrail's static G3 scan reports this intentional raw UI-prefix route
+as an advisory. Runtime strict-mode coverage rejects the alias; only the migration test
+starts a standalone Javalin instance with it enabled. Normal application tests remain strict.
+
```java
new AlertsQBitConfig()
.withLegacyAlertmanagerPath(true)
diff --git a/pom.xml b/pom.xml
index 7572037..39b553f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,12 +22,14 @@
21
21
0.8.15
+ true
+ true
com.kingsrook
qbit-build-parent
- 2.0.0
+ 2.1.0
com.kingsrook.qbits
diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java
index 94307e2..cfadf9c 100644
--- a/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java
+++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/DeliveryWorker.java
@@ -334,7 +334,7 @@ public class DeliveryWorker
/*******************************************************************************
- ** The stand-in alert a TEST message describes.
+ ** The sample alert a TEST message describes.
*******************************************************************************/
private static QRecord testAlert(Instant now)
{
diff --git a/src/main/java/com/kingsrook/qbits/alerts/delivery/channels/ExternalChannels.java b/src/main/java/com/kingsrook/qbits/alerts/delivery/channels/ExternalChannels.java
index 307e7dd..5e4a4f1 100644
--- a/src/main/java/com/kingsrook/qbits/alerts/delivery/channels/ExternalChannels.java
+++ b/src/main/java/com/kingsrook/qbits/alerts/delivery/channels/ExternalChannels.java
@@ -391,7 +391,7 @@ public final class ExternalChannels
/*******************************************************************************
- ** The message's entries (a TEST message gets one stand-in entry).
+ ** The message's entries (a TEST message gets one sample entry).
*******************************************************************************/
static List entries(AlertMessage message)
{
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 889228f..36b3f91 100644
--- a/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java
+++ b/src/main/java/com/kingsrook/qbits/alerts/engine/AlertRouter.java
@@ -45,14 +45,21 @@ import com.kingsrook.qbits.alerts.model.AlertsSubscription;
import com.kingsrook.qbits.alerts.model.AlertsTeamMember;
import com.kingsrook.qbits.alerts.model.AlertsType;
import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
+import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
+import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
+import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
+import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
+import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
+import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface;
@@ -303,7 +310,7 @@ public class AlertRouter
/*******************************************************************************
** Every destination for an alert: subscribed users (directly or through a
** team) with their in-app and email channels (or the subscription's
- ** chosen channels), and subscribed channels.
+ ** chosen channels), subscribed channels, and readable record followers.
*******************************************************************************/
List destinations(AlertTarget target, AlertSeverity severity, DeliveryKind kind) throws QException
{
@@ -353,8 +360,24 @@ public class AlertRouter
}
}
}
- // TODO STAND-IN(core=QRun-IO/qqq#1164, removeBy=4.1.0-RC.7): merge record followers
- // from RecordFollowersProviderInterface here, before the existing visibility/suppression checks.
+ for(String recordKey : target.recordKeys())
+ {
+ String[] parts = recordKey.split(":", 2);
+ RecordReference reference = new RecordReference(parts[0], parts[1]);
+ for(RecordFollower follower : RecordFollowers.get(runtime.getQInstance(), reference, FollowEvent.ALERT))
+ {
+ String userId = follower == null ? null : follower.getUserId();
+ if(!StringUtils.hasContent(userId) || !canReadRecord(userId, reference))
+ {
+ continue;
+ }
+ QRecord preference = AlertsDb.first(AlertsUserPreference.TABLE_NAME, AlertsDb.eq("userId", userId));
+ for(QRecord channel : userChannels(null, preference))
+ {
+ destinations.putIfAbsent("user:" + userId + ":" + channel.getValue("id"), new Destination(userId, channel, null, null, preference));
+ }
+ }
+ }
return (new ArrayList<>(destinations.values()));
}
@@ -367,7 +390,7 @@ public class AlertRouter
private List userChannels(QRecord subscription, QRecord preference) throws QException
{
List channels = new ArrayList<>();
- String overrides = subscription.getValueString("channelOverrideIds");
+ String overrides = subscription == null ? null : subscription.getValueString("channelOverrideIds");
if(StringUtils.hasContent(overrides))
{
for(String id : overrides.split("[,\\s]+"))
@@ -518,6 +541,39 @@ public class AlertRouter
+ /*******************************************************************************
+ ** Followers must be able to read the source record, including its record locks.
+ ** Reuse recipient sessions; unknown access fails closed before adding channels.
+ *******************************************************************************/
+ private boolean canReadRecord(String userId, RecordReference reference)
+ {
+ try
+ {
+ QSession session = automatedSession(userId);
+ if(session == null || !CollectionUtils.nullSafeHasContents(session.getPermissions()))
+ {
+ return (false);
+ }
+ QInstance instance = runtime.getQInstance();
+ AlertsSecurity.applyToSession(instance, session);
+ boolean[] readable = new boolean[1];
+ QContext.withTemporaryContext(new CapturedContext(instance, session), () ->
+ {
+ String primaryKey = instance.getTable(reference.tableName()).getPrimaryKeyField();
+ readable[0] = PermissionsHelper.hasTablePermission(new AbstractActionInput(), reference.tableName(), TablePermissionSubType.READ)
+ && AlertsDb.first(reference.tableName(), AlertsDb.eq(primaryKey, reference.primaryKey())) != null;
+ });
+ return (readable[0]);
+ }
+ catch(Exception e)
+ {
+ LOG.debug("Could not verify follower source-record access", e, logPair("userId", userId));
+ return (false);
+ }
+ }
+
+
+
/*******************************************************************************
** A session for a user, from the instance's authentication module (null
** when it can't make one).
diff --git a/src/test/java/com/kingsrook/qbits/alerts/delivery/ExternalChannelsTest.java b/src/test/java/com/kingsrook/qbits/alerts/delivery/ExternalChannelsTest.java
index 19db70b..2544265 100644
--- a/src/test/java/com/kingsrook/qbits/alerts/delivery/ExternalChannelsTest.java
+++ b/src/test/java/com/kingsrook/qbits/alerts/delivery/ExternalChannelsTest.java
@@ -237,9 +237,9 @@ class ExternalChannelsTest
List> resolvedBody = JsonUtils.toObject(received.get(1).body(), List.class);
assertThat(((Map, ?>) resolvedBody.get(0)).get("endsAt")).isEqualTo("2026-10-12T11:00:00Z");
- //////////////////////////////////////////////////////
- // a test message is one stand-in alert, already over //
- //////////////////////////////////////////////////////
+ ////////////////////////////////////////////////////
+ // a test message is one sample alert, already over //
+ ////////////////////////////////////////////////////
alertmanager.deliver(new AlertMessage("TEST: hello", "t", "h", "INFO", "TEST", List.of(), "https://x/app/alerts_alert"), channel, null, secrets);
assertThat(received.get(2).body()).contains("alerts.test");
}
diff --git a/src/test/java/com/kingsrook/qbits/alerts/engine/FollowerFanoutTest.java b/src/test/java/com/kingsrook/qbits/alerts/engine/FollowerFanoutTest.java
new file mode 100644
index 0000000..6e89048
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/alerts/engine/FollowerFanoutTest.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 2026 Kingsrook, LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.kingsrook.qbits.alerts.engine;
+
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import com.kingsrook.qbits.alerts.AlertsSecurity;
+import com.kingsrook.qbits.alerts.BaseAlertsTest;
+import com.kingsrook.qbits.alerts.model.AlertsDelivery;
+import com.kingsrook.qbits.alerts.model.AlertsNotificationRecipient;
+import com.kingsrook.qbits.alerts.model.AlertsSubscription;
+import com.kingsrook.qbits.alerts.model.AlertsUserPreference;
+import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface;
+import com.kingsrook.qqq.backend.core.alerts.AlertRecordRef;
+import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
+import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.data.QRecord;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
+import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
+import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
+import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
+import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
+import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+/*******************************************************************************
+ ** Core followers enter the normal routing pipeline without a collaboration qbit.
+ *******************************************************************************/
+class FollowerFanoutTest extends BaseAlertsTest
+{
+ /*******************************************************************************
+ ** Keep provider state isolated between tests; absence is the default.
+ *******************************************************************************/
+ @BeforeEach
+ void resetProvider()
+ {
+ Provider.followers = List.of();
+ Provider.references.clear();
+ Provider.failure = false;
+ }
+
+
+
+ /*******************************************************************************
+ ** Register the optional core SPI in instance metadata, with FOLLOW enabled.
+ *******************************************************************************/
+ private void followers(String... userIds)
+ {
+ Provider.followers = java.util.Arrays.stream(userIds).map(id -> new RecordFollower().withUserId(id).withNotifyOnChange(true)).toList();
+ RecordFeaturesMetaData.ofOrWithNew(qInstance).withEnabled(RecordFeature.FOLLOW, true)
+ .withFollowersProvider(new QCodeReference(Provider.class));
+ }
+
+
+
+ /*******************************************************************************
+ ** A normal subscriber must retain their destination when also following.
+ *******************************************************************************/
+ private void subscribe(String userId) throws QException
+ {
+ runtime.asSystem(() -> AlertsDb.insert(AlertsSubscription.TABLE_NAME, new QRecord()
+ .withValue("subscriberType", "USER").withValue("userId", userId)
+ .withValue("scopeType", "ALL").withValue("minSeverity", "WARNING").withValue("enabled", true)));
+ }
+
+
+
+ /*******************************************************************************
+ ** Use a real source row, without a subject-table visibility lock on the alert.
+ *******************************************************************************/
+ private void raise(Integer... widgetIds) throws QException
+ {
+ AlertSignal signal = new AlertSignal("test.followers").withTitle("Widget needs attention").withSeverity(AlertSeverity.ERROR)
+ .withDedupKey(java.util.Arrays.toString(widgetIds));
+ for(Integer widgetId : widgetIds)
+ {
+ signal.withRecordRef(AlertRecordRef.of(WIDGET, widgetId));
+ }
+ runtime.getEngine().raise(signal);
+ }
+
+
+
+ /*******************************************************************************
+ ** Followers from multiple refs and an existing subscription receive only once.
+ *******************************************************************************/
+ @Test
+ void followersJoinSubscribersOnce() throws Exception
+ {
+ List widgets = insertWidgets("BROKEN", "BROKEN");
+ followers(VIEWER, "viewer-2", VIEWER);
+ subscribe(VIEWER);
+ raise(widgets.toArray(Integer[]::new));
+
+ assertThat(Provider.references).containsExactly(new RecordReference(WIDGET, widgets.get(0).toString()), new RecordReference(WIDGET, widgets.get(1).toString()));
+ assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactlyInAnyOrder(VIEWER, "viewer-2");
+ assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(2).allSatisfy(row -> assertThat(row.getValueString("status")).isEqualTo("SENT"));
+ assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", VIEWER))).singleElement()
+ .satisfies(row -> assertThat(row.getValue("subscriptionId")).isNotNull());
+ assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo(ADMIN);
+ }
+
+
+
+ /*******************************************************************************
+ ** Table access and a known recipient session are required, even for the inbox.
+ *******************************************************************************/
+ @Test
+ void inaccessibleAndUnknownFollowersAreExcluded() throws Exception
+ {
+ followers(VIEWER, OUTSIDER, "unknown-user", "");
+ raise(insertWidgets("BROKEN").get(0));
+ assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
+ assertThat(all(AlertsDelivery.TABLE_NAME)).extracting(row -> row.getValueString("recipientUserId")).containsExactly(VIEWER);
+ }
+
+
+
+ /*******************************************************************************
+ ** Table permission alone is insufficient when the source has record locks.
+ *******************************************************************************/
+ @Test
+ void sourceRecordLocksAreEnforced() throws Exception
+ {
+ Integer widget = insertWidgets("BROKEN").get(0);
+ runtime.asSystem(() -> AlertsDb.update(WIDGET, new QRecord().withValue("id", widget).withValue("name", VIEWER)));
+ qInstance.getTable(WIDGET).withRecordSecurityLock(AlertsSecurity.ownerLock("name"));
+ followers(VIEWER, "viewer-2");
+ raise(widget);
+ assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
+ }
+
+
+
+ /*******************************************************************************
+ ** No provider preserves subscription behavior, even with a source record.
+ *******************************************************************************/
+ @Test
+ void noProviderPreservesSubscribers() throws Exception
+ {
+ subscribe(VIEWER);
+ raise(insertWidgets("BROKEN").get(0));
+ assertThat(Provider.references).isEmpty();
+ assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
+ }
+
+
+
+ /*******************************************************************************
+ ** Recordless alerts never ask for followers.
+ *******************************************************************************/
+ @Test
+ void noRecordDoesNotFanOut() throws Exception
+ {
+ followers(VIEWER);
+ raise();
+ assertThat(Provider.references).isEmpty();
+ assertThat(all(AlertsDelivery.TABLE_NAME)).isEmpty();
+ }
+
+
+
+ /*******************************************************************************
+ ** A disabled core FOLLOW feature leaves existing recipients intact.
+ *******************************************************************************/
+ @Test
+ void disabledFollowPreservesSubscribers() throws Exception
+ {
+ followers("viewer-2");
+ subscribe(VIEWER);
+ Integer widget = insertWidgets("BROKEN").get(0);
+ RecordFeaturesMetaData.ofOrWithNew(qInstance.getTable(WIDGET)).withEnabled(RecordFeature.FOLLOW, false);
+ raise(widget);
+ assertThat(Provider.references).isEmpty();
+ assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
+ }
+
+
+
+ /*******************************************************************************
+ ** Core isolates provider failures so normal subscribers still receive the alert.
+ *******************************************************************************/
+ @Test
+ void providerFailurePreservesSubscribers() throws Exception
+ {
+ followers("viewer-2");
+ subscribe(VIEWER);
+ Provider.failure = true;
+ raise(insertWidgets("BROKEN").get(0));
+ assertThat(Provider.references).hasSize(1);
+ assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly(VIEWER);
+ }
+
+
+
+ /*******************************************************************************
+ ** Followers use ordinary in-app/email preferences and vacation suppression.
+ *******************************************************************************/
+ @Test
+ void followerPreferencesAndPauseApply() throws Exception
+ {
+ followers(VIEWER, "viewer-2", "viewer-3");
+ runtime.asSystem(() ->
+ {
+ AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", VIEWER).withValue("inAppEnabled", false).withValue("emailEnabled", false));
+ AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "viewer-2").withValue("inAppEnabled", true)
+ .withValue("emailEnabled", true).withValue("emailAddress", "follower@example.com").withValue("pausedUntil", clock.instant().plus(Duration.ofDays(1))));
+ AlertsDb.insert(AlertsUserPreference.TABLE_NAME, new QRecord().withValue("userId", "viewer-3").withValue("inAppEnabled", false)
+ .withValue("emailEnabled", true).withValue("emailAddress", "other@example.com"));
+ return (null);
+ });
+ raise(insertWidgets("BROKEN").get(0));
+ assertThat(all(AlertsNotificationRecipient.TABLE_NAME)).extracting(row -> row.getValueString("userId")).containsExactly("viewer-2");
+ assertThat(all(AlertsDelivery.TABLE_NAME)).hasSize(3);
+ assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("suppressedReason", "USER_PAUSED"))).singleElement()
+ .satisfies(row -> assertThat(row.getValueString("recipientUserId")).isEqualTo("viewer-2"));
+ assertThat(where(AlertsDelivery.TABLE_NAME, AlertsDb.eq("recipientUserId", "viewer-3"))).singleElement()
+ .satisfies(row -> assertThat(row.getValueString("status")).isEqualTo("PENDING"));
+ }
+
+
+
+ /*******************************************************************************
+ ** A core-only provider; the event preference decision belongs to the provider.
+ *******************************************************************************/
+ public static class Provider implements RecordFollowersProviderInterface
+ {
+ static List followers = List.of();
+ static final List references = new ArrayList<>();
+ static boolean failure;
+
+
+
+ /***************************************************************************
+ ** Assert ALERT dispatch and record lookup context at the SPI boundary.
+ ***************************************************************************/
+ @Override
+ public List getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException
+ {
+ assertThat(instance).isSameAs(QContext.getQInstance());
+ assertThat(event).isEqualTo(FollowEvent.ALERT);
+ references.add(reference);
+ if(failure)
+ {
+ throw (new QException("Test provider unavailable"));
+ }
+ return (followers);
+ }
+ }
+}