Merge branch 'feat/phase-c-quiet-hours' into 'main'
feat(notifications): Phase C — Don't Disturb (quiet hours window) See merge request 2-6/2-6-4/terravault/ordinis!237
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)
|
||||
}
|
||||
|
||||
+109
@@ -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:00–08: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;
|
||||
}
|
||||
}
|
||||
+14
@@ -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> {
|
||||
}
|
||||
+51
@@ -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:00–08: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:00–08:00 = quiet когда now is 22:00–23:59 OR 00:00–08: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/0024-notification-read-state.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>
|
||||
|
||||
+78
-1
@@ -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?
|
||||
*
|
||||
* <p>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).
|
||||
*
|
||||
* <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: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+170
@@ -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);
|
||||
}
|
||||
}
|
||||
+153
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user