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
@@ -16,36 +16,37 @@ 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.LinkedHashMap;
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.
* Phase B + B-2 — per-user notification preferences endpoint.
*
* <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):
* <p>Schema timeline:
* <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>
* <li>0021-2: {@code (user_id, channel) → enabled} — per-channel toggle</li>
* <li>0025: + {@code event_type} column. PK now {@code (user_id, event_type, channel)}.
* Wildcard {@code event_type='*'} matches любое event для backward compat.</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).
* <p>Endpoints (require auth — anonymous → 401):
* <ul>
* <li>{@code GET /preferences} — matrix shape:
* <pre>{
* "RecordDraftSubmitted": {"email": true, "express": false, "telegram": false},
* "RecordDraftApproved": {"email": true, "express": false, "telegram": false},
* "RecordDraftRejected": {"email": true, "express": false, "telegram": false},
* "RecordDraftWithdrawn": {"email": true, "express": false, "telegram": false}
* }</pre>
* Resolution: specific row → wildcard '*' → default policy.</li>
* <li>{@code PUT /preferences} — atomic upsert per (event_type, channel) pair.</li>
* </ul>
*
* <p>Default semantics для missing rows: EMAIL ON, EXPRESS/TELEGRAM OFF.
*
* <p>Frontend matrix UI: 4 events × 3 channels = 12 toggles. User может
* opt-out 'Submitted via email' (queue noise) keep 'Approved via email'.
*/
@RestController
@RequestMapping("/api/v1/me/notifications/preferences")
@@ -57,53 +58,111 @@ public class MeNotificationsPreferencesController {
private static final String CHAN_EXPRESS = "express";
private static final String CHAN_TELEGRAM = "telegram";
/**
* Канonical event types — должны matchить {@code NotificationsDispatcher.DRAFT_EVENT_TYPES}.
* Listed в order для stable UI rendering. New event types добавятся обоим
* лежим (dispatcher + this list) одним PR.
*/
private static final List<String> KNOWN_EVENT_TYPES = List.of(
"RecordDraftSubmitted",
"RecordDraftApproved",
"RecordDraftRejected",
"RecordDraftWithdrawn"
);
private final UserNotificationPrefRepository repo;
public MeNotificationsPreferencesController(UserNotificationPrefRepository repo) {
this.repo = repo;
}
/** Read current preferences. Missing rows resolve через defaults (email ON, others OFF). */
/**
* Read matrix preferences. For каждого known event type returns
* {email, express, telegram} with defaults filled in.
*
* <p>Resolution для каждой ячейки (eventType, channel):
* 1. specific row если exists; else
* 2. wildcard '*' row (Phase B compat); else
* 3. default policy (EMAIL ON, others OFF).
*/
@GetMapping
public PreferencesDto get() {
public Map<String, ChannelToggles> get() {
String sub = requireSub();
Map<String, Boolean> saved = new HashMap<>();
// Single fetch всех rows этого user'a — затем в-memory lookup.
// Дешевле чем 12 individual queries.
Map<String, Map<String, Boolean>> byEventThenChannel = new LinkedHashMap<>();
for (UserNotificationPref row : repo.findByIdUserId(sub)) {
saved.put(row.getChannel(), row.isEnabled());
byEventThenChannel
.computeIfAbsent(row.getEventType(), k -> new LinkedHashMap<>())
.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)
Map<String, ChannelToggles> result = new LinkedHashMap<>();
for (String eventType : KNOWN_EVENT_TYPES) {
result.put(eventType, resolveToggles(byEventThenChannel, eventType));
}
return result;
}
private ChannelToggles resolveToggles(
Map<String, Map<String, Boolean>> byEventThenChannel, String eventType) {
Map<String, Boolean> specific = byEventThenChannel.getOrDefault(eventType, Map.of());
Map<String, Boolean> wildcard = byEventThenChannel.getOrDefault(
UserNotificationPref.ANY_EVENT_TYPE, Map.of());
return new ChannelToggles(
resolveOne(specific, wildcard, CHAN_EMAIL, true), // default ON
resolveOne(specific, wildcard, CHAN_EXPRESS, false), // default OFF
resolveOne(specific, wildcard, CHAN_TELEGRAM, false) // default OFF
);
}
private boolean resolveOne(
Map<String, Boolean> specific, Map<String, Boolean> wildcard,
String channel, boolean defaultValue) {
if (specific.containsKey(channel)) return specific.get(channel);
if (wildcard.containsKey(channel)) return wildcard.get(channel);
return defaultValue;
}
/**
* 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).
* Upsert all (eventType, channel) cells. Body shape mirrors GET response —
* matrix of event types → channel toggles. Atomic transaction.
*
* <p>Wildcard rows ('*') не writes'я — UI manages только specific event types.
* Existing wildcard rows preserved (но overshadowed specific rows если они есть).
*/
@PutMapping
@Transactional
public PreferencesDto update(@RequestBody PreferencesDto body) {
public Map<String, ChannelToggles> update(@RequestBody Map<String, ChannelToggles> body) {
String sub = requireSub();
if (body == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "request body required");
if (body == null || body.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "matrix 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());
int writes = 0;
for (Map.Entry<String, ChannelToggles> entry : body.entrySet()) {
String eventType = entry.getKey();
// Only allow whitelisted event types — guard против garbage rows.
if (!KNOWN_EVENT_TYPES.contains(eventType)) {
log.warn("Skipping unknown event type in PUT prefs: user={} eventType={}", sub, eventType);
continue;
}
ChannelToggles t = entry.getValue();
if (t == null) continue;
upsert(sub, eventType, CHAN_EMAIL, t.email());
upsert(sub, eventType, CHAN_EXPRESS, t.express());
upsert(sub, eventType, CHAN_TELEGRAM, t.telegram());
writes += 3;
}
log.info("Notification prefs updated: user={} cells={}", sub, writes);
return get();
}
private void upsert(String userId, String channel, boolean enabled) {
UserNotificationPref.Pk pk = new UserNotificationPref.Pk(userId, channel);
private void upsert(String userId, String eventType, String channel, boolean enabled) {
UserNotificationPref.Pk pk = new UserNotificationPref.Pk(userId, eventType, channel);
UserNotificationPref existing = repo.findById(pk).orElse(null);
if (existing == null) {
repo.save(new UserNotificationPref(userId, channel, enabled));
repo.save(new UserNotificationPref(userId, eventType, channel, enabled));
} else if (existing.isEnabled() != enabled) {
existing.setEnabled(enabled);
repo.save(existing);
@@ -124,11 +183,9 @@ public class MeNotificationsPreferencesController {
}
/**
* 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.
* Toggles per channel для одного event type. Future channels добавятся как
* новые поля — JSON deserialization tolerates missing fields через record
* defaults (Java reflection treats missing as false для boolean primitive).
*/
public record PreferencesDto(boolean email, boolean express, boolean telegram) {}
public record ChannelToggles(boolean email, boolean express, boolean telegram) {}
}