feat(notifications): Phase B-2 — per-event-type granular preferences

This commit is contained in:
Александр Зимин
2026-05-15 16:53:32 +00:00
parent b5c9d07403
commit f04b1be1f6
11 changed files with 420 additions and 175 deletions
@@ -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;
}
@@ -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;
}
/**
@@ -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;
}
};