Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module.exports = {
setupFiles: ['<rootDir>/tests/setup.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.js$': ['ts-jest', { diagnostics: false }],
},
globals: {
'ts-jest': {
Expand All @@ -23,7 +24,7 @@ module.exports = {
},
},
transformIgnorePatterns: [
'/node_modules/(?!@stellar/stellar-sdk)',
'/node_modules/(?!(@stellar/stellar-sdk|uuid))',
],
coverageThreshold: {
global: {
Expand Down
19 changes: 19 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
160 changes: 97 additions & 63 deletions backend/src/services/quiet-hours-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
import { UserPreferences, NotificationPriority, NotificationPayload } from '../types/reminder';
import logger from '../config/logger';

Expand All @@ -8,132 +9,166 @@ 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) {
return false;
}

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;
}
}

/**
* 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,
shouldDelay: false,
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 {
Expand All @@ -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,
Expand All @@ -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();
export const quietHoursService = new QuietHoursService();
Loading
Loading