diff --git a/src/notifications/notification-runner.service.ts b/src/notifications/notification-runner.service.ts index b4f4b3b..883bd4f 100644 --- a/src/notifications/notification-runner.service.ts +++ b/src/notifications/notification-runner.service.ts @@ -17,6 +17,7 @@ import { type NotificationRule, type Severity, } from './notification-rules'; +import { quietHoursDelay } from './quiet-hours'; const BATCH_SIZE = 200; const SAFETY_LAG_MS = 60_000; @@ -96,6 +97,11 @@ export class NotificationRunnerService { // Agregare: daca exista deja o notificare necitita cu aceeasi cheie in // fereastra, o incrementam in loc sa cream a doua (spec ยง12). + // + // Nota deliberata: agregarea NU reevalueaza quiet hours. Incrementarea + // unui contor pe o notificare deja vizibila nu e o intrerupere noua, deci + // decision.scheduledAt se aplica doar randurilor nou create. Daca + // notificarea existenta e SCHEDULED, ramane SCHEDULED. if (rule.aggregationWindowMinutes) { const windowStart = new Date(Date.now() - rule.aggregationWindowMinutes * 60_000); const existing = await db.query.notifications.findFirst({ @@ -210,37 +216,15 @@ export class NotificationRunnerService { return { deliver: false, scheduledAt: null }; } - const scheduledAt = this.quietHoursDelay( - prefs.quietHoursStart, - prefs.quietHoursEnd, - rule.severity, - prefs.criticalBypassesQuietHours === 1, - ); + const scheduledAt = quietHoursDelay({ + start: prefs.quietHoursStart, + end: prefs.quietHoursEnd, + severity: rule.severity, + criticalBypasses: prefs.criticalBypassesQuietHours === 1, + }); return { deliver: true, scheduledAt }; } - private quietHoursDelay( - start: number | null, - end: number | null, - severity: Severity, - criticalBypasses: boolean, - ): Date | null { - if (start === null || end === null || start === end) return null; - if (severity === 'CRITICAL' && criticalBypasses) return null; - - const now = new Date(); - const hour = now.getUTCHours(); - // Fereastra poate traversa miezul noptii (ex. 22 -> 7). - const inQuiet = start < end ? hour >= start && hour < end : hour >= start || hour < end; - if (!inQuiet) return null; - - const deliverAt = new Date(now); - deliverAt.setUTCMinutes(0, 0, 0); - if (hour >= end) deliverAt.setUTCDate(deliverAt.getUTCDate() + 1); - deliverAt.setUTCHours(end); - return deliverAt; - } - private deduplicationKey( rule: NotificationRule, event: NotificationEvent, diff --git a/src/notifications/quiet-hours.spec.ts b/src/notifications/quiet-hours.spec.ts new file mode 100644 index 0000000..d2b0c69 --- /dev/null +++ b/src/notifications/quiet-hours.spec.ts @@ -0,0 +1,68 @@ +import { quietHoursDelay } from './quiet-hours'; + +const at = (hourUtc: number) => new Date(Date.UTC(2026, 6, 29, hourUtc, 30, 0)); + +describe('quietHoursDelay', () => { + it('livreaza imediat cand userul n-a configurat quiet hours', () => { + expect( + quietHoursDelay({ start: null, end: null, severity: 'INFORMATION', criticalBypasses: true, now: at(3) }), + ).toBeNull(); + }); + + it('trateaza start === end ca fiind fara quiet hours, nu ca 24h de liniste', () => { + expect( + quietHoursDelay({ start: 9, end: 9, severity: 'INFORMATION', criticalBypasses: true, now: at(9) }), + ).toBeNull(); + }); + + describe('fereastra simpla, in aceeasi zi (9 -> 17)', () => { + it('amana pana la sfarsitul ferestrei cand suntem inauntru', () => { + const result = quietHoursDelay({ start: 9, end: 17, severity: 'INFORMATION', criticalBypasses: true, now: at(13) }); + expect(result?.toISOString()).toBe('2026-07-29T17:00:00.000Z'); + }); + + it('livreaza imediat inainte de inceputul ferestrei', () => { + expect( + quietHoursDelay({ start: 9, end: 17, severity: 'INFORMATION', criticalBypasses: true, now: at(8) }), + ).toBeNull(); + }); + + it('livreaza imediat exact la ora de sfarsit -- capatul e exclusiv', () => { + expect( + quietHoursDelay({ start: 9, end: 17, severity: 'INFORMATION', criticalBypasses: true, now: at(17) }), + ).toBeNull(); + }); + }); + + describe('fereastra care traverseaza miezul noptii (22 -> 7)', () => { + it('seara amana pana dimineata ZILEI URMATOARE', () => { + const result = quietHoursDelay({ start: 22, end: 7, severity: 'INFORMATION', criticalBypasses: true, now: at(23) }); + expect(result?.toISOString()).toBe('2026-07-30T07:00:00.000Z'); + }); + + it('noaptea tarziu amana pana dimineata ACELEIASI zi', () => { + const result = quietHoursDelay({ start: 22, end: 7, severity: 'INFORMATION', criticalBypasses: true, now: at(3) }); + expect(result?.toISOString()).toBe('2026-07-29T07:00:00.000Z'); + }); + + it('la pranz livreaza imediat -- suntem in afara ferestrei', () => { + expect( + quietHoursDelay({ start: 22, end: 7, severity: 'INFORMATION', criticalBypasses: true, now: at(12) }), + ).toBeNull(); + }); + }); + + describe('CRITICAL', () => { + it('depaseste quiet hours cand politica userului permite', () => { + expect( + quietHoursDelay({ start: 22, end: 7, severity: 'CRITICAL', criticalBypasses: true, now: at(3) }), + ).toBeNull(); + }); + + it('respecta quiet hours cand userul a dezactivat bypass-ul', () => { + // Nu presupunem ca "critical" invinge mereu preferinta explicita a userului. + const result = quietHoursDelay({ start: 22, end: 7, severity: 'CRITICAL', criticalBypasses: false, now: at(3) }); + expect(result?.toISOString()).toBe('2026-07-29T07:00:00.000Z'); + }); + }); +}); diff --git a/src/notifications/quiet-hours.ts b/src/notifications/quiet-hours.ts new file mode 100644 index 0000000..1db3d66 --- /dev/null +++ b/src/notifications/quiet-hours.ts @@ -0,0 +1,38 @@ +import type { Severity } from './notification-rules'; + +/** + * Calculeaza cand se poate livra o notificare data fiind fereastra de liniste + * a userului. Intoarce null cand se poate livra imediat. + * + * Functie pura, separata de runner, ca sa fie testabila: aritmetica de ore cu + * fereastra care traverseaza miezul noptii e locul unde apar erorile. + */ +export function quietHoursDelay(params: { + start: number | null; + end: number | null; + severity: Severity; + criticalBypasses: boolean; + now?: Date; +}): Date | null { + const { start, end, severity, criticalBypasses } = params; + const now = params.now ?? new Date(); + + // start === end ar insemna fereastra de 24h sau de 0h -- ambiguu, deci o + // tratam ca "fara quiet hours" in loc sa ghicim. + if (start === null || end === null || start === end) return null; + if (severity === 'CRITICAL' && criticalBypasses) return null; + + const hour = now.getUTCHours(); + const crossesMidnight = start > end; + const inQuiet = crossesMidnight ? hour >= start || hour < end : hour >= start && hour < end; + if (!inQuiet) return null; + + const deliverAt = new Date(now); + deliverAt.setUTCMinutes(0, 0, 0); + // Daca ora curenta e deja peste ora de sfarsit, sfarsitul ferestrei e maine. + if (hour >= end) { + deliverAt.setUTCDate(deliverAt.getUTCDate() + 1); + } + deliverAt.setUTCHours(end); + return deliverAt; +}