Spaces:
Running
Running
| import { Injectable, Logger } from "@nestjs/common"; | |
| import { ConfigService } from "@nestjs/config"; | |
| import { Cron, CronExpression } from "@nestjs/schedule"; | |
| import { PrismaService } from "../database/prisma.service"; | |
| import { EmailService } from "../email/email.service"; | |
| import { CarePriority } from "../care-priority/types/care-priority.types"; | |
| import { NotificationType, NotificationPriority } from "@prisma/client"; | |
| import type { ExpoPushMessage } from "expo-server-sdk"; | |
| import { | |
| CARE_PRIORITY_TEMPLATES, | |
| BP_ALERT_TEMPLATES, | |
| SYMPTOM_ALERT_TEMPLATES, | |
| AUTH_TEMPLATES, | |
| MONITORING_TEMPLATES, | |
| TREND_ALERT_TEMPLATES, | |
| } from "./templates/notification.templates"; | |
| import { generateEmailHtml } from "./templates/email.template"; | |
| /** | |
| * Notification Service | |
| * | |
| * RESPONSIBILITIES: | |
| * - Send care priority escalation notifications | |
| * - Send BP alert notifications | |
| * - Send symptom alert notifications | |
| * - Log all notifications for audit trail | |
| * - Persist notifications to database | |
| * - Send push notifications via Expo | |
| * - Background monitoring (Inactivity, Daily reminders, Trends, Follow-ups) | |
| */ | |
| () | |
| export class NotificationsService { | |
| private readonly logger = new Logger(NotificationsService.name); | |
| private expoInstance: any = null; | |
| constructor( | |
| private readonly prisma: PrismaService, | |
| private readonly emailService: EmailService, | |
| private readonly configService: ConfigService, | |
| ) {} | |
| /** | |
| * Helper to get Expo instance dynamically (fixes ERR_REQUIRE_ESM) | |
| */ | |
| private async getExpo() { | |
| if (!this.expoInstance) { | |
| const { Expo } = await import("expo-server-sdk"); | |
| this.expoInstance = new Expo(); | |
| } | |
| return this.expoInstance; | |
| } | |
| private async getLatestBPDate(userId: string): Promise<Date | null> { | |
| const latest = await this.prisma.bloodPressureReading.findFirst({ | |
| where: { userId }, | |
| orderBy: { recordedAt: "desc" }, | |
| select: { recordedAt: true }, | |
| }); | |
| return latest?.recordedAt || null; | |
| } | |
| private async hasLoggedBPSince(userId: string, cutoff: Date): Promise<boolean> { | |
| const latest = await this.getLatestBPDate(userId); | |
| return Boolean(latest && latest >= cutoff); | |
| } | |
| /** | |
| * Helper to get user's email | |
| */ | |
| private async getUserEmail(userId: string): Promise<string | null> { | |
| const user = await this.prisma.userAuth.findUnique({ | |
| where: { id: userId }, | |
| select: { email: true }, | |
| }); | |
| return user?.email || null; | |
| } | |
| /** | |
| * Internal helper to send push notifications | |
| */ | |
| public async sendPushNotification( | |
| userId: string, | |
| title: string, | |
| body: string, | |
| data?: any, | |
| ): Promise<void> { | |
| const user = await this.prisma.userAuth.findUnique({ | |
| where: { id: userId }, | |
| select: { pushToken: true }, | |
| }); | |
| if (!user?.pushToken) { | |
| this.logger.debug( | |
| `No push token found for user ${userId}, skipping push.`, | |
| ); | |
| return; | |
| } | |
| const { Expo } = await import("expo-server-sdk"); | |
| if (!Expo.isExpoPushToken(user.pushToken)) { | |
| this.logger.error( | |
| `Push token ${user.pushToken} is not a valid Expo push token`, | |
| ); | |
| return; | |
| } | |
| const messages: ExpoPushMessage[] = [ | |
| { | |
| to: user.pushToken, | |
| sound: "default", | |
| title, | |
| body, | |
| data, | |
| }, | |
| ]; | |
| try { | |
| const expo = await this.getExpo(); | |
| const chunks = expo.chunkPushNotifications(messages); | |
| for (const chunk of chunks) { | |
| const tickets = await expo.sendPushNotificationsAsync(chunk); | |
| this.logger.log( | |
| `Push notification tickets for user ${userId}: ${JSON.stringify(tickets)}`, | |
| ); | |
| } | |
| this.logger.log(`Push notification sent to user ${userId}`); | |
| } catch (error) { | |
| this.logger.error( | |
| `Error sending push notification to user ${userId}:`, | |
| error, | |
| ); | |
| } | |
| } | |
| /** | |
| * Daily BP Reminder Monitor | |
| * Runs every minute to check which users should be reminded | |
| */ | |
| (CronExpression.EVERY_MINUTE) | |
| async checkDailyReminders() { | |
| const now = new Date(); | |
| const hours = now.getHours().toString().padStart(2, "0"); | |
| const minutes = now.getMinutes().toString().padStart(2, "0"); | |
| const currentHHmm = `${hours}:${minutes}`; | |
| const today = new Date(); | |
| today.setHours(0, 0, 0, 0); | |
| // Find users who: | |
| // 1. Have reminders enabled | |
| // 2. Their reminderTime matches current time | |
| // 3. Haven't logged BP today | |
| const usersToRemind = await this.prisma.userAuth.findMany({ | |
| where: { | |
| isActive: true, | |
| profile: { | |
| notifyReminders: true, | |
| reminderTime: currentHHmm, | |
| }, | |
| bloodPressureReadings: { | |
| none: { | |
| recordedAt: { gte: today }, | |
| }, | |
| }, | |
| }, | |
| select: { id: true, email: true }, | |
| }); | |
| if (usersToRemind.length > 0) { | |
| this.logger.log( | |
| `[REMINDER] Sending daily BP reminders to ${usersToRemind.length} users for time ${currentHHmm}`, | |
| ); | |
| } | |
| for (const user of usersToRemind) { | |
| try { | |
| if (await this.hasLoggedBPSince(user.id, today)) continue; | |
| const template = MONITORING_TEMPLATES.DAILY_REMINDER; | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId: user.id, | |
| type: NotificationType.REMINDER, | |
| title: template.subject, | |
| message: template.body, | |
| }, | |
| }); | |
| await this.sendPushNotification( | |
| user.id, | |
| template.subject, | |
| template.body, | |
| { type: "BP_REMINDER" }, | |
| ); | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to send daily reminder to user ${user.id}:`, | |
| error, | |
| ); | |
| } | |
| } | |
| } | |
| /** | |
| * Inactivity Monitor - Checks for users who haven't logged BP in 3 days or more | |
| * Runs daily at midnight | |
| */ | |
| (CronExpression.EVERY_DAY_AT_MIDNIGHT) | |
| async checkInactivity() { | |
| this.logger.log("Running inactivity check..."); | |
| const threeDaysAgo = new Date(); | |
| threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); | |
| // Find users whose last reading was > 3 days ago or who never logged | |
| const users = await this.prisma.userAuth.findMany({ | |
| where: { | |
| isActive: true, | |
| bloodPressureReadings: { | |
| none: { | |
| recordedAt: { gte: threeDaysAgo }, | |
| }, | |
| }, | |
| }, | |
| select: { | |
| id: true, | |
| email: true, | |
| bloodPressureReadings: { | |
| orderBy: { recordedAt: "desc" }, | |
| take: 1, | |
| select: { recordedAt: true } | |
| }, | |
| profile: { | |
| select: { createdAt: true } | |
| } | |
| }, | |
| }); | |
| for (const user of users) { | |
| try { | |
| if (await this.hasLoggedBPSince(user.id, threeDaysAgo)) continue; | |
| // Calculate days inactive | |
| const lastReading = user.bloodPressureReadings[0]; | |
| let daysInactive: number; | |
| if (lastReading) { | |
| const now = new Date(); | |
| const diffInMs = now.getTime() - lastReading.recordedAt.getTime(); | |
| daysInactive = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); | |
| } else if (user.profile?.createdAt) { | |
| // Never logged, use days since profile creation | |
| const now = new Date(); | |
| const diffInMs = now.getTime() - user.profile.createdAt.getTime(); | |
| daysInactive = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); | |
| } else { | |
| daysInactive = 3; // Fallback | |
| } | |
| // Ensure minimum 3 days | |
| if (daysInactive < 3) continue; | |
| const template = MONITORING_TEMPLATES.INACTIVITY_REMINDER; | |
| const body = typeof template.body === 'function' | |
| ? template.body(daysInactive) | |
| : template.body; | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId: user.id, | |
| type: NotificationType.REMINDER, | |
| title: template.subject, | |
| message: body, | |
| }, | |
| }); | |
| // Send Push | |
| await this.sendPushNotification( | |
| user.id, | |
| template.subject, | |
| body, | |
| { type: "INACTIVITY_REMINDER" }, | |
| ); | |
| // Send Email | |
| if (user.email) { | |
| const html = generateEmailHtml( | |
| template.subject, | |
| body, | |
| "Log BP Reading", | |
| `${this.configService.get("FRONTEND_URL")}/bridge?type=inactivity-reminder`, | |
| "#2DE474", | |
| ); | |
| await this.emailService.sendEmail( | |
| user.email, | |
| template.subject, | |
| body, | |
| html, | |
| ); | |
| } | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to process inactivity reminder for user ${user.id}:`, | |
| error, | |
| ); | |
| } | |
| } | |
| this.logger.log( | |
| `Inactivity check finished. ${users.length} users notified.`, | |
| ); | |
| } | |
| /** | |
| * 4-Hour Follow-Up Monitor | |
| * Runs every 30 minutes | |
| */ | |
| (CronExpression.EVERY_30_MINUTES) | |
| async checkFollowUps() { | |
| this.logger.log("Running follow-up recheck monitor..."); | |
| const now = new Date(); | |
| const fourHoursAgo = new Date(now.getTime() - 4 * 60 * 60 * 1000); | |
| const fiveHoursAgo = new Date(now.getTime() - 5 * 60 * 60 * 1000); | |
| // Find users who had an elevated reading (130/80 - 159/109) 4-5 hours ago | |
| // AND haven't logged since then | |
| const users = await this.prisma.userAuth.findMany({ | |
| where: { | |
| isActive: true, | |
| bloodPressureReadings: { | |
| some: { | |
| recordedAt: { gte: fiveHoursAgo, lte: fourHoursAgo }, | |
| OR: [{ systolic: { gte: 130 } }, { diastolic: { gte: 80 } }], | |
| }, | |
| none: { | |
| recordedAt: { gt: fourHoursAgo }, | |
| }, | |
| }, | |
| }, | |
| select: { id: true, email: true }, | |
| }); | |
| for (const user of users) { | |
| try { | |
| if (await this.hasLoggedBPSince(user.id, fourHoursAgo)) continue; | |
| const template = MONITORING_TEMPLATES.FOLLOW_UP_REMINDER; | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId: user.id, | |
| type: NotificationType.REMINDER, | |
| title: template.subject, | |
| message: template.body, | |
| }, | |
| }); | |
| // Send Push | |
| await this.sendPushNotification( | |
| user.id, | |
| template.subject, | |
| template.body, | |
| { type: "FOLLOW_UP" }, | |
| ); | |
| // Send Email | |
| if (user.email) { | |
| const html = generateEmailHtml( | |
| template.subject, | |
| template.body, | |
| "Recheck BP Now", | |
| `${this.configService.get("FRONTEND_URL")}/bridge?type=follow-up-recheck`, | |
| "#FF9B3E", | |
| ); | |
| await this.emailService.sendEmail( | |
| user.email, | |
| template.subject, | |
| template.body, | |
| html, | |
| ); | |
| } | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to process follow-up reminder for user ${user.id}:`, | |
| error, | |
| ); | |
| } | |
| } | |
| } | |
| /** | |
| * Trend Analysis Monitor - Analyzes 7 days of readings for patterns | |
| * Runs every day at 1 AM | |
| */ | |
| (CronExpression.EVERY_DAY_AT_1AM) | |
| async analyzeTrends() { | |
| this.logger.log("Running trend analysis..."); | |
| const sevenDaysAgo = new Date(); | |
| sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); | |
| const users = await this.prisma.userAuth.findMany({ | |
| where: { isActive: true }, | |
| select: { id: true, email: true }, | |
| }); | |
| for (const user of users) { | |
| try { | |
| const readings = await this.prisma.bloodPressureReading.findMany({ | |
| where: { | |
| userId: user.id, | |
| recordedAt: { gte: sevenDaysAgo }, | |
| }, | |
| orderBy: { recordedAt: "asc" }, | |
| }); | |
| if (readings.length < 3) continue; | |
| // 1. Creeping Rise Detection (Consistent rise over 3+ readings) | |
| let riseCount = 0; | |
| for (let i = 1; i < readings.length; i++) { | |
| if (readings[i].systolic > readings[i - 1].systolic) riseCount++; | |
| else riseCount = 0; | |
| } | |
| if (riseCount >= 3) { | |
| await this.sendTrendAlert( | |
| user.id, | |
| TREND_ALERT_TEMPLATES.CREEPING_RISE, | |
| "CREEPING_RISE", | |
| user.email, | |
| ); | |
| continue; // Don't spam multiple trend alerts | |
| } | |
| // 2. Repeated High Readings (3+ elevated readings in a week) | |
| const highReadings = readings.filter( | |
| (r) => r.systolic >= 135 || r.diastolic >= 85, | |
| ); | |
| if (highReadings.length >= 3) { | |
| await this.sendTrendAlert( | |
| user.id, | |
| TREND_ALERT_TEMPLATES.REPEATED_HIGH, | |
| "REPEATED_HIGH", | |
| user.email, | |
| ); | |
| continue; | |
| } | |
| // 3. Sudden Spike Detection (Latest reading is 20% > personal average) | |
| const avgSystolic = | |
| readings.slice(0, -1).reduce((acc, curr) => acc + curr.systolic, 0) / | |
| (readings.length - 1); | |
| const latest = readings[readings.length - 1]; | |
| if (latest.systolic > avgSystolic * 1.2) { | |
| await this.sendTrendAlert( | |
| user.id, | |
| TREND_ALERT_TEMPLATES.SUDDEN_SPIKE, | |
| "SUDDEN_SPIKE", | |
| user.email, | |
| ); | |
| } | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to analyze trends for user ${user.id}:`, | |
| error, | |
| ); | |
| } | |
| } | |
| } | |
| private async sendTrendAlert( | |
| userId: string, | |
| template: any, | |
| trendType: string, | |
| email?: string | null, | |
| ) { | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId, | |
| type: NotificationType.BP_ALERT, | |
| title: template.subject, | |
| message: template.body, | |
| }, | |
| }); | |
| await this.sendPushNotification(userId, template.subject, template.body, { | |
| type: "TREND_ALERT", | |
| trendType, | |
| }); | |
| if (email) { | |
| await this.emailService.sendTrendAlert( | |
| email, | |
| trendType, | |
| template.body, | |
| "Mama" // Default for now | |
| ); | |
| } | |
| } | |
| /** | |
| * Send care priority escalation notification | |
| */ | |
| async sendCarePriorityNotification( | |
| userId: string, | |
| priority: CarePriority, | |
| ): Promise<void> { | |
| const template = CARE_PRIORITY_TEMPLATES[priority]; | |
| const email = await this.getUserEmail(userId); | |
| // Log notification | |
| this.logger.log(`[NOTIFICATION] User: ${userId}, Priority: ${priority}`); | |
| // Persist to database | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId, | |
| type: NotificationType.CARE_PRIORITY, | |
| title: template.subject, | |
| message: `${template.body}\n\n${template.callToAction}`, | |
| }, | |
| }); | |
| // Send email | |
| if (email) { | |
| const html = generateEmailHtml( | |
| template.subject, | |
| `${template.body}\n\n${template.callToAction}`, | |
| "View Details", | |
| `${this.configService.get("FRONTEND_URL")}/bridge?type=tracking`, | |
| priority === CarePriority.EMERGENCY | |
| ? "#FF4B4B" | |
| : priority === CarePriority.URGENT_REVIEW | |
| ? "#FF9B3E" | |
| : "#2DE474", | |
| ); | |
| await this.emailService.sendEmail( | |
| email, | |
| template.subject, | |
| `${template.body}\n\n${template.callToAction}`, | |
| html, | |
| ); | |
| } | |
| // Send Push | |
| await this.sendPushNotification(userId, template.subject, template.body, { | |
| type: "ESCALATION_ALERT", | |
| priority, | |
| }); | |
| this.logger.log(`✅ Notification handled for user ${userId}`); | |
| } | |
| /** | |
| * Send severe BP alert | |
| */ | |
| async sendSevereBPAlert( | |
| userId: string, | |
| systolic: number, | |
| diastolic: number, | |
| ): Promise<void> { | |
| const template = BP_ALERT_TEMPLATES.SEVERE_HYPERTENSION; | |
| const email = await this.getUserEmail(userId); | |
| this.logger.warn( | |
| `[ALERT] Severe BP for user ${userId}: ${systolic}/${diastolic}`, | |
| ); | |
| const message = `${template.body}\nReading: ${systolic}/${diastolic}\n\n${template.callToAction}`; | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId, | |
| type: NotificationType.BP_ALERT, | |
| title: template.subject, | |
| message, | |
| }, | |
| }); | |
| // Send email | |
| if (email) { | |
| const html = generateEmailHtml( | |
| template.subject, | |
| message, | |
| "Log Now", | |
| `${this.configService.get("FRONTEND_URL")}/bridge?type=bp-entry`, | |
| "#FF4B4B", | |
| ); | |
| await this.emailService.sendEmail(email, template.subject, message, html); | |
| } | |
| // Send Push | |
| await this.sendPushNotification(userId, template.subject, template.body, { | |
| type: "TREND_ALERT", | |
| systolic, | |
| diastolic, | |
| }); | |
| this.logger.log(`✅ Severe BP alert handled for user ${userId}`); | |
| } | |
| /** | |
| * Send elevated BP notification | |
| */ | |
| async sendElevatedBPNotification( | |
| userId: string, | |
| systolic: number, | |
| diastolic: number, | |
| ): Promise<void> { | |
| const template = BP_ALERT_TEMPLATES.ELEVATED_BP; | |
| const email = await this.getUserEmail(userId); | |
| this.logger.log( | |
| `[NOTIFICATION] Elevated BP for user ${userId}: ${systolic}/${diastolic}`, | |
| ); | |
| const message = `${template.body}\nReading: ${systolic}/${diastolic}\n\n${template.callToAction}`; | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId, | |
| type: NotificationType.BP_ALERT, | |
| title: template.subject, | |
| message, | |
| }, | |
| }); | |
| // Send email | |
| if (email) { | |
| const html = generateEmailHtml( | |
| template.subject, | |
| message, | |
| "Log Now", | |
| `${this.configService.get("FRONTEND_URL")}/bridge?type=bp-entry`, | |
| "#FF9B3E", | |
| ); | |
| await this.emailService.sendEmail(email, template.subject, message, html); | |
| } | |
| // Send Push | |
| await this.sendPushNotification(userId, template.subject, template.body, { | |
| type: "TREND_ALERT", | |
| systolic, | |
| diastolic, | |
| }); | |
| this.logger.log(`✅ Elevated BP notification handled for user ${userId}`); | |
| } | |
| /** | |
| * Send dangerous symptom combination alert | |
| */ | |
| async sendDangerousSymptomAlert( | |
| userId: string, | |
| symptoms: string[], | |
| ): Promise<void> { | |
| const template = SYMPTOM_ALERT_TEMPLATES.DANGEROUS_COMBINATION; | |
| const email = await this.getUserEmail(userId); | |
| this.logger.warn( | |
| `[ALERT] Dangerous symptoms for user ${userId}: ${symptoms.join(", ")}`, | |
| ); | |
| const message = `${template.body}\nSymptoms reported: ${symptoms.join(", ")}\n\n${template.callToAction}`; | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId, | |
| type: NotificationType.SYMPTOM_ALERT, | |
| title: template.subject, | |
| message, | |
| }, | |
| }); | |
| // Send email | |
| if (email) { | |
| const html = generateEmailHtml( | |
| template.subject, | |
| message, | |
| "Open App", | |
| `${this.configService.get("FRONTEND_URL")}/bridge?type=tracking`, | |
| "#FF4B4B", | |
| ); | |
| await this.emailService.sendEmail(email, template.subject, message, html); | |
| } | |
| // Send Push | |
| await this.sendPushNotification(userId, template.subject, template.body, { | |
| type: "TREND_ALERT", | |
| symptoms, | |
| }); | |
| this.logger.log(`✅ Dangerous symptom alert handled for user ${userId}`); | |
| } | |
| /** | |
| * Send warning symptom notification | |
| */ | |
| async sendWarningSymptomNotification( | |
| userId: string, | |
| symptom: string, | |
| ): Promise<void> { | |
| const template = SYMPTOM_ALERT_TEMPLATES.SINGLE_WARNING_SYMPTOM; | |
| const email = await this.getUserEmail(userId); | |
| this.logger.log( | |
| `[NOTIFICATION] Warning symptom for user ${userId}: ${symptom}`, | |
| ); | |
| const message = `${template.body}\nSymptom reported: ${symptom}\n\n${template.callToAction}`; | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId, | |
| type: NotificationType.SYMPTOM_ALERT, | |
| title: template.subject, | |
| message, | |
| }, | |
| }); | |
| // Send email | |
| if (email) { | |
| const html = generateEmailHtml( | |
| template.subject, | |
| message, | |
| "Open App", | |
| `${this.configService.get("FRONTEND_URL")}/bridge?type=tracking`, | |
| "#FF9B3E", | |
| ); | |
| await this.emailService.sendEmail(email, template.subject, message, html); | |
| } | |
| // Send Push | |
| await this.sendPushNotification(userId, template.subject, template.body, { | |
| type: "TREND_ALERT", | |
| symptom, | |
| }); | |
| this.logger.log( | |
| `✅ Warning symptom notification handled for user ${userId}`, | |
| ); | |
| } | |
| /** | |
| * Send reset password notification | |
| * | |
| */ | |
| async sendResetPasswordNotification( | |
| userId: string, | |
| token: string, | |
| ): Promise<void> { | |
| const email = await this.getUserEmail(userId); | |
| this.logger.log( | |
| `[AUTH] Reset password request for user ${userId}. Email: ${email || 'N/A'}`, | |
| ); | |
| // Store in database | |
| await this.prisma.notification.create({ | |
| data: { | |
| userId, | |
| type: NotificationType.REMINDER, | |
| title: 'Reset your MaternAlert password', | |
| message: 'A password reset link has been sent to your email.', | |
| }, | |
| }); | |
| // Send email if user has one using our new template | |
| if (email) { | |
| await this.emailService.sendPasswordReset(email, token); | |
| } | |
| // Push notification — tapping it opens the login screen | |
| await this.sendPushNotification( | |
| userId, | |
| 'Reset your MaternAlert password', | |
| 'Tap to open MaternAlert and follow the link in your email.', | |
| { type: 'SESSION_EXPIRY' }, | |
| ); | |
| this.logger.log( | |
| `✅ Reset password notification handled for user ${userId}`, | |
| ); | |
| } | |
| /** | |
| * Register push token for user | |
| */ | |
| async registerPushToken(userId: string, pushToken: string) { | |
| this.logger.log(`Registering push token for user ${userId}`); | |
| return this.prisma.userAuth.update({ | |
| where: { id: userId }, | |
| data: { pushToken }, | |
| }); | |
| } | |
| /** | |
| * Get notification history for user | |
| */ | |
| async getNotificationHistory(userId: string) { | |
| this.logger.log(`Fetching notification history for user ${userId}`); | |
| return this.prisma.notification.findMany({ | |
| where: { userId }, | |
| orderBy: { createdAt: "desc" }, | |
| }); | |
| } | |
| /** | |
| * Mark notification as read | |
| */ | |
| async markAsRead(notificationId: string, userId: string) { | |
| const notification = await this.prisma.notification.findUnique({ | |
| where: { id: notificationId }, | |
| }); | |
| if (!notification || notification.userId !== userId) { | |
| throw new Error("Notification not found or access denied"); | |
| } | |
| return this.prisma.notification.update({ | |
| where: { id: notificationId }, | |
| data: { read: true }, | |
| }); | |
| } | |
| } | |