diff --git a/backend/services/notification/__tests__/preferenceService.test.ts b/backend/services/notification/__tests__/preferenceService.test.ts new file mode 100644 index 00000000..d49622d9 --- /dev/null +++ b/backend/services/notification/__tests__/preferenceService.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, beforeEach, afterEach, jest } from '@jest/globals'; +import { NotificationPreferenceService } from '../preferenceService'; + +describe('NotificationPreferenceService', () => { + let service: NotificationPreferenceService; + + beforeEach(() => { + service = new NotificationPreferenceService(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns updated preferences and merges nested patches', async () => { + expect(await service.getPreferences('user-1')).toBeNull(); + + await service.updatePreferences('user-1', { + channels: { email: false } as never, + quietHours: { enabled: true } as never, + }); + + await expect(service.getPreferences('user-1')).resolves.toEqual({ + userId: 'user-1', + channels: { push: true, email: false, sms: false, inApp: true }, + frequency: 'immediate', + quietHours: { enabled: true, startTime: '22:00', endTime: '08:00', timezone: 'UTC' }, + }); + }); + + it('evaluates quiet hours in the configured timezone', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-03-01T23:30:00.000Z')); + const preferences = { + userId: 'user-1', + channels: { push: true, email: true, sms: false, inApp: true }, + frequency: 'immediate' as const, + quietHours: { enabled: true, startTime: '22:00', endTime: '08:00', timezone: 'UTC' }, + }; + + expect(service.shouldDeliverNow(preferences)).toBe(false); + expect(service.shouldDeliverNow({ ...preferences, quietHours: { ...preferences.quietHours, enabled: false } })).toBe(true); + }); +}); diff --git a/backend/services/notification/preferenceService.ts b/backend/services/notification/preferenceService.ts index 7eef91e0..4991bd57 100644 --- a/backend/services/notification/preferenceService.ts +++ b/backend/services/notification/preferenceService.ts @@ -1,3 +1,5 @@ +import { logger } from '../logging'; + export interface NotificationPreferences { userId: string; channels: { @@ -15,25 +17,65 @@ export interface NotificationPreferences { }; } -import { logger } from '../logging'; +const DEFAULT_PREFERENCES: Omit = { + channels: { push: true, email: true, sms: false, inApp: true }, + frequency: 'immediate', + quietHours: { enabled: false, startTime: '22:00', endTime: '08:00', timezone: 'UTC' }, +}; + +const parseTime = (value: string): number | null => { + const match = /^(\d{2}):(\d{2})$/.exec(value); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + return hours < 24 && minutes < 60 ? hours * 60 + minutes : null; +}; export class NotificationPreferenceService { + private readonly preferences = new Map(); + async getPreferences(userId: string): Promise { - // Mock database fetch - return null; + return this.preferences.get(userId) ?? null; } async updatePreferences(userId: string, prefs: Partial): Promise { - // Cross-device synchronization logic - logger.info('Updated notification preferences for user', { userId, prefs }); + const current = this.preferences.get(userId) ?? { userId, ...DEFAULT_PREFERENCES }; + const updated: NotificationPreferences = { + ...current, + ...prefs, + userId, + channels: { ...current.channels, ...prefs.channels }, + quietHours: { ...current.quietHours, ...prefs.quietHours }, + }; + this.preferences.set(userId, updated); + logger.info('Updated notification preferences for user', { userId, prefs: updated }); return true; } shouldDeliverNow(prefs: NotificationPreferences): boolean { if (!prefs.quietHours.enabled) return true; - - // Evaluate timezone-aware quiet hours - // (Mock implementation) - return true; + + const start = parseTime(prefs.quietHours.startTime); + const end = parseTime(prefs.quietHours.endTime); + if (start === null || end === null) return true; + + let current: number; + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: prefs.quietHours.timezone, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(new Date()); + const hour = Number(parts.find((part) => part.type === 'hour')?.value); + const minute = Number(parts.find((part) => part.type === 'minute')?.value); + current = hour * 60 + minute; + } catch { + return true; + } + + return start === end ? false : start < end + ? current < start || current >= end + : current < start && current >= end; } }