From f04b1be1f6a75ad13d2b7a43e71273a3599f0691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Fri, 15 May 2026 16:53:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(notifications):=20Phase=20B-2=20=E2=80=94?= =?UTF-8?q?=20per-event-type=20granular=20preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/api/client.ts | 36 +++- ordinis-admin-ui/src/i18n.ts | 30 +++ .../routes/me.notifications.preferences.tsx | 199 ++++++++++-------- .../notification/UserNotificationPref.java | 42 +++- .../UserNotificationPrefRepository.java | 13 +- .../0025-notification-prefs-event-type.xml | 50 +++++ .../main/resources/db/changelog/master.xml | 1 + .../dispatcher/NotificationsDispatcher.java | 14 +- .../dispatcher/UserPreferencesGate.java | 45 +++- .../NotificationsDispatcherTest.java | 4 +- .../MeNotificationsPreferencesController.java | 161 +++++++++----- 11 files changed, 420 insertions(+), 175 deletions(-) create mode 100644 ordinis-migrations/src/main/resources/db/changelog/changes/0025-notification-prefs-event-type.xml diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 97df656..5ee7122 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -721,16 +721,38 @@ export type NotificationsResponse = { } /** - * Phase B — per-channel notification opt-in/out для current user. Backend - * endpoint: GET/PUT /api/v1/me/notifications/preferences. Default semantics - * (server-side, when row missing): email=true, express=false, telegram=false. + * Phase B-2 — per-(eventType, channel) notification opt-in/out для current + * user. Backend endpoint: GET/PUT /api/v1/me/notifications/preferences. * - *

Express/Telegram channels пока not wired backend-side — toggles в UI - * disabled с tooltip 'Скоро'. Email — единственный фактически работающий - * канал в Phase A/B. + *

Matrix shape: 4 known event types × 3 channels = 12 toggles. User может + * opt-out 'Submitted via email' (queue noise) keep 'Approved via email' + * (action confirmation). + * + *

Defaults (server-side, для missing rows): + * email=true, express=false, telegram=false. + * + *

Resolution для каждой ячейки: specific row → wildcard '*' (Phase B + * compat) → default policy. + * + *

Express/Telegram channels не wired backend-side — toggles в UI disabled. */ -export type NotificationPreferences = { +export type ChannelToggles = { email: boolean express: boolean telegram: boolean } + +/** Map keyed по event type. Backend сейчас возвращает 4 known events. */ +export type NotificationPreferences = Record + +/** + * Canonical event types — должны совпадать с backend + * MeNotificationsPreferencesController.KNOWN_EVENT_TYPES. + */ +export const NOTIFICATION_EVENT_TYPES = [ + 'RecordDraftSubmitted', + 'RecordDraftApproved', + 'RecordDraftRejected', + 'RecordDraftWithdrawn', +] as const +export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number] diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index f74a613..563dbce 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -635,6 +635,21 @@ i18n 'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.', 'prefs.comingSoon': 'Скоро', 'prefs.saveError': 'Не удалось сохранить настройки. Попробуйте ещё раз.', + 'prefs.descriptionMatrix': + 'Per-event настройки доставки. Reviewer pool inbox остаётся ON независимо от ваших настроек.', + 'prefs.matrix.eventCol': 'Событие', + 'prefs.event.RecordDraftSubmitted.label': 'Отправлено на ревью', + 'prefs.event.RecordDraftSubmitted.description': + 'Maker submit-нул draft. Reviewer pool ping о новом элементе в очереди.', + 'prefs.event.RecordDraftApproved.label': 'Одобрено', + 'prefs.event.RecordDraftApproved.description': + 'Reviewer одобрил ваш draft — record live-нут.', + 'prefs.event.RecordDraftRejected.label': 'Отклонено', + 'prefs.event.RecordDraftRejected.description': + 'Reviewer отклонил draft с комментарием — нужны правки.', + 'prefs.event.RecordDraftWithdrawn.label': 'Отозвано', + 'prefs.event.RecordDraftWithdrawn.description': + 'Maker отозвал свой draft до решения. Pool ping чтобы вычистить очередь.', 'notifications.preferencesLink': 'Настроить каналы →', 'reviews.mine.filter.all': 'Все', 'reviews.mine.filter.pending': 'На ревью', @@ -1414,6 +1429,21 @@ i18n 'Telegram bot. Channel not wired yet — toggle is stored for later.', 'prefs.comingSoon': 'Soon', 'prefs.saveError': 'Failed to save preferences. Please try again.', + 'prefs.descriptionMatrix': + 'Per-event delivery preferences. Reviewer pool inbox stays ON regardless of your settings.', + 'prefs.matrix.eventCol': 'Event', + 'prefs.event.RecordDraftSubmitted.label': 'Submitted for review', + 'prefs.event.RecordDraftSubmitted.description': + 'Maker submitted a draft. Reviewer pool ping about new queue item.', + 'prefs.event.RecordDraftApproved.label': 'Approved', + 'prefs.event.RecordDraftApproved.description': + 'Reviewer approved your draft — record went live.', + 'prefs.event.RecordDraftRejected.label': 'Rejected', + 'prefs.event.RecordDraftRejected.description': + 'Reviewer rejected the draft with a comment — needs fixes.', + 'prefs.event.RecordDraftWithdrawn.label': 'Withdrawn', + 'prefs.event.RecordDraftWithdrawn.description': + 'Maker withdrew their draft before decision. Pool ping for queue cleanup.', 'notifications.preferencesLink': 'Manage channels →', 'reviews.mine.emptyFilter': 'No drafts in this category', 'reviews.mine.filter.all': 'All', diff --git a/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx index b674b60..f349bc7 100644 --- a/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx +++ b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx @@ -12,22 +12,40 @@ import { } from '@/ui' import { useMyNotificationPreferences } from '@/api/queries' import { useUpdateNotificationPreferences } from '@/api/mutations' -import type { NotificationPreferences } from '@/api/client' +import { + NOTIFICATION_EVENT_TYPES, + type ChannelToggles, + type NotificationEventType, + type NotificationPreferences, +} from '@/api/client' export const Route = createFileRoute('/me/notifications/preferences')({ component: NotificationPreferencesPage, }) +const CHANNELS: { key: keyof ChannelToggles; disabled: boolean }[] = [ + { key: 'email', disabled: false }, + { key: 'express', disabled: true }, + { key: 'telegram', disabled: true }, +] + +const DEFAULT_TOGGLES: ChannelToggles = { + email: true, + express: false, + telegram: false, +} + /** - * Phase B — UI для per-channel notification opt-in/out. + * Phase B-2 — matrix UI для per-(eventType, channel) preferences. * - *

Email — единственный actually wired channel в Phase A/B (через Spring - * \`spring.mail.host\`). Express + Telegram toggles disabled с подсказкой - * "Скоро" — backend хранит preference (на случай early opt-in), но dispatcher - * никогда не пошлёт пока channel bean не зарегистрирован. + *

4 events × 3 channels = 12 toggles. User can opt-out 'Submitted via + * email' (queue noise) keep 'Approved via email' (action confirmation). * - *

Optimistic update — toggle flips мгновенно, network roundtrip ~200ms - * почти не виден. onError refetch восстанавливает state если PUT 4xx/5xx. + *

Express + Telegram disabled с подсказкой "Скоро" — backend хранит + * preferences (на случай early opt-in), но dispatcher не пошлёт пока channel + * bean не зарегистрирован. + * + *

Optimistic update — toggle flips мгновенно. onError refetch восстанавливает. */ function NotificationPreferencesPage() { const { t } = useTranslation() @@ -42,7 +60,7 @@ function NotificationPreferencesPage() { title={t('prefs.title', { defaultValue: 'Уведомления' })} description={t('prefs.description', { defaultValue: - 'Per-channel настройки доставки. Применяется только к вашим уведомлениям.', + 'Per-event настройки доставки. Применяется только к вашим уведомлениям.', })} /> prefsQ.refetch()} /> } - const prefs: NotificationPreferences = prefsQ.data ?? { - email: true, - express: false, - telegram: false, - } + // Backend гарантирует что все KNOWN_EVENT_TYPES present с filled defaults. + // Если query возвращает пустоту (cold start), local-fill через DEFAULT_TOGGLES. + const prefs: NotificationPreferences = prefsQ.data ?? {} - const handleToggle = (channel: keyof NotificationPreferences, next: boolean) => { - updateMut.mutate({ ...prefs, [channel]: next }) + const handleToggle = ( + eventType: NotificationEventType, + channel: keyof ChannelToggles, + next: boolean, + ) => { + const currentRow = prefs[eventType] ?? { ...DEFAULT_TOGGLES } + const updatedRow: ChannelToggles = { ...currentRow, [channel]: next } + const updatedPrefs: NotificationPreferences = { ...prefs, [eventType]: updatedRow } + updateMut.mutate(updatedPrefs) } return ( -

