Auspicious14 commited on
Commit
f963af3
·
1 Parent(s): 8c16374

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 CHANGED
@@ -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 @id @default(uuid())
279
  userId String
280
  type NotificationType
 
 
281
  title String
282
  message String
283
- read Boolean @default(false)
284
- createdAt DateTime @default(now())
285
-
286
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
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])
src/monitoring-engine/monitoring-scheduler.service.ts CHANGED
@@ -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 fiveDaysAgo = new Date(now.getTime() - 5 * 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,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 5 days of account creation
86
  try {
87
  const userProfile = await this.userProfileService.findByUserId(
88
  user.id,
89
  );
90
- if (userProfile && userProfile.createdAt < fiveDaysAgo) {
 
 
91
  await this.notificationsService.sendPushNotification(
92
  user.id,
93
  "BP Log Reminder",
94
- "You haven't logged your blood pressure yet. Please log your first reading.",
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
- "It's been over 7 days since your last blood pressure reading. Please log it now.",
125
  { type: AppNotificationType.URGENT_INACTIVITY_REMINDER },
126
  );
127
  this.logger.warn(
128
- `Urgent inactivity reminder sent to user ${user.id} (>7 days).`,
129
  );
130
- } else if (lastBpReading.recordedAt < fiveDaysAgo) {
131
- // No BP logged in >5 days -> send reminder
132
  await this.notificationsService.sendPushNotification(
133
  user.id,
134
  "BP Log Reminder",
135
- "It's been over 5 days since your last blood pressure reading. Please log it soon.",
136
  { type: AppNotificationType.INACTIVITY_REMINDER },
137
  );
138
  this.logger.log(
139
- `Inactivity reminder sent to user ${user.id} (>5 days).`,
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
  }
src/notifications/educational-scheduler.service.ts ADDED
@@ -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
+ }
src/notifications/notification-orchestrator.service.ts ADDED
@@ -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
+ }
src/notifications/notifications.module.ts CHANGED
@@ -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
- * - Currently stub implementation (console logging)
22
- * - Ready for integration with email/SMS services
23
- * - Examples: SendGrid, Twilio, AWS SNS
24
  */
25
 
26
  @Module({
27
  imports: [EmailModule],
28
  controllers: [NotificationsController],
29
- providers: [NotificationsService],
30
- exports: [NotificationsService],
 
 
 
 
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 {}
src/notifications/notifications.service.ts CHANGED
@@ -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 5 days
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 fiveDaysAgo = new Date();
211
- fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5);
212
 
213
- // Find users whose last reading was > 5 days ago or who never logged
214
  const users = await this.prisma.userAuth.findMany({
215
  where: {
216
  isActive: true,
217
  bloodPressureReadings: {
218
  none: {
219
- recordedAt: { gte: fiveDaysAgo },
220
  },
221
  },
222
  },
223
- select: { id: true, email: true },
 
 
 
 
 
 
 
 
 
 
 
224
  });
225
 
226
  for (const user of users) {
227
  try {
228
- if (await this.hasLoggedBPSince(user.id, fiveDaysAgo)) continue;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: template.body,
236
  },
237
  });
238
 
@@ -240,7 +278,7 @@ export class NotificationsService {
240
  await this.sendPushNotification(
241
  user.id,
242
  template.subject,
243
- template.body,
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
- template.body,
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
- template.body,
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
  }
src/notifications/templates/educational.templates.ts ADDED
@@ -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
+ ];
src/notifications/templates/notification.templates.ts CHANGED
@@ -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: "We haven't seen a blood pressure entry from you in 5 days. Regular logging is key to a healthy pregnancy.",
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