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. */ /** Map keyed по event type. Backend сейчас возвращает 4 known events. */
export type NotificationPreferences = Record<string, ChannelToggles> 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 * Canonical event types — должны совпадать с backend
* MeNotificationsPreferencesController.KNOWN_EVENT_TYPES. * 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 = () => export const useMyNotificationPreferences = () =>
useQuery(myNotificationPreferencesQuery()) 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 сохраняется на будущее.', 'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
'prefs.comingSoon': 'Скоро', 'prefs.comingSoon': 'Скоро',
'prefs.saveError': 'Не удалось сохранить настройки. Попробуйте ещё раз.', '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': 'prefs.descriptionMatrix':
'Per-event настройки доставки. Reviewer pool inbox остаётся ON независимо от ваших настроек.', 'Per-event настройки доставки. Reviewer pool inbox остаётся ON независимо от ваших настроек.',
'prefs.matrix.eventCol': 'Событие', 'prefs.matrix.eventCol': 'Событие',
@@ -1503,6 +1514,17 @@ 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.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': 'prefs.descriptionMatrix':
'Per-event delivery preferences. Reviewer pool inbox stays ON regardless of your settings.', 'Per-event delivery preferences. Reviewer pool inbox stays ON regardless of your settings.',
'prefs.matrix.eventCol': 'Event', 'prefs.matrix.eventCol': 'Event',
@@ -10,8 +10,9 @@ import {
QueryErrorState, QueryErrorState,
Switch, Switch,
} from '@/ui' } from '@/ui'
import { useMyNotificationPreferences } from '@/api/queries' import { useMyNotificationPreferences, useMyNotificationSettings } from '@/api/queries'
import { useUpdateNotificationPreferences } from '@/api/mutations' import { useUpdateNotificationPreferences, useUpdateNotificationSettings } from '@/api/mutations'
import { useState, useEffect } from 'react'
import { import {
NOTIFICATION_EVENT_TYPES, NOTIFICATION_EVENT_TYPES,
type ChannelToggles, type ChannelToggles,
@@ -181,10 +182,159 @@ function NotificationPreferencesPage() {
})} })}
</div> </div>
)} )}
{/* Phase C Don't Disturb quiet hours window. Applies к external
* channels (email + express). Bell badge остаётся доступным. */}
<QuietHoursSection />
</Panel> </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 { function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1) return s.charAt(0).toUpperCase() + s.slice(1)
} }
@@ -0,0 +1,109 @@
package cloud.nstart.terravault.ordinis.domain.notification;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalTime;
import java.time.OffsetDateTime;
/**
* Per-user global notification settings (Phase C Don't Disturb).
*
* <p>Хранит one quiet hours window per user (start/end LocalTime + IANA TZ).
* Если оба start и end null OR равны quiet hours disabled (no muting).
*
* <p>Cross-midnight ranges OK: window {@code 22:0008:00} активен когда
* текущий момент в зоне попадает либо в {@code [22:00, 23:59:59]}, либо в
* {@code [00:00, 08:00)}. Resolved в {@code UserPreferencesGate#isQuiet}.
*
* <p>Применяется только к external channels (email + express). In-app bell
* badge остаётся (юзер всё равно видит при следующем визите).
*
* <p>В отличие от {@link UserNotificationPref} (per event×channel matrix)
* это global per-user setting отдельная таблица чище чем denormalize
* в каждую prefs row.
*/
@Entity
@Table(name = "user_notification_settings")
public class UserNotificationSettings {
/** Default IANA timezone когда user не указал. */
public static final String DEFAULT_TZ = "Europe/Moscow";
@Id
@Column(name = "user_id", length = 128, nullable = false)
private String userId;
/** NULL → quiet hours disabled. */
@Column(name = "quiet_hours_start")
private LocalTime quietHoursStart;
@Column(name = "quiet_hours_end")
private LocalTime quietHoursEnd;
@Column(name = "quiet_hours_tz", length = 64)
private String quietHoursTz;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
protected UserNotificationSettings() {}
public UserNotificationSettings(String userId) {
this.userId = userId;
}
@PrePersist
void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
updatedAt = now;
}
@PreUpdate
void onUpdate() {
updatedAt = OffsetDateTime.now();
}
public String getUserId() { return userId; }
public LocalTime getQuietHoursStart() { return quietHoursStart; }
public LocalTime getQuietHoursEnd() { return quietHoursEnd; }
public String getQuietHoursTz() { return quietHoursTz; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setQuietHoursStart(LocalTime quietHoursStart) {
this.quietHoursStart = quietHoursStart;
}
public void setQuietHoursEnd(LocalTime quietHoursEnd) {
this.quietHoursEnd = quietHoursEnd;
}
public void setQuietHoursTz(String quietHoursTz) {
this.quietHoursTz = quietHoursTz;
}
/**
* True если quiet hours effective (both bounds set, not equal).
* Equal start==end treated as disabled (zero-duration window likely user
* confusion vs intent).
*/
public boolean hasEffectiveQuietHours() {
return quietHoursStart != null
&& quietHoursEnd != null
&& !quietHoursStart.equals(quietHoursEnd);
}
/** Effective TZ ID для resolution — fallback к DEFAULT_TZ. */
public String effectiveTz() {
return (quietHoursTz == null || quietHoursTz.isBlank()) ? DEFAULT_TZ : quietHoursTz;
}
}
@@ -0,0 +1,14 @@
package cloud.nstart.terravault.ordinis.domain.notification;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* Repository для per-user global notification settings (Phase C Don't Disturb).
*
* <p>Single-row-per-user table все queries via PK lookup {@code findById(userId)}.
* Inherits stock CRUD methods из {@link JpaRepository}, custom queries
* не нужны на этой фазе.
*/
public interface UserNotificationSettingsRepository
extends JpaRepository<UserNotificationSettings, String> {
}
@@ -0,0 +1,51 @@
<?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 C: Don't Disturb (time-of-day quiet hours).
User может задать window (e.g. 22:0008:00 Europe/Moscow) в течение
которой emails + express пуши DROP'аются. In-app bell badge остаётся
(user видит при следующем визите UI). Telegram TBD.
Why drop, not defer:
- Defer requires queue + re-fire scheduler — complex
- Most notifications are time-relevant (draft submitted N hours ago
less actionable, чем «just now»)
- User видит full feed через bell badge — ничего не теряется
- Phase D можно build summary digest если demand surfaces
Per-user single window (одна quiet zone per user). Cross-midnight OK
(e.g. 22:0008:00 = quiet когда now is 22:0023:59 OR 00:0008:00).
New table вместо extending user_notification_prefs — schemas разные:
prefs = per (user, event, channel), settings = per user global.
Splitting cleaner чем denormalising. -->
<changeSet id="0026-1-notification-quiet-hours" author="ordinis">
<comment>Per-user quiet hours window + timezone — Phase C Don't Disturb</comment>
<createTable tableName="user_notification_settings">
<column name="user_id" type="VARCHAR(128)">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- Quiet hours: NULL означает не задано (always deliver).
Format LOCAL TIME (HH:mm:ss) — interpreted в указанной TZ.
Если start == end → disabled (treat as no quiet hours). -->
<column name="quiet_hours_start" type="TIME"/>
<column name="quiet_hours_end" type="TIME"/>
<!-- IANA timezone, e.g. 'Europe/Moscow'. NULL → server default 'Europe/Moscow'. -->
<column name="quiet_hours_tz" type="VARCHAR(64)"/>
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
</databaseChangeLog>
@@ -35,5 +35,6 @@
<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"/> <include file="changes/0025-notification-prefs-event-type.xml" relativeToChangelogFile="true"/>
<include file="changes/0026-notification-quiet-hours.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -2,12 +2,17 @@ package cloud.nstart.terravault.ordinis.notifications.dispatcher;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref; import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPrefRepository; import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPrefRepository;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationSettings;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationSettingsRepository;
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind; import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Optional; import java.util.Optional;
import static cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref.ANY_EVENT_TYPE; import static cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref.ANY_EVENT_TYPE;
@@ -39,9 +44,20 @@ public class UserPreferencesGate {
public static final String REVIEWER_POOL_USER_ID = "reviewer-pool"; public static final String REVIEWER_POOL_USER_ID = "reviewer-pool";
private final UserNotificationPrefRepository repo; private final UserNotificationPrefRepository repo;
private final UserNotificationSettingsRepository settingsRepo;
/** Override clock для tests — null = system. */
private java.time.Clock clock = java.time.Clock.systemDefaultZone();
public UserPreferencesGate(UserNotificationPrefRepository repo) { public UserPreferencesGate(
UserNotificationPrefRepository repo,
UserNotificationSettingsRepository settingsRepo) {
this.repo = repo; this.repo = repo;
this.settingsRepo = settingsRepo;
}
/** Test seam — внутрипакетный setter для clock override. */
void setClockForTesting(java.time.Clock clock) {
this.clock = clock;
} }
/** /**
@@ -66,6 +82,13 @@ 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;
} }
// Phase C Don't Disturb: пользователь в quiet hours DROP для external
// channels (email/express). Bell badge (in-app) НЕ затронут он не
// проходит через gate (rendered напрямую UI'ем из notification log).
if (isExternalChannel(channel) && isQuiet(userId)) {
log.debug("Quiet hours active для user={} → dropping channel={}", userId, channel);
return false;
}
String channelWire = channel.wireName(); String channelWire = channel.wireName();
// Step 1: specific (user, eventType, channel) // Step 1: specific (user, eventType, channel)
@@ -96,4 +119,58 @@ public class UserPreferencesGate {
private boolean defaultFor(ChannelKind channel) { private boolean defaultFor(ChannelKind channel) {
return channel == ChannelKind.EMAIL; return channel == ChannelKind.EMAIL;
} }
/**
* External = доставляется наружу из приложения (EMAIL/EXPRESS/TELEGRAM).
* In-app channels (bell badge) НЕ external не fired через dispatcher.
* Сейчас все ChannelKind external; метод оставлен expansion-safe.
*/
private static boolean isExternalChannel(ChannelKind channel) {
return channel == ChannelKind.EMAIL
|| channel == ChannelKind.EXPRESS
|| channel == ChannelKind.TELEGRAM;
}
/**
* Phase C: current time falls in user's quiet hours window?
*
* <p>Cross-midnight aware: window {@code 22:0008:00} true когда current
* local time (in user's TZ) [22:00, 24:00) [00:00, 08:00).
*
* <p>Tolerant: settings row missing false. start == end false (treat
* as disabled, see {@link UserNotificationSettings#hasEffectiveQuietHours}).
* Invalid TZ id fallback к {@link UserNotificationSettings#DEFAULT_TZ}.
*/
boolean isQuiet(String userId) {
Optional<UserNotificationSettings> optS = settingsRepo.findById(userId);
if (optS.isEmpty()) return false;
UserNotificationSettings s = optS.get();
if (!s.hasEffectiveQuietHours()) return false;
ZoneId zone;
try {
zone = ZoneId.of(s.effectiveTz());
} catch (Exception e) {
log.warn("Invalid TZ '{}' для user={}, falling back к {}",
s.effectiveTz(), userId, UserNotificationSettings.DEFAULT_TZ);
zone = ZoneId.of(UserNotificationSettings.DEFAULT_TZ);
}
LocalTime nowLocal = ZonedDateTime.now(clock.withZone(zone)).toLocalTime();
return inWindow(nowLocal, s.getQuietHoursStart(), s.getQuietHoursEnd());
}
/**
* True если {@code t} [start, end). Cross-midnight semantics: если
* start > end (e.g. 22:00 > 08:00), window wraps midnight match если
* t >= start ИЛИ t < end. Standard "do not disturb" interpretation.
*/
static boolean inWindow(LocalTime t, LocalTime start, LocalTime end) {
if (start.isBefore(end)) {
// Same-day window: 09:0018:00 t in [09:00, 18:00).
return !t.isBefore(start) && t.isBefore(end);
} else {
// Cross-midnight: 22:0008:00 t in [22:00, 24:00) [00:00, 08:00).
return !t.isBefore(start) || t.isBefore(end);
}
}
} }
@@ -78,7 +78,8 @@ 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) // Phase B-2 signature: allows(userId, eventType, channel)
UserPreferencesGate allAllowGate = new UserPreferencesGate(null) { // Phase C extended constructor (settingsRepo too) stub все all-allow.
UserPreferencesGate allAllowGate = new UserPreferencesGate(null, null) {
@Override @Override
public boolean allows(String userId, String eventType, public boolean allows(String userId, String eventType,
cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) { cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) {
@@ -0,0 +1,170 @@
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPref;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationPrefRepository;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationSettings;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationSettingsRepository;
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Phase C Don't Disturb gate tests: quiet hours window calculation +
* cross-midnight semantics + dispatcher integration.
*/
class UserPreferencesGateQuietHoursTest {
private UserNotificationPrefRepository prefRepo;
private UserNotificationSettingsRepository settingsRepo;
private UserPreferencesGate gate;
private static final String USER = "user-123";
@BeforeEach
void setUp() {
prefRepo = mock(UserNotificationPrefRepository.class);
settingsRepo = mock(UserNotificationSettingsRepository.class);
// Default: no specific prefs found fallback к default policy
when(prefRepo.findByIdUserIdAndIdEventTypeAndIdChannel(anyString(), anyString(), anyString()))
.thenReturn(Optional.empty());
gate = new UserPreferencesGate(prefRepo, settingsRepo);
}
// inWindow pure function
@Test
void same_day_window_match() {
LocalTime start = LocalTime.of(9, 0);
LocalTime end = LocalTime.of(18, 0);
assertThat(UserPreferencesGate.inWindow(LocalTime.of(12, 0), start, end)).isTrue();
assertThat(UserPreferencesGate.inWindow(LocalTime.of(9, 0), start, end)).isTrue();
assertThat(UserPreferencesGate.inWindow(LocalTime.of(8, 59), start, end)).isFalse();
assertThat(UserPreferencesGate.inWindow(LocalTime.of(18, 0), start, end)).isFalse(); // exclusive
assertThat(UserPreferencesGate.inWindow(LocalTime.of(23, 30), start, end)).isFalse();
}
@Test
void cross_midnight_window_match() {
LocalTime start = LocalTime.of(22, 0);
LocalTime end = LocalTime.of(8, 0);
// Evening side
assertThat(UserPreferencesGate.inWindow(LocalTime.of(22, 0), start, end)).isTrue();
assertThat(UserPreferencesGate.inWindow(LocalTime.of(23, 59), start, end)).isTrue();
// Morning side
assertThat(UserPreferencesGate.inWindow(LocalTime.of(0, 0), start, end)).isTrue();
assertThat(UserPreferencesGate.inWindow(LocalTime.of(7, 59), start, end)).isTrue();
// Outside
assertThat(UserPreferencesGate.inWindow(LocalTime.of(8, 0), start, end)).isFalse();
assertThat(UserPreferencesGate.inWindow(LocalTime.of(12, 0), start, end)).isFalse();
assertThat(UserPreferencesGate.inWindow(LocalTime.of(21, 59), start, end)).isFalse();
}
// allows() с quiet hours
@Test
void no_settings_row_means_no_quiet_hours_email_allowed() {
when(settingsRepo.findById(USER)).thenReturn(Optional.empty());
assertThat(gate.allows(USER, "RecordDraftSubmitted", ChannelKind.EMAIL)).isTrue();
}
@Test
void quiet_hours_block_email_during_window() {
var settings = settingsAt(USER, "22:00", "08:00", "Europe/Moscow");
when(settingsRepo.findById(USER)).thenReturn(Optional.of(settings));
// Fix clock at 02:00 Europe/Moscow (cross-midnight window match)
gate.setClockForTesting(fixedClockMoscow("2026-05-17T02:00:00"));
assertThat(gate.allows(USER, "RecordDraftSubmitted", ChannelKind.EMAIL)).isFalse();
assertThat(gate.allows(USER, "RecordDraftSubmitted", ChannelKind.EXPRESS)).isFalse();
}
@Test
void quiet_hours_allow_email_outside_window() {
var settings = settingsAt(USER, "22:00", "08:00", "Europe/Moscow");
when(settingsRepo.findById(USER)).thenReturn(Optional.of(settings));
// 14:00 Moscow not in quiet window
gate.setClockForTesting(fixedClockMoscow("2026-05-17T14:00:00"));
assertThat(gate.allows(USER, "RecordDraftSubmitted", ChannelKind.EMAIL)).isTrue();
}
@Test
void quiet_hours_disabled_when_start_equals_end() {
var settings = settingsAt(USER, "08:00", "08:00", "Europe/Moscow");
when(settingsRepo.findById(USER)).thenReturn(Optional.of(settings));
gate.setClockForTesting(fixedClockMoscow("2026-05-17T08:00:00"));
// Zero-length window treated as disabled
assertThat(gate.allows(USER, "RecordDraftSubmitted", ChannelKind.EMAIL)).isTrue();
}
@Test
void reviewer_pool_bypasses_quiet_hours() {
var settings = settingsAt(UserPreferencesGate.REVIEWER_POOL_USER_ID, "00:00", "23:59", null);
when(settingsRepo.findById(UserPreferencesGate.REVIEWER_POOL_USER_ID))
.thenReturn(Optional.of(settings));
// Pool bypass early return before quiet check
assertThat(gate.allows(
UserPreferencesGate.REVIEWER_POOL_USER_ID,
"RecordDraftSubmitted",
ChannelKind.EMAIL)).isTrue();
}
@Test
void invalid_tz_falls_back_к_default() {
var settings = new UserNotificationSettings(USER);
settings.setQuietHoursStart(LocalTime.of(22, 0));
settings.setQuietHoursEnd(LocalTime.of(8, 0));
settings.setQuietHoursTz("Mars/Olympus_Mons"); // invalid
when(settingsRepo.findById(USER)).thenReturn(Optional.of(settings));
// 02:00 default (Europe/Moscow) should still match quiet window
gate.setClockForTesting(fixedClockMoscow("2026-05-17T02:00:00"));
assertThat(gate.allows(USER, "RecordDraftSubmitted", ChannelKind.EMAIL)).isFalse();
}
@Test
void quiet_hours_respect_specific_pref_disabled() {
// Specific pref OFF; quiet hours also active OFF (both паttерны
// disabling, no special order needed short-circuit на quiet first).
var pref = new UserNotificationPref(USER, "RecordDraftSubmitted", "email", true);
when(prefRepo.findByIdUserIdAndIdEventTypeAndIdChannel(USER, "RecordDraftSubmitted", "email"))
.thenReturn(Optional.of(pref));
var settings = settingsAt(USER, "22:00", "08:00", "Europe/Moscow");
when(settingsRepo.findById(USER)).thenReturn(Optional.of(settings));
gate.setClockForTesting(fixedClockMoscow("2026-05-17T02:00:00"));
// Even though specific pref ON, quiet hours win
assertThat(gate.allows(USER, "RecordDraftSubmitted", ChannelKind.EMAIL)).isFalse();
}
// helpers
private static UserNotificationSettings settingsAt(
String userId, String start, String end, String tz) {
var s = new UserNotificationSettings(userId);
s.setQuietHoursStart(LocalTime.parse(start));
s.setQuietHoursEnd(LocalTime.parse(end));
s.setQuietHoursTz(tz);
return s;
}
private static Clock fixedClockMoscow(String localIso) {
ZoneId zone = ZoneId.of("Europe/Moscow");
var instant = java.time.LocalDateTime.parse(localIso).atZone(zone).toInstant();
return Clock.fixed(instant, zone);
}
}
@@ -0,0 +1,153 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationSettings;
import cloud.nstart.terravault.ordinis.domain.notification.UserNotificationSettingsRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
/**
* Phase C Don't Disturb per-user notification settings (quiet hours window).
*
* <p>{@code GET /api/v1/me/notifications/settings}:
* <pre>
* {
* "quietHoursEnabled": true,
* "quietHoursStart": "22:00",
* "quietHoursEnd": "08:00",
* "quietHoursTz": "Europe/Moscow"
* }
* </pre>
*
* <p>{@code PUT /api/v1/me/notifications/settings} same shape.
* {@code quietHoursEnabled=false} clears window (start/end null).
*
* <p>Когда window active (current local time window в указанной TZ),
* external channels (email + express) DROPPED dispatcher'ом. Bell badge
* (in-app) НЕ затронут.
*
* <p>Auth required anonymous 401.
*/
@RestController
@RequestMapping("/api/v1/me/notifications/settings")
public class MeNotificationsSettingsController {
private static final Logger log =
LoggerFactory.getLogger(MeNotificationsSettingsController.class);
private final UserNotificationSettingsRepository repo;
public MeNotificationsSettingsController(UserNotificationSettingsRepository repo) {
this.repo = repo;
}
@GetMapping
public SettingsResponse get() {
String sub = requireSub();
UserNotificationSettings s = repo.findById(sub).orElse(null);
if (s == null) {
return new SettingsResponse(false, null, null, UserNotificationSettings.DEFAULT_TZ);
}
return new SettingsResponse(
s.hasEffectiveQuietHours(),
s.getQuietHoursStart() == null ? null : s.getQuietHoursStart().toString(),
s.getQuietHoursEnd() == null ? null : s.getQuietHoursEnd().toString(),
s.effectiveTz());
}
@PutMapping
@Transactional
public SettingsResponse update(@RequestBody SettingsRequest req) {
String sub = requireSub();
if (req == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "body required");
}
UserNotificationSettings s = repo.findById(sub)
.orElseGet(() -> new UserNotificationSettings(sub));
if (Boolean.FALSE.equals(req.quietHoursEnabled)) {
s.setQuietHoursStart(null);
s.setQuietHoursEnd(null);
} else {
LocalTime start = parseTime(req.quietHoursStart, "quietHoursStart");
LocalTime end = parseTime(req.quietHoursEnd, "quietHoursEnd");
if (start == null || end == null) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"quietHoursStart и quietHoursEnd обязательны когда quietHoursEnabled=true");
}
s.setQuietHoursStart(start);
s.setQuietHoursEnd(end);
}
if (req.quietHoursTz != null && !req.quietHoursTz.isBlank()) {
try {
ZoneId.of(req.quietHoursTz); // validate IANA id
s.setQuietHoursTz(req.quietHoursTz);
} catch (Exception e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Неизвестная timezone: " + req.quietHoursTz);
}
} else {
s.setQuietHoursTz(null);
}
repo.save(s);
log.info("Quiet hours updated: user={} start={} end={} tz={}",
sub, s.getQuietHoursStart(), s.getQuietHoursEnd(), s.effectiveTz());
return get();
}
private LocalTime parseTime(String raw, String field) {
if (raw == null || raw.isBlank()) return null;
try {
// Accept "HH:mm" or "HH:mm:ss" both. LocalTime.parse handles both.
return LocalTime.parse(raw);
} catch (DateTimeParseException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
field + ": ожидается формат HH:mm, got '" + raw + "'");
}
}
private String requireSub() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwt)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "JWT required");
}
String sub = jwt.getToken().getSubject();
if (sub == null || sub.isBlank()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "JWT sub claim missing");
}
return sub;
}
/** Request body. {@code quietHoursEnabled=false} clears window. */
public static class SettingsRequest {
public Boolean quietHoursEnabled;
public String quietHoursStart; // "HH:mm" or "HH:mm:ss"
public String quietHoursEnd;
public String quietHoursTz; // IANA TZ id, e.g. "Europe/Moscow"
}
public record SettingsResponse(
boolean quietHoursEnabled,
String quietHoursStart,
String quietHoursEnd,
String quietHoursTz) {}
}