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 MessageRenderer messageRenderer;
|
||||||
private final List<NotificationChannel> channels;
|
private final List<NotificationChannel> channels;
|
||||||
private final RecipientResolver recipientResolver;
|
private final RecipientResolver recipientResolver;
|
||||||
|
private final UserPreferencesGate preferencesGate;
|
||||||
|
|
||||||
private final Counter sentCounter;
|
private final Counter sentCounter;
|
||||||
private final Counter failedCounter;
|
private final Counter failedCounter;
|
||||||
@@ -101,6 +102,7 @@ public class NotificationsDispatcher {
|
|||||||
MessageRenderer messageRenderer,
|
MessageRenderer messageRenderer,
|
||||||
List<NotificationChannel> channels,
|
List<NotificationChannel> channels,
|
||||||
RecipientResolver recipientResolver,
|
RecipientResolver recipientResolver,
|
||||||
|
UserPreferencesGate preferencesGate,
|
||||||
MeterRegistry meterRegistry) {
|
MeterRegistry meterRegistry) {
|
||||||
this.outboxRepository = outboxRepository;
|
this.outboxRepository = outboxRepository;
|
||||||
this.notificationLogRepository = notificationLogRepository;
|
this.notificationLogRepository = notificationLogRepository;
|
||||||
@@ -108,6 +110,7 @@ public class NotificationsDispatcher {
|
|||||||
this.messageRenderer = messageRenderer;
|
this.messageRenderer = messageRenderer;
|
||||||
this.channels = channels;
|
this.channels = channels;
|
||||||
this.recipientResolver = recipientResolver;
|
this.recipientResolver = recipientResolver;
|
||||||
|
this.preferencesGate = preferencesGate;
|
||||||
|
|
||||||
this.sentCounter = Counter.builder("nsi_notification_sent_total")
|
this.sentCounter = Counter.builder("nsi_notification_sent_total")
|
||||||
.description("Total notifications successfully sent")
|
.description("Total notifications successfully sent")
|
||||||
@@ -197,6 +200,16 @@ public class NotificationsDispatcher {
|
|||||||
for (NotificationChannel channel : channels) {
|
for (NotificationChannel channel : channels) {
|
||||||
if (!channel.isEnabled()) continue;
|
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());
|
String recipientId = recipient.recipientFor(channel.kind());
|
||||||
if (recipientId == null || recipientId.isBlank()) {
|
if (recipientId == null || recipientId.isBlank()) {
|
||||||
skippedCounter.increment();
|
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();
|
notificationLogRepository = new StubNotificationLogRepository();
|
||||||
idempotencyGuard = new StubIdempotencyGuard(notificationLogRepository);
|
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(
|
dispatcher = new NotificationsDispatcher(
|
||||||
outboxRepository,
|
outboxRepository,
|
||||||
notificationLogRepository,
|
notificationLogRepository,
|
||||||
@@ -82,6 +90,7 @@ class NotificationsDispatcherTest {
|
|||||||
messageRenderer,
|
messageRenderer,
|
||||||
List.of(emailChannel),
|
List.of(emailChannel),
|
||||||
recipientResolver,
|
recipientResolver,
|
||||||
|
allAllowGate,
|
||||||
new SimpleMeterRegistry()
|
new SimpleMeterRegistry()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPrefRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase B — per-user channel preferences endpoint. Phase A был channel-agnostic
|
||||||
|
* (всем по email если subscribed in keycloak). Phase B даёт пользователю явный
|
||||||
|
* toggle per channel.
|
||||||
|
*
|
||||||
|
* <p>Schema (migration 0021 changeSet 0021-2): {@code user_notification_prefs
|
||||||
|
* (user_id VARCHAR(128), channel VARCHAR(16), enabled BOOLEAN)} с PK
|
||||||
|
* (user_id, channel). Channel CHECK constraint: email / express / telegram.
|
||||||
|
*
|
||||||
|
* <p>Default semantics when row missing: EMAIL ON, EXPRESS/TELEGRAM OFF.
|
||||||
|
* Mirrors {@link cloud.nstart.terravault.ordinis.notifications.dispatcher.UserPreferencesGate}.
|
||||||
|
*
|
||||||
|
* <p>Endpoints (both require auth — anonymous → 401):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET /api/v1/me/notifications/preferences} — returns current state
|
||||||
|
* as {@code {email: bool, express: bool, telegram: bool}}. Missing rows
|
||||||
|
* resolve через defaults.</li>
|
||||||
|
* <li>{@code PUT /api/v1/me/notifications/preferences} — upsert all three
|
||||||
|
* channels. Idempotent. Returns echoed state.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>NB: nginx routing трап (см. comment в admin-ui nginx.conf) — endpoint
|
||||||
|
* под {@code /api/v1/me/*} требует explicit route к writer (ordinis-app).
|
||||||
|
* Generic {@code /api/v1/*} GET fallback идёт в read-api → 500. Already
|
||||||
|
* mitigated через {@code /api/v1/me} location block добавленный в MR !197
|
||||||
|
* (см. nginx.conf).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/me/notifications/preferences")
|
||||||
|
public class MeNotificationsPreferencesController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MeNotificationsPreferencesController.class);
|
||||||
|
|
||||||
|
private static final String CHAN_EMAIL = "email";
|
||||||
|
private static final String CHAN_EXPRESS = "express";
|
||||||
|
private static final String CHAN_TELEGRAM = "telegram";
|
||||||
|
|
||||||
|
private final UserNotificationPrefRepository repo;
|
||||||
|
|
||||||
|
public MeNotificationsPreferencesController(UserNotificationPrefRepository repo) {
|
||||||
|
this.repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read current preferences. Missing rows resolve через defaults (email ON, others OFF). */
|
||||||
|
@GetMapping
|
||||||
|
public PreferencesDto get() {
|
||||||
|
String sub = requireSub();
|
||||||
|
Map<String, Boolean> saved = new HashMap<>();
|
||||||
|
for (UserNotificationPref row : repo.findByIdUserId(sub)) {
|
||||||
|
saved.put(row.getChannel(), row.isEnabled());
|
||||||
|
}
|
||||||
|
return new PreferencesDto(
|
||||||
|
saved.getOrDefault(CHAN_EMAIL, true), // default ON
|
||||||
|
saved.getOrDefault(CHAN_EXPRESS, false), // default OFF (channel TBD)
|
||||||
|
saved.getOrDefault(CHAN_TELEGRAM, false) // default OFF (channel TBD)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert all channel toggles. Always writes three rows (one per channel)
|
||||||
|
* чтобы DB state matched UI state — иначе "missing row" semantics утечет в
|
||||||
|
* surprise (e.g. user expects "email OFF" но row missing → dispatcher sees
|
||||||
|
* default-ON).
|
||||||
|
*/
|
||||||
|
@PutMapping
|
||||||
|
@Transactional
|
||||||
|
public PreferencesDto update(@RequestBody PreferencesDto body) {
|
||||||
|
String sub = requireSub();
|
||||||
|
if (body == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "request body required");
|
||||||
|
}
|
||||||
|
upsert(sub, CHAN_EMAIL, body.email());
|
||||||
|
upsert(sub, CHAN_EXPRESS, body.express());
|
||||||
|
upsert(sub, CHAN_TELEGRAM, body.telegram());
|
||||||
|
log.info("Notification prefs updated for user={}: email={}, express={}, telegram={}",
|
||||||
|
sub, body.email(), body.express(), body.telegram());
|
||||||
|
return new PreferencesDto(body.email(), body.express(), body.telegram());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void upsert(String userId, String channel, boolean enabled) {
|
||||||
|
UserNotificationPref.Pk pk = new UserNotificationPref.Pk(userId, channel);
|
||||||
|
UserNotificationPref existing = repo.findById(pk).orElse(null);
|
||||||
|
if (existing == null) {
|
||||||
|
repo.save(new UserNotificationPref(userId, channel, enabled));
|
||||||
|
} else if (existing.isEnabled() != enabled) {
|
||||||
|
existing.setEnabled(enabled);
|
||||||
|
repo.save(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull sub claim from JWT or 401. */
|
||||||
|
private String requireSub() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (!(auth instanceof JwtAuthenticationToken jwt)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "JWT required");
|
||||||
|
}
|
||||||
|
String sub = jwt.getToken().getSubject();
|
||||||
|
if (sub == null || sub.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "JWT sub claim missing");
|
||||||
|
}
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response/request shape — flat boolean per channel. Extensible (future
|
||||||
|
* channels добавятся как новые fields, backwards-compatible because JSON
|
||||||
|
* deserialization tolerates missing fields с record defaults). Per-event-type
|
||||||
|
* granularity defer'нута — потребует schema migration с {@code event_type}
|
||||||
|
* column на user_notification_prefs.
|
||||||
|
*/
|
||||||
|
public record PreferencesDto(boolean email, boolean express, boolean telegram) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user