diff --git a/backend/jest.config.js b/backend/jest.config.js index c8031f6..758359b 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -10,6 +10,7 @@ module.exports = { setupFiles: ['/tests/setup.ts'], transform: { '^.+\\.tsx?$': 'ts-jest', + '^.+\\.js$': ['ts-jest', { diagnostics: false }], }, globals: { 'ts-jest': { @@ -23,7 +24,7 @@ module.exports = { }, }, transformIgnorePatterns: [ - '/node_modules/(?!@stellar/stellar-sdk)', + '/node_modules/(?!(@stellar/stellar-sdk|uuid))', ], coverageThreshold: { global: { diff --git a/backend/package-lock.json b/backend/package-lock.json index 49da1ce..b290349 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -3649,6 +3649,16 @@ "buffer-fill": "^1.0.0" } }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, "node_modules/buffer-alloc-unsafe": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", @@ -4329,6 +4339,15 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/backend/package.json b/backend/package.json index d96bb82..43c8020 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,6 +43,7 @@ "cookie-parser": "^1.4.7", "csv-parse": "^6.2.1", "date-fns": "^4.1.0", + "date-fns-tz": "3.2.0", "dotenv": "^17.4.2", "express": "^5.2.1", "express-rate-limit": "^8.4.1", diff --git a/backend/src/services/quiet-hours-service.ts b/backend/src/services/quiet-hours-service.ts index 3c0063b..d2f9a06 100644 --- a/backend/src/services/quiet-hours-service.ts +++ b/backend/src/services/quiet-hours-service.ts @@ -1,3 +1,4 @@ +import { toZonedTime, fromZonedTime } from 'date-fns-tz'; import { UserPreferences, NotificationPriority, NotificationPayload } from '../types/reminder'; import logger from '../config/logger'; @@ -8,9 +9,40 @@ export interface QuietHoursCheck { reason?: string; } +/** + * Resolve a user's IANA timezone string, falling back to UTC if the value is + * absent or unrecognised by the runtime. + */ +function resolveTimezone(tz: string | undefined | null): string { + if (!tz) return 'UTC'; + try { + // Validate by attempting a conversion — date-fns-tz returns Invalid Date + // for unknown identifiers rather than throwing, so we check for that too. + const result = toZonedTime(new Date(), tz); + if (isNaN(result.getTime())) { + logger.warn(`Unrecognised timezone "${tz}", falling back to UTC`); + return 'UTC'; + } + return tz; + } catch { + logger.warn(`Unrecognised timezone "${tz}", falling back to UTC`); + return 'UTC'; + } +} + +/** + * Return the wall-clock hour and minute for a UTC instant in the given IANA + * timezone. Always returns values in [0–23] and [0–59]. + */ +function localHourMinute(utcDate: Date, tz: string): { hour: number; minute: number } { + const zoned = toZonedTime(utcDate, tz); + return { hour: zoned.getHours(), minute: zoned.getMinutes() }; +} + export class QuietHoursService { /** - * Check if current time is within user's quiet hours + * Check if a UTC instant falls within the user's quiet hours window, + * evaluated in the user's own timezone. */ isInQuietHours(preferences: UserPreferences, currentTime: Date = new Date()): boolean { if (!preferences.quiet_hours_enabled) { @@ -18,26 +50,23 @@ export class QuietHoursService { } try { - // For simplicity, we'll work in UTC for now - // In a production environment, you'd want proper timezone handling - const currentHour = currentTime.getUTCHours(); - const currentMinute = currentTime.getUTCMinutes(); - const currentTimeMinutes = currentHour * 60 + currentMinute; + const tz = resolveTimezone(preferences.quiet_hours_timezone); + const { hour, minute } = localHourMinute(currentTime, tz); + const currentTimeMinutes = hour * 60 + minute; - // Parse start and end times const [startHour, startMinute] = preferences.quiet_hours_start.split(':').map(Number); const [endHour, endMinute] = preferences.quiet_hours_end.split(':').map(Number); - + const startTimeMinutes = startHour * 60 + startMinute; const endTimeMinutes = endHour * 60 + endMinute; - // Handle overnight quiet hours (e.g., 22:00 to 08:00) + // Overnight window (e.g. 22:00 → 08:00 crosses midnight) if (startTimeMinutes > endTimeMinutes) { return currentTimeMinutes >= startTimeMinutes || currentTimeMinutes < endTimeMinutes; } - - // Handle same-day quiet hours (e.g., 13:00 to 17:00) - return currentTimeMinutes >= startTimeMinutes && currentTimeMinutes <= endTimeMinutes; + + // Same-day window (e.g. 13:00 → 17:00) + return currentTimeMinutes >= startTimeMinutes && currentTimeMinutes < endTimeMinutes; } catch (error) { logger.error('Error checking quiet hours:', error); return false; @@ -45,86 +74,93 @@ export class QuietHoursService { } /** - * Calculate when quiet hours end for scheduling delayed notifications + * Calculate the next UTC instant at which quiet hours end, expressed in the + * user's timezone. The returned Date is always in the future relative to + * currentTime. */ getQuietHoursEndTime(preferences: UserPreferences, currentTime: Date = new Date()): Date { try { + const tz = resolveTimezone(preferences.quiet_hours_timezone); const [endHour, endMinute] = preferences.quiet_hours_end.split(':').map(Number); - - // Create end time in UTC - const endTime = new Date(currentTime); - endTime.setUTCHours(endHour, endMinute, 0, 0); - - // If end time is before current time, it's tomorrow - if (endTime <= currentTime) { - endTime.setUTCDate(endTime.getUTCDate() + 1); + + // Convert the UTC instant to the user's local calendar date/time + const zonedNow = toZonedTime(currentTime, tz); + + // Build an ISO-like local datetime string from the zoned components — + // this avoids any dependency on the server's system timezone. + const year = zonedNow.getFullYear(); + const month = String(zonedNow.getMonth() + 1).padStart(2, '0'); + const day = String(zonedNow.getDate()).padStart(2, '0'); + const hh = String(endHour).padStart(2, '0'); + const mm = String(endMinute).padStart(2, '0'); + const localDateStr = `${year}-${month}-${day}T${hh}:${mm}:00`; + + // fromZonedTime interprets the string as a wall-clock time in `tz` + // and returns the corresponding UTC instant. + let candidate = fromZonedTime(localDateStr, tz); + + // If the candidate is not strictly after currentTime, advance by 24 hours. + // Adding exactly 24 h is safe here: DST shifts only affect the wall-clock + // representation, not the UTC arithmetic, and we only need "tomorrow's + // end time" — not a precise local-midnight boundary. + if (candidate <= currentTime) { + candidate = new Date(candidate.getTime() + 24 * 60 * 60 * 1000); } - - return endTime; + + return candidate; } catch (error) { logger.error('Error calculating quiet hours end time:', error); - // Fallback: delay by 8 hours - const fallback = new Date(currentTime); - fallback.setUTCHours(fallback.getUTCHours() + 8); - return fallback; + // Fallback: 8 hours from now + return new Date(currentTime.getTime() + 8 * 60 * 60 * 1000); } } /** - * Determine notification priority based on content and type + * Determine notification priority based on content and type. + * + * Priority tiers: + * critical — renewal ≤ 1 day away, or trial expiring today + * high — trial expiring ≤ 2 days, or renewal ≤ 3 days + * normal — standard renewal / trial_expiry reminders + * low — cancellation reminders */ determineNotificationPriority(payload: NotificationPayload): NotificationPriority { - // Critical: Last day reminders for paid subscriptions if (payload.reminderType === 'renewal' && payload.daysBefore <= 1) { return 'critical'; } - - // Critical: Trial expiring today if (payload.reminderType === 'trial_expiry' && payload.daysBefore <= 0) { return 'critical'; } - - // High: Trial expiring within 2 days if (payload.reminderType === 'trial_expiry' && payload.daysBefore <= 2) { return 'high'; } - - // High: Renewal within 3 days if (payload.reminderType === 'renewal' && payload.daysBefore <= 3) { return 'high'; } - - // Normal: Standard reminders if (payload.reminderType === 'renewal' || payload.reminderType === 'trial_expiry') { return 'normal'; } - - // Low: Cancellation reminders if (payload.reminderType === 'cancellation') { return 'low'; } - return 'normal'; } /** - * Check if notification should be sent during quiet hours + * Decide whether a notification should be sent immediately or delayed. + * Critical alerts always pass through, even during quiet hours. */ shouldSendDuringQuietHours( - preferences: UserPreferences, + preferences: UserPreferences, payload: NotificationPayload, - currentTime: Date = new Date() + currentTime: Date = new Date(), ): QuietHoursCheck { if (!this.isInQuietHours(preferences, currentTime)) { - return { - isQuietHours: false, - shouldDelay: false, - }; + return { isQuietHours: false, shouldDelay: false }; } const priority = this.determineNotificationPriority(payload); - - // Always allow critical alerts during quiet hours + if (priority === 'critical') { return { isQuietHours: true, @@ -132,8 +168,7 @@ export class QuietHoursService { reason: 'Critical alert allowed during quiet hours', }; } - - // If user allows only critical alerts, delay non-critical ones + if (preferences.critical_alerts_only) { const delayUntil = this.getQuietHoursEndTime(preferences, currentTime); return { @@ -143,8 +178,7 @@ export class QuietHoursService { reason: `Non-critical alert delayed until ${delayUntil.toISOString()}`, }; } - - // User allows all alerts during quiet hours + return { isQuietHours: true, shouldDelay: false, @@ -153,32 +187,32 @@ export class QuietHoursService { } /** - * Check if it's an appropriate time to send delayed notifications + * Return true when it is an appropriate local time to deliver delayed + * notifications for this user (08:00–22:00 in the user's own timezone, + * and not currently within quiet hours). */ isAppropriateTimeForDelayedNotifications( preferences: UserPreferences, - currentTime: Date = new Date() + currentTime: Date = new Date(), ): boolean { if (!preferences.quiet_hours_enabled) { return true; } - // Don't send during quiet hours if (this.isInQuietHours(preferences, currentTime)) { return false; } try { - // For simplicity, work in UTC for now - const currentHour = currentTime.getUTCHours(); - - // Send delayed notifications between 8 AM and 10 PM UTC - return currentHour >= 8 && currentHour < 22; + const tz = resolveTimezone(preferences.quiet_hours_timezone); + const { hour } = localHourMinute(currentTime, tz); + // Deliver delayed notifications between 08:00 and 22:00 local time + return hour >= 8 && hour < 22; } catch (error) { logger.error('Error checking appropriate time for delayed notifications:', error); - return true; // Default to allowing notifications + return true; } } } -export const quietHoursService = new QuietHoursService(); \ No newline at end of file +export const quietHoursService = new QuietHoursService(); diff --git a/backend/src/types/reminder.ts b/backend/src/types/reminder.ts index 5457c1e..bc3cc65 100644 --- a/backend/src/types/reminder.ts +++ b/backend/src/types/reminder.ts @@ -37,27 +37,25 @@ export interface Subscription { price: number; billing_cycle: string; status: string; + /** ISO-8601 UTC timestamp of the next billing event. */ next_billing_date: string | null; logo_url: string | null; website_url: string | null; renewal_url: string | null; notes: string | null; tags: string[]; + /** ISO-8601 UTC timestamp when the subscription expired. */ expired_at: string | null; + /** ISO-8601 UTC timestamp until which the subscription is active. */ active_until: string | null; // Trial tracking fields is_trial: boolean; + /** ISO-8601 UTC timestamp when the trial ends. */ trial_ends_at: string | null; trial_converts_to_price: number | null; credit_card_required: boolean; created_at: string; updated_at: string; - // Trial tracking - is_trial: boolean; - trial_ends_at: string | null; - trial_converts_to_price: number | null; - credit_card_required: boolean; - website_url: string | null; } export interface UserProfile { @@ -86,6 +84,23 @@ export interface DeliveryResult { metadata?: Record; } +/** + * Timestamp and timezone storage rules for UserPreferences + * ───────────────────────────────────────────────────────── + * • `updated_at` — ISO-8601 UTC string (TIMESTAMPTZ in DB). + * • `quiet_hours_start` — Wall-clock time in HH:MM (24-hour) format. + * Stored as a bare TIME in the DB; has no timezone + * component of its own. Always interpreted in the + * context of `quiet_hours_timezone`. + * • `quiet_hours_end` — Same format and rules as `quiet_hours_start`. + * • `quiet_hours_timezone` — IANA timezone identifier (e.g. "America/New_York"). + * All quiet-hours comparisons MUST be performed in + * this timezone, never in raw UTC. + * + * Rendering rule: when displaying any timestamp to the user, convert from UTC + * to `quiet_hours_timezone` (or the profile-level `timezone` field) using the + * `date-fns-tz` library on the backend or `Intl.DateTimeFormat` on the client. + */ export interface UserPreferences { user_id: string; notification_channels: ('email' | 'push' | 'telegram')[]; @@ -101,15 +116,19 @@ export interface UserPreferences { }; risk_notification_threshold?: 'LOW' | 'MEDIUM' | 'HIGH'; quiet_hours_enabled: boolean; - quiet_hours_start: string; // HH:MM format - quiet_hours_end: string; // HH:MM format - quiet_hours_timezone: string; // IANA timezone identifier + /** Wall-clock start of quiet hours in HH:MM (24-hour) format. */ + quiet_hours_start: string; + /** Wall-clock end of quiet hours in HH:MM (24-hour) format. */ + quiet_hours_end: string; + /** IANA timezone identifier used to interpret quiet_hours_start/end. */ + quiet_hours_timezone: string; critical_alerts_only: boolean; currency: string; timezone: string; locale: string; calendar_sync_enabled: boolean; calendar_export_reminders: boolean; + /** ISO-8601 UTC string. */ updated_at: string; } diff --git a/backend/tests/timestamp-timezone.test.ts b/backend/tests/timestamp-timezone.test.ts new file mode 100644 index 0000000..c3b9da3 --- /dev/null +++ b/backend/tests/timestamp-timezone.test.ts @@ -0,0 +1,399 @@ +/** + * Tests for Issue #71 — Normalize timestamp and timezone storage rules + * + * Covers: + * - QuietHoursService using the stored IANA timezone (not raw UTC) + * - Overnight quiet-hours windows evaluated in user timezone + * - getQuietHoursEndTime returning a correct UTC instant for non-UTC users + * - isAppropriateTimeForDelayedNotifications respecting user timezone + * - DST edge cases (US Eastern spring-forward / fall-back) + * - Fallback to UTC when timezone is empty or invalid + */ + +import { QuietHoursService } from '../src/services/quiet-hours-service'; +import { UserPreferences, NotificationPayload } from '../src/types/reminder'; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function makePrefs(overrides: Partial = {}): UserPreferences { + return { + user_id: 'test-user', + notification_channels: ['email'], + reminder_timing: [7, 3, 1], + email_opt_ins: { marketing: false, reminders: true, updates: true }, + automation_flags: { auto_renew: false, auto_retry: true }, + quiet_hours_enabled: true, + quiet_hours_start: '22:00', + quiet_hours_end: '08:00', + quiet_hours_timezone: 'UTC', + critical_alerts_only: true, + calendar_sync_enabled: false, + calendar_export_reminders: true, + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +function makePayload(overrides: Partial = {}): NotificationPayload { + return { + title: 'Test', + body: 'Test body', + subscription: { id: 'sub-1', name: 'Netflix' } as any, + reminderType: 'renewal', + daysBefore: 7, + renewalDate: new Date().toISOString(), + ...overrides, + }; +} + +// ─── suite ────────────────────────────────────────────────────────────────── + +describe('QuietHoursService — timezone-aware behaviour (Issue #71)', () => { + let service: QuietHoursService; + + beforeEach(() => { + service = new QuietHoursService(); + }); + + // ── isInQuietHours ───────────────────────────────────────────────────────── + + describe('isInQuietHours — non-UTC timezone', () => { + /** + * User is in America/New_York (UTC-5 in winter). + * Quiet hours: 22:00–08:00 Eastern. + * 03:00 UTC = 22:00 Eastern → start of quiet hours → should be IN quiet hours. + */ + it('detects quiet hours start in Eastern Standard Time (UTC-5)', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + // 2024-01-15 03:00 UTC = 2024-01-14 22:00 EST + const utcTime = new Date('2024-01-15T03:00:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(true); + }); + + /** + * 13:00 UTC = 08:00 EST — exactly at the end boundary. + * The end boundary is exclusive (< endTimeMinutes), so 08:00 is NOT quiet hours. + */ + it('treats quiet-hours end boundary as exclusive (08:00 local is NOT quiet hours)', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + // 2024-01-15 13:00 UTC = 2024-01-15 08:00 EST + const utcTime = new Date('2024-01-15T13:00:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(false); + }); + + /** + * 12:59 UTC = 07:59 EST — one minute before end → still in quiet hours. + */ + it('is still in quiet hours one minute before end boundary', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + // 2024-01-15 12:59 UTC = 2024-01-15 07:59 EST + const utcTime = new Date('2024-01-15T12:59:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(true); + }); + + /** + * 18:00 UTC = 13:00 EST — middle of the day, well outside quiet hours. + */ + it('returns false during daytime in Eastern timezone', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + // 2024-01-15 18:00 UTC = 2024-01-15 13:00 EST + const utcTime = new Date('2024-01-15T18:00:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(false); + }); + + /** + * User in Asia/Tokyo (UTC+9). + * Quiet hours: 22:00–08:00 JST. + * 13:00 UTC = 22:00 JST → start of quiet hours. + */ + it('detects quiet hours start in Asia/Tokyo (UTC+9)', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'Asia/Tokyo' }); + // 2024-01-15 13:00 UTC = 2024-01-15 22:00 JST + const utcTime = new Date('2024-01-15T13:00:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(true); + }); + + /** + * User in Asia/Tokyo. + * 23:00 UTC = 08:00 JST next day → end boundary (exclusive) → NOT quiet hours. + */ + it('returns false at quiet-hours end boundary in Asia/Tokyo', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'Asia/Tokyo' }); + // 2024-01-15 23:00 UTC = 2024-01-16 08:00 JST + const utcTime = new Date('2024-01-15T23:00:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(false); + }); + }); + + // ── DST edge cases ───────────────────────────────────────────────────────── + + describe('isInQuietHours — DST transitions (America/New_York)', () => { + /** + * Spring-forward: 2024-03-10 02:00 EST → 03:00 EDT (clocks skip 02:00–02:59). + * At 06:00 UTC on that day = 01:00 EST (before spring-forward) → in quiet hours. + */ + it('handles spring-forward: 01:00 local is still in quiet hours', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + // 2024-03-10 06:00 UTC = 01:00 EST (before clocks spring forward at 07:00 UTC) + const utcTime = new Date('2024-03-10T06:00:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(true); + }); + + /** + * Spring-forward: 2024-03-10 12:00 UTC = 08:00 EDT (after spring-forward). + * 08:00 is the end boundary (exclusive) → NOT quiet hours. + */ + it('handles spring-forward: 08:00 EDT is not in quiet hours', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + // 2024-03-10 12:00 UTC = 08:00 EDT + const utcTime = new Date('2024-03-10T12:00:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(false); + }); + + /** + * Fall-back: 2024-11-03 02:00 EDT → 01:00 EST (clocks repeat 01:00–01:59). + * At 06:00 UTC = 01:00 EST (second occurrence, after fall-back) → in quiet hours. + */ + it('handles fall-back: 01:00 local (post-transition) is still in quiet hours', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + // 2024-11-03 06:00 UTC = 01:00 EST (after fall-back) + const utcTime = new Date('2024-11-03T06:00:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(true); + }); + }); + + // ── getQuietHoursEndTime ─────────────────────────────────────────────────── + + describe('getQuietHoursEndTime — returns correct UTC instant', () => { + /** + * User in America/New_York (UTC-5 in winter). + * Quiet hours end at 08:00 EST = 13:00 UTC. + * Current time: 03:00 UTC (22:00 EST previous day) → end is 13:00 UTC same UTC day. + */ + it('returns 13:00 UTC for 08:00 EST end time (UTC-5)', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + // 2024-01-15 03:00 UTC = 2024-01-14 22:00 EST + const utcNow = new Date('2024-01-15T03:00:00Z'); + const endTime = service.getQuietHoursEndTime(prefs, utcNow); + + // 08:00 EST on 2024-01-15 = 13:00 UTC + expect(endTime.toISOString()).toBe('2024-01-15T13:00:00.000Z'); + }); + + /** + * User in Asia/Tokyo (UTC+9). + * Quiet hours end at 08:00 JST = 23:00 UTC (previous UTC day). + * Current time: 14:00 UTC = 23:00 JST → end is 23:00 UTC same UTC day. + */ + it('returns 23:00 UTC for 08:00 JST end time (UTC+9)', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'Asia/Tokyo' }); + // 2024-01-15 14:00 UTC = 2024-01-15 23:00 JST (in quiet hours) + const utcNow = new Date('2024-01-15T14:00:00Z'); + const endTime = service.getQuietHoursEndTime(prefs, utcNow); + + // 08:00 JST on 2024-01-16 = 2024-01-15 23:00 UTC + expect(endTime.toISOString()).toBe('2024-01-15T23:00:00.000Z'); + }); + + /** + * UTC user, overnight window. + * Current time: 23:00 UTC → end is 08:00 UTC next day. + */ + it('returns next-day 08:00 UTC for UTC user at 23:00 UTC', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'UTC' }); + const utcNow = new Date('2024-01-01T23:00:00Z'); + const endTime = service.getQuietHoursEndTime(prefs, utcNow); + + expect(endTime.getUTCHours()).toBe(8); + expect(endTime.getUTCDate()).toBe(2); + }); + + /** + * UTC user, early morning. + * Current time: 06:00 UTC → end is 08:00 UTC same day. + */ + it('returns same-day 08:00 UTC for UTC user at 06:00 UTC', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'UTC' }); + const utcNow = new Date('2024-01-01T06:00:00Z'); + const endTime = service.getQuietHoursEndTime(prefs, utcNow); + + expect(endTime.getUTCHours()).toBe(8); + expect(endTime.getUTCDate()).toBe(1); + }); + + /** + * The returned end time must always be strictly in the future. + */ + it('always returns a future UTC instant', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + const utcNow = new Date('2024-01-15T03:00:00Z'); + const endTime = service.getQuietHoursEndTime(prefs, utcNow); + expect(endTime.getTime()).toBeGreaterThan(utcNow.getTime()); + }); + }); + + // ── isAppropriateTimeForDelayedNotifications ─────────────────────────────── + + describe('isAppropriateTimeForDelayedNotifications — user timezone', () => { + /** + * User in America/New_York. + * 15:00 UTC = 10:00 EST → appropriate (08:00–22:00 local, not in quiet hours). + */ + it('returns true at 10:00 EST (15:00 UTC) for Eastern user', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + const utcTime = new Date('2024-01-15T15:00:00Z'); // 10:00 EST + expect(service.isAppropriateTimeForDelayedNotifications(prefs, utcTime)).toBe(true); + }); + + /** + * User in America/New_York. + * 04:00 UTC = 23:00 EST → in quiet hours → not appropriate. + */ + it('returns false at 23:00 EST (04:00 UTC) — in quiet hours', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + const utcTime = new Date('2024-01-15T04:00:00Z'); // 23:00 EST + expect(service.isAppropriateTimeForDelayedNotifications(prefs, utcTime)).toBe(false); + }); + + /** + * User in America/New_York. + * 11:00 UTC = 06:00 EST → outside quiet hours but before 08:00 local → not appropriate. + */ + it('returns false at 06:00 EST (11:00 UTC) — too early', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + const utcTime = new Date('2024-01-15T11:00:00Z'); // 06:00 EST + expect(service.isAppropriateTimeForDelayedNotifications(prefs, utcTime)).toBe(false); + }); + + /** + * User in Asia/Tokyo. + * 01:00 UTC = 10:00 JST → appropriate. + */ + it('returns true at 10:00 JST (01:00 UTC) for Tokyo user', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'Asia/Tokyo' }); + const utcTime = new Date('2024-01-15T01:00:00Z'); // 10:00 JST + expect(service.isAppropriateTimeForDelayedNotifications(prefs, utcTime)).toBe(true); + }); + + /** + * Quiet hours disabled → always appropriate regardless of time. + */ + it('returns true at any time when quiet hours are disabled', () => { + const prefs = makePrefs({ + quiet_hours_enabled: false, + quiet_hours_timezone: 'America/New_York', + }); + const utcTime = new Date('2024-01-15T04:00:00Z'); // 23:00 EST + expect(service.isAppropriateTimeForDelayedNotifications(prefs, utcTime)).toBe(true); + }); + }); + + // ── timezone fallback ────────────────────────────────────────────────────── + + describe('timezone fallback behaviour', () => { + /** + * Empty timezone string → falls back to UTC, no crash. + */ + it('falls back to UTC when quiet_hours_timezone is empty string', () => { + const prefs = makePrefs({ quiet_hours_timezone: '' }); + const utcTime = new Date('2024-01-01T23:00:00Z'); // 23:00 UTC → in quiet hours + expect(() => service.isInQuietHours(prefs, utcTime)).not.toThrow(); + expect(service.isInQuietHours(prefs, utcTime)).toBe(true); + }); + + /** + * Invalid timezone string → falls back to UTC, no crash. + */ + it('falls back to UTC when quiet_hours_timezone is invalid', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'Not/ATimezone' }); + const utcTime = new Date('2024-01-01T23:00:00Z'); + expect(() => service.isInQuietHours(prefs, utcTime)).not.toThrow(); + // Falls back to UTC: 23:00 UTC is in quiet hours (22:00–08:00 UTC) + expect(service.isInQuietHours(prefs, utcTime)).toBe(true); + }); + + /** + * getQuietHoursEndTime with invalid timezone → falls back gracefully. + */ + it('getQuietHoursEndTime falls back gracefully on invalid timezone', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'Bad/Zone' }); + const utcNow = new Date('2024-01-01T23:00:00Z'); + expect(() => service.getQuietHoursEndTime(prefs, utcNow)).not.toThrow(); + const endTime = service.getQuietHoursEndTime(prefs, utcNow); + // Should return a future date + expect(endTime.getTime()).toBeGreaterThan(utcNow.getTime()); + }); + }); + + // ── shouldSendDuringQuietHours with non-UTC timezone ────────────────────── + + describe('shouldSendDuringQuietHours — non-UTC timezone', () => { + /** + * User in America/New_York, 23:00 EST (04:00 UTC next day). + * Non-critical payload → should be delayed. + * delayUntil should be 08:00 EST = 13:00 UTC. + */ + it('delays non-critical alert and sets delayUntil to 08:00 local time (EST)', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + const payload = makePayload({ daysBefore: 7 }); // normal priority + // 2024-01-15 04:00 UTC = 2024-01-14 23:00 EST → in quiet hours + const utcNow = new Date('2024-01-15T04:00:00Z'); + + const result = service.shouldSendDuringQuietHours(prefs, payload, utcNow); + + expect(result.isQuietHours).toBe(true); + expect(result.shouldDelay).toBe(true); + expect(result.delayUntil).toBeDefined(); + // 08:00 EST on 2024-01-15 = 13:00 UTC + expect(result.delayUntil!.toISOString()).toBe('2024-01-15T13:00:00.000Z'); + }); + + /** + * Critical alert during quiet hours → always passes through. + */ + it('allows critical alert during quiet hours regardless of timezone', () => { + const prefs = makePrefs({ quiet_hours_timezone: 'America/New_York' }); + const payload = makePayload({ reminderType: 'renewal', daysBefore: 1 }); // critical + const utcNow = new Date('2024-01-15T04:00:00Z'); // 23:00 EST + + const result = service.shouldSendDuringQuietHours(prefs, payload, utcNow); + + expect(result.isQuietHours).toBe(true); + expect(result.shouldDelay).toBe(false); + }); + }); + + // ── same-day quiet window ────────────────────────────────────────────────── + + describe('same-day quiet window (e.g. 13:00–17:00)', () => { + it('detects time inside a same-day window', () => { + const prefs = makePrefs({ + quiet_hours_start: '13:00', + quiet_hours_end: '17:00', + quiet_hours_timezone: 'UTC', + }); + const utcTime = new Date('2024-01-01T15:00:00Z'); // 15:00 UTC + expect(service.isInQuietHours(prefs, utcTime)).toBe(true); + }); + + it('returns false before a same-day window', () => { + const prefs = makePrefs({ + quiet_hours_start: '13:00', + quiet_hours_end: '17:00', + quiet_hours_timezone: 'UTC', + }); + const utcTime = new Date('2024-01-01T12:59:00Z'); + expect(service.isInQuietHours(prefs, utcTime)).toBe(false); + }); + + it('returns false after a same-day window (end boundary exclusive)', () => { + const prefs = makePrefs({ + quiet_hours_start: '13:00', + quiet_hours_end: '17:00', + quiet_hours_timezone: 'UTC', + }); + const utcTime = new Date('2024-01-01T17:00:00Z'); // exactly at end + expect(service.isInQuietHours(prefs, utcTime)).toBe(false); + }); + }); +}); diff --git a/docs/timestamp-timezone-rules.md b/docs/timestamp-timezone-rules.md new file mode 100644 index 0000000..948c73e --- /dev/null +++ b/docs/timestamp-timezone-rules.md @@ -0,0 +1,192 @@ +# Timestamp and Timezone Storage Rules + +> **Issue #71 — P1** +> Canonical reference for how timestamps and timezones are stored, computed, +> and rendered across the SYNCRO backend and frontend. + +--- + +## 1. Canonical Storage Rules + +| Data type | Storage format | Column type | Example | +|-----------|---------------|-------------|---------| +| Point-in-time | ISO-8601 UTC | `TIMESTAMPTZ` | `2026-05-28T09:00:00Z` | +| Wall-clock time | `HH:MM` (24-hour) | `TIME` | `22:00` | +| Timezone identifier | IANA string | `TEXT` | `America/New_York` | +| Blockchain timestamp | Unix epoch seconds | `BIGINT` | `1716883200` | +| Calendar date (no time) | `YYYY-MM-DD` | `DATE` | `2026-05-28` | + +### Rules + +1. **All point-in-time values are stored in UTC** (`TIMESTAMPTZ`). + Application code must never store a local time as if it were UTC. + +2. **Quiet-hours wall-clock times (`quiet_hours_start`, `quiet_hours_end`) + have no timezone component.** + They are always evaluated in the context of the companion + `quiet_hours_timezone` column. + Example: `quiet_hours_start = '22:00'` with `quiet_hours_timezone = + 'America/New_York'` means 22:00 Eastern, which is 03:00 UTC in winter and + 02:00 UTC in summer. + +3. **`quiet_hours_timezone` must be a valid IANA identifier** (e.g. + `America/New_York`, `Europe/London`, `Asia/Tokyo`). + It must not be empty when `quiet_hours_enabled = TRUE`. + The backend falls back to `UTC` if the value is absent or unrecognised. + +4. **Blockchain lifecycle timestamps are Unix epoch seconds (`BIGINT`).** + Convert to a displayable timestamp with `to_timestamp(value)` in SQL or + `new Date(value * 1000)` in TypeScript. + +5. **`reminder_date` is a bare `DATE`** (no time, no timezone). + The scheduler fires at **09:00 UTC** on that date. + Timezone-aware display is handled at the presentation layer. + +--- + +## 2. Affected Tables + +### `user_preferences` + +| Column | Type | Rule | +|--------|------|------| +| `created_at` | `TIMESTAMPTZ` | UTC, set by DB default | +| `updated_at` | `TIMESTAMPTZ` | UTC, maintained by trigger | +| `quiet_hours_start` | `TIME` | Wall-clock HH:MM, no tz | +| `quiet_hours_end` | `TIME` | Wall-clock HH:MM, no tz | +| `quiet_hours_timezone` | `TEXT` | IANA identifier | + +### `subscriptions` + +| Column | Type | Rule | +|--------|------|------| +| `next_billing_date` | `TIMESTAMPTZ` | UTC | +| `expired_at` | `TIMESTAMPTZ` | UTC | +| `paused_at` | `TIMESTAMPTZ` | UTC | +| `resume_at` | `TIMESTAMPTZ` | UTC | +| `last_interaction_at` | `TIMESTAMPTZ` | UTC | +| `last_renewal_attempt_at` | `TIMESTAMPTZ` | UTC | +| `blockchain_created_at` | `BIGINT` | Unix epoch seconds | +| `blockchain_activated_at` | `BIGINT` | Unix epoch seconds | +| `blockchain_last_renewed_at` | `BIGINT` | Unix epoch seconds | +| `blockchain_canceled_at` | `BIGINT` | Unix epoch seconds | + +### `reminder_schedules` + +| Column | Type | Rule | +|--------|------|------| +| `reminder_date` | `DATE` | Calendar day, UTC context | +| `created_at` | `TIMESTAMPTZ` | UTC | +| `updated_at` | `TIMESTAMPTZ` | UTC | + +### `notification_deliveries` + +| Column | Type | Rule | +|--------|------|------| +| `last_attempt_at` | `TIMESTAMPTZ` | UTC | +| `next_retry_at` | `TIMESTAMPTZ` | UTC | +| `created_at` | `TIMESTAMPTZ` | UTC | +| `updated_at` | `TIMESTAMPTZ` | UTC | + +### `delayed_notifications` + +| Column | Type | Rule | +|--------|------|------| +| `original_send_time` | `TIMESTAMPTZ` | UTC — when the notification was originally due | +| `scheduled_send_time` | `TIMESTAMPTZ` | UTC — when quiet hours end in the user's timezone | +| `created_at` | `TIMESTAMPTZ` | UTC | +| `updated_at` | `TIMESTAMPTZ` | UTC | + +--- + +## 3. Backend Implementation + +### Library + +The backend uses **`date-fns-tz`** (v3) for all timezone-aware date arithmetic. +Do not use `moment-timezone` or manual UTC-offset arithmetic. + +```typescript +import { toZonedTime, fromZonedTime } from 'date-fns-tz'; +``` + +### QuietHoursService + +`QuietHoursService` (`backend/src/services/quiet-hours-service.ts`) is the +single source of truth for quiet-hours evaluation. All methods accept a UTC +`Date` and the user's `UserPreferences` (which carries `quiet_hours_timezone`). + +Key methods: + +| Method | What it does | +|--------|-------------| +| `isInQuietHours(prefs, utcNow)` | Converts `utcNow` to the user's timezone, then checks whether the local time falls within the quiet window | +| `getQuietHoursEndTime(prefs, utcNow)` | Returns the next UTC instant at which quiet hours end, computed in the user's timezone (handles DST correctly via `fromZonedTime`) | +| `isAppropriateTimeForDelayedNotifications(prefs, utcNow)` | Returns `true` when the local time is 08:00–22:00 and not within quiet hours | +| `determineNotificationPriority(payload)` | Pure function; no timezone dependency | + +### DST handling + +`date-fns-tz` uses the IANA timezone database bundled with the Node.js runtime +(`Intl`). `fromZonedTime` correctly resolves ambiguous wall-clock times during +DST transitions (e.g. the repeated 01:30 when clocks fall back) by choosing the +first occurrence. No special-casing is required in application code. + +### Renewal cooldown + +`last_renewal_attempt_at` is stored as UTC `TIMESTAMPTZ`. Cooldown arithmetic +is performed in milliseconds against `Date.now()` — no timezone conversion is +needed because both sides are UTC. + +--- + +## 4. Frontend Implementation + +The client uses the browser's `Intl.DateTimeFormat` API (via +`client/lib/timezone-utils.ts`) for display formatting. + +```typescript +// Display a UTC ISO string in the user's local timezone +formatDateInUserTimezone(isoString, 'long'); + +// Detect the browser's timezone for the timezone selector default +getUserTimezone(); // → e.g. "America/New_York" +``` + +When submitting quiet-hours settings, the client sends: +- `quiet_hours_start` / `quiet_hours_end` as `HH:MM` strings (wall-clock) +- `quiet_hours_timezone` as the IANA identifier selected by the user + +The backend validates the timezone with `Intl` before persisting. + +--- + +## 5. DST and Locale Edge Cases + +| Scenario | Behaviour | +|----------|-----------| +| Clocks spring forward (e.g. 02:00 → 03:00) | `toZonedTime` skips the gap; a quiet-hours window that would start at 02:30 is treated as starting at 03:00 | +| Clocks fall back (ambiguous hour) | `fromZonedTime` picks the first occurrence (pre-transition) | +| User sets `quiet_hours_timezone = ''` | Backend falls back to `UTC` and logs a warning | +| User in UTC+14 (Line Islands) | Fully supported; IANA identifier `Pacific/Kiritimati` | +| Overnight quiet window (22:00–08:00) | Handled by the `startTimeMinutes > endTimeMinutes` branch in `isInQuietHours` | +| Same-day quiet window (13:00–17:00) | Handled by the standard `>=` / `<` branch | + +--- + +## 6. Testing + +Tests live in `backend/tests/`: + +| File | Coverage | +|------|----------| +| `quiet-hours-service.test.ts` | Unit tests for all `QuietHoursService` methods, including UTC-only and timezone-aware cases | +| `quiet-hours-integration.test.ts` | End-to-end quiet-hours flow | +| `timestamp-timezone.test.ts` | **New** — DST edge cases, overnight windows, timezone fallback, `getQuietHoursEndTime` UTC correctness | + +Run tests: + +```bash +cd backend +npm test +``` diff --git a/supabase/migrations/20260528000000_normalize_timestamp_timezone_rules.sql b/supabase/migrations/20260528000000_normalize_timestamp_timezone_rules.sql new file mode 100644 index 0000000..5e5d183 --- /dev/null +++ b/supabase/migrations/20260528000000_normalize_timestamp_timezone_rules.sql @@ -0,0 +1,111 @@ +-- Migration: Normalize timestamp and timezone storage rules (Issue #71) +-- +-- Canonical rules enforced by this migration: +-- +-- 1. All TIMESTAMP columns that represent a point in time MUST be +-- TIMESTAMPTZ (timestamp with time zone), stored in UTC. +-- +-- 2. Bare TIME columns (quiet_hours_start, quiet_hours_end) represent a +-- wall-clock time with NO timezone component. They are always +-- interpreted in the context of the companion quiet_hours_timezone +-- column (IANA identifier, e.g. "America/New_York"). +-- +-- 3. Blockchain lifecycle timestamps (blockchain_created_at, etc.) are +-- stored as BIGINT Unix epoch seconds, as dictated by the Soroban +-- contract. Application code converts them to TIMESTAMPTZ for display. +-- +-- 4. The `reminder_date` column in reminder_schedules is a bare DATE. +-- It represents a calendar day in UTC. The scheduler fires at 09:00 UTC +-- on that date; per-user timezone rendering is handled at the +-- presentation layer. +-- +-- This migration adds or corrects column comments to make the rules +-- self-documenting in the database schema. + +-- ── user_preferences ──────────────────────────────────────────────────────── + +COMMENT ON COLUMN public.user_preferences.created_at IS + 'UTC timestamp (TIMESTAMPTZ) when the row was created.'; + +COMMENT ON COLUMN public.user_preferences.updated_at IS + 'UTC timestamp (TIMESTAMPTZ) of the last update, maintained by trigger.'; + +COMMENT ON COLUMN public.user_preferences.quiet_hours_start IS + 'Wall-clock start of quiet hours in HH:MM (24-hour) format. ' + 'No timezone component; always interpreted in quiet_hours_timezone.'; + +COMMENT ON COLUMN public.user_preferences.quiet_hours_end IS + 'Wall-clock end of quiet hours in HH:MM (24-hour) format. ' + 'No timezone component; always interpreted in quiet_hours_timezone.'; + +COMMENT ON COLUMN public.user_preferences.quiet_hours_timezone IS + 'IANA timezone identifier (e.g. America/New_York) used to evaluate ' + 'quiet_hours_start and quiet_hours_end. Must never be empty when ' + 'quiet_hours_enabled is TRUE; defaults to UTC.'; + +-- ── subscriptions ──────────────────────────────────────────────────────────── + +COMMENT ON COLUMN public.subscriptions.next_billing_date IS + 'UTC timestamp (TIMESTAMPTZ) of the next scheduled billing event.'; + +COMMENT ON COLUMN public.subscriptions.expired_at IS + 'UTC timestamp (TIMESTAMPTZ) when the subscription expired, or NULL if active.'; + +COMMENT ON COLUMN public.subscriptions.paused_at IS + 'UTC timestamp (TIMESTAMPTZ) when the subscription was paused, or NULL.'; + +COMMENT ON COLUMN public.subscriptions.resume_at IS + 'UTC timestamp (TIMESTAMPTZ) when a paused subscription is scheduled to resume.'; + +COMMENT ON COLUMN public.subscriptions.last_interaction_at IS + 'UTC timestamp (TIMESTAMPTZ) of the most recent user interaction.'; + +COMMENT ON COLUMN public.subscriptions.last_renewal_attempt_at IS + 'UTC timestamp (TIMESTAMPTZ) of the most recent renewal attempt (success or failure). ' + 'Used to enforce the renewal cooldown window.'; + +COMMENT ON COLUMN public.subscriptions.blockchain_created_at IS + 'Unix epoch seconds (BIGINT) from the Soroban contract ledger timestamp. ' + 'Convert to TIMESTAMPTZ for display: to_timestamp(blockchain_created_at).'; + +COMMENT ON COLUMN public.subscriptions.blockchain_activated_at IS + 'Unix epoch seconds (BIGINT) from the Soroban contract ledger timestamp. ' + 'Convert to TIMESTAMPTZ for display: to_timestamp(blockchain_activated_at).'; + +COMMENT ON COLUMN public.subscriptions.blockchain_last_renewed_at IS + 'Unix epoch seconds (BIGINT) from the Soroban contract ledger timestamp. ' + 'Convert to TIMESTAMPTZ for display: to_timestamp(blockchain_last_renewed_at).'; + +COMMENT ON COLUMN public.subscriptions.blockchain_canceled_at IS + 'Unix epoch seconds (BIGINT) from the Soroban contract ledger timestamp. ' + 'Convert to TIMESTAMPTZ for display: to_timestamp(blockchain_canceled_at).'; + +-- ── reminder_schedules ─────────────────────────────────────────────────────── + +COMMENT ON COLUMN public.reminder_schedules.reminder_date IS + 'Calendar date (DATE, no time component) on which the reminder fires. ' + 'The scheduler processes reminders at 09:00 UTC on this date. ' + 'Timezone rendering for display is handled at the presentation layer.'; + +COMMENT ON COLUMN public.reminder_schedules.created_at IS + 'UTC timestamp (TIMESTAMPTZ) when the reminder was scheduled.'; + +COMMENT ON COLUMN public.reminder_schedules.updated_at IS + 'UTC timestamp (TIMESTAMPTZ) of the last status change.'; + +-- ── notification_deliveries ────────────────────────────────────────────────── + +COMMENT ON COLUMN public.notification_deliveries.last_attempt_at IS + 'UTC timestamp (TIMESTAMPTZ) of the most recent delivery attempt.'; + +COMMENT ON COLUMN public.notification_deliveries.next_retry_at IS + 'UTC timestamp (TIMESTAMPTZ) after which the next retry may be attempted.'; + +-- ── delayed_notifications ──────────────────────────────────────────────────── + +COMMENT ON COLUMN public.delayed_notifications.original_send_time IS + 'UTC timestamp (TIMESTAMPTZ) when the notification was originally due to be sent.'; + +COMMENT ON COLUMN public.delayed_notifications.scheduled_send_time IS + 'UTC timestamp (TIMESTAMPTZ) of the rescheduled delivery time ' + '(i.e. when quiet hours end in the user''s timezone).';