Spaces:
Running
Running
Commit ·
a8daede
1
Parent(s): 1687bb1
feat(notifications): integrate health assessment and enhance email service
Browse filesAdd immediate health assessment trigger after blood pressure reading creation
to enable real-time care priority notifications. Enhance email service with
connection pooling and improved error handling. Update notification service
to include detailed push notification logging and standardized formatting.
src/blood-pressure/blood-pressure.controller.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { BloodPressureService } from "./blood-pressure.service";
|
|
| 18 |
import { CreateBloodPressureDto } from "./dto/create-blood-pressure.dto";
|
| 19 |
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
| 20 |
import { MonitoringEngineService } from "../monitoring-engine/monitoring-engine.service";
|
|
|
|
| 21 |
|
| 22 |
@Controller("blood-pressure")
|
| 23 |
@UseGuards(JwtAuthGuard)
|
|
@@ -25,21 +26,27 @@ export class BloodPressureController {
|
|
| 25 |
constructor(
|
| 26 |
private readonly bloodPressureService: BloodPressureService,
|
| 27 |
private readonly monitoringEngineService: MonitoringEngineService,
|
|
|
|
|
|
|
| 28 |
) {}
|
| 29 |
|
| 30 |
@Post()
|
| 31 |
@HttpCode(HttpStatus.CREATED)
|
| 32 |
async create(
|
| 33 |
@Request() req: any,
|
| 34 |
-
@Body() createBloodPressureDto: CreateBloodPressureDto
|
| 35 |
) {
|
| 36 |
const userId = req.user.id;
|
| 37 |
const reading = await this.bloodPressureService.create(
|
| 38 |
userId,
|
| 39 |
-
createBloodPressureDto
|
| 40 |
);
|
| 41 |
|
| 42 |
-
const monitoringResult =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
return {
|
| 45 |
reading,
|
|
@@ -61,7 +68,7 @@ export class BloodPressureController {
|
|
| 61 |
@HttpCode(HttpStatus.OK)
|
| 62 |
async getReadings(
|
| 63 |
@Request() req: any,
|
| 64 |
-
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number
|
| 65 |
) {
|
| 66 |
const userId = req.user.id;
|
| 67 |
return this.bloodPressureService.findByUserId(userId, limit);
|
|
|
|
| 18 |
import { CreateBloodPressureDto } from "./dto/create-blood-pressure.dto";
|
| 19 |
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
| 20 |
import { MonitoringEngineService } from "../monitoring-engine/monitoring-engine.service";
|
| 21 |
+
import { HealthAssessmentService } from "../care-priority/health-assessment.service";
|
| 22 |
|
| 23 |
@Controller("blood-pressure")
|
| 24 |
@UseGuards(JwtAuthGuard)
|
|
|
|
| 26 |
constructor(
|
| 27 |
private readonly bloodPressureService: BloodPressureService,
|
| 28 |
private readonly monitoringEngineService: MonitoringEngineService,
|
| 29 |
+
@Inject(forwardRef(() => HealthAssessmentService))
|
| 30 |
+
private readonly healthAssessmentService: HealthAssessmentService,
|
| 31 |
) {}
|
| 32 |
|
| 33 |
@Post()
|
| 34 |
@HttpCode(HttpStatus.CREATED)
|
| 35 |
async create(
|
| 36 |
@Request() req: any,
|
| 37 |
+
@Body() createBloodPressureDto: CreateBloodPressureDto,
|
| 38 |
) {
|
| 39 |
const userId = req.user.id;
|
| 40 |
const reading = await this.bloodPressureService.create(
|
| 41 |
userId,
|
| 42 |
+
createBloodPressureDto,
|
| 43 |
);
|
| 44 |
|
| 45 |
+
const monitoringResult =
|
| 46 |
+
await this.monitoringEngineService.evaluate(userId);
|
| 47 |
+
|
| 48 |
+
// Trigger health assessment and notifications (immediate Email/Push if needed)
|
| 49 |
+
this.healthAssessmentService.assessAndNotify(userId);
|
| 50 |
|
| 51 |
return {
|
| 52 |
reading,
|
|
|
|
| 68 |
@HttpCode(HttpStatus.OK)
|
| 69 |
async getReadings(
|
| 70 |
@Request() req: any,
|
| 71 |
+
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
|
| 72 |
) {
|
| 73 |
const userId = req.user.id;
|
| 74 |
return this.bloodPressureService.findByUserId(userId, limit);
|
src/blood-pressure/blood-pressure.module.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { Module, forwardRef } from "@nestjs/common";
|
|
| 2 |
import { BloodPressureController } from "./blood-pressure.controller";
|
| 3 |
import { BloodPressureService } from "./blood-pressure.service";
|
| 4 |
import { MonitoringEngineModule } from "../monitoring-engine/monitoring-engine.module";
|
|
|
|
| 5 |
|
| 6 |
@Module({
|
| 7 |
-
imports: [MonitoringEngineModule],
|
| 8 |
controllers: [BloodPressureController],
|
| 9 |
providers: [BloodPressureService],
|
| 10 |
exports: [BloodPressureService],
|
|
|
|
| 2 |
import { BloodPressureController } from "./blood-pressure.controller";
|
| 3 |
import { BloodPressureService } from "./blood-pressure.service";
|
| 4 |
import { MonitoringEngineModule } from "../monitoring-engine/monitoring-engine.module";
|
| 5 |
+
import { CarePriorityModule } from "../care-priority/care-priority.module";
|
| 6 |
|
| 7 |
@Module({
|
| 8 |
+
imports: [MonitoringEngineModule, forwardRef(() => CarePriorityModule)],
|
| 9 |
controllers: [BloodPressureController],
|
| 10 |
providers: [BloodPressureService],
|
| 11 |
exports: [BloodPressureService],
|
src/notifications/email.service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
import { Injectable, Logger } from
|
| 2 |
-
import { ConfigService } from
|
| 3 |
-
import * as nodemailer from
|
| 4 |
|
| 5 |
@Injectable()
|
| 6 |
export class EmailService {
|
|
@@ -9,20 +9,31 @@ export class EmailService {
|
|
| 9 |
|
| 10 |
constructor(private readonly configService: ConfigService) {
|
| 11 |
this.transporter = nodemailer.createTransport({
|
| 12 |
-
host: this.configService.get(
|
| 13 |
-
port: this.configService.get(
|
| 14 |
-
secure:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
auth: {
|
| 16 |
-
user: this.configService.get(
|
| 17 |
-
pass: this.configService.get(
|
| 18 |
},
|
| 19 |
});
|
| 20 |
}
|
| 21 |
|
| 22 |
-
async sendEmail(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
try {
|
| 24 |
-
const from = this.configService.get(
|
| 25 |
-
|
| 26 |
const info = await this.transporter.sendMail({
|
| 27 |
from,
|
| 28 |
to,
|
|
@@ -34,7 +45,10 @@ export class EmailService {
|
|
| 34 |
this.logger.log(`Email sent successfully: ${info.messageId} to ${to}`);
|
| 35 |
return true;
|
| 36 |
} catch (error: any) {
|
| 37 |
-
this.logger.error(
|
|
|
|
|
|
|
|
|
|
| 38 |
return false;
|
| 39 |
}
|
| 40 |
}
|
|
|
|
| 1 |
+
import { Injectable, Logger } from "@nestjs/common";
|
| 2 |
+
import { ConfigService } from "@nestjs/config";
|
| 3 |
+
import * as nodemailer from "nodemailer";
|
| 4 |
|
| 5 |
@Injectable()
|
| 6 |
export class EmailService {
|
|
|
|
| 9 |
|
| 10 |
constructor(private readonly configService: ConfigService) {
|
| 11 |
this.transporter = nodemailer.createTransport({
|
| 12 |
+
host: this.configService.get("SMTP_HOST"),
|
| 13 |
+
port: this.configService.get("SMTP_PORT"),
|
| 14 |
+
secure: this.configService.get("SMTP_SECURE") === "true",
|
| 15 |
+
pool: true,
|
| 16 |
+
maxConnections: 5,
|
| 17 |
+
maxMessages: 100,
|
| 18 |
+
connectionTimeout: 10000, // 10 seconds
|
| 19 |
+
greetingTimeout: 10000,
|
| 20 |
+
socketTimeout: 30000,
|
| 21 |
auth: {
|
| 22 |
+
user: this.configService.get("SMTP_USER"),
|
| 23 |
+
pass: this.configService.get("SMTP_PASS"),
|
| 24 |
},
|
| 25 |
});
|
| 26 |
}
|
| 27 |
|
| 28 |
+
async sendEmail(
|
| 29 |
+
to: string,
|
| 30 |
+
subject: string,
|
| 31 |
+
text: string,
|
| 32 |
+
html?: string,
|
| 33 |
+
): Promise<boolean> {
|
| 34 |
try {
|
| 35 |
+
const from = this.configService.get("SMTP_FROM");
|
| 36 |
+
|
| 37 |
const info = await this.transporter.sendMail({
|
| 38 |
from,
|
| 39 |
to,
|
|
|
|
| 45 |
this.logger.log(`Email sent successfully: ${info.messageId} to ${to}`);
|
| 46 |
return true;
|
| 47 |
} catch (error: any) {
|
| 48 |
+
this.logger.error(
|
| 49 |
+
`Failed to send email to ${to}: ${error.message}`,
|
| 50 |
+
error.stack,
|
| 51 |
+
);
|
| 52 |
return false;
|
| 53 |
}
|
| 54 |
}
|
src/notifications/notifications.service.ts
CHANGED
|
@@ -36,7 +36,7 @@ export class NotificationsService {
|
|
| 36 |
constructor(
|
| 37 |
private readonly prisma: PrismaService,
|
| 38 |
private readonly emailService: EmailService,
|
| 39 |
-
private readonly configService: ConfigService
|
| 40 |
) {}
|
| 41 |
|
| 42 |
/**
|
|
@@ -68,7 +68,7 @@ export class NotificationsService {
|
|
| 68 |
userId: string,
|
| 69 |
title: string,
|
| 70 |
body: string,
|
| 71 |
-
data?: any
|
| 72 |
): Promise<void> {
|
| 73 |
const user = await this.prisma.userAuth.findUnique({
|
| 74 |
where: { id: userId },
|
|
@@ -76,13 +76,17 @@ export class NotificationsService {
|
|
| 76 |
});
|
| 77 |
|
| 78 |
if (!user?.pushToken) {
|
| 79 |
-
this.logger.debug(
|
|
|
|
|
|
|
| 80 |
return;
|
| 81 |
}
|
| 82 |
|
| 83 |
const { Expo } = await import("expo-server-sdk");
|
| 84 |
if (!Expo.isExpoPushToken(user.pushToken)) {
|
| 85 |
-
this.logger.error(
|
|
|
|
|
|
|
| 86 |
return;
|
| 87 |
}
|
| 88 |
|
|
@@ -100,11 +104,17 @@ export class NotificationsService {
|
|
| 100 |
const expo = await this.getExpo();
|
| 101 |
const chunks = expo.chunkPushNotifications(messages);
|
| 102 |
for (const chunk of chunks) {
|
| 103 |
-
await expo.sendPushNotificationsAsync(chunk);
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
this.logger.log(`Push notification sent to user ${userId}`);
|
| 106 |
} catch (error) {
|
| 107 |
-
this.logger.error(
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
}
|
| 110 |
|
|
@@ -115,11 +125,9 @@ export class NotificationsService {
|
|
| 115 |
@Cron(CronExpression.EVERY_MINUTE)
|
| 116 |
async checkDailyReminders() {
|
| 117 |
const now = new Date();
|
| 118 |
-
const
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
hour12: false,
|
| 122 |
-
});
|
| 123 |
|
| 124 |
const today = new Date();
|
| 125 |
today.setHours(0, 0, 0, 0);
|
|
@@ -166,10 +174,13 @@ export class NotificationsService {
|
|
| 166 |
user.id,
|
| 167 |
template.subject,
|
| 168 |
template.body,
|
| 169 |
-
{ type: "
|
| 170 |
);
|
| 171 |
} catch (error) {
|
| 172 |
-
this.logger.error(
|
|
|
|
|
|
|
|
|
|
| 173 |
}
|
| 174 |
}
|
| 175 |
}
|
|
@@ -214,7 +225,7 @@ export class NotificationsService {
|
|
| 214 |
user.id,
|
| 215 |
template.subject,
|
| 216 |
template.body,
|
| 217 |
-
{ type: "INACTIVITY_REMINDER" }
|
| 218 |
);
|
| 219 |
|
| 220 |
// Send Email
|
|
@@ -224,21 +235,26 @@ export class NotificationsService {
|
|
| 224 |
template.body,
|
| 225 |
"Log BP Reading",
|
| 226 |
`${this.configService.get("FRONTEND_URL")}/bp-entry`,
|
| 227 |
-
"#2DE474"
|
| 228 |
);
|
| 229 |
|
| 230 |
await this.emailService.sendEmail(
|
| 231 |
user.email,
|
| 232 |
template.subject,
|
| 233 |
template.body,
|
| 234 |
-
html
|
| 235 |
);
|
| 236 |
}
|
| 237 |
} catch (error) {
|
| 238 |
-
this.logger.error(
|
|
|
|
|
|
|
|
|
|
| 239 |
}
|
| 240 |
}
|
| 241 |
-
this.logger.log(
|
|
|
|
|
|
|
| 242 |
}
|
| 243 |
|
| 244 |
/**
|
|
@@ -260,10 +276,7 @@ export class NotificationsService {
|
|
| 260 |
bloodPressureReadings: {
|
| 261 |
some: {
|
| 262 |
recordedAt: { gte: fiveHoursAgo, lte: fourHoursAgo },
|
| 263 |
-
OR: [
|
| 264 |
-
{ systolic: { gte: 130 } },
|
| 265 |
-
{ diastolic: { gte: 80 } },
|
| 266 |
-
],
|
| 267 |
},
|
| 268 |
none: {
|
| 269 |
recordedAt: { gt: fourHoursAgo },
|
|
@@ -290,7 +303,7 @@ export class NotificationsService {
|
|
| 290 |
user.id,
|
| 291 |
template.subject,
|
| 292 |
template.body,
|
| 293 |
-
{ type: "FOLLOW_UP" }
|
| 294 |
);
|
| 295 |
|
| 296 |
// Send Email
|
|
@@ -300,18 +313,21 @@ export class NotificationsService {
|
|
| 300 |
template.body,
|
| 301 |
"Recheck BP Now",
|
| 302 |
`${this.configService.get("FRONTEND_URL")}/bp-entry`,
|
| 303 |
-
"#FF9B3E"
|
| 304 |
);
|
| 305 |
|
| 306 |
await this.emailService.sendEmail(
|
| 307 |
user.email,
|
| 308 |
template.subject,
|
| 309 |
template.body,
|
| 310 |
-
html
|
| 311 |
);
|
| 312 |
}
|
| 313 |
} catch (error) {
|
| 314 |
-
this.logger.error(
|
|
|
|
|
|
|
|
|
|
| 315 |
}
|
| 316 |
}
|
| 317 |
}
|
|
@@ -346,34 +362,61 @@ export class NotificationsService {
|
|
| 346 |
// 1. Creeping Rise Detection (Consistent rise over 3+ readings)
|
| 347 |
let riseCount = 0;
|
| 348 |
for (let i = 1; i < readings.length; i++) {
|
| 349 |
-
if (readings[i].systolic > readings[i-1].systolic) riseCount++;
|
| 350 |
else riseCount = 0;
|
| 351 |
}
|
| 352 |
if (riseCount >= 3) {
|
| 353 |
-
await this.sendTrendAlert(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
continue; // Don't spam multiple trend alerts
|
| 355 |
}
|
| 356 |
|
| 357 |
// 2. Repeated High Readings (3+ elevated readings in a week)
|
| 358 |
-
const highReadings = readings.filter(
|
|
|
|
|
|
|
| 359 |
if (highReadings.length >= 3) {
|
| 360 |
-
await this.sendTrendAlert(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
continue;
|
| 362 |
}
|
| 363 |
|
| 364 |
// 3. Sudden Spike Detection (Latest reading is 20% > personal average)
|
| 365 |
-
const avgSystolic =
|
|
|
|
|
|
|
| 366 |
const latest = readings[readings.length - 1];
|
| 367 |
if (latest.systolic > avgSystolic * 1.2) {
|
| 368 |
-
await this.sendTrendAlert(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
}
|
| 370 |
} catch (error) {
|
| 371 |
-
this.logger.error(
|
|
|
|
|
|
|
|
|
|
| 372 |
}
|
| 373 |
}
|
| 374 |
}
|
| 375 |
|
| 376 |
-
private async sendTrendAlert(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
await this.prisma.notification.create({
|
| 378 |
data: {
|
| 379 |
userId,
|
|
@@ -383,12 +426,10 @@ export class NotificationsService {
|
|
| 383 |
},
|
| 384 |
});
|
| 385 |
|
| 386 |
-
await this.sendPushNotification(
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
{ type: "TREND_ALERT", trendType }
|
| 391 |
-
);
|
| 392 |
|
| 393 |
if (email) {
|
| 394 |
const html = generateEmailHtml(
|
|
@@ -396,14 +437,14 @@ export class NotificationsService {
|
|
| 396 |
template.body,
|
| 397 |
"View Trends",
|
| 398 |
`${this.configService.get("FRONTEND_URL")}/(tabs)/tracking`,
|
| 399 |
-
"#FF9B3E"
|
| 400 |
);
|
| 401 |
|
| 402 |
await this.emailService.sendEmail(
|
| 403 |
email,
|
| 404 |
template.subject,
|
| 405 |
template.body,
|
| 406 |
-
html
|
| 407 |
);
|
| 408 |
}
|
| 409 |
}
|
|
@@ -413,14 +454,14 @@ export class NotificationsService {
|
|
| 413 |
*/
|
| 414 |
async sendCarePriorityNotification(
|
| 415 |
userId: string,
|
| 416 |
-
priority: CarePriority
|
| 417 |
): Promise<void> {
|
| 418 |
const template = CARE_PRIORITY_TEMPLATES[priority];
|
| 419 |
const email = await this.getUserEmail(userId);
|
| 420 |
|
| 421 |
// Log notification
|
| 422 |
this.logger.log(`[NOTIFICATION] User: ${userId}, Priority: ${priority}`);
|
| 423 |
-
|
| 424 |
// Persist to database
|
| 425 |
await this.prisma.notification.create({
|
| 426 |
data: {
|
|
@@ -438,24 +479,26 @@ export class NotificationsService {
|
|
| 438 |
`${template.body}\n\n${template.callToAction}`,
|
| 439 |
"View Details",
|
| 440 |
`${this.configService.get("FRONTEND_URL")}/(tabs)/tracking`,
|
| 441 |
-
priority === CarePriority.EMERGENCY
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
);
|
| 443 |
|
| 444 |
await this.emailService.sendEmail(
|
| 445 |
email,
|
| 446 |
template.subject,
|
| 447 |
`${template.body}\n\n${template.callToAction}`,
|
| 448 |
-
html
|
| 449 |
);
|
| 450 |
}
|
| 451 |
|
| 452 |
// Send Push
|
| 453 |
-
await this.sendPushNotification(
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
{ type: "ESCALATION_ALERT", priority }
|
| 458 |
-
);
|
| 459 |
|
| 460 |
this.logger.log(`✅ Notification handled for user ${userId}`);
|
| 461 |
}
|
|
@@ -466,13 +509,13 @@ export class NotificationsService {
|
|
| 466 |
async sendSevereBPAlert(
|
| 467 |
userId: string,
|
| 468 |
systolic: number,
|
| 469 |
-
diastolic: number
|
| 470 |
): Promise<void> {
|
| 471 |
const template = BP_ALERT_TEMPLATES.SEVERE_HYPERTENSION;
|
| 472 |
const email = await this.getUserEmail(userId);
|
| 473 |
|
| 474 |
this.logger.warn(
|
| 475 |
-
`[ALERT] Severe BP for user ${userId}: ${systolic}/${diastolic}`
|
| 476 |
);
|
| 477 |
|
| 478 |
const message = `${template.body}\nReading: ${systolic}/${diastolic}\n\n${template.callToAction}`;
|
|
@@ -493,24 +536,18 @@ export class NotificationsService {
|
|
| 493 |
message,
|
| 494 |
"Log Now",
|
| 495 |
`${this.configService.get("FRONTEND_URL")}/bp-entry`,
|
| 496 |
-
"#FF4B4B"
|
| 497 |
);
|
| 498 |
|
| 499 |
-
await this.emailService.sendEmail(
|
| 500 |
-
email,
|
| 501 |
-
template.subject,
|
| 502 |
-
message,
|
| 503 |
-
html
|
| 504 |
-
);
|
| 505 |
}
|
| 506 |
|
| 507 |
// Send Push
|
| 508 |
-
await this.sendPushNotification(
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
);
|
| 514 |
|
| 515 |
this.logger.log(`✅ Severe BP alert handled for user ${userId}`);
|
| 516 |
}
|
|
@@ -521,13 +558,13 @@ export class NotificationsService {
|
|
| 521 |
async sendElevatedBPNotification(
|
| 522 |
userId: string,
|
| 523 |
systolic: number,
|
| 524 |
-
diastolic: number
|
| 525 |
): Promise<void> {
|
| 526 |
const template = BP_ALERT_TEMPLATES.ELEVATED_BP;
|
| 527 |
const email = await this.getUserEmail(userId);
|
| 528 |
|
| 529 |
this.logger.log(
|
| 530 |
-
`[NOTIFICATION] Elevated BP for user ${userId}: ${systolic}/${diastolic}`
|
| 531 |
);
|
| 532 |
|
| 533 |
const message = `${template.body}\nReading: ${systolic}/${diastolic}\n\n${template.callToAction}`;
|
|
@@ -548,24 +585,18 @@ export class NotificationsService {
|
|
| 548 |
message,
|
| 549 |
"Log Now",
|
| 550 |
`${this.configService.get("FRONTEND_URL")}/bp-entry`,
|
| 551 |
-
"#FF9B3E"
|
| 552 |
);
|
| 553 |
|
| 554 |
-
await this.emailService.sendEmail(
|
| 555 |
-
email,
|
| 556 |
-
template.subject,
|
| 557 |
-
message,
|
| 558 |
-
html
|
| 559 |
-
);
|
| 560 |
}
|
| 561 |
|
| 562 |
// Send Push
|
| 563 |
-
await this.sendPushNotification(
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
);
|
| 569 |
|
| 570 |
this.logger.log(`✅ Elevated BP notification handled for user ${userId}`);
|
| 571 |
}
|
|
@@ -575,13 +606,13 @@ export class NotificationsService {
|
|
| 575 |
*/
|
| 576 |
async sendDangerousSymptomAlert(
|
| 577 |
userId: string,
|
| 578 |
-
symptoms: string[]
|
| 579 |
): Promise<void> {
|
| 580 |
const template = SYMPTOM_ALERT_TEMPLATES.DANGEROUS_COMBINATION;
|
| 581 |
const email = await this.getUserEmail(userId);
|
| 582 |
|
| 583 |
this.logger.warn(
|
| 584 |
-
`[ALERT] Dangerous symptoms for user ${userId}: ${symptoms.join(", ")}`
|
| 585 |
);
|
| 586 |
|
| 587 |
const message = `${template.body}\nSymptoms reported: ${symptoms.join(", ")}\n\n${template.callToAction}`;
|
|
@@ -602,24 +633,17 @@ export class NotificationsService {
|
|
| 602 |
message,
|
| 603 |
"Open App",
|
| 604 |
`${this.configService.get("FRONTEND_URL")}/(tabs)/tracking`,
|
| 605 |
-
"#FF4B4B"
|
| 606 |
);
|
| 607 |
|
| 608 |
-
await this.emailService.sendEmail(
|
| 609 |
-
email,
|
| 610 |
-
template.subject,
|
| 611 |
-
message,
|
| 612 |
-
html
|
| 613 |
-
);
|
| 614 |
}
|
| 615 |
|
| 616 |
// Send Push
|
| 617 |
-
await this.sendPushNotification(
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
{ type: "TREND_ALERT", symptoms }
|
| 622 |
-
);
|
| 623 |
|
| 624 |
this.logger.log(`✅ Dangerous symptom alert handled for user ${userId}`);
|
| 625 |
}
|
|
@@ -629,13 +653,13 @@ export class NotificationsService {
|
|
| 629 |
*/
|
| 630 |
async sendWarningSymptomNotification(
|
| 631 |
userId: string,
|
| 632 |
-
symptom: string
|
| 633 |
): Promise<void> {
|
| 634 |
const template = SYMPTOM_ALERT_TEMPLATES.SINGLE_WARNING_SYMPTOM;
|
| 635 |
const email = await this.getUserEmail(userId);
|
| 636 |
|
| 637 |
this.logger.log(
|
| 638 |
-
`[NOTIFICATION] Warning symptom for user ${userId}: ${symptom}`
|
| 639 |
);
|
| 640 |
|
| 641 |
const message = `${template.body}\nSymptom reported: ${symptom}\n\n${template.callToAction}`;
|
|
@@ -656,26 +680,21 @@ export class NotificationsService {
|
|
| 656 |
message,
|
| 657 |
"Open App",
|
| 658 |
`${this.configService.get("FRONTEND_URL")}/(tabs)/tracking`,
|
| 659 |
-
"#FF9B3E"
|
| 660 |
);
|
| 661 |
|
| 662 |
-
await this.emailService.sendEmail(
|
| 663 |
-
email,
|
| 664 |
-
template.subject,
|
| 665 |
-
message,
|
| 666 |
-
html
|
| 667 |
-
);
|
| 668 |
}
|
| 669 |
|
| 670 |
// Send Push
|
| 671 |
-
await this.sendPushNotification(
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
{ type: "TREND_ALERT", symptom }
|
| 676 |
-
);
|
| 677 |
|
| 678 |
-
this.logger.log(
|
|
|
|
|
|
|
| 679 |
}
|
| 680 |
|
| 681 |
/**
|
|
@@ -683,12 +702,14 @@ export class NotificationsService {
|
|
| 683 |
*/
|
| 684 |
async sendResetPasswordNotification(
|
| 685 |
userId: string,
|
| 686 |
-
token: string
|
| 687 |
): Promise<void> {
|
| 688 |
const template = AUTH_TEMPLATES.RESET_PASSWORD;
|
| 689 |
const email = await this.getUserEmail(userId);
|
| 690 |
|
| 691 |
-
this.logger.log(
|
|
|
|
|
|
|
| 692 |
|
| 693 |
const resetLink = `${this.configService.get("FRONTEND_URL")}/reset-password?token=${token}`;
|
| 694 |
const message = `${template.body}\n\nReset Link: ${resetLink}`;
|
|
@@ -710,15 +731,10 @@ export class NotificationsService {
|
|
| 710 |
template.body,
|
| 711 |
"Reset Password",
|
| 712 |
resetLink,
|
| 713 |
-
"#1E6BFF" // Use setup blue for auth actions
|
| 714 |
);
|
| 715 |
|
| 716 |
-
await this.emailService.sendEmail(
|
| 717 |
-
email,
|
| 718 |
-
template.subject,
|
| 719 |
-
message,
|
| 720 |
-
html
|
| 721 |
-
);
|
| 722 |
}
|
| 723 |
|
| 724 |
// Send Push
|
|
@@ -726,10 +742,12 @@ export class NotificationsService {
|
|
| 726 |
userId,
|
| 727 |
template.subject,
|
| 728 |
template.body,
|
| 729 |
-
{ type: "SESSION_EXPIRY" } // Using session expiry as a proxy for generic auth actions
|
| 730 |
);
|
| 731 |
|
| 732 |
-
this.logger.log(
|
|
|
|
|
|
|
| 733 |
}
|
| 734 |
|
| 735 |
/**
|
|
|
|
| 36 |
constructor(
|
| 37 |
private readonly prisma: PrismaService,
|
| 38 |
private readonly emailService: EmailService,
|
| 39 |
+
private readonly configService: ConfigService,
|
| 40 |
) {}
|
| 41 |
|
| 42 |
/**
|
|
|
|
| 68 |
userId: string,
|
| 69 |
title: string,
|
| 70 |
body: string,
|
| 71 |
+
data?: any,
|
| 72 |
): Promise<void> {
|
| 73 |
const user = await this.prisma.userAuth.findUnique({
|
| 74 |
where: { id: userId },
|
|
|
|
| 76 |
});
|
| 77 |
|
| 78 |
if (!user?.pushToken) {
|
| 79 |
+
this.logger.debug(
|
| 80 |
+
`No push token found for user ${userId}, skipping push.`,
|
| 81 |
+
);
|
| 82 |
return;
|
| 83 |
}
|
| 84 |
|
| 85 |
const { Expo } = await import("expo-server-sdk");
|
| 86 |
if (!Expo.isExpoPushToken(user.pushToken)) {
|
| 87 |
+
this.logger.error(
|
| 88 |
+
`Push token ${user.pushToken} is not a valid Expo push token`,
|
| 89 |
+
);
|
| 90 |
return;
|
| 91 |
}
|
| 92 |
|
|
|
|
| 104 |
const expo = await this.getExpo();
|
| 105 |
const chunks = expo.chunkPushNotifications(messages);
|
| 106 |
for (const chunk of chunks) {
|
| 107 |
+
const tickets = await expo.sendPushNotificationsAsync(chunk);
|
| 108 |
+
this.logger.log(
|
| 109 |
+
`Push notification tickets for user ${userId}: ${JSON.stringify(tickets)}`,
|
| 110 |
+
);
|
| 111 |
}
|
| 112 |
this.logger.log(`Push notification sent to user ${userId}`);
|
| 113 |
} catch (error) {
|
| 114 |
+
this.logger.error(
|
| 115 |
+
`Error sending push notification to user ${userId}:`,
|
| 116 |
+
error,
|
| 117 |
+
);
|
| 118 |
}
|
| 119 |
}
|
| 120 |
|
|
|
|
| 125 |
@Cron(CronExpression.EVERY_MINUTE)
|
| 126 |
async checkDailyReminders() {
|
| 127 |
const now = new Date();
|
| 128 |
+
const hours = now.getHours().toString().padStart(2, "0");
|
| 129 |
+
const minutes = now.getMinutes().toString().padStart(2, "0");
|
| 130 |
+
const currentHHmm = `${hours}:${minutes}`;
|
|
|
|
|
|
|
| 131 |
|
| 132 |
const today = new Date();
|
| 133 |
today.setHours(0, 0, 0, 0);
|
|
|
|
| 174 |
user.id,
|
| 175 |
template.subject,
|
| 176 |
template.body,
|
| 177 |
+
{ type: "BP_REMINDER" },
|
| 178 |
);
|
| 179 |
} catch (error) {
|
| 180 |
+
this.logger.error(
|
| 181 |
+
`Failed to send daily reminder to user ${user.id}:`,
|
| 182 |
+
error,
|
| 183 |
+
);
|
| 184 |
}
|
| 185 |
}
|
| 186 |
}
|
|
|
|
| 225 |
user.id,
|
| 226 |
template.subject,
|
| 227 |
template.body,
|
| 228 |
+
{ type: "INACTIVITY_REMINDER" },
|
| 229 |
);
|
| 230 |
|
| 231 |
// Send Email
|
|
|
|
| 235 |
template.body,
|
| 236 |
"Log BP Reading",
|
| 237 |
`${this.configService.get("FRONTEND_URL")}/bp-entry`,
|
| 238 |
+
"#2DE474",
|
| 239 |
);
|
| 240 |
|
| 241 |
await this.emailService.sendEmail(
|
| 242 |
user.email,
|
| 243 |
template.subject,
|
| 244 |
template.body,
|
| 245 |
+
html,
|
| 246 |
);
|
| 247 |
}
|
| 248 |
} catch (error) {
|
| 249 |
+
this.logger.error(
|
| 250 |
+
`Failed to process inactivity reminder for user ${user.id}:`,
|
| 251 |
+
error,
|
| 252 |
+
);
|
| 253 |
}
|
| 254 |
}
|
| 255 |
+
this.logger.log(
|
| 256 |
+
`Inactivity check finished. ${users.length} users notified.`,
|
| 257 |
+
);
|
| 258 |
}
|
| 259 |
|
| 260 |
/**
|
|
|
|
| 276 |
bloodPressureReadings: {
|
| 277 |
some: {
|
| 278 |
recordedAt: { gte: fiveHoursAgo, lte: fourHoursAgo },
|
| 279 |
+
OR: [{ systolic: { gte: 130 } }, { diastolic: { gte: 80 } }],
|
|
|
|
|
|
|
|
|
|
| 280 |
},
|
| 281 |
none: {
|
| 282 |
recordedAt: { gt: fourHoursAgo },
|
|
|
|
| 303 |
user.id,
|
| 304 |
template.subject,
|
| 305 |
template.body,
|
| 306 |
+
{ type: "FOLLOW_UP" },
|
| 307 |
);
|
| 308 |
|
| 309 |
// Send Email
|
|
|
|
| 313 |
template.body,
|
| 314 |
"Recheck BP Now",
|
| 315 |
`${this.configService.get("FRONTEND_URL")}/bp-entry`,
|
| 316 |
+
"#FF9B3E",
|
| 317 |
);
|
| 318 |
|
| 319 |
await this.emailService.sendEmail(
|
| 320 |
user.email,
|
| 321 |
template.subject,
|
| 322 |
template.body,
|
| 323 |
+
html,
|
| 324 |
);
|
| 325 |
}
|
| 326 |
} catch (error) {
|
| 327 |
+
this.logger.error(
|
| 328 |
+
`Failed to process follow-up reminder for user ${user.id}:`,
|
| 329 |
+
error,
|
| 330 |
+
);
|
| 331 |
}
|
| 332 |
}
|
| 333 |
}
|
|
|
|
| 362 |
// 1. Creeping Rise Detection (Consistent rise over 3+ readings)
|
| 363 |
let riseCount = 0;
|
| 364 |
for (let i = 1; i < readings.length; i++) {
|
| 365 |
+
if (readings[i].systolic > readings[i - 1].systolic) riseCount++;
|
| 366 |
else riseCount = 0;
|
| 367 |
}
|
| 368 |
if (riseCount >= 3) {
|
| 369 |
+
await this.sendTrendAlert(
|
| 370 |
+
user.id,
|
| 371 |
+
TREND_ALERT_TEMPLATES.CREEPING_RISE,
|
| 372 |
+
"CREEPING_RISE",
|
| 373 |
+
user.email,
|
| 374 |
+
);
|
| 375 |
continue; // Don't spam multiple trend alerts
|
| 376 |
}
|
| 377 |
|
| 378 |
// 2. Repeated High Readings (3+ elevated readings in a week)
|
| 379 |
+
const highReadings = readings.filter(
|
| 380 |
+
(r) => r.systolic >= 135 || r.diastolic >= 85,
|
| 381 |
+
);
|
| 382 |
if (highReadings.length >= 3) {
|
| 383 |
+
await this.sendTrendAlert(
|
| 384 |
+
user.id,
|
| 385 |
+
TREND_ALERT_TEMPLATES.REPEATED_HIGH,
|
| 386 |
+
"REPEATED_HIGH",
|
| 387 |
+
user.email,
|
| 388 |
+
);
|
| 389 |
continue;
|
| 390 |
}
|
| 391 |
|
| 392 |
// 3. Sudden Spike Detection (Latest reading is 20% > personal average)
|
| 393 |
+
const avgSystolic =
|
| 394 |
+
readings.slice(0, -1).reduce((acc, curr) => acc + curr.systolic, 0) /
|
| 395 |
+
(readings.length - 1);
|
| 396 |
const latest = readings[readings.length - 1];
|
| 397 |
if (latest.systolic > avgSystolic * 1.2) {
|
| 398 |
+
await this.sendTrendAlert(
|
| 399 |
+
user.id,
|
| 400 |
+
TREND_ALERT_TEMPLATES.SUDDEN_SPIKE,
|
| 401 |
+
"SUDDEN_SPIKE",
|
| 402 |
+
user.email,
|
| 403 |
+
);
|
| 404 |
}
|
| 405 |
} catch (error) {
|
| 406 |
+
this.logger.error(
|
| 407 |
+
`Failed to analyze trends for user ${user.id}:`,
|
| 408 |
+
error,
|
| 409 |
+
);
|
| 410 |
}
|
| 411 |
}
|
| 412 |
}
|
| 413 |
|
| 414 |
+
private async sendTrendAlert(
|
| 415 |
+
userId: string,
|
| 416 |
+
template: any,
|
| 417 |
+
trendType: string,
|
| 418 |
+
email?: string | null,
|
| 419 |
+
) {
|
| 420 |
await this.prisma.notification.create({
|
| 421 |
data: {
|
| 422 |
userId,
|
|
|
|
| 426 |
},
|
| 427 |
});
|
| 428 |
|
| 429 |
+
await this.sendPushNotification(userId, template.subject, template.body, {
|
| 430 |
+
type: "TREND_ALERT",
|
| 431 |
+
trendType,
|
| 432 |
+
});
|
|
|
|
|
|
|
| 433 |
|
| 434 |
if (email) {
|
| 435 |
const html = generateEmailHtml(
|
|
|
|
| 437 |
template.body,
|
| 438 |
"View Trends",
|
| 439 |
`${this.configService.get("FRONTEND_URL")}/(tabs)/tracking`,
|
| 440 |
+
"#FF9B3E",
|
| 441 |
);
|
| 442 |
|
| 443 |
await this.emailService.sendEmail(
|
| 444 |
email,
|
| 445 |
template.subject,
|
| 446 |
template.body,
|
| 447 |
+
html,
|
| 448 |
);
|
| 449 |
}
|
| 450 |
}
|
|
|
|
| 454 |
*/
|
| 455 |
async sendCarePriorityNotification(
|
| 456 |
userId: string,
|
| 457 |
+
priority: CarePriority,
|
| 458 |
): Promise<void> {
|
| 459 |
const template = CARE_PRIORITY_TEMPLATES[priority];
|
| 460 |
const email = await this.getUserEmail(userId);
|
| 461 |
|
| 462 |
// Log notification
|
| 463 |
this.logger.log(`[NOTIFICATION] User: ${userId}, Priority: ${priority}`);
|
| 464 |
+
|
| 465 |
// Persist to database
|
| 466 |
await this.prisma.notification.create({
|
| 467 |
data: {
|
|
|
|
| 479 |
`${template.body}\n\n${template.callToAction}`,
|
| 480 |
"View Details",
|
| 481 |
`${this.configService.get("FRONTEND_URL")}/(tabs)/tracking`,
|
| 482 |
+
priority === CarePriority.EMERGENCY
|
| 483 |
+
? "#FF4B4B"
|
| 484 |
+
: priority === CarePriority.URGENT_REVIEW
|
| 485 |
+
? "#FF9B3E"
|
| 486 |
+
: "#2DE474",
|
| 487 |
);
|
| 488 |
|
| 489 |
await this.emailService.sendEmail(
|
| 490 |
email,
|
| 491 |
template.subject,
|
| 492 |
`${template.body}\n\n${template.callToAction}`,
|
| 493 |
+
html,
|
| 494 |
);
|
| 495 |
}
|
| 496 |
|
| 497 |
// Send Push
|
| 498 |
+
await this.sendPushNotification(userId, template.subject, template.body, {
|
| 499 |
+
type: "ESCALATION_ALERT",
|
| 500 |
+
priority,
|
| 501 |
+
});
|
|
|
|
|
|
|
| 502 |
|
| 503 |
this.logger.log(`✅ Notification handled for user ${userId}`);
|
| 504 |
}
|
|
|
|
| 509 |
async sendSevereBPAlert(
|
| 510 |
userId: string,
|
| 511 |
systolic: number,
|
| 512 |
+
diastolic: number,
|
| 513 |
): Promise<void> {
|
| 514 |
const template = BP_ALERT_TEMPLATES.SEVERE_HYPERTENSION;
|
| 515 |
const email = await this.getUserEmail(userId);
|
| 516 |
|
| 517 |
this.logger.warn(
|
| 518 |
+
`[ALERT] Severe BP for user ${userId}: ${systolic}/${diastolic}`,
|
| 519 |
);
|
| 520 |
|
| 521 |
const message = `${template.body}\nReading: ${systolic}/${diastolic}\n\n${template.callToAction}`;
|
|
|
|
| 536 |
message,
|
| 537 |
"Log Now",
|
| 538 |
`${this.configService.get("FRONTEND_URL")}/bp-entry`,
|
| 539 |
+
"#FF4B4B",
|
| 540 |
);
|
| 541 |
|
| 542 |
+
await this.emailService.sendEmail(email, template.subject, message, html);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
}
|
| 544 |
|
| 545 |
// Send Push
|
| 546 |
+
await this.sendPushNotification(userId, template.subject, template.body, {
|
| 547 |
+
type: "TREND_ALERT",
|
| 548 |
+
systolic,
|
| 549 |
+
diastolic,
|
| 550 |
+
});
|
|
|
|
| 551 |
|
| 552 |
this.logger.log(`✅ Severe BP alert handled for user ${userId}`);
|
| 553 |
}
|
|
|
|
| 558 |
async sendElevatedBPNotification(
|
| 559 |
userId: string,
|
| 560 |
systolic: number,
|
| 561 |
+
diastolic: number,
|
| 562 |
): Promise<void> {
|
| 563 |
const template = BP_ALERT_TEMPLATES.ELEVATED_BP;
|
| 564 |
const email = await this.getUserEmail(userId);
|
| 565 |
|
| 566 |
this.logger.log(
|
| 567 |
+
`[NOTIFICATION] Elevated BP for user ${userId}: ${systolic}/${diastolic}`,
|
| 568 |
);
|
| 569 |
|
| 570 |
const message = `${template.body}\nReading: ${systolic}/${diastolic}\n\n${template.callToAction}`;
|
|
|
|
| 585 |
message,
|
| 586 |
"Log Now",
|
| 587 |
`${this.configService.get("FRONTEND_URL")}/bp-entry`,
|
| 588 |
+
"#FF9B3E",
|
| 589 |
);
|
| 590 |
|
| 591 |
+
await this.emailService.sendEmail(email, template.subject, message, html);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
}
|
| 593 |
|
| 594 |
// Send Push
|
| 595 |
+
await this.sendPushNotification(userId, template.subject, template.body, {
|
| 596 |
+
type: "TREND_ALERT",
|
| 597 |
+
systolic,
|
| 598 |
+
diastolic,
|
| 599 |
+
});
|
|
|
|
| 600 |
|
| 601 |
this.logger.log(`✅ Elevated BP notification handled for user ${userId}`);
|
| 602 |
}
|
|
|
|
| 606 |
*/
|
| 607 |
async sendDangerousSymptomAlert(
|
| 608 |
userId: string,
|
| 609 |
+
symptoms: string[],
|
| 610 |
): Promise<void> {
|
| 611 |
const template = SYMPTOM_ALERT_TEMPLATES.DANGEROUS_COMBINATION;
|
| 612 |
const email = await this.getUserEmail(userId);
|
| 613 |
|
| 614 |
this.logger.warn(
|
| 615 |
+
`[ALERT] Dangerous symptoms for user ${userId}: ${symptoms.join(", ")}`,
|
| 616 |
);
|
| 617 |
|
| 618 |
const message = `${template.body}\nSymptoms reported: ${symptoms.join(", ")}\n\n${template.callToAction}`;
|
|
|
|
| 633 |
message,
|
| 634 |
"Open App",
|
| 635 |
`${this.configService.get("FRONTEND_URL")}/(tabs)/tracking`,
|
| 636 |
+
"#FF4B4B",
|
| 637 |
);
|
| 638 |
|
| 639 |
+
await this.emailService.sendEmail(email, template.subject, message, html);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
}
|
| 641 |
|
| 642 |
// Send Push
|
| 643 |
+
await this.sendPushNotification(userId, template.subject, template.body, {
|
| 644 |
+
type: "TREND_ALERT",
|
| 645 |
+
symptoms,
|
| 646 |
+
});
|
|
|
|
|
|
|
| 647 |
|
| 648 |
this.logger.log(`✅ Dangerous symptom alert handled for user ${userId}`);
|
| 649 |
}
|
|
|
|
| 653 |
*/
|
| 654 |
async sendWarningSymptomNotification(
|
| 655 |
userId: string,
|
| 656 |
+
symptom: string,
|
| 657 |
): Promise<void> {
|
| 658 |
const template = SYMPTOM_ALERT_TEMPLATES.SINGLE_WARNING_SYMPTOM;
|
| 659 |
const email = await this.getUserEmail(userId);
|
| 660 |
|
| 661 |
this.logger.log(
|
| 662 |
+
`[NOTIFICATION] Warning symptom for user ${userId}: ${symptom}`,
|
| 663 |
);
|
| 664 |
|
| 665 |
const message = `${template.body}\nSymptom reported: ${symptom}\n\n${template.callToAction}`;
|
|
|
|
| 680 |
message,
|
| 681 |
"Open App",
|
| 682 |
`${this.configService.get("FRONTEND_URL")}/(tabs)/tracking`,
|
| 683 |
+
"#FF9B3E",
|
| 684 |
);
|
| 685 |
|
| 686 |
+
await this.emailService.sendEmail(email, template.subject, message, html);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
}
|
| 688 |
|
| 689 |
// Send Push
|
| 690 |
+
await this.sendPushNotification(userId, template.subject, template.body, {
|
| 691 |
+
type: "TREND_ALERT",
|
| 692 |
+
symptom,
|
| 693 |
+
});
|
|
|
|
|
|
|
| 694 |
|
| 695 |
+
this.logger.log(
|
| 696 |
+
`✅ Warning symptom notification handled for user ${userId}`,
|
| 697 |
+
);
|
| 698 |
}
|
| 699 |
|
| 700 |
/**
|
|
|
|
| 702 |
*/
|
| 703 |
async sendResetPasswordNotification(
|
| 704 |
userId: string,
|
| 705 |
+
token: string,
|
| 706 |
): Promise<void> {
|
| 707 |
const template = AUTH_TEMPLATES.RESET_PASSWORD;
|
| 708 |
const email = await this.getUserEmail(userId);
|
| 709 |
|
| 710 |
+
this.logger.log(
|
| 711 |
+
`[AUTH] Reset password request for user ${userId}. Email: ${email || "N/A"}`,
|
| 712 |
+
);
|
| 713 |
|
| 714 |
const resetLink = `${this.configService.get("FRONTEND_URL")}/reset-password?token=${token}`;
|
| 715 |
const message = `${template.body}\n\nReset Link: ${resetLink}`;
|
|
|
|
| 731 |
template.body,
|
| 732 |
"Reset Password",
|
| 733 |
resetLink,
|
| 734 |
+
"#1E6BFF", // Use setup blue for auth actions
|
| 735 |
);
|
| 736 |
|
| 737 |
+
await this.emailService.sendEmail(email, template.subject, message, html);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
}
|
| 739 |
|
| 740 |
// Send Push
|
|
|
|
| 742 |
userId,
|
| 743 |
template.subject,
|
| 744 |
template.body,
|
| 745 |
+
{ type: "SESSION_EXPIRY" }, // Using session expiry as a proxy for generic auth actions
|
| 746 |
);
|
| 747 |
|
| 748 |
+
this.logger.log(
|
| 749 |
+
`✅ Reset password notification handled for user ${userId}`,
|
| 750 |
+
);
|
| 751 |
}
|
| 752 |
|
| 753 |
/**
|