feat(notifications): Phase B-2 — per-event-type granular preferences
This commit is contained in:
+8
-6
@@ -206,12 +206,14 @@ public class NotificationsDispatcher {
|
||||
for (NotificationChannel channel : channels) {
|
||||
if (!channel.isEnabled()) continue;
|
||||
|
||||
// Phase B: check user preferences before send. Default policy:
|
||||
// EMAIL = ON, EXPRESS/TELEGRAM = OFF when no row. Reviewer-pool
|
||||
// bypasses gate (shared inbox).
|
||||
if (!preferencesGate.allows(recipient.userId(), channel.kind())) {
|
||||
log.debug("Skip channel={} for user={} — preferences opt-out",
|
||||
channel.kind().wireName(), recipient.userId());
|
||||
// Phase B-2: per-event-type granularity. Gate резолвит:
|
||||
// 1) (user, eventType, channel) — specific row
|
||||
// 2) (user, '*', channel) — Phase B wildcard fallback
|
||||
// 3) Default policy (EMAIL ON, EXPRESS/TELEGRAM OFF)
|
||||
// Reviewer-pool bypass (shared inbox) — игнорирует eventType.
|
||||
if (!preferencesGate.allows(recipient.userId(), eventType, channel.kind())) {
|
||||
log.debug("Skip channel={} eventType={} for user={} — preferences opt-out",
|
||||
channel.kind().wireName(), eventType, recipient.userId());
|
||||
skippedCounter.increment();
|
||||
continue;
|
||||
}
|
||||
|
||||
+35
-10
@@ -10,6 +10,8 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref.ANY_EVENT_TYPE;
|
||||
|
||||
/**
|
||||
* Per-recipient channel preferences gate — checks user_notification_prefs row
|
||||
* before dispatcher actually sends. Phase B (`/me/notifications/preferences` UI).
|
||||
@@ -43,10 +45,20 @@ public class UserPreferencesGate {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true если recipient should receive notification on this channel.
|
||||
* Considers default-on policy (EMAIL) и default-off (EXPRESS/TELEGRAM).
|
||||
* Phase B-2: per-event-type granularity. Lookup precedence:
|
||||
* <ol>
|
||||
* <li>Specific {@code (user, eventType, channel)} → use its enabled flag</li>
|
||||
* <li>Wildcard {@code (user, '*', channel)} → fallback (Phase B per-channel)</li>
|
||||
* <li>Default policy — EMAIL ON, EXPRESS/TELEGRAM OFF</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param userId Keycloak sub claim, или special {@link #REVIEWER_POOL_USER_ID}
|
||||
* @param eventType outbox event type (e.g. RecordDraftSubmitted) — passed
|
||||
* by dispatcher per-event. Reviewer-pool bypass игнорирует
|
||||
* eventType (всегда ON).
|
||||
* @param channel target channel
|
||||
*/
|
||||
public boolean allows(String userId, ChannelKind channel) {
|
||||
public boolean allows(String userId, String eventType, ChannelKind channel) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
return defaultFor(channel);
|
||||
}
|
||||
@@ -54,14 +66,27 @@ public class UserPreferencesGate {
|
||||
// Pool is a shared inbox — always ON, prefs don't apply.
|
||||
return true;
|
||||
}
|
||||
Optional<UserNotificationPref> row = repo.findByIdUserIdAndIdChannel(userId, channel.wireName());
|
||||
if (row.isEmpty()) {
|
||||
boolean defaultOn = defaultFor(channel);
|
||||
log.trace("No prefs row for user={} channel={}, defaulting to {}",
|
||||
userId, channel.wireName(), defaultOn);
|
||||
return defaultOn;
|
||||
String channelWire = channel.wireName();
|
||||
|
||||
// Step 1: specific (user, eventType, channel)
|
||||
Optional<UserNotificationPref> specific =
|
||||
repo.findByIdUserIdAndIdEventTypeAndIdChannel(userId, eventType, channelWire);
|
||||
if (specific.isPresent()) {
|
||||
return specific.get().isEnabled();
|
||||
}
|
||||
return row.get().isEnabled();
|
||||
|
||||
// Step 2: wildcard (user, '*', channel) — Phase B compatibility
|
||||
Optional<UserNotificationPref> wildcard =
|
||||
repo.findByIdUserIdAndIdEventTypeAndIdChannel(userId, ANY_EVENT_TYPE, channelWire);
|
||||
if (wildcard.isPresent()) {
|
||||
return wildcard.get().isEnabled();
|
||||
}
|
||||
|
||||
// Step 3: default policy
|
||||
boolean defaultOn = defaultFor(channel);
|
||||
log.trace("No prefs row для user={} eventType={} channel={}, defaulting to {}",
|
||||
userId, eventType, channelWire, defaultOn);
|
||||
return defaultOn;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -77,9 +77,11 @@ class NotificationsDispatcherTest {
|
||||
|
||||
// Phase B: UserPreferencesGate — stub all-allow для existing tests (no
|
||||
// prefs row → defaults apply, email ON). Per-channel skip tested separately.
|
||||
// Phase B-2 signature: allows(userId, eventType, channel)
|
||||
UserPreferencesGate allAllowGate = new UserPreferencesGate(null) {
|
||||
@Override
|
||||
public boolean allows(String userId, cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) {
|
||||
public boolean allows(String userId, String eventType,
|
||||
cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user