feat(notifications): Phase C — Don't Disturb (quiet hours window)
User задаёт quiet window (e.g. 22:00–08:00 Europe/Moscow) — external channels (email + express) DROP'аются dispatcher'ом. In-app bell badge остаётся (юзер видит при следующем визите UI). Cross-midnight aware. ## Backend - Migration 0026: user_notification_settings table (user_id PK + quiet_hours_start/end LocalTime + tz) - Entity UserNotificationSettings + Repository (stock CRUD) - UserPreferencesGate extended: - Constructor takes settingsRepo - allows() checks isQuiet(userId) первым for external channels (EMAIL/EXPRESS/TELEGRAM) - isQuiet: load settings → if hasEffectiveQuietHours() → check current LocalTime в user's TZ - inWindow() helper: cross-midnight semantics (22:00–08:00 wraps midnight) - Invalid TZ → fallback к DEFAULT_TZ (Europe/Moscow) с WARN log - Test seam: setClockForTesting(Clock) для deterministic tests - MeNotificationsSettingsController: GET/PUT /api/v1/me/notifications/settings - HH:mm input parsing, IANA TZ validation, enabled=false clears window - Auth required (JWT sub claim) ## Frontend - NotificationSettings type в client.ts - useMyNotificationSettings query (60s stale) - useUpdateNotificationSettings mutation - QuietHoursSection в /me/notifications/preferences route: - Switch toggle + 2 time inputs + TZ select (12 RU/UTC zones) - Optimistic local state synced from query - Save button с pending/success/error states - i18n RU + EN ## Tests - 9 new tests UserPreferencesGateQuietHoursTest: - Same-day window match (inclusive start, exclusive end) - Cross-midnight window match (22:00–08:00) - No settings row → не блокирует - Quiet window active → email + express dropped - Outside window → email allowed - start == end → disabled (zero-duration treated as off) - Reviewer pool bypasses (always ON) - Invalid TZ → fallback default - Specific pref ON + quiet ON → quiet wins (drop) - NotificationsDispatcherTest constructor updated к new signature - Total notifications tests: 53 → 62, все green ## Design rationale (drop vs defer) Drop chosen за defer (queue + re-fire scheduler) потому что: - Defer needs persistent queue + scheduled retry — high complexity - Most notifications time-relevant (draft N hours ago less actionable) - Bell badge показывает full feed — ничего не «теряется» - Phase D summary digest можно build later если demand surfaces
This commit is contained in:
@@ -764,6 +764,18 @@ export type ChannelToggles = {
|
||||
/** Map keyed по event type. Backend сейчас возвращает 4 known events. */
|
||||
export type NotificationPreferences = Record<string, ChannelToggles>
|
||||
|
||||
/**
|
||||
* Phase C Don't Disturb — per-user quiet hours. Когда window активен,
|
||||
* external channels (email + express) DROPPED dispatcher'ом. Bell badge
|
||||
* остаётся.
|
||||
*/
|
||||
export type NotificationSettings = {
|
||||
quietHoursEnabled: boolean
|
||||
quietHoursStart: string | null // "HH:mm" or "HH:mm:ss"
|
||||
quietHoursEnd: string | null
|
||||
quietHoursTz: string // IANA, default "Europe/Moscow"
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical event types — должны совпадать с backend
|
||||
* MeNotificationsPreferencesController.KNOWN_EVENT_TYPES.
|
||||
|
||||
@@ -767,3 +767,25 @@ export const useAiSuggestField = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase C Don't Disturb — update quiet hours settings.
|
||||
* Optimistic update + invalidation на settled.
|
||||
*/
|
||||
export const useUpdateNotificationSettings = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
next: import('./client').NotificationSettings,
|
||||
): Promise<import('./client').NotificationSettings> => {
|
||||
const { data } = await apiClient.put<import('./client').NotificationSettings>(
|
||||
'/me/notifications/settings',
|
||||
next,
|
||||
)
|
||||
return data
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'me', 'settings'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -968,3 +968,21 @@ export const myNotificationPreferencesQuery = () =>
|
||||
|
||||
export const useMyNotificationPreferences = () =>
|
||||
useQuery(myNotificationPreferencesQuery())
|
||||
|
||||
/**
|
||||
* Phase C Don't Disturb — per-user quiet hours settings query.
|
||||
* GET возвращает default shape если settings row missing на бэке.
|
||||
*/
|
||||
export const myNotificationSettingsQuery = () =>
|
||||
queryOptions({
|
||||
queryKey: ['notifications', 'me', 'settings'] as const,
|
||||
queryFn: async (): Promise<import('./client').NotificationSettings> => {
|
||||
const { data } = await apiClient.get<import('./client').NotificationSettings>(
|
||||
'/me/notifications/settings',
|
||||
)
|
||||
return data
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
export const useMyNotificationSettings = () => useQuery(myNotificationSettingsQuery())
|
||||
|
||||
@@ -674,6 +674,17 @@ i18n
|
||||
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
|
||||
'prefs.comingSoon': 'Скоро',
|
||||
'prefs.saveError': 'Не удалось сохранить настройки. Попробуйте ещё раз.',
|
||||
'prefs.quietHours.title': 'Тихие часы',
|
||||
'prefs.quietHours.description':
|
||||
'В этом окне email и express не отправляются. Внутренние уведомления в шапке остаются.',
|
||||
'prefs.quietHours.toggle': 'Включить тихие часы',
|
||||
'prefs.quietHours.start': 'Начало',
|
||||
'prefs.quietHours.end': 'Конец',
|
||||
'prefs.quietHours.tz': 'Часовой пояс',
|
||||
'prefs.quietHours.save': 'Сохранить',
|
||||
'prefs.quietHours.saving': 'Сохраняю…',
|
||||
'prefs.quietHours.saved': 'Сохранено',
|
||||
'prefs.quietHours.saveError': 'Не удалось сохранить тихие часы.',
|
||||
'prefs.descriptionMatrix':
|
||||
'Per-event настройки доставки. Reviewer pool inbox остаётся ON независимо от ваших настроек.',
|
||||
'prefs.matrix.eventCol': 'Событие',
|
||||
@@ -1503,6 +1514,17 @@ 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.quietHours.title': 'Quiet hours',
|
||||
'prefs.quietHours.description':
|
||||
'Email and express muted during this window. In-app notifications remain visible.',
|
||||
'prefs.quietHours.toggle': 'Enable quiet hours',
|
||||
'prefs.quietHours.start': 'From',
|
||||
'prefs.quietHours.end': 'To',
|
||||
'prefs.quietHours.tz': 'Timezone',
|
||||
'prefs.quietHours.save': 'Save',
|
||||
'prefs.quietHours.saving': 'Saving…',
|
||||
'prefs.quietHours.saved': 'Saved',
|
||||
'prefs.quietHours.saveError': 'Failed to save quiet hours.',
|
||||
'prefs.descriptionMatrix':
|
||||
'Per-event delivery preferences. Reviewer pool inbox stays ON regardless of your settings.',
|
||||
'prefs.matrix.eventCol': 'Event',
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
QueryErrorState,
|
||||
Switch,
|
||||
} from '@/ui'
|
||||
import { useMyNotificationPreferences } from '@/api/queries'
|
||||
import { useUpdateNotificationPreferences } from '@/api/mutations'
|
||||
import { useMyNotificationPreferences, useMyNotificationSettings } from '@/api/queries'
|
||||
import { useUpdateNotificationPreferences, useUpdateNotificationSettings } from '@/api/mutations'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
NOTIFICATION_EVENT_TYPES,
|
||||
type ChannelToggles,
|
||||
@@ -181,10 +182,159 @@ function NotificationPreferencesPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase C — Don't Disturb quiet hours window. Applies к external
|
||||
* channels (email + express). Bell badge остаётся доступным. */}
|
||||
<QuietHoursSection />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const TZ_OPTIONS = [
|
||||
'Europe/Moscow',
|
||||
'Europe/Kaliningrad',
|
||||
'Europe/Samara',
|
||||
'Asia/Yekaterinburg',
|
||||
'Asia/Omsk',
|
||||
'Asia/Krasnoyarsk',
|
||||
'Asia/Irkutsk',
|
||||
'Asia/Yakutsk',
|
||||
'Asia/Vladivostok',
|
||||
'Asia/Magadan',
|
||||
'Asia/Kamchatka',
|
||||
'UTC',
|
||||
]
|
||||
|
||||
function QuietHoursSection() {
|
||||
const { t } = useTranslation()
|
||||
const settingsQ = useMyNotificationSettings()
|
||||
const updateMut = useUpdateNotificationSettings()
|
||||
|
||||
// Local form state — synced from query on load + after successful save.
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [start, setStart] = useState('22:00')
|
||||
const [end, setEnd] = useState('08:00')
|
||||
const [tz, setTz] = useState('Europe/Moscow')
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsQ.data) return
|
||||
setEnabled(settingsQ.data.quietHoursEnabled)
|
||||
setStart(normalizeHm(settingsQ.data.quietHoursStart) ?? '22:00')
|
||||
setEnd(normalizeHm(settingsQ.data.quietHoursEnd) ?? '08:00')
|
||||
setTz(settingsQ.data.quietHoursTz || 'Europe/Moscow')
|
||||
}, [settingsQ.data])
|
||||
|
||||
const handleSave = () => {
|
||||
updateMut.mutate({
|
||||
quietHoursEnabled: enabled,
|
||||
quietHoursStart: enabled ? start : null,
|
||||
quietHoursEnd: enabled ? end : null,
|
||||
quietHoursTz: tz,
|
||||
})
|
||||
}
|
||||
|
||||
if (settingsQ.isLoading) return null
|
||||
|
||||
return (
|
||||
<section className="mt-8 pt-6 border-t border-line">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-ink">
|
||||
{t('prefs.quietHours.title', { defaultValue: 'Тихие часы' })}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-ink-2">
|
||||
{t('prefs.quietHours.description', {
|
||||
defaultValue:
|
||||
'В этом окне email и express не отправляются. Внутренние уведомления в шапке остаются.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-label={t('prefs.quietHours.toggle', { defaultValue: 'Включить тихие часы' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-sm text-ink-2">
|
||||
{t('prefs.quietHours.start', { defaultValue: 'Начало' })}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="h-9 px-2 rounded-md border border-line bg-surface text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-sm text-ink-2">
|
||||
{t('prefs.quietHours.end', { defaultValue: 'Конец' })}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
className="h-9 px-2 rounded-md border border-line bg-surface text-body text-ink"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-sm text-ink-2">
|
||||
{t('prefs.quietHours.tz', { defaultValue: 'Часовой пояс' })}
|
||||
</span>
|
||||
<select
|
||||
value={tz}
|
||||
onChange={(e) => setTz(e.target.value)}
|
||||
className="h-9 px-2 rounded-md border border-line bg-surface text-body text-ink"
|
||||
>
|
||||
{TZ_OPTIONS.map((z) => (
|
||||
<option key={z} value={z}>{z}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={updateMut.isPending}
|
||||
>
|
||||
{updateMut.isPending
|
||||
? t('prefs.quietHours.saving', { defaultValue: 'Сохраняю…' })
|
||||
: t('prefs.quietHours.save', { defaultValue: 'Сохранить' })}
|
||||
</Button>
|
||||
{updateMut.isSuccess && !updateMut.isPending && (
|
||||
<span className="text-sm text-success">
|
||||
{t('prefs.quietHours.saved', { defaultValue: 'Сохранено' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updateMut.isError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-3 rounded border border-danger bg-danger-bg p-3 text-sm text-danger"
|
||||
>
|
||||
{t('prefs.quietHours.saveError', {
|
||||
defaultValue: 'Не удалось сохранить тихие часы.',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** "HH:mm:ss" → "HH:mm"; passthrough иначе. */
|
||||
function normalizeHm(raw: string | null): string | null {
|
||||
if (!raw) return null
|
||||
const m = /^(\d{2}:\d{2})/.exec(raw)
|
||||
return m ? m[1] : raw
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user