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
- />
+
+
+
+
+ |
+ {t('prefs.matrix.eventCol', { defaultValue: 'Событие' })}
+ |
+ {CHANNELS.map(({ key, disabled }) => (
+
+
+ {t(`prefs.${key}.label`, { defaultValue: capitalize(key) })}
+ {disabled && (
+
+ {t('prefs.comingSoon', { defaultValue: 'Скоро' })}
+
+ )}
+
+ |
+ ))}
+
+
+
+ {NOTIFICATION_EVENT_TYPES.map((eventType) => {
+ const row = prefs[eventType] ?? DEFAULT_TOGGLES
+ return (
+
+ |
+
+ {t(`prefs.event.${eventType}.label`, {
+ defaultValue: humanizeEventType(eventType),
+ })}
+
+
+ {t(`prefs.event.${eventType}.description`, {
+ defaultValue: '',
+ })}
+
+ |
+ {CHANNELS.map(({ key, disabled }) => (
+
+ 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}:
+ *
+ * - Specific {@code (user, eventType, channel)} → use если найдено
+ * - Wildcard {@code (user, '*', channel)} → fallback (Phase B behavior)
+ * - Default policy — email ON, express+telegram OFF
+ *
*/
@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:
+ *
+ * - Specific {@code (user, eventType, channel)} → use its enabled flag
+ * - Wildcard {@code (user, '*', channel)} → fallback (Phase B per-channel)
+ * - Default policy — EMAIL ON, EXPRESS/TELEGRAM OFF
+ *
+ *
+ * @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) {}
}