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
+29 -7
View File
@@ -721,16 +721,38 @@ export type NotificationsResponse = {
} }
/** /**
* Phase B — per-channel notification opt-in/out для current user. Backend * Phase B-2 — per-(eventType, channel) notification opt-in/out для current
* endpoint: GET/PUT /api/v1/me/notifications/preferences. Default semantics * user. Backend endpoint: GET/PUT /api/v1/me/notifications/preferences.
* (server-side, when row missing): email=true, express=false, telegram=false.
* *
* <p>Express/Telegram channels пока not wired backend-side — toggles в UI * <p>Matrix shape: 4 known event types × 3 channels = 12 toggles. User может
* disabled с tooltip 'Скоро'. Email — единственный фактически работающий * opt-out 'Submitted via email' (queue noise) keep 'Approved via email'
* канал в Phase A/B. * (action confirmation).
*
* <p>Defaults (server-side, для missing rows):
* email=true, express=false, telegram=false.
*
* <p>Resolution для каждой ячейки: specific row → wildcard '*' (Phase B
* compat) → default policy.
*
* <p>Express/Telegram channels не wired backend-side — toggles в UI disabled.
*/ */
export type NotificationPreferences = { export type ChannelToggles = {
email: boolean email: boolean
express: boolean express: boolean
telegram: boolean telegram: boolean
} }
/** Map keyed по event type. Backend сейчас возвращает 4 known events. */
export type NotificationPreferences = Record<string, ChannelToggles>
/**
* 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]
+30
View File
@@ -635,6 +635,21 @@ i18n
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.', 'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
'prefs.comingSoon': 'Скоро', 'prefs.comingSoon': 'Скоро',
'prefs.saveError': 'Не удалось сохранить настройки. Попробуйте ещё раз.', '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': 'Настроить каналы →', 'notifications.preferencesLink': 'Настроить каналы →',
'reviews.mine.filter.all': 'Все', 'reviews.mine.filter.all': 'Все',
'reviews.mine.filter.pending': 'На ревью', 'reviews.mine.filter.pending': 'На ревью',
@@ -1414,6 +1429,21 @@ i18n
'Telegram bot. Channel not wired yet — toggle is stored for later.', 'Telegram bot. Channel not wired yet — toggle is stored for later.',
'prefs.comingSoon': 'Soon', 'prefs.comingSoon': 'Soon',
'prefs.saveError': 'Failed to save preferences. Please try again.', '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 →', 'notifications.preferencesLink': 'Manage channels →',
'reviews.mine.emptyFilter': 'No drafts in this category', 'reviews.mine.emptyFilter': 'No drafts in this category',
'reviews.mine.filter.all': 'All', 'reviews.mine.filter.all': 'All',
@@ -12,22 +12,40 @@ import {
} from '@/ui' } from '@/ui'
import { useMyNotificationPreferences } from '@/api/queries' import { useMyNotificationPreferences } from '@/api/queries'
import { useUpdateNotificationPreferences } from '@/api/mutations' 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')({ export const Route = createFileRoute('/me/notifications/preferences')({
component: NotificationPreferencesPage, 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.
* *
* <p>Email — единственный actually wired channel в Phase A/B (через Spring * <p>4 events × 3 channels = 12 toggles. User can opt-out 'Submitted via
* \`spring.mail.host\`). Express + Telegram toggles disabled с подсказкой * email' (queue noise) keep 'Approved via email' (action confirmation).
* "Скоро" — backend хранит preference (на случай early opt-in), но dispatcher
* никогда не пошлёт пока channel bean не зарегистрирован.
* *
* <p>Optimistic update — toggle flips мгновенно, network roundtrip ~200ms * <p>Express + Telegram disabled с подсказкой "Скоро" — backend хранит
* почти не виден. onError refetch восстанавливает state если PUT 4xx/5xx. * preferences (на случай early opt-in), но dispatcher не пошлёт пока channel
* bean не зарегистрирован.
*
* <p>Optimistic update — toggle flips мгновенно. onError refetch восстанавливает.
*/ */
function NotificationPreferencesPage() { function NotificationPreferencesPage() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -42,7 +60,7 @@ function NotificationPreferencesPage() {
title={t('prefs.title', { defaultValue: 'Уведомления' })} title={t('prefs.title', { defaultValue: 'Уведомления' })}
description={t('prefs.description', { description={t('prefs.description', {
defaultValue: defaultValue:
'Per-channel настройки доставки. Применяется только к вашим уведомлениям.', 'Per-event настройки доставки. Применяется только к вашим уведомлениям.',
})} })}
/> />
<EmptyState <EmptyState
@@ -70,58 +88,87 @@ function NotificationPreferencesPage() {
return <QueryErrorState error={prefsQ.error} onRetry={() => prefsQ.refetch()} /> return <QueryErrorState error={prefsQ.error} onRetry={() => prefsQ.refetch()} />
} }
const prefs: NotificationPreferences = prefsQ.data ?? { // Backend гарантирует что все KNOWN_EVENT_TYPES present с filled defaults.
email: true, // Если query возвращает пустоту (cold start), local-fill через DEFAULT_TOGGLES.
express: false, const prefs: NotificationPreferences = prefsQ.data ?? {}
telegram: false,
}
const handleToggle = (channel: keyof NotificationPreferences, next: boolean) => { const handleToggle = (
updateMut.mutate({ ...prefs, [channel]: next }) 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 ( return (
<Panel> <Panel>
<PageHeader <PageHeader
title={t('prefs.title', { defaultValue: 'Уведомления' })} title={t('prefs.title', { defaultValue: 'Уведомления' })}
description={t('prefs.description', { description={t('prefs.descriptionMatrix', {
defaultValue: defaultValue:
'Per-channel настройки доставки. Применяется только к вашим уведомлениям (reviewer pool inbox остаётся ON).', 'Per-event настройки доставки. Reviewer pool inbox остаётся ON независимо от ваших настроек.',
})} })}
/> />
<div className="mt-6 space-y-1"> <div className="mt-6 overflow-x-auto">
<ToggleRow <table className="w-full border-collapse">
label={t('prefs.email.label', { defaultValue: 'Email' })} <thead>
description={t('prefs.email.description', { <tr className="border-b border-line">
defaultValue: <th className="text-left py-3 pr-4 text-sm font-semibold text-ink-2">
'SMTP доставка через сконфигурированный mail.host. На staging это mailpit catcher; на prod — реальный relay.', {t('prefs.matrix.eventCol', { defaultValue: 'Событие' })}
</th>
{CHANNELS.map(({ key, disabled }) => (
<th
key={key}
className="text-center py-3 px-3 text-sm font-semibold text-ink-2 min-w-[7rem]"
>
<div className="inline-flex items-center gap-1">
{t(`prefs.${key}.label`, { defaultValue: capitalize(key) })}
{disabled && (
<span className="rounded bg-mute-bg px-1.5 py-0.5 text-[10px] text-mute font-normal">
{t('prefs.comingSoon', { defaultValue: 'Скоро' })}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{NOTIFICATION_EVENT_TYPES.map((eventType) => {
const row = prefs[eventType] ?? DEFAULT_TOGGLES
return (
<tr key={eventType} className="border-b border-line last:border-b-0">
<td className="py-4 pr-4">
<div className="text-base font-medium text-ink">
{t(`prefs.event.${eventType}.label`, {
defaultValue: humanizeEventType(eventType),
})} })}
checked={prefs.email} </div>
onChange={(v) => handleToggle('email', v)} <p className="mt-0.5 text-sm text-ink-2">
/> {t(`prefs.event.${eventType}.description`, {
<ToggleRow defaultValue: '',
label={t('prefs.express.label', { defaultValue: 'Express' })}
description={t('prefs.express.description', {
defaultValue:
'Корпоративный мессенджер. Канал ещё не подключён — toggle сохраняется на будущее.',
})} })}
checked={prefs.express} </p>
onChange={(v) => handleToggle('express', v)} </td>
disabled {CHANNELS.map(({ key, disabled }) => (
comingSoon <td key={key} className="text-center py-4 px-3">
<Switch
checked={row[key]}
onCheckedChange={(v) => handleToggle(eventType, key, v)}
disabled={disabled}
aria-label={`${eventType} / ${key}`}
/> />
<ToggleRow </td>
label={t('prefs.telegram.label', { defaultValue: 'Telegram' })} ))}
description={t('prefs.telegram.description', { </tr>
defaultValue: )
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
})} })}
checked={prefs.telegram} </tbody>
onChange={(v) => handleToggle('telegram', v)} </table>
disabled
comingSoon
/>
</div> </div>
{updateMut.isError && ( {updateMut.isError && (
@@ -138,43 +185,21 @@ function NotificationPreferencesPage() {
) )
} }
type ToggleRowProps = { function capitalize(s: string): string {
label: string return s.charAt(0).toUpperCase() + s.slice(1)
description: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
comingSoon?: boolean
} }
function ToggleRow({ function humanizeEventType(eventType: NotificationEventType): string {
label, switch (eventType) {
description, case 'RecordDraftSubmitted':
checked, return 'Отправлено на ревью'
onChange, case 'RecordDraftApproved':
disabled, return 'Одобрено'
comingSoon, case 'RecordDraftRejected':
}: ToggleRowProps) { return 'Отклонено'
const { t } = useTranslation() case 'RecordDraftWithdrawn':
return ( return 'Отозвано'
<div className="flex items-start justify-between gap-6 border-b border-line py-4 last:border-b-0"> default:
<div className="flex-1"> return eventType
<div className="flex items-center gap-2"> }
<span className="text-base font-medium text-ink">{label}</span>
{comingSoon && (
<span className="rounded bg-mute-bg px-2 py-0.5 text-xs text-mute">
{t('prefs.comingSoon', { defaultValue: 'Скоро' })}
</span>
)}
</div>
<p className="mt-1 text-sm text-ink-2">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
aria-label={label}
/>
</div>
)
} }
@@ -10,47 +10,70 @@ import java.io.Serializable;
import java.util.Objects; 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).
* *
* <p>Composite PK {@code (user_id, channel)}. Default {@code enabled=false} для * <p>Composite PK {@code (user_id, event_type, channel)}. Phase B-2 added
* новых rows; bootstrap loadData может включить email для existing reviewer/maker * {@code event_type} dimension чтобы reviewer мог отключить
* ролей при первом deploy. * 'RecordDraftSubmitted' emails (queue noise) и оставить
* 'RecordDraftApproved/Rejected' (action confirmations).
*
* <p>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.
* *
* <p>Channel — lowercase wire format ('email' / 'express' / 'telegram'). * <p>Channel — lowercase wire format ('email' / 'express' / 'telegram').
*
* <p>Lookup precedence в {@code UserPreferencesGate}:
* <ol>
* <li>Specific {@code (user, eventType, channel)} → use если найдено</li>
* <li>Wildcard {@code (user, '*', channel)} → fallback (Phase B behavior)</li>
* <li>Default policy — email ON, express+telegram OFF</li>
* </ol>
*/ */
@Entity @Entity
@Table(name = "user_notification_prefs") @Table(name = "user_notification_prefs")
public class UserNotificationPref { public class UserNotificationPref {
/** Wildcard event_type — matches любое event для (user, channel). */
public static final String ANY_EVENT_TYPE = "*";
@Embeddable @Embeddable
public static class Pk implements Serializable { public static class Pk implements Serializable {
@Column(name = "user_id", length = 128, nullable = false) @Column(name = "user_id", length = 128, nullable = false)
private String userId; private String userId;
@Column(name = "event_type", length = 64, nullable = false)
private String eventType;
@Column(name = "channel", length = 16, nullable = false) @Column(name = "channel", length = 16, nullable = false)
private String channel; private String channel;
protected Pk() {} protected Pk() {}
public Pk(String userId, String channel) { public Pk(String userId, String eventType, String channel) {
this.userId = userId; this.userId = userId;
this.eventType = eventType;
this.channel = channel; this.channel = channel;
} }
public String getUserId() { return userId; } public String getUserId() { return userId; }
public String getEventType() { return eventType; }
public String getChannel() { return channel; } public String getChannel() { return channel; }
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;
if (!(o instanceof Pk pk)) return false; 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 @Override
public int hashCode() { public int hashCode() {
return Objects.hash(userId, channel); return Objects.hash(userId, eventType, channel);
} }
} }
@@ -62,13 +85,14 @@ public class UserNotificationPref {
protected UserNotificationPref() {} protected UserNotificationPref() {}
public UserNotificationPref(String userId, String channel, boolean enabled) { public UserNotificationPref(String userId, String eventType, String channel, boolean enabled) {
this.id = new Pk(userId, channel); this.id = new Pk(userId, eventType, channel);
this.enabled = enabled; this.enabled = enabled;
} }
public Pk getId() { return id; } public Pk getId() { return id; }
public String getUserId() { return id.getUserId(); } public String getUserId() { return id.getUserId(); }
public String getEventType() { return id.getEventType(); }
public String getChannel() { return id.getChannel(); } public String getChannel() { return id.getChannel(); }
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }
@@ -8,9 +8,16 @@ import java.util.Optional;
public interface UserNotificationPrefRepository public interface UserNotificationPrefRepository
extends JpaRepository<UserNotificationPref, UserNotificationPref.Pk> { extends JpaRepository<UserNotificationPref, UserNotificationPref.Pk> {
/** Все каналы пользователя — для GET /api/v1/me/notifications page. */ /**
* Все prefs пользователя — для GET /api/v1/me/notifications/preferences
* matrix view. Includes wildcard ('*') rows + per-event rows.
*/
List<UserNotificationPref> findByIdUserId(String userId); List<UserNotificationPref> findByIdUserId(String userId);
/** Single channel lookup для dispatcher (skip if disabled). */ /**
Optional<UserNotificationPref> findByIdUserIdAndIdChannel(String userId, String channel); * Specific (user, eventType, channel) lookup для dispatcher fast path.
* Если empty → caller fallback на wildcard ('*' eventType).
*/
Optional<UserNotificationPref> findByIdUserIdAndIdEventTypeAndIdChannel(
String userId, String eventType, String channel);
} }
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
<!-- Phase B-2: per-event-type granularity для notification prefs.
Раньше (Phase B / migration 0021-2) prefs хранились (user_id, channel) →
enabled — global toggle per channel независимо от типа события.
Phase B-2 добавляет event_type column чтобы reviewer мог отключить
'RecordDraftSubmitted' emails (queue noise) но оставить
'RecordDraftApproved/Rejected' (action confirmations).
Backward compat: existing rows получают event_type='*' (wildcard).
Gate резолвит lookup так:
1. Try (user, eventType, channel) → use если найдено
2. Fallback к (user, '*', channel) → behaves as Phase B
3. Default policy (email ON / express+telegram OFF) если оба missing.
Drop+recreate PK потому что Liquibase не поддерживает ADD COLUMN TO PK
atomically на Postgres. Standard pattern. -->
<changeSet id="0025-1-notification-prefs-event-type" author="ordinis">
<comment>Add event_type column with '*' wildcard default для backward compat</comment>
<addColumn tableName="user_notification_prefs">
<column name="event_type" type="VARCHAR(64)" defaultValue="*">
<constraints nullable="false"/>
</column>
</addColumn>
<!-- Drop old PK (user_id, channel) — нужно расширить до 3 columns -->
<sql>
ALTER TABLE user_notification_prefs
DROP CONSTRAINT user_notification_prefs_pkey;
</sql>
<!-- New PK (user_id, event_type, channel). Order column: event_type
second потому что typical lookup pattern — for one user, fetch
all events × channels (single user_id scan, then range over
event_type+channel via index). -->
<sql>
ALTER TABLE user_notification_prefs
ADD CONSTRAINT user_notification_prefs_pkey
PRIMARY KEY (user_id, event_type, channel);
</sql>
</changeSet>
</databaseChangeLog>
@@ -34,5 +34,6 @@
<include file="changes/0022-schema-workflow.xml" relativeToChangelogFile="true"/> <include file="changes/0022-schema-workflow.xml" relativeToChangelogFile="true"/>
<include file="changes/0023-user-display-cache.xml" relativeToChangelogFile="true"/> <include file="changes/0023-user-display-cache.xml" relativeToChangelogFile="true"/>
<include file="changes/0024-notification-read-state.xml" relativeToChangelogFile="true"/> <include file="changes/0024-notification-read-state.xml" relativeToChangelogFile="true"/>
<include file="changes/0025-notification-prefs-event-type.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -206,12 +206,14 @@ public class NotificationsDispatcher {
for (NotificationChannel channel : channels) { for (NotificationChannel channel : channels) {
if (!channel.isEnabled()) continue; if (!channel.isEnabled()) continue;
// Phase B: check user preferences before send. Default policy: // Phase B-2: per-event-type granularity. Gate резолвит:
// EMAIL = ON, EXPRESS/TELEGRAM = OFF when no row. Reviewer-pool // 1) (user, eventType, channel) — specific row
// bypasses gate (shared inbox). // 2) (user, '*', channel) — Phase B wildcard fallback
if (!preferencesGate.allows(recipient.userId(), channel.kind())) { // 3) Default policy (EMAIL ON, EXPRESS/TELEGRAM OFF)
log.debug("Skip channel={} for user={} — preferences opt-out", // Reviewer-pool bypass (shared inbox) — игнорирует eventType.
channel.kind().wireName(), recipient.userId()); 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(); skippedCounter.increment();
continue; continue;
} }
@@ -10,6 +10,8 @@ import org.springframework.stereotype.Component;
import java.util.Optional; 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 * Per-recipient channel preferences gate — checks user_notification_prefs row
* before dispatcher actually sends. Phase B (`/me/notifications/preferences` UI). * 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. * Phase B-2: per-event-type granularity. Lookup precedence:
* Considers default-on policy (EMAIL) и default-off (EXPRESS/TELEGRAM). * <ol>
* <li>Specific {@code (user, eventType, channel)} → use its enabled flag</li>
* <li>Wildcard {@code (user, '*', channel)} → fallback (Phase B per-channel)</li>
* <li>Default policy — EMAIL ON, EXPRESS/TELEGRAM OFF</li>
* </ol>
*
* @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()) { if (userId == null || userId.isBlank()) {
return defaultFor(channel); return defaultFor(channel);
} }
@@ -54,14 +66,27 @@ public class UserPreferencesGate {
// Pool is a shared inbox — always ON, prefs don't apply. // Pool is a shared inbox — always ON, prefs don't apply.
return true; return true;
} }
Optional<UserNotificationPref> row = repo.findByIdUserIdAndIdChannel(userId, channel.wireName()); String channelWire = channel.wireName();
if (row.isEmpty()) {
boolean defaultOn = defaultFor(channel); // Step 1: specific (user, eventType, channel)
log.trace("No prefs row for user={} channel={}, defaulting to {}", Optional<UserNotificationPref> specific =
userId, channel.wireName(), defaultOn); repo.findByIdUserIdAndIdEventTypeAndIdChannel(userId, eventType, channelWire);
return defaultOn; if (specific.isPresent()) {
return specific.get().isEnabled();
} }
return row.get().isEnabled();
// Step 2: wildcard (user, '*', channel) — Phase B compatibility
Optional<UserNotificationPref> 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;
} }
/** /**
@@ -77,9 +77,11 @@ class NotificationsDispatcherTest {
// Phase B: UserPreferencesGate — stub all-allow для existing tests (no // Phase B: UserPreferencesGate — stub all-allow для existing tests (no
// prefs row → defaults apply, email ON). Per-channel skip tested separately. // prefs row → defaults apply, email ON). Per-channel skip tested separately.
// Phase B-2 signature: allows(userId, eventType, channel)
UserPreferencesGate allAllowGate = new UserPreferencesGate(null) { UserPreferencesGate allAllowGate = new UserPreferencesGate(null) {
@Override @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; return true;
} }
}; };
@@ -16,36 +16,37 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.HashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* Phase B — per-user channel preferences endpoint. Phase A был channel-agnostic * Phase B + B-2 — per-user notification preferences endpoint.
* (всем по email если subscribed in keycloak). Phase B даёт пользователю явный
* toggle per channel.
* *
* <p>Schema (migration 0021 changeSet 0021-2): {@code user_notification_prefs * <p>Schema timeline:
* (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):
* <ul> * <ul>
* <li>{@code GET /api/v1/me/notifications/preferences} — returns current state * <li>0021-2: {@code (user_id, channel) → enabled} — per-channel toggle</li>
* as {@code {email: bool, express: bool, telegram: bool}}. Missing rows * <li>0025: + {@code event_type} column. PK now {@code (user_id, event_type, channel)}.
* resolve через defaults.</li> * Wildcard {@code event_type='*'} matches любое event для backward compat.</li>
* <li>{@code PUT /api/v1/me/notifications/preferences} — upsert all three
* channels. Idempotent. Returns echoed state.</li>
* </ul> * </ul>
* *
* <p>NB: nginx routing трап (см. comment в admin-ui nginx.conf) — endpoint * <p>Endpoints (require auth — anonymous → 401):
* под {@code /api/v1/me/*} требует explicit route к writer (ordinis-app). * <ul>
* Generic {@code /api/v1/*} GET fallback идёт в read-api → 500. Already * <li>{@code GET /preferences} — matrix shape:
* mitigated через {@code /api/v1/me} location block добавленный в MR !197 * <pre>{
* (см. nginx.conf). * "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 @RestController
@RequestMapping("/api/v1/me/notifications/preferences") @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_EXPRESS = "express";
private static final String CHAN_TELEGRAM = "telegram"; 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; private final UserNotificationPrefRepository repo;
public MeNotificationsPreferencesController(UserNotificationPrefRepository repo) { public MeNotificationsPreferencesController(UserNotificationPrefRepository repo) {
this.repo = 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 @GetMapping
public PreferencesDto get() { public Map<String, ChannelToggles> get() {
String sub = requireSub(); 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)) { 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 Map<String, ChannelToggles> result = new LinkedHashMap<>();
saved.getOrDefault(CHAN_EXPRESS, false), // default OFF (channel TBD) for (String eventType : KNOWN_EVENT_TYPES) {
saved.getOrDefault(CHAN_TELEGRAM, false) // default OFF (channel TBD) 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) * Upsert all (eventType, channel) cells. Body shape mirrors GET response —
* чтобы DB state matched UI state — иначе "missing row" semantics утечет в * matrix of event types → channel toggles. Atomic transaction.
* surprise (e.g. user expects "email OFF" но row missing → dispatcher sees *
* default-ON). * <p>Wildcard rows ('*') не writes'я — UI manages только specific event types.
* Existing wildcard rows preserved (но overshadowed specific rows если они есть).
*/ */
@PutMapping @PutMapping
@Transactional @Transactional
public PreferencesDto update(@RequestBody PreferencesDto body) { public Map<String, ChannelToggles> update(@RequestBody Map<String, ChannelToggles> body) {
String sub = requireSub(); String sub = requireSub();
if (body == null) { if (body == null || body.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "request body required"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "matrix body required");
} }
upsert(sub, CHAN_EMAIL, body.email()); int writes = 0;
upsert(sub, CHAN_EXPRESS, body.express()); for (Map.Entry<String, ChannelToggles> entry : body.entrySet()) {
upsert(sub, CHAN_TELEGRAM, body.telegram()); String eventType = entry.getKey();
log.info("Notification prefs updated for user={}: email={}, express={}, telegram={}", // Only allow whitelisted event types — guard против garbage rows.
sub, body.email(), body.express(), body.telegram()); if (!KNOWN_EVENT_TYPES.contains(eventType)) {
return new PreferencesDto(body.email(), body.express(), body.telegram()); 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) { private void upsert(String userId, String eventType, String channel, boolean enabled) {
UserNotificationPref.Pk pk = new UserNotificationPref.Pk(userId, channel); UserNotificationPref.Pk pk = new UserNotificationPref.Pk(userId, eventType, channel);
UserNotificationPref existing = repo.findById(pk).orElse(null); UserNotificationPref existing = repo.findById(pk).orElse(null);
if (existing == null) { if (existing == null) {
repo.save(new UserNotificationPref(userId, channel, enabled)); repo.save(new UserNotificationPref(userId, eventType, channel, enabled));
} else if (existing.isEnabled() != enabled) { } else if (existing.isEnabled() != enabled) {
existing.setEnabled(enabled); existing.setEnabled(enabled);
repo.save(existing); repo.save(existing);
@@ -124,11 +183,9 @@ public class MeNotificationsPreferencesController {
} }
/** /**
* Response/request shape — flat boolean per channel. Extensible (future * Toggles per channel для одного event type. Future channels добавятся как
* channels добавятся как новые fields, backwards-compatible because JSON * новые поля — JSON deserialization tolerates missing fields через record
* deserialization tolerates missing fields с record defaults). Per-event-type * defaults (Java reflection treats missing as false для boolean primitive).
* granularity defer'нута — потребует schema migration с {@code event_type}
* column на user_notification_prefs.
*/ */
public record PreferencesDto(boolean email, boolean express, boolean telegram) {} public record ChannelToggles(boolean email, boolean express, boolean telegram) {}
} }