- handleToggle('email', v)} - /> - handleToggle('express', v)} - disabled - comingSoon - /> - handleToggle('telegram', v)} - disabled - comingSoon - /> +
+ + + + + {CHANNELS.map(({ key, disabled }) => ( + + ))} + + + + {NOTIFICATION_EVENT_TYPES.map((eventType) => { + const row = prefs[eventType] ?? DEFAULT_TOGGLES + return ( + + + {CHANNELS.map(({ key, disabled }) => ( + + ))} + + ) + })} + +
+ {t('prefs.matrix.eventCol', { defaultValue: 'Событие' })} + +
+ {t(`prefs.${key}.label`, { defaultValue: capitalize(key) })} + {disabled && ( + + {t('prefs.comingSoon', { defaultValue: 'Скоро' })} + + )} +
+
+
+ {t(`prefs.event.${eventType}.label`, { + defaultValue: humanizeEventType(eventType), + })} +
+

+ {t(`prefs.event.${eventType}.description`, { + defaultValue: '', + })} +

+
+ handleToggle(eventType, key, v)} + disabled={disabled} + aria-label={`${eventType} / ${key}`} + /> +
{updateMut.isError && ( @@ -138,43 +185,21 @@ function NotificationPreferencesPage() { ) } -type ToggleRowProps = { - label: string - description: string - checked: boolean - onChange: (v: boolean) => void - disabled?: boolean - comingSoon?: boolean +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1) } -function ToggleRow({ - label, - description, - checked, - onChange, - disabled, - comingSoon, -}: ToggleRowProps) { - const { t } = useTranslation() - return ( -
-
-
- {label} - {comingSoon && ( - - {t('prefs.comingSoon', { defaultValue: 'Скоро' })} - - )} -
-

{description}

-
- -
- ) +function humanizeEventType(eventType: NotificationEventType): string { + switch (eventType) { + case 'RecordDraftSubmitted': + return 'Отправлено на ревью' + case 'RecordDraftApproved': + return 'Одобрено' + case 'RecordDraftRejected': + return 'Отклонено' + case 'RecordDraftWithdrawn': + return 'Отозвано' + default: + return eventType + } } diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPref.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPref.java index f0914be..cdf0746 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPref.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPref.java @@ -10,47 +10,70 @@ import java.io.Serializable; import java.util.Objects; /** - * Per-user channel opt-in/out (см. migration 0021 + design doc D3). + * Per-user notification preferences row (см. migration 0021 + 0025 + + * design doc D3). * - *

Composite PK {@code (user_id, channel)}. Default {@code enabled=false} для - * новых rows; bootstrap loadData может включить email для existing reviewer/maker - * ролей при первом deploy. + *

Composite PK {@code (user_id, event_type, channel)}. Phase B-2 added + * {@code event_type} dimension чтобы reviewer мог отключить + * 'RecordDraftSubmitted' emails (queue noise) и оставить + * 'RecordDraftApproved/Rejected' (action confirmations). + * + *

Special value {@code event_type='*'} — wildcard: matches любое event type + * для (user, channel) pair. Используется для bulk per-channel opt-out (Phase B + * default) и backward compatibility — pre-0025 rows получили '*' через DEFAULT. * *

Channel — lowercase wire format ('email' / 'express' / 'telegram'). + * + *

Lookup precedence в {@code UserPreferencesGate}: + *

    + *
  1. Specific {@code (user, eventType, channel)} → use если найдено
  2. + *
  3. Wildcard {@code (user, '*', channel)} → fallback (Phase B behavior)
  4. + *
  5. Default policy — email ON, express+telegram OFF
  6. + *
*/ @Entity @Table(name = "user_notification_prefs") public class UserNotificationPref { + /** Wildcard event_type — matches любое event для (user, channel). */ + public static final String ANY_EVENT_TYPE = "*"; + @Embeddable public static class Pk implements Serializable { @Column(name = "user_id", length = 128, nullable = false) private String userId; + @Column(name = "event_type", length = 64, nullable = false) + private String eventType; + @Column(name = "channel", length = 16, nullable = false) private String channel; protected Pk() {} - public Pk(String userId, String channel) { + public Pk(String userId, String eventType, String channel) { this.userId = userId; + this.eventType = eventType; this.channel = channel; } public String getUserId() { return userId; } + public String getEventType() { return eventType; } public String getChannel() { return channel; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Pk pk)) return false; - return Objects.equals(userId, pk.userId) && Objects.equals(channel, pk.channel); + return Objects.equals(userId, pk.userId) + && Objects.equals(eventType, pk.eventType) + && Objects.equals(channel, pk.channel); } @Override public int hashCode() { - return Objects.hash(userId, channel); + return Objects.hash(userId, eventType, channel); } } @@ -62,13 +85,14 @@ public class UserNotificationPref { protected UserNotificationPref() {} - public UserNotificationPref(String userId, String channel, boolean enabled) { - this.id = new Pk(userId, channel); + public UserNotificationPref(String userId, String eventType, String channel, boolean enabled) { + this.id = new Pk(userId, eventType, channel); this.enabled = enabled; } public Pk getId() { return id; } public String getUserId() { return id.getUserId(); } + public String getEventType() { return id.getEventType(); } public String getChannel() { return id.getChannel(); } public boolean isEnabled() { return enabled; } diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java index 721c83f..8fae28f 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationPrefRepository.java @@ -8,9 +8,16 @@ import java.util.Optional; public interface UserNotificationPrefRepository extends JpaRepository { - /** Все каналы пользователя — для GET /api/v1/me/notifications page. */ + /** + * Все prefs пользователя — для GET /api/v1/me/notifications/preferences + * matrix view. Includes wildcard ('*') rows + per-event rows. + */ List findByIdUserId(String userId); - /** Single channel lookup для dispatcher (skip if disabled). */ - Optional findByIdUserIdAndIdChannel(String userId, String channel); + /** + * Specific (user, eventType, channel) lookup для dispatcher fast path. + * Если empty → caller fallback на wildcard ('*' eventType). + */ + Optional findByIdUserIdAndIdEventTypeAndIdChannel( + String userId, String eventType, String channel); } diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0025-notification-prefs-event-type.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0025-notification-prefs-event-type.xml new file mode 100644 index 0000000..f0f62b7 --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0025-notification-prefs-event-type.xml @@ -0,0 +1,50 @@ + + + + + + + Add event_type column with '*' wildcard default для backward compat + + + + + + + + + + ALTER TABLE user_notification_prefs + DROP CONSTRAINT user_notification_prefs_pkey; + + + + + ALTER TABLE user_notification_prefs + ADD CONSTRAINT user_notification_prefs_pkey + PRIMARY KEY (user_id, event_type, channel); + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index f717ddd..10910e4 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -34,5 +34,6 @@ + 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 6784017..c3143c1 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 @@ -206,12 +206,14 @@ 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()); + // Phase B-2: per-event-type granularity. Gate резолвит: + // 1) (user, eventType, channel) — specific row + // 2) (user, '*', channel) — Phase B wildcard fallback + // 3) Default policy (EMAIL ON, EXPRESS/TELEGRAM OFF) + // Reviewer-pool bypass (shared inbox) — игнорирует eventType. + if (!preferencesGate.allows(recipient.userId(), eventType, channel.kind())) { + log.debug("Skip channel={} eventType={} for user={} — preferences opt-out", + channel.kind().wireName(), eventType, recipient.userId()); skippedCounter.increment(); continue; } 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 index 175bc08..ca92044 100644 --- 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 @@ -10,6 +10,8 @@ import org.springframework.stereotype.Component; import java.util.Optional; +import static cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref.ANY_EVENT_TYPE; + /** * Per-recipient channel preferences gate — checks user_notification_prefs row * before dispatcher actually sends. Phase B (`/me/notifications/preferences` UI). @@ -43,10 +45,20 @@ public class UserPreferencesGate { } /** - * @return true если recipient should receive notification on this channel. - * Considers default-on policy (EMAIL) и default-off (EXPRESS/TELEGRAM). + * Phase B-2: per-event-type granularity. Lookup precedence: + *
    + *
  1. Specific {@code (user, eventType, channel)} → use its enabled flag
  2. + *
  3. Wildcard {@code (user, '*', channel)} → fallback (Phase B per-channel)
  4. + *
  5. Default policy — EMAIL ON, EXPRESS/TELEGRAM OFF
  6. + *
+ * + * @param userId Keycloak sub claim, или special {@link #REVIEWER_POOL_USER_ID} + * @param eventType outbox event type (e.g. RecordDraftSubmitted) — passed + * by dispatcher per-event. Reviewer-pool bypass игнорирует + * eventType (всегда ON). + * @param channel target channel */ - public boolean allows(String userId, ChannelKind channel) { + public boolean allows(String userId, String eventType, ChannelKind channel) { if (userId == null || userId.isBlank()) { return defaultFor(channel); } @@ -54,14 +66,27 @@ public class UserPreferencesGate { // 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; + String channelWire = channel.wireName(); + + // Step 1: specific (user, eventType, channel) + Optional specific = + repo.findByIdUserIdAndIdEventTypeAndIdChannel(userId, eventType, channelWire); + if (specific.isPresent()) { + return specific.get().isEnabled(); } - return row.get().isEnabled(); + + // Step 2: wildcard (user, '*', channel) — Phase B compatibility + Optional wildcard = + repo.findByIdUserIdAndIdEventTypeAndIdChannel(userId, ANY_EVENT_TYPE, channelWire); + if (wildcard.isPresent()) { + return wildcard.get().isEnabled(); + } + + // Step 3: default policy + boolean defaultOn = defaultFor(channel); + log.trace("No prefs row для user={} eventType={} channel={}, defaulting to {}", + userId, eventType, channelWire, defaultOn); + return defaultOn; } /** 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 dfc8ee8..c4f63c9 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 @@ -77,9 +77,11 @@ class NotificationsDispatcherTest { // Phase B: UserPreferencesGate — stub all-allow для existing tests (no // prefs row → defaults apply, email ON). Per-channel skip tested separately. + // Phase B-2 signature: allows(userId, eventType, channel) UserPreferencesGate allAllowGate = new UserPreferencesGate(null) { @Override - public boolean allows(String userId, cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) { + public boolean allows(String userId, String eventType, + cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) { return true; } }; 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 index 000b7ad..9542fde 100644 --- 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 @@ -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. * - *

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): + *

Schema timeline: *

    - *
  • {@code GET /api/v1/me/notifications/preferences} — returns current state - * as {@code {email: bool, express: bool, telegram: bool}}. Missing rows - * resolve через defaults.
  • - *
  • {@code PUT /api/v1/me/notifications/preferences} — upsert all three - * channels. Idempotent. Returns echoed state.
  • + *
  • 0021-2: {@code (user_id, channel) → enabled} — per-channel toggle
  • + *
  • 0025: + {@code event_type} column. PK now {@code (user_id, event_type, channel)}. + * Wildcard {@code event_type='*'} matches любое event для backward compat.
  • *
* - *

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

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

    + *
  • {@code GET /preferences} — matrix shape: + *
    {
    + *         "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}
    + *       }
    + * Resolution: specific row → wildcard '*' → default policy.
  • + *
  • {@code PUT /preferences} — atomic upsert per (event_type, channel) pair.
  • + *
+ * + *

Default semantics для missing rows: EMAIL ON, EXPRESS/TELEGRAM OFF. + * + *

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

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 get() { String sub = requireSub(); - Map saved = new HashMap<>(); + + // Single fetch всех rows этого user'a — затем в-memory lookup. + // Дешевле чем 12 individual queries. + Map> 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 result = new LinkedHashMap<>(); + for (String eventType : KNOWN_EVENT_TYPES) { + result.put(eventType, resolveToggles(byEventThenChannel, eventType)); + } + return result; + } + + private ChannelToggles resolveToggles( + Map> byEventThenChannel, String eventType) { + Map specific = byEventThenChannel.getOrDefault(eventType, Map.of()); + Map 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 specific, Map 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. + * + *

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 update(@RequestBody Map 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 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) {} }