From 9b5cb03f8c237d5c1b84644421af44350fb1d778 Mon Sep 17 00:00:00 2001 From: Xuccessor Date: Tue, 30 Jun 2026 03:35:49 +0000 Subject: [PATCH] perf(#765): use included user.preferences to drop 6 DB roundtrips per transaction update --- src/notifications/notifications.service.ts | 63 ++++++++---- src/users/user-preferences.service.ts | 106 ++++++++++++++------- 2 files changed, 115 insertions(+), 54 deletions(-) diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 47ed8f6d..9a0747ed 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -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 | null, +}; + @Injectable() export class NotificationsService { constructor( @@ -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 diff --git a/src/users/user-preferences.service.ts b/src/users/user-preferences.service.ts index ff064b9c..bd5e4dc0 100644 --- a/src/users/user-preferences.service.ts +++ b/src/users/user-preferences.service.ts @@ -144,42 +144,7 @@ export class UserPreferencesService { channel: 'email' | 'sms' | 'push' | 'inApp', ): Promise { const prefs = await this.findByUserId(userId); - - // Check if the channel is enabled globally - const channelMap: Record = { - 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 | 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); } } @@ -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 | null; + }, + eventType: string, + channel: 'email' | 'sms' | 'push' | 'inApp', +): boolean { + // 1. Channel enabled globally? + const channelMap: Record = { + 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 | 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; +}