diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcher.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcher.java index c1407ae..0af927c 100644 --- a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcher.java +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcher.java @@ -77,6 +77,7 @@ public class NotificationsDispatcher { private final MessageRenderer messageRenderer; private final List 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 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(); diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGate.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGate.java new file mode 100644 index 0000000..175bc08 --- /dev/null +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGate.java @@ -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). + * + *

Default semantics when row is MISSING (user never visited preferences page): + *

+ * + *

Reviewer-pool pseudo-user ({@code userId="reviewer-pool"}) bypasses gate — + * pool is a shared inbox, individual prefs don't apply. Always considered ON. + * + *

{@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 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; + } +} diff --git a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcherTest.java b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcherTest.java index 7ae23bd..3786cb7 100644 --- a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcherTest.java +++ b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcherTest.java @@ -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() ); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsPreferencesController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsPreferencesController.java new file mode 100644 index 0000000..000b7ad --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsPreferencesController.java @@ -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. + * + *

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. + * + *

Default semantics when row missing: EMAIL ON, EXPRESS/TELEGRAM OFF. + * Mirrors {@link cloud.nstart.terravault.ordinis.notifications.dispatcher.UserPreferencesGate}. + * + *

Endpoints (both require auth — anonymous → 401): + *

+ * + *

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 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) {} +}