Merge branch 'feat/phase-b2-per-event-prefs' into 'main'
feat(notifications): Phase B-2 — per-event-type granular preferences See merge request 2-6/2-6-4/terravault/ordinis!219
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -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>
|
||||||
checked={prefs.email}
|
{CHANNELS.map(({ key, disabled }) => (
|
||||||
onChange={(v) => handleToggle('email', v)}
|
<th
|
||||||
/>
|
key={key}
|
||||||
<ToggleRow
|
className="text-center py-3 px-3 text-sm font-semibold text-ink-2 min-w-[7rem]"
|
||||||
label={t('prefs.express.label', { defaultValue: 'Express' })}
|
>
|
||||||
description={t('prefs.express.description', {
|
<div className="inline-flex items-center gap-1">
|
||||||
defaultValue:
|
{t(`prefs.${key}.label`, { defaultValue: capitalize(key) })}
|
||||||
'Корпоративный мессенджер. Канал ещё не подключён — toggle сохраняется на будущее.',
|
{disabled && (
|
||||||
})}
|
<span className="rounded bg-mute-bg px-1.5 py-0.5 text-[10px] text-mute font-normal">
|
||||||
checked={prefs.express}
|
{t('prefs.comingSoon', { defaultValue: 'Скоро' })}
|
||||||
onChange={(v) => handleToggle('express', v)}
|
</span>
|
||||||
disabled
|
)}
|
||||||
comingSoon
|
</div>
|
||||||
/>
|
</th>
|
||||||
<ToggleRow
|
))}
|
||||||
label={t('prefs.telegram.label', { defaultValue: 'Telegram' })}
|
</tr>
|
||||||
description={t('prefs.telegram.description', {
|
</thead>
|
||||||
defaultValue:
|
<tbody>
|
||||||
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
|
{NOTIFICATION_EVENT_TYPES.map((eventType) => {
|
||||||
})}
|
const row = prefs[eventType] ?? DEFAULT_TOGGLES
|
||||||
checked={prefs.telegram}
|
return (
|
||||||
onChange={(v) => handleToggle('telegram', v)}
|
<tr key={eventType} className="border-b border-line last:border-b-0">
|
||||||
disabled
|
<td className="py-4 pr-4">
|
||||||
comingSoon
|
<div className="text-base font-medium text-ink">
|
||||||
/>
|
{t(`prefs.event.${eventType}.label`, {
|
||||||
|
defaultValue: humanizeEventType(eventType),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-sm text-ink-2">
|
||||||
|
{t(`prefs.event.${eventType}.description`, {
|
||||||
|
defaultValue: '',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
{CHANNELS.map(({ key, disabled }) => (
|
||||||
|
<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}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-9
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+50
@@ -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>
|
||||||
|
|||||||
+8
-6
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-10
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-1
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+109
-52
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user