feat(notifications): Phase B-2 — per-event-type granular preferences
This commit is contained in:
+109
-52
@@ -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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user