diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 299eed0..8520ee3 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -764,6 +764,18 @@ export type ChannelToggles = { /** Map keyed по event type. Backend сейчас возвращает 4 known events. */ export type NotificationPreferences = Record +/** + * 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. diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 111eb09..9df50d5 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -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 => { + const { data } = await apiClient.put( + '/me/notifications/settings', + next, + ) + return data + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: ['notifications', 'me', 'settings'] }) + }, + }) +} diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 938a25b..0365fef 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + const { data } = await apiClient.get( + '/me/notifications/settings', + ) + return data + }, + staleTime: 60_000, + }) + +export const useMyNotificationSettings = () => useQuery(myNotificationSettingsQuery()) diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index b9f09d9..b5c0ccf 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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', diff --git a/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx index f349bc7..a1aca9b 100644 --- a/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx +++ b/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx @@ -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() { })} )} + + {/* Phase C — Don't Disturb quiet hours window. Applies к external + * channels (email + express). Bell badge остаётся доступным. */} + ) } +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 ( +
+
+
+

+ {t('prefs.quietHours.title', { defaultValue: 'Тихие часы' })} +

+

+ {t('prefs.quietHours.description', { + defaultValue: + 'В этом окне email и express не отправляются. Внутренние уведомления в шапке остаются.', + })} +

+
+ +
+ + {enabled && ( +
+ + + +
+ )} + +
+ + {updateMut.isSuccess && !updateMut.isPending && ( + + {t('prefs.quietHours.saved', { defaultValue: 'Сохранено' })} + + )} +
+ + {updateMut.isError && ( +
+ {t('prefs.quietHours.saveError', { + defaultValue: 'Не удалось сохранить тихие часы.', + })} +
+ )} +
+ ) +} + +/** "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) } diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationSettings.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationSettings.java new file mode 100644 index 0000000..8d450da --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationSettings.java @@ -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). + * + *

Хранит one quiet hours window per user (start/end LocalTime + IANA TZ). + * Если оба start и end null OR равны — quiet hours disabled (no muting). + * + *

Cross-midnight ranges OK: window {@code 22:00–08:00} активен когда + * текущий момент в зоне попадает либо в {@code [22:00, 23:59:59]}, либо в + * {@code [00:00, 08:00)}. Resolved в {@code UserPreferencesGate#isQuiet}. + * + *

Применяется только к external channels (email + express). In-app bell + * badge остаётся (юзер всё равно видит при следующем визите). + * + *

В отличие от {@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; + } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationSettingsRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationSettingsRepository.java new file mode 100644 index 0000000..b057c64 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/notification/UserNotificationSettingsRepository.java @@ -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). + * + *

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 { +} diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0026-notification-quiet-hours.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0026-notification-quiet-hours.xml new file mode 100644 index 0000000..c5964db --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0026-notification-quiet-hours.xml @@ -0,0 +1,51 @@ + + + + + + + Per-user quiet hours window + timezone — Phase C Don't Disturb + + + + + + + + + + + + + + + + + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index 10910e4..8b744e8 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -35,5 +35,6 @@ + diff --git a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGate.java b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGate.java index ca92044..c0f077d 100644 --- a/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGate.java +++ b/ordinis-notifications/src/main/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGate.java @@ -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.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.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Optional; 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"; 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.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. 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(); // Step 1: specific (user, eventType, channel) @@ -96,4 +119,58 @@ public class UserPreferencesGate { private boolean defaultFor(ChannelKind channel) { 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? + * + *

Cross-midnight aware: window {@code 22:00–08:00} → true когда current + * local time (in user's TZ) ∈ [22:00, 24:00) ∪ [00:00, 08:00). + * + *

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 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:00–18:00 → t in [09:00, 18:00). + return !t.isBefore(start) && t.isBefore(end); + } else { + // Cross-midnight: 22:00–08:00 → t in [22:00, 24:00) ∪ [00:00, 08:00). + return !t.isBefore(start) || t.isBefore(end); + } + } } diff --git a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcherTest.java b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcherTest.java index c4f63c9..e2f8171 100644 --- a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcherTest.java +++ b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/NotificationsDispatcherTest.java @@ -78,7 +78,8 @@ class NotificationsDispatcherTest { // Phase B: UserPreferencesGate — stub all-allow для existing tests (no // prefs row → defaults apply, email ON). Per-channel skip tested separately. // 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 public boolean allows(String userId, String eventType, cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind channel) { diff --git a/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGateQuietHoursTest.java b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGateQuietHoursTest.java new file mode 100644 index 0000000..656fa16 --- /dev/null +++ b/ordinis-notifications/src/test/java/cloud/nstart/terravault/ordinis/notifications/dispatcher/UserPreferencesGateQuietHoursTest.java @@ -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); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsSettingsController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsSettingsController.java new file mode 100644 index 0000000..8b4d173 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/MeNotificationsSettingsController.java @@ -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). + * + *

{@code GET /api/v1/me/notifications/settings}: + *

+ * {
+ *   "quietHoursEnabled": true,
+ *   "quietHoursStart": "22:00",
+ *   "quietHoursEnd": "08:00",
+ *   "quietHoursTz": "Europe/Moscow"
+ * }
+ * 
+ * + *

{@code PUT /api/v1/me/notifications/settings} — same shape. + * {@code quietHoursEnabled=false} clears window (start/end → null). + * + *

Когда window active (current local time ∈ window в указанной TZ), + * external channels (email + express) DROPPED dispatcher'ом. Bell badge + * (in-app) НЕ затронут. + * + *

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) {} +}