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:
+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> {
|
||||
}
|
||||
Reference in New Issue
Block a user