feat(notifications): Phase B backend — per-user channel preferences
This commit is contained in:
+13
@@ -77,6 +77,7 @@ public class NotificationsDispatcher {
|
||||
private final MessageRenderer messageRenderer;
|
||||
private final List<NotificationChannel> channels;
|
||||
private final RecipientResolver recipientResolver;
|
||||
private final UserPreferencesGate preferencesGate;
|
||||
|
||||
private final Counter sentCounter;
|
||||
private final Counter failedCounter;
|
||||
@@ -101,6 +102,7 @@ public class NotificationsDispatcher {
|
||||
MessageRenderer messageRenderer,
|
||||
List<NotificationChannel> channels,
|
||||
RecipientResolver recipientResolver,
|
||||
UserPreferencesGate preferencesGate,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.outboxRepository = outboxRepository;
|
||||
this.notificationLogRepository = notificationLogRepository;
|
||||
@@ -108,6 +110,7 @@ public class NotificationsDispatcher {
|
||||
this.messageRenderer = messageRenderer;
|
||||
this.channels = channels;
|
||||
this.recipientResolver = recipientResolver;
|
||||
this.preferencesGate = preferencesGate;
|
||||
|
||||
this.sentCounter = Counter.builder("nsi_notification_sent_total")
|
||||
.description("Total notifications successfully sent")
|
||||
@@ -197,6 +200,16 @@ 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());
|
||||
skippedCounter.increment();
|
||||
continue;
|
||||
}
|
||||
|
||||
String recipientId = recipient.recipientFor(channel.kind());
|
||||
if (recipientId == null || recipientId.isBlank()) {
|
||||
skippedCounter.increment();
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref;
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPrefRepository;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Per-recipient channel preferences gate — checks user_notification_prefs row
|
||||
* before dispatcher actually sends. Phase B (`/me/notifications/preferences` UI).
|
||||
*
|
||||
* <p>Default semantics when row is MISSING (user never visited preferences page):
|
||||
* <ul>
|
||||
* <li>{@link ChannelKind#EMAIL} — ON (matches Phase A default-on behavior).</li>
|
||||
* <li>{@link ChannelKind#EXPRESS} / {@link ChannelKind#TELEGRAM} — OFF (require
|
||||
* explicit opt-in; channels also not implemented yet on backend).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Reviewer-pool pseudo-user ({@code userId="reviewer-pool"}) bypasses gate —
|
||||
* pool is a shared inbox, individual prefs don't apply. Always considered ON.
|
||||
*
|
||||
* <p>{@code @ConditionalOnProperty} mirrors {@link NotificationsDispatcher} —
|
||||
* bean не register'ится без {@code ordinis.notifications.enabled=true}.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class UserPreferencesGate {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserPreferencesGate.class);
|
||||
|
||||
/** Pseudo userId used for the reviewer-pool shared inbox. Never has prefs. */
|
||||
public static final String REVIEWER_POOL_USER_ID = "reviewer-pool";
|
||||
|
||||
private final UserNotificationPrefRepository repo;
|
||||
|
||||
public UserPreferencesGate(UserNotificationPrefRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true если recipient should receive notification on this channel.
|
||||
* Considers default-on policy (EMAIL) и default-off (EXPRESS/TELEGRAM).
|
||||
*/
|
||||
public boolean allows(String userId, ChannelKind channel) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
return defaultFor(channel);
|
||||
}
|
||||
if (REVIEWER_POOL_USER_ID.equals(userId)) {
|
||||
// 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;
|
||||
}
|
||||
return row.get().isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default policy when no row exists. EMAIL = ON (preserves Phase A behavior
|
||||
* где users получали emails by default). Others = OFF (require opt-in).
|
||||
*/
|
||||
private boolean defaultFor(ChannelKind channel) {
|
||||
return channel == ChannelKind.EMAIL;
|
||||
}
|
||||
}
|
||||
+9
@@ -75,6 +75,14 @@ class NotificationsDispatcherTest {
|
||||
notificationLogRepository = new StubNotificationLogRepository();
|
||||
idempotencyGuard = new StubIdempotencyGuard(notificationLogRepository);
|
||||
|
||||
// Phase B: UserPreferencesGate — stub all-allow для existing tests (no
|
||||
// prefs row → defaults apply, email ON). Per-channel skip tested separately.
|
||||
UserPreferencesGate allAllowGate = new UserPreferencesGate(null) {
|
||||
@Override
|
||||
public boolean allows(String userId, cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
dispatcher = new NotificationsDispatcher(
|
||||
outboxRepository,
|
||||
notificationLogRepository,
|
||||
@@ -82,6 +90,7 @@ class NotificationsDispatcherTest {
|
||||
messageRenderer,
|
||||
List.of(emailChannel),
|
||||
recipientResolver,
|
||||
allAllowGate,
|
||||
new SimpleMeterRegistry()
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user