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:
Zimin A.N.
2026-05-17 11:11:21 +03:00
parent 6ed0b459de
commit 74704dbf5b
13 changed files with 804 additions and 4 deletions
+12
View File
@@ -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.
+22
View File
@@ -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'] })
},
})
}
+18
View File
@@ -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())
+22
View File
@@ -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)
}