Spaces:
Running
feat(notifications): add educational notifications
Browse files- Add Prisma schema updates: new EDUCATIONAL notification type, NotificationPriority and NotificationCategory enums, extend Notification model with priority and category fields
- Implement full educational notification system: template library, scheduled daily delivery job, personalized targeting based on pregnancy trimester and risk level, anti-spam fatigue rules
- Update inactivity monitoring logic to use 3-day threshold instead of 5, make reminder messages dynamic to show actual inactive day count
- Refactor existing notification templates to support dynamic content functions
- Add centralized NotificationOrchestratorService to handle all notification delivery, filtering, and dispatch
- Register new services in NotificationsModule and export orchestrator service
- prisma/schema.prisma +34 -6
- src/monitoring-engine/monitoring-scheduler.service.ts +17 -11
- src/notifications/educational-scheduler.service.ts +49 -0
- src/notifications/notification-orchestrator.service.ts +323 -0
- src/notifications/notifications.module.ts +12 -5
- src/notifications/notifications.service.ts +50 -12
- src/notifications/templates/educational.templates.ts +112 -0
- src/notifications/templates/notification.templates.ts +1 -1
|
@@ -265,25 +265,53 @@ enum NotificationType {
|
|
| 265 |
BP_ALERT
|
| 266 |
SYMPTOM_ALERT
|
| 267 |
REMINDER
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
}
|
| 269 |
|
| 270 |
/**
|
| 271 |
* Notification Entity
|
| 272 |
-
*
|
| 273 |
* PURPOSE:
|
| 274 |
* - Store history of alerts and notifications
|
| 275 |
* - Track read status
|
| 276 |
*/
|
| 277 |
model Notification {
|
| 278 |
-
id String
|
| 279 |
userId String
|
| 280 |
type NotificationType
|
|
|
|
|
|
|
| 281 |
title String
|
| 282 |
message String
|
| 283 |
-
read Boolean
|
| 284 |
-
createdAt DateTime
|
| 285 |
-
|
| 286 |
-
userAuth UserAuth
|
| 287 |
|
| 288 |
@@index([userId])
|
| 289 |
@@index([createdAt])
|
|
|
|
| 265 |
BP_ALERT
|
| 266 |
SYMPTOM_ALERT
|
| 267 |
REMINDER
|
| 268 |
+
EDUCATIONAL
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
/**
|
| 272 |
+
* Notification Priority Enum
|
| 273 |
+
*/
|
| 274 |
+
enum NotificationPriority {
|
| 275 |
+
INFORMATIONAL
|
| 276 |
+
EDUCATIONAL
|
| 277 |
+
REMINDER
|
| 278 |
+
ELEVATED_RISK
|
| 279 |
+
CRITICAL
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/**
|
| 283 |
+
* Notification Category Enum
|
| 284 |
+
*/
|
| 285 |
+
enum NotificationCategory {
|
| 286 |
+
PREECLAMPSIA_AWARENESS
|
| 287 |
+
BP_MONITORING
|
| 288 |
+
PREGNANCY_WARNING_SIGNS
|
| 289 |
+
HYDRATION
|
| 290 |
+
REST
|
| 291 |
+
ANTENATAL_CARE
|
| 292 |
+
SYMPTOM_AWARENESS
|
| 293 |
+
TRIMESTER_GUIDANCE
|
| 294 |
}
|
| 295 |
|
| 296 |
/**
|
| 297 |
* Notification Entity
|
| 298 |
+
*
|
| 299 |
* PURPOSE:
|
| 300 |
* - Store history of alerts and notifications
|
| 301 |
* - Track read status
|
| 302 |
*/
|
| 303 |
model Notification {
|
| 304 |
+
id String @id @default(uuid())
|
| 305 |
userId String
|
| 306 |
type NotificationType
|
| 307 |
+
priority NotificationPriority @default(REMINDER)
|
| 308 |
+
category NotificationCategory?
|
| 309 |
title String
|
| 310 |
message String
|
| 311 |
+
read Boolean @default(false)
|
| 312 |
+
createdAt DateTime @default(now())
|
| 313 |
+
|
| 314 |
+
userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 315 |
|
| 316 |
@@index([userId])
|
| 317 |
@@index([createdAt])
|
|
@@ -66,7 +66,7 @@ export class MonitoringSchedulerService {
|
|
| 66 |
async handleInactivity() {
|
| 67 |
this.logger.log("Checking for user inactivity...");
|
| 68 |
const now = new Date();
|
| 69 |
-
const
|
| 70 |
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
| 71 |
|
| 72 |
const users = await this.prisma.userAuth.findMany({
|
|
@@ -82,20 +82,22 @@ export class MonitoringSchedulerService {
|
|
| 82 |
for (const user of users) {
|
| 83 |
const lastBpReading = user.bloodPressureReadings[0];
|
| 84 |
if (!lastBpReading) {
|
| 85 |
-
// No BP ever logged, send initial reminder after
|
| 86 |
try {
|
| 87 |
const userProfile = await this.userProfileService.findByUserId(
|
| 88 |
user.id,
|
| 89 |
);
|
| 90 |
-
if (userProfile && userProfile.createdAt <
|
|
|
|
|
|
|
| 91 |
await this.notificationsService.sendPushNotification(
|
| 92 |
user.id,
|
| 93 |
"BP Log Reminder",
|
| 94 |
-
|
| 95 |
{ type: AppNotificationType.INACTIVITY_REMINDER },
|
| 96 |
);
|
| 97 |
this.logger.log(
|
| 98 |
-
`Inactivity reminder sent to user ${user.id} (no BP ever logged).`,
|
| 99 |
);
|
| 100 |
}
|
| 101 |
} catch (error) {
|
|
@@ -116,27 +118,31 @@ export class MonitoringSchedulerService {
|
|
| 116 |
continue;
|
| 117 |
}
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
if (lastBpReading.recordedAt < sevenDaysAgo) {
|
| 120 |
// No BP logged in >7 days -> escalate messaging urgency
|
| 121 |
await this.notificationsService.sendPushNotification(
|
| 122 |
user.id,
|
| 123 |
"Urgent BP Reminder",
|
| 124 |
-
|
| 125 |
{ type: AppNotificationType.URGENT_INACTIVITY_REMINDER },
|
| 126 |
);
|
| 127 |
this.logger.warn(
|
| 128 |
-
`Urgent inactivity reminder sent to user ${user.id} (
|
| 129 |
);
|
| 130 |
-
} else if (lastBpReading.recordedAt <
|
| 131 |
-
// No BP logged in >
|
| 132 |
await this.notificationsService.sendPushNotification(
|
| 133 |
user.id,
|
| 134 |
"BP Log Reminder",
|
| 135 |
-
|
| 136 |
{ type: AppNotificationType.INACTIVITY_REMINDER },
|
| 137 |
);
|
| 138 |
this.logger.log(
|
| 139 |
-
`Inactivity reminder sent to user ${user.id} (
|
| 140 |
);
|
| 141 |
}
|
| 142 |
}
|
|
|
|
| 66 |
async handleInactivity() {
|
| 67 |
this.logger.log("Checking for user inactivity...");
|
| 68 |
const now = new Date();
|
| 69 |
+
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
| 70 |
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
| 71 |
|
| 72 |
const users = await this.prisma.userAuth.findMany({
|
|
|
|
| 82 |
for (const user of users) {
|
| 83 |
const lastBpReading = user.bloodPressureReadings[0];
|
| 84 |
if (!lastBpReading) {
|
| 85 |
+
// No BP ever logged, send initial reminder after 3 days of account creation
|
| 86 |
try {
|
| 87 |
const userProfile = await this.userProfileService.findByUserId(
|
| 88 |
user.id,
|
| 89 |
);
|
| 90 |
+
if (userProfile && userProfile.createdAt < threeDaysAgo) {
|
| 91 |
+
const diffInMs = now.getTime() - userProfile.createdAt.getTime();
|
| 92 |
+
const daysInactive = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
| 93 |
await this.notificationsService.sendPushNotification(
|
| 94 |
user.id,
|
| 95 |
"BP Log Reminder",
|
| 96 |
+
`You haven't logged your blood pressure yet in ${daysInactive} days. Please log your first reading.`,
|
| 97 |
{ type: AppNotificationType.INACTIVITY_REMINDER },
|
| 98 |
);
|
| 99 |
this.logger.log(
|
| 100 |
+
`Inactivity reminder sent to user ${user.id} (no BP ever logged, ${daysInactive} days).`,
|
| 101 |
);
|
| 102 |
}
|
| 103 |
} catch (error) {
|
|
|
|
| 118 |
continue;
|
| 119 |
}
|
| 120 |
|
| 121 |
+
// Calculate days inactive
|
| 122 |
+
const diffInMs = now.getTime() - lastBpReading.recordedAt.getTime();
|
| 123 |
+
const daysInactive = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
| 124 |
+
|
| 125 |
if (lastBpReading.recordedAt < sevenDaysAgo) {
|
| 126 |
// No BP logged in >7 days -> escalate messaging urgency
|
| 127 |
await this.notificationsService.sendPushNotification(
|
| 128 |
user.id,
|
| 129 |
"Urgent BP Reminder",
|
| 130 |
+
`It's been over ${daysInactive} days since your last blood pressure reading. Please log it now.`,
|
| 131 |
{ type: AppNotificationType.URGENT_INACTIVITY_REMINDER },
|
| 132 |
);
|
| 133 |
this.logger.warn(
|
| 134 |
+
`Urgent inactivity reminder sent to user ${user.id} (${daysInactive} days).`,
|
| 135 |
);
|
| 136 |
+
} else if (lastBpReading.recordedAt < threeDaysAgo) {
|
| 137 |
+
// No BP logged in >3 days -> send reminder
|
| 138 |
await this.notificationsService.sendPushNotification(
|
| 139 |
user.id,
|
| 140 |
"BP Log Reminder",
|
| 141 |
+
`It's been over ${daysInactive} days since your last blood pressure reading. Please log it soon.`,
|
| 142 |
{ type: AppNotificationType.INACTIVITY_REMINDER },
|
| 143 |
);
|
| 144 |
this.logger.log(
|
| 145 |
+
`Inactivity reminder sent to user ${user.id} (${daysInactive} days).`,
|
| 146 |
);
|
| 147 |
}
|
| 148 |
}
|
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from '@nestjs/common';
|
| 2 |
+
import { Cron, CronExpression } from '@nestjs/schedule';
|
| 3 |
+
import { PrismaService } from '../database/prisma.service';
|
| 4 |
+
import { NotificationOrchestratorService } from './notification-orchestrator.service';
|
| 5 |
+
|
| 6 |
+
@Injectable()
|
| 7 |
+
export class EducationalSchedulerService {
|
| 8 |
+
private readonly logger = new Logger(EducationalSchedulerService.name);
|
| 9 |
+
|
| 10 |
+
constructor(
|
| 11 |
+
private readonly prisma: PrismaService,
|
| 12 |
+
private readonly notificationOrchestrator: NotificationOrchestratorService,
|
| 13 |
+
) {}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Send educational notifications to active users
|
| 17 |
+
* Runs every day at 10 AM
|
| 18 |
+
*/
|
| 19 |
+
@Cron('0 10 * * *')
|
| 20 |
+
async sendEducationalNotifications() {
|
| 21 |
+
this.logger.log('Starting educational notification job...');
|
| 22 |
+
|
| 23 |
+
try {
|
| 24 |
+
const activeUsers = await this.prisma.userAuth.findMany({
|
| 25 |
+
where: { isActive: true },
|
| 26 |
+
select: { id: true },
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
this.logger.log(
|
| 30 |
+
`Found ${activeUsers.length} active users to send educational notifications to`,
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
for (const user of activeUsers) {
|
| 34 |
+
try {
|
| 35 |
+
await this.notificationOrchestrator.sendEducationalNotification(user.id);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
this.logger.error(
|
| 38 |
+
`Failed to send educational notification to user ${user.id}`,
|
| 39 |
+
error,
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
this.logger.log('Educational notification job completed successfully');
|
| 45 |
+
} catch (error) {
|
| 46 |
+
this.logger.error('Educational notification job failed', error);
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from '@nestjs/common';
|
| 2 |
+
import { PrismaService } from '../database/prisma.service';
|
| 3 |
+
import {
|
| 4 |
+
NotificationType,
|
| 5 |
+
NotificationPriority,
|
| 6 |
+
NotificationCategory,
|
| 7 |
+
UserAuth,
|
| 8 |
+
UserProfile,
|
| 9 |
+
} from '@prisma/client';
|
| 10 |
+
import { EDUCATIONAL_TEMPLATES } from './templates/educational.templates';
|
| 11 |
+
import { NotificationsService } from './notifications.service';
|
| 12 |
+
|
| 13 |
+
export interface NotificationIntent {
|
| 14 |
+
userId: string;
|
| 15 |
+
type: NotificationType;
|
| 16 |
+
priority: NotificationPriority;
|
| 17 |
+
category?: NotificationCategory;
|
| 18 |
+
title: string;
|
| 19 |
+
body: string;
|
| 20 |
+
data?: any;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
@Injectable()
|
| 24 |
+
export class NotificationOrchestratorService {
|
| 25 |
+
private readonly logger = new Logger(NotificationOrchestratorService.name);
|
| 26 |
+
|
| 27 |
+
constructor(
|
| 28 |
+
private readonly prisma: PrismaService,
|
| 29 |
+
private readonly notificationsService: NotificationsService,
|
| 30 |
+
) {}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Main entry point for all notifications
|
| 34 |
+
*/
|
| 35 |
+
async sendNotification(intent: NotificationIntent): Promise<void> {
|
| 36 |
+
this.logger.log(
|
| 37 |
+
`Processing notification intent for user ${intent.userId}: ${intent.title}`,
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
// 1. Check if user has notifications enabled (optional, based on user profile)
|
| 41 |
+
const user = await this.prisma.userAuth.findUnique({
|
| 42 |
+
where: { id: intent.userId },
|
| 43 |
+
include: { profile: true },
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
if (!user) {
|
| 47 |
+
this.logger.warn(`User ${intent.userId} not found, skipping notification`);
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// 2. Apply anti-spam/fatigue rules
|
| 52 |
+
const shouldSend = await this.shouldSendNotification(user, intent);
|
| 53 |
+
if (!shouldSend) {
|
| 54 |
+
this.logger.log(`Notification skipped for user ${intent.userId} due to fatigue rules`);
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// 3. Prioritize (critical notifications always go through)
|
| 59 |
+
if (intent.priority === NotificationPriority.CRITICAL) {
|
| 60 |
+
// Send immediately
|
| 61 |
+
await this.dispatchNotification(intent, user);
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// 4. Check for recent higher-priority notifications
|
| 66 |
+
const hasRecentHighPriority = await this.hasRecentHighPriorityNotification(
|
| 67 |
+
intent.userId,
|
| 68 |
+
intent.priority,
|
| 69 |
+
);
|
| 70 |
+
if (hasRecentHighPriority) {
|
| 71 |
+
this.logger.log(
|
| 72 |
+
`Notification skipped for user ${intent.userId} due to recent high-priority notifications`,
|
| 73 |
+
);
|
| 74 |
+
return;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// 5. Dispatch the notification
|
| 78 |
+
await this.dispatchNotification(intent, user);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Send an educational notification to a user
|
| 83 |
+
*/
|
| 84 |
+
async sendEducationalNotification(userId: string): Promise<void> {
|
| 85 |
+
const user = await this.prisma.userAuth.findUnique({
|
| 86 |
+
where: { id: userId },
|
| 87 |
+
include: { profile: true },
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
if (!user?.profile) {
|
| 91 |
+
this.logger.warn(
|
| 92 |
+
`User ${userId} or profile not found, skipping educational notification`,
|
| 93 |
+
);
|
| 94 |
+
return;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Get personalized templates
|
| 98 |
+
const eligibleTemplates = this.getPersonalizedTemplates(user.profile);
|
| 99 |
+
|
| 100 |
+
if (eligibleTemplates.length === 0) {
|
| 101 |
+
this.logger.log(`No eligible educational templates for user ${userId}`);
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Pick a random template that hasn't been sent recently
|
| 106 |
+
const template = await this.pickUniqueTemplate(userId, eligibleTemplates);
|
| 107 |
+
|
| 108 |
+
if (!template) {
|
| 109 |
+
this.logger.log(`No unique educational templates available for user ${userId}`);
|
| 110 |
+
return;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const intent: NotificationIntent = {
|
| 114 |
+
userId,
|
| 115 |
+
type: NotificationType.EDUCATIONAL,
|
| 116 |
+
priority: NotificationPriority.EDUCATIONAL,
|
| 117 |
+
category: template.category,
|
| 118 |
+
title: template.title,
|
| 119 |
+
body: template.body,
|
| 120 |
+
data: { type: 'EDUCATIONAL', category: template.category },
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
await this.sendNotification(intent);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Get templates personalized to user's profile
|
| 128 |
+
*/
|
| 129 |
+
private getPersonalizedTemplates(profile: UserProfile) {
|
| 130 |
+
const trimester = this.getTrimester(profile.pregnancyWeeks);
|
| 131 |
+
const hasHighRisk = this.hasHighRiskConditions(profile);
|
| 132 |
+
|
| 133 |
+
return EDUCATIONAL_TEMPLATES.filter((template) => {
|
| 134 |
+
// Filter by trimester
|
| 135 |
+
if (template.trimesters && !template.trimesters.includes(trimester)) {
|
| 136 |
+
return false;
|
| 137 |
+
}
|
| 138 |
+
// Filter by risk level (simplified)
|
| 139 |
+
if (template.riskLevels) {
|
| 140 |
+
const userRiskLevel = hasHighRisk ? 'high' : 'low';
|
| 141 |
+
if (!template.riskLevels.includes(userRiskLevel)) {
|
| 142 |
+
return false;
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
return true;
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* Determine user's trimester based on pregnancy weeks
|
| 151 |
+
*/
|
| 152 |
+
private getTrimester(weeks: number): number {
|
| 153 |
+
if (weeks <= 13) return 1;
|
| 154 |
+
if (weeks <= 27) return 2;
|
| 155 |
+
return 3;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Check if user has high-risk conditions
|
| 160 |
+
*/
|
| 161 |
+
private hasHighRiskConditions(profile: UserProfile): boolean {
|
| 162 |
+
return profile.knownConditions.some(
|
| 163 |
+
(condition) =>
|
| 164 |
+
condition === 'CHRONIC_HYPERTENSION' ||
|
| 165 |
+
condition === 'PREECLAMPSIA_HISTORY' ||
|
| 166 |
+
condition === 'KIDNEY_DISEASE',
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* Pick a unique template that hasn't been sent recently
|
| 172 |
+
*/
|
| 173 |
+
private async pickUniqueTemplate(
|
| 174 |
+
userId: string,
|
| 175 |
+
templates: typeof EDUCATIONAL_TEMPLATES,
|
| 176 |
+
): Promise<(typeof EDUCATIONAL_TEMPLATES)[0] | null> {
|
| 177 |
+
// Get recent educational notifications (last 7 days)
|
| 178 |
+
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
| 179 |
+
const recentNotifications = await this.prisma.notification.findMany({
|
| 180 |
+
where: {
|
| 181 |
+
userId,
|
| 182 |
+
type: NotificationType.EDUCATIONAL,
|
| 183 |
+
createdAt: { gte: sevenDaysAgo },
|
| 184 |
+
},
|
| 185 |
+
select: { category: true, message: true },
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
// Filter out templates that have been sent recently
|
| 189 |
+
const availableTemplates = templates.filter((template) => {
|
| 190 |
+
return !recentNotifications.some(
|
| 191 |
+
(notif) => notif.message === template.body,
|
| 192 |
+
);
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
if (availableTemplates.length === 0) {
|
| 196 |
+
// If all templates have been used recently, pick any
|
| 197 |
+
return templates[Math.floor(Math.random() * templates.length)];
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
return availableTemplates[Math.floor(Math.random() * availableTemplates.length)];
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/**
|
| 204 |
+
* Apply anti-spam/fatigue rules
|
| 205 |
+
*/
|
| 206 |
+
private async shouldSendNotification(
|
| 207 |
+
user: UserAuth & { profile?: UserProfile | null },
|
| 208 |
+
intent: NotificationIntent,
|
| 209 |
+
): Promise<boolean> {
|
| 210 |
+
// Critical notifications always go through
|
| 211 |
+
if (intent.priority === NotificationPriority.CRITICAL) {
|
| 212 |
+
return true;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Check nighttime suppression (22:00 - 06:00)
|
| 216 |
+
const now = new Date();
|
| 217 |
+
const hour = now.getHours();
|
| 218 |
+
if (hour >= 22 || hour < 6) {
|
| 219 |
+
if (intent.priority !== NotificationPriority.ELEVATED_RISK) {
|
| 220 |
+
return false;
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// Check recent notifications of the same category
|
| 225 |
+
if (intent.category) {
|
| 226 |
+
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
| 227 |
+
const recentSameCategory = await this.prisma.notification.count({
|
| 228 |
+
where: {
|
| 229 |
+
userId: intent.userId,
|
| 230 |
+
category: intent.category,
|
| 231 |
+
createdAt: { gte: oneHourAgo },
|
| 232 |
+
},
|
| 233 |
+
});
|
| 234 |
+
if (recentSameCategory > 0) {
|
| 235 |
+
return false;
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// Limit educational notifications to max 2 per day
|
| 240 |
+
if (intent.type === NotificationType.EDUCATIONAL) {
|
| 241 |
+
const today = new Date();
|
| 242 |
+
today.setHours(0, 0, 0, 0);
|
| 243 |
+
const todayEducationalCount = await this.prisma.notification.count({
|
| 244 |
+
where: {
|
| 245 |
+
userId: intent.userId,
|
| 246 |
+
type: NotificationType.EDUCATIONAL,
|
| 247 |
+
createdAt: { gte: today },
|
| 248 |
+
},
|
| 249 |
+
});
|
| 250 |
+
if (todayEducationalCount >= 2) {
|
| 251 |
+
return false;
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
return true;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
/**
|
| 259 |
+
* Check for recent higher-priority notifications
|
| 260 |
+
*/
|
| 261 |
+
private async hasRecentHighPriorityNotification(
|
| 262 |
+
userId: string,
|
| 263 |
+
currentPriority: NotificationPriority,
|
| 264 |
+
): Promise<boolean> {
|
| 265 |
+
const priorityOrder = [
|
| 266 |
+
NotificationPriority.INFORMATIONAL,
|
| 267 |
+
NotificationPriority.EDUCATIONAL,
|
| 268 |
+
NotificationPriority.REMINDER,
|
| 269 |
+
NotificationPriority.ELEVATED_RISK,
|
| 270 |
+
NotificationPriority.CRITICAL,
|
| 271 |
+
];
|
| 272 |
+
|
| 273 |
+
const currentIndex = priorityOrder.indexOf(currentPriority);
|
| 274 |
+
const higherPriorities = priorityOrder.slice(currentIndex + 1);
|
| 275 |
+
|
| 276 |
+
if (higherPriorities.length === 0) return false;
|
| 277 |
+
|
| 278 |
+
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
| 279 |
+
const count = await this.prisma.notification.count({
|
| 280 |
+
where: {
|
| 281 |
+
userId,
|
| 282 |
+
priority: { in: higherPriorities },
|
| 283 |
+
createdAt: { gte: twoHoursAgo },
|
| 284 |
+
},
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
return count > 0;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/**
|
| 291 |
+
* Dispatch the notification (save to DB and send push)
|
| 292 |
+
*/
|
| 293 |
+
private async dispatchNotification(
|
| 294 |
+
intent: NotificationIntent,
|
| 295 |
+
user: UserAuth,
|
| 296 |
+
): Promise<void> {
|
| 297 |
+
// Save to database
|
| 298 |
+
await this.prisma.notification.create({
|
| 299 |
+
data: {
|
| 300 |
+
userId: intent.userId,
|
| 301 |
+
type: intent.type,
|
| 302 |
+
priority: intent.priority,
|
| 303 |
+
category: intent.category,
|
| 304 |
+
title: intent.title,
|
| 305 |
+
message: intent.body,
|
| 306 |
+
},
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
// Send push notification
|
| 310 |
+
if (user.pushToken) {
|
| 311 |
+
await this.notificationsService.sendPushNotification(
|
| 312 |
+
intent.userId,
|
| 313 |
+
intent.title,
|
| 314 |
+
intent.body,
|
| 315 |
+
intent.data,
|
| 316 |
+
);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
this.logger.log(
|
| 320 |
+
`Notification dispatched to user ${intent.userId}: ${intent.title}`,
|
| 321 |
+
);
|
| 322 |
+
}
|
| 323 |
+
}
|
|
@@ -1,5 +1,7 @@
|
|
| 1 |
import { Module } from "@nestjs/common";
|
| 2 |
import { NotificationsService } from "./notifications.service";
|
|
|
|
|
|
|
| 3 |
import { NotificationsController } from "./notifications.controller";
|
| 4 |
import { EmailModule } from "../email/email.module";
|
| 5 |
|
|
@@ -10,6 +12,8 @@ import { EmailModule } from "../email/email.module";
|
|
| 10 |
* - Send care escalation alerts
|
| 11 |
* - Template-based messaging only
|
| 12 |
* - No dynamic medical text generation
|
|
|
|
|
|
|
| 13 |
*
|
| 14 |
* CLINICAL SAFETY CONSTRAINTS:
|
| 15 |
* - No fear-based language
|
|
@@ -18,15 +22,18 @@ import { EmailModule } from "../email/email.module";
|
|
| 18 |
* - Clear, actionable guidance
|
| 19 |
*
|
| 20 |
* DELIVERY:
|
| 21 |
-
* -
|
| 22 |
-
* -
|
| 23 |
-
* - Examples: SendGrid, Twilio, AWS SNS
|
| 24 |
*/
|
| 25 |
|
| 26 |
@Module({
|
| 27 |
imports: [EmailModule],
|
| 28 |
controllers: [NotificationsController],
|
| 29 |
-
providers: [
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
})
|
| 32 |
export class NotificationsModule {}
|
|
|
|
| 1 |
import { Module } from "@nestjs/common";
|
| 2 |
import { NotificationsService } from "./notifications.service";
|
| 3 |
+
import { NotificationOrchestratorService } from "./notification-orchestrator.service";
|
| 4 |
+
import { EducationalSchedulerService } from "./educational-scheduler.service";
|
| 5 |
import { NotificationsController } from "./notifications.controller";
|
| 6 |
import { EmailModule } from "../email/email.module";
|
| 7 |
|
|
|
|
| 12 |
* - Send care escalation alerts
|
| 13 |
* - Template-based messaging only
|
| 14 |
* - No dynamic medical text generation
|
| 15 |
+
* - Centralized notification orchestration
|
| 16 |
+
* - Educational engagement notifications
|
| 17 |
*
|
| 18 |
* CLINICAL SAFETY CONSTRAINTS:
|
| 19 |
* - No fear-based language
|
|
|
|
| 22 |
* - Clear, actionable guidance
|
| 23 |
*
|
| 24 |
* DELIVERY:
|
| 25 |
+
* - Push notifications via Expo
|
| 26 |
+
* - Email
|
|
|
|
| 27 |
*/
|
| 28 |
|
| 29 |
@Module({
|
| 30 |
imports: [EmailModule],
|
| 31 |
controllers: [NotificationsController],
|
| 32 |
+
providers: [
|
| 33 |
+
NotificationsService,
|
| 34 |
+
NotificationOrchestratorService,
|
| 35 |
+
EducationalSchedulerService,
|
| 36 |
+
],
|
| 37 |
+
exports: [NotificationsService, NotificationOrchestratorService],
|
| 38 |
})
|
| 39 |
export class NotificationsModule {}
|
|
@@ -4,7 +4,7 @@ import { Cron, CronExpression } from "@nestjs/schedule";
|
|
| 4 |
import { PrismaService } from "../database/prisma.service";
|
| 5 |
import { EmailService } from "../email/email.service";
|
| 6 |
import { CarePriority } from "../care-priority/types/care-priority.types";
|
| 7 |
-
import { NotificationType } from "@prisma/client";
|
| 8 |
import type { ExpoPushMessage } from "expo-server-sdk";
|
| 9 |
import {
|
| 10 |
CARE_PRIORITY_TEMPLATES,
|
|
@@ -15,6 +15,7 @@ import {
|
|
| 15 |
TREND_ALERT_TEMPLATES,
|
| 16 |
} from "./templates/notification.templates";
|
| 17 |
import { generateEmailHtml } from "./templates/email.template";
|
|
|
|
| 18 |
|
| 19 |
/**
|
| 20 |
* Notification Service
|
|
@@ -37,6 +38,7 @@ export class NotificationsService {
|
|
| 37 |
private readonly prisma: PrismaService,
|
| 38 |
private readonly emailService: EmailService,
|
| 39 |
private readonly configService: ConfigService,
|
|
|
|
| 40 |
) {}
|
| 41 |
|
| 42 |
/**
|
|
@@ -201,38 +203,74 @@ export class NotificationsService {
|
|
| 201 |
}
|
| 202 |
|
| 203 |
/**
|
| 204 |
-
* Inactivity Monitor - Checks for users who haven't logged BP in
|
| 205 |
* Runs daily at midnight
|
| 206 |
*/
|
| 207 |
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
| 208 |
async checkInactivity() {
|
| 209 |
this.logger.log("Running inactivity check...");
|
| 210 |
-
const
|
| 211 |
-
|
| 212 |
|
| 213 |
-
// Find users whose last reading was >
|
| 214 |
const users = await this.prisma.userAuth.findMany({
|
| 215 |
where: {
|
| 216 |
isActive: true,
|
| 217 |
bloodPressureReadings: {
|
| 218 |
none: {
|
| 219 |
-
recordedAt: { gte:
|
| 220 |
},
|
| 221 |
},
|
| 222 |
},
|
| 223 |
-
select: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
});
|
| 225 |
|
| 226 |
for (const user of users) {
|
| 227 |
try {
|
| 228 |
-
if (await this.hasLoggedBPSince(user.id,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
const template = MONITORING_TEMPLATES.INACTIVITY_REMINDER;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
await this.prisma.notification.create({
|
| 231 |
data: {
|
| 232 |
userId: user.id,
|
| 233 |
type: NotificationType.REMINDER,
|
| 234 |
title: template.subject,
|
| 235 |
-
message:
|
| 236 |
},
|
| 237 |
});
|
| 238 |
|
|
@@ -240,7 +278,7 @@ export class NotificationsService {
|
|
| 240 |
await this.sendPushNotification(
|
| 241 |
user.id,
|
| 242 |
template.subject,
|
| 243 |
-
|
| 244 |
{ type: "INACTIVITY_REMINDER" },
|
| 245 |
);
|
| 246 |
|
|
@@ -248,7 +286,7 @@ export class NotificationsService {
|
|
| 248 |
if (user.email) {
|
| 249 |
const html = generateEmailHtml(
|
| 250 |
template.subject,
|
| 251 |
-
|
| 252 |
"Log BP Reading",
|
| 253 |
`${this.configService.get("FRONTEND_URL")}/bridge?type=inactivity-reminder`,
|
| 254 |
"#2DE474",
|
|
@@ -257,7 +295,7 @@ export class NotificationsService {
|
|
| 257 |
await this.emailService.sendEmail(
|
| 258 |
user.email,
|
| 259 |
template.subject,
|
| 260 |
-
|
| 261 |
html,
|
| 262 |
);
|
| 263 |
}
|
|
|
|
| 4 |
import { PrismaService } from "../database/prisma.service";
|
| 5 |
import { EmailService } from "../email/email.service";
|
| 6 |
import { CarePriority } from "../care-priority/types/care-priority.types";
|
| 7 |
+
import { NotificationType, NotificationPriority } from "@prisma/client";
|
| 8 |
import type { ExpoPushMessage } from "expo-server-sdk";
|
| 9 |
import {
|
| 10 |
CARE_PRIORITY_TEMPLATES,
|
|
|
|
| 15 |
TREND_ALERT_TEMPLATES,
|
| 16 |
} from "./templates/notification.templates";
|
| 17 |
import { generateEmailHtml } from "./templates/email.template";
|
| 18 |
+
import { NotificationOrchestratorService, NotificationIntent } from "./notification-orchestrator.service";
|
| 19 |
|
| 20 |
/**
|
| 21 |
* Notification Service
|
|
|
|
| 38 |
private readonly prisma: PrismaService,
|
| 39 |
private readonly emailService: EmailService,
|
| 40 |
private readonly configService: ConfigService,
|
| 41 |
+
private readonly notificationOrchestrator: NotificationOrchestratorService,
|
| 42 |
) {}
|
| 43 |
|
| 44 |
/**
|
|
|
|
| 203 |
}
|
| 204 |
|
| 205 |
/**
|
| 206 |
+
* Inactivity Monitor - Checks for users who haven't logged BP in 3 days or more
|
| 207 |
* Runs daily at midnight
|
| 208 |
*/
|
| 209 |
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
| 210 |
async checkInactivity() {
|
| 211 |
this.logger.log("Running inactivity check...");
|
| 212 |
+
const threeDaysAgo = new Date();
|
| 213 |
+
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
|
| 214 |
|
| 215 |
+
// Find users whose last reading was > 3 days ago or who never logged
|
| 216 |
const users = await this.prisma.userAuth.findMany({
|
| 217 |
where: {
|
| 218 |
isActive: true,
|
| 219 |
bloodPressureReadings: {
|
| 220 |
none: {
|
| 221 |
+
recordedAt: { gte: threeDaysAgo },
|
| 222 |
},
|
| 223 |
},
|
| 224 |
},
|
| 225 |
+
select: {
|
| 226 |
+
id: true,
|
| 227 |
+
email: true,
|
| 228 |
+
bloodPressureReadings: {
|
| 229 |
+
orderBy: { recordedAt: "desc" },
|
| 230 |
+
take: 1,
|
| 231 |
+
select: { recordedAt: true }
|
| 232 |
+
},
|
| 233 |
+
profile: {
|
| 234 |
+
select: { createdAt: true }
|
| 235 |
+
}
|
| 236 |
+
},
|
| 237 |
});
|
| 238 |
|
| 239 |
for (const user of users) {
|
| 240 |
try {
|
| 241 |
+
if (await this.hasLoggedBPSince(user.id, threeDaysAgo)) continue;
|
| 242 |
+
|
| 243 |
+
// Calculate days inactive
|
| 244 |
+
const lastReading = user.bloodPressureReadings[0];
|
| 245 |
+
let daysInactive: number;
|
| 246 |
+
|
| 247 |
+
if (lastReading) {
|
| 248 |
+
const now = new Date();
|
| 249 |
+
const diffInMs = now.getTime() - lastReading.recordedAt.getTime();
|
| 250 |
+
daysInactive = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
| 251 |
+
} else if (user.profile?.createdAt) {
|
| 252 |
+
// Never logged, use days since profile creation
|
| 253 |
+
const now = new Date();
|
| 254 |
+
const diffInMs = now.getTime() - user.profile.createdAt.getTime();
|
| 255 |
+
daysInactive = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
| 256 |
+
} else {
|
| 257 |
+
daysInactive = 3; // Fallback
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// Ensure minimum 3 days
|
| 261 |
+
if (daysInactive < 3) continue;
|
| 262 |
+
|
| 263 |
const template = MONITORING_TEMPLATES.INACTIVITY_REMINDER;
|
| 264 |
+
const body = typeof template.body === 'function'
|
| 265 |
+
? template.body(daysInactive)
|
| 266 |
+
: template.body;
|
| 267 |
+
|
| 268 |
await this.prisma.notification.create({
|
| 269 |
data: {
|
| 270 |
userId: user.id,
|
| 271 |
type: NotificationType.REMINDER,
|
| 272 |
title: template.subject,
|
| 273 |
+
message: body,
|
| 274 |
},
|
| 275 |
});
|
| 276 |
|
|
|
|
| 278 |
await this.sendPushNotification(
|
| 279 |
user.id,
|
| 280 |
template.subject,
|
| 281 |
+
body,
|
| 282 |
{ type: "INACTIVITY_REMINDER" },
|
| 283 |
);
|
| 284 |
|
|
|
|
| 286 |
if (user.email) {
|
| 287 |
const html = generateEmailHtml(
|
| 288 |
template.subject,
|
| 289 |
+
body,
|
| 290 |
"Log BP Reading",
|
| 291 |
`${this.configService.get("FRONTEND_URL")}/bridge?type=inactivity-reminder`,
|
| 292 |
"#2DE474",
|
|
|
|
| 295 |
await this.emailService.sendEmail(
|
| 296 |
user.email,
|
| 297 |
template.subject,
|
| 298 |
+
body,
|
| 299 |
html,
|
| 300 |
);
|
| 301 |
}
|
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NotificationCategory } from '@prisma/client';
|
| 2 |
+
|
| 3 |
+
export interface EducationalTemplate {
|
| 4 |
+
title: string;
|
| 5 |
+
body: string;
|
| 6 |
+
category: NotificationCategory;
|
| 7 |
+
trimesters?: number[]; // 1, 2, 3
|
| 8 |
+
riskLevels?: string[]; // 'low', 'high'
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const EDUCATIONAL_TEMPLATES: EducationalTemplate[] = [
|
| 12 |
+
// PREECLAMPSIA_AWARENESS
|
| 13 |
+
{
|
| 14 |
+
title: 'Know the Signs',
|
| 15 |
+
body: 'Regular blood pressure checks help detect preeclampsia early.',
|
| 16 |
+
category: NotificationCategory.PREECLAMPSIA_AWARENESS,
|
| 17 |
+
trimesters: [2, 3],
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
title: 'Stay Informed',
|
| 21 |
+
body: 'Preeclampsia usually develops after 20 weeks of pregnancy.',
|
| 22 |
+
category: NotificationCategory.PREECLAMPSIA_AWARENESS,
|
| 23 |
+
trimesters: [2, 3],
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
// BP_MONITORING
|
| 27 |
+
{
|
| 28 |
+
title: 'Check In',
|
| 29 |
+
body: 'Consistent blood pressure monitoring helps you stay on top of your health.',
|
| 30 |
+
category: NotificationCategory.BP_MONITORING,
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
title: 'Quick Tip',
|
| 34 |
+
body: 'Rest for 5 minutes before checking your blood pressure for accurate results.',
|
| 35 |
+
category: NotificationCategory.BP_MONITORING,
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
title: 'Your Routine',
|
| 39 |
+
body: 'Logging your blood pressure regularly helps your care team understand your health better.',
|
| 40 |
+
category: NotificationCategory.BP_MONITORING,
|
| 41 |
+
},
|
| 42 |
+
|
| 43 |
+
// PREGNANCY_WARNING_SIGNS
|
| 44 |
+
{
|
| 45 |
+
title: 'Be Aware',
|
| 46 |
+
body: 'Swelling and headaches during pregnancy shouldn’t always be ignored.',
|
| 47 |
+
category: NotificationCategory.PREGNANCY_WARNING_SIGNS,
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
title: 'When to Check In',
|
| 51 |
+
body: 'If you have persistent headaches or vision changes, contact your healthcare provider.',
|
| 52 |
+
category: NotificationCategory.PREGNANCY_WARNING_SIGNS,
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
// HYDRATION
|
| 56 |
+
{
|
| 57 |
+
title: 'Stay Hydrated',
|
| 58 |
+
body: 'Drinking enough water helps support a healthy pregnancy.',
|
| 59 |
+
category: NotificationCategory.HYDRATION,
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
title: 'Water Break',
|
| 63 |
+
body: 'Aim for 8-10 glasses of water daily to stay hydrated.',
|
| 64 |
+
category: NotificationCategory.HYDRATION,
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
// REST
|
| 68 |
+
{
|
| 69 |
+
title: 'Take It Easy',
|
| 70 |
+
body: 'Getting enough rest is important for you and your baby.',
|
| 71 |
+
category: NotificationCategory.REST,
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
title: 'Rest Up',
|
| 75 |
+
body: 'Try to rest on your left side to improve blood flow to your baby.',
|
| 76 |
+
category: NotificationCategory.REST,
|
| 77 |
+
},
|
| 78 |
+
|
| 79 |
+
// ANTENATAL_CARE
|
| 80 |
+
{
|
| 81 |
+
title: 'Your Appointments',
|
| 82 |
+
body: 'Attending all your antenatal appointments helps ensure a healthy pregnancy.',
|
| 83 |
+
category: NotificationCategory.ANTENATAL_CARE,
|
| 84 |
+
},
|
| 85 |
+
|
| 86 |
+
// SYMPTOM_AWARENESS
|
| 87 |
+
{
|
| 88 |
+
title: 'Listen to Your Body',
|
| 89 |
+
body: 'If you notice any unusual symptoms, don’t hesitate to contact your healthcare provider.',
|
| 90 |
+
category: NotificationCategory.SYMPTOM_AWARENESS,
|
| 91 |
+
},
|
| 92 |
+
|
| 93 |
+
// TRIMESTER_GUIDANCE
|
| 94 |
+
{
|
| 95 |
+
title: 'First Trimester',
|
| 96 |
+
body: 'The first trimester is a time of rapid development for your baby.',
|
| 97 |
+
category: NotificationCategory.TRIMESTER_GUIDANCE,
|
| 98 |
+
trimesters: [1],
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
title: 'Second Trimester',
|
| 102 |
+
body: 'You may start feeling your baby move during the second trimester.',
|
| 103 |
+
category: NotificationCategory.TRIMESTER_GUIDANCE,
|
| 104 |
+
trimesters: [2],
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
title: 'Third Trimester',
|
| 108 |
+
body: 'In the third trimester, your baby is growing quickly and preparing for birth.',
|
| 109 |
+
category: NotificationCategory.TRIMESTER_GUIDANCE,
|
| 110 |
+
trimesters: [3],
|
| 111 |
+
},
|
| 112 |
+
];
|
|
@@ -106,7 +106,7 @@ export const AUTH_TEMPLATES = {
|
|
| 106 |
export const MONITORING_TEMPLATES = {
|
| 107 |
INACTIVITY_REMINDER: {
|
| 108 |
subject: "It's Been a While",
|
| 109 |
-
body:
|
| 110 |
callToAction: "Log your BP now to stay on track.",
|
| 111 |
},
|
| 112 |
|
|
|
|
| 106 |
export const MONITORING_TEMPLATES = {
|
| 107 |
INACTIVITY_REMINDER: {
|
| 108 |
subject: "It's Been a While",
|
| 109 |
+
body: (days: number) => `We haven't seen a blood pressure entry from you in ${days} days. Regular logging is key to a healthy pregnancy.`,
|
| 110 |
callToAction: "Log your BP now to stay on track.",
|
| 111 |
},
|
| 112 |
|