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
63 changes: 45 additions & 18 deletions src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,33 @@ import { PrismaService } from '../database/prisma.service';
import { NotificationsGateway } from './notifications.gateway';
import { EmailService } from '../email/email.service';
import { SmsService } from './sms.service';
import { UserPreferencesService } from '../users/user-preferences.service';
import {
UserPreferencesService,
shouldDeliverNotificationFromPrefs,
} from '../users/user-preferences.service';
import { Transaction, TransactionStatus, User } from '@prisma/client';

/**
* Schema-default notification preferences for users who have never set
* their own. Mirrors the Prisma defaults in `prisma/schema.prisma` for
* UserPreferences (email:true, sms:false, inApp:true, push:false,
* eventTypes:[], quietHours disabled). Used by handleTransactionUpdate when
* `user.preferences` is null, avoiding the hidden upsert side-effect that
* `UserPreferencesService.findByUserId` performs when prefs are missing.
*/
const NOTIFICATION_PREFERENCES_DEFAULTS = {
emailNotifications: true,
smsNotifications: false,
inAppNotifications: true,
pushNotifications: false,
notificationEventTypes: [] as string[],
quietHoursEnabled: false,
quietHoursStart: null as string | null,
quietHoursEnd: null as string | null,
timezone: 'UTC',
perEventSettings: null as Record<string, any> | null,
};

@Injectable()
export class NotificationsService {
constructor(
Expand Down Expand Up @@ -40,23 +64,26 @@ export class NotificationsService {
const title = `Transaction ${transaction.status}`;
const message = `Your transaction for property "${transaction.property.title}" has been updated to ${transaction.status}.`;

const [canInApp, canEmail, canSms] = await Promise.all([
this.userPreferencesService.shouldDeliverNotification(
user.id,
'TRANSACTION_UPDATE',
'inApp',
),
this.userPreferencesService.shouldDeliverNotification(
user.id,
'TRANSACTION_UPDATE',
'email',
),
this.userPreferencesService.shouldDeliverNotification(
user.id,
'TRANSACTION_UPDATE',
'sms',
),
]);
// #765 — use the already-included user.preferences to avoid 3 redundant
// DB roundtrips per party. If a user has no preferences row, fall back
// to schema defaults locally instead of triggering the upsert
// side-effect in `UserPreferencesService.findByUserId`.
const prefs = user.preferences ?? NOTIFICATION_PREFERENCES_DEFAULTS;
const canInApp = shouldDeliverNotificationFromPrefs(
prefs,
'TRANSACTION_UPDATE',
'inApp',
);
const canEmail = shouldDeliverNotificationFromPrefs(
prefs,
'TRANSACTION_UPDATE',
'email',
);
const canSms = shouldDeliverNotificationFromPrefs(
prefs,
'TRANSACTION_UPDATE',
'sms',
);

await Promise.all([
canInApp
Expand Down
106 changes: 70 additions & 36 deletions src/users/user-preferences.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,42 +144,7 @@ export class UserPreferencesService {
channel: 'email' | 'sms' | 'push' | 'inApp',
): Promise<boolean> {
const prefs = await this.findByUserId(userId);

// Check if the channel is enabled globally
const channelMap: Record<string, boolean> = {
email: prefs.emailNotifications,
sms: prefs.smsNotifications,
push: prefs.pushNotifications,
inApp: prefs.inAppNotifications,
};
if (!channelMap[channel]) return false;

// Check per-event channel override
const perEvent = (prefs.perEventSettings as Record<string, any> | null) ?? {};
if (perEvent[eventType] && perEvent[eventType][channel] === false) return false;

// Check event type subscription (empty array = all events allowed)
const subscribedTypes: string[] = prefs.notificationEventTypes ?? [];
if (subscribedTypes.length > 0 && !subscribedTypes.includes(eventType)) return false;

// Check quiet hours
if (prefs.quietHoursEnabled && prefs.quietHoursStart && prefs.quietHoursEnd) {
const tz = prefs.timezone ?? 'UTC';
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const currentTime = formatter.format(now); // "HH:MM"

if (isInQuietWindow(currentTime, prefs.quietHoursStart, prefs.quietHoursEnd)) {
return false;
}
}

return true;
return shouldDeliverNotificationFromPrefs(prefs, eventType, channel);
}
}

Expand All @@ -205,3 +170,72 @@ function isInQuietWindow(current: string, start: string, end: string): boolean {
// Overnight window
return c >= s || c < e;
}

/**
* Pure decision function: given a UserPreferences-shaped object plus an event
* type and a channel, returns whether a notification should be delivered.
*
* Reused by:
* - `UserPreferencesService.shouldDeliverNotification(userId, …)` — fetches
* prefs via `findByUserId` then delegates here so the logic lives in
* exactly one place.
* - `NotificationsService.handleTransactionUpdate` — consumes the
* already-included `user.preferences` from the parties query, removing
* 6 redundant `findUnique` roundtrips per transaction update (#765).
*
* Takes a structurally-typed object so callers can pass either a real
* Prisma `UserPreferences` row or schema-default values for users who have
* never set preferences (no DB write side-effect).
*/
export function shouldDeliverNotificationFromPrefs(
prefs: {
emailNotifications: boolean;
smsNotifications: boolean;
pushNotifications: boolean;
inAppNotifications: boolean;
notificationEventTypes?: string[] | null;
quietHoursEnabled: boolean;
quietHoursStart?: string | null;
quietHoursEnd?: string | null;
timezone?: string | null;
perEventSettings?: Record<string, any> | null;
},
eventType: string,
channel: 'email' | 'sms' | 'push' | 'inApp',
): boolean {
// 1. Channel enabled globally?
const channelMap: Record<string, boolean> = {
email: prefs.emailNotifications,
sms: prefs.smsNotifications,
push: prefs.pushNotifications,
inApp: prefs.inAppNotifications,
};
if (!channelMap[channel]) return false;

// 2. Per-event channel override (channel explicitly disabled for this event)
const perEvent = (prefs.perEventSettings as Record<string, any> | null) ?? {};
if (perEvent[eventType] && perEvent[eventType][channel] === false) return false;

// 3. Event-type subscription — empty array means all events are allowed
const subscribedTypes: string[] = prefs.notificationEventTypes ?? [];
if (subscribedTypes.length > 0 && !subscribedTypes.includes(eventType)) return false;

// 4. Quiet hours — only enforced when start, end, and the flag are all set
if (prefs.quietHoursEnabled && prefs.quietHoursStart && prefs.quietHoursEnd) {
const tz = prefs.timezone ?? 'UTC';
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const currentTime = formatter.format(now); // "HH:MM"

if (isInQuietWindow(currentTime, prefs.quietHoursStart, prefs.quietHoursEnd)) {
return false;
}
}

return true;
}
Loading