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
@@ -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> {
}