Spaces:
Running
feat(monitoring): add real-time monitoring engine with state management
Browse filesIntroduce a comprehensive monitoring engine that evaluates user health data (blood pressure and symptoms) in real-time. The engine determines monitoring states (NORMAL, MONITOR, URGENT, EMERGENCY) based on clinical rules and creates automated follow-up tasks. A scheduler service handles overdue tasks and inactivity reminders. The blood pressure module now integrates with the monitoring engine instead of the legacy care priority system.
- Add new MonitoringEngineModule, service, controller, and scheduler
- Define AppNotificationType enum for notification categorization
- Extend Prisma schema with MonitoringStateRecord and FollowUpTask entities
- Modify BloodPressureController to trigger monitoring evaluation on new readings
- Make sendPushNotification method public for cross-module use
- Remove direct dependency on CarePriorityModule in BloodPressureModule
- prisma/schema.prisma +78 -0
- src/app.module.ts +2 -0
- src/blood-pressure/blood-pressure.controller.ts +10 -6
- src/blood-pressure/blood-pressure.module.ts +2 -17
- src/common/enums/app-notification-type.enum.ts +12 -0
- src/monitoring-engine/monitoring-engine.controller.ts +49 -0
- src/monitoring-engine/monitoring-engine.module.ts +26 -0
- src/monitoring-engine/monitoring-engine.service.ts +273 -0
- src/monitoring-engine/monitoring-scheduler.service.ts +127 -0
- src/notifications/notifications.service.ts +1 -1
|
@@ -67,6 +67,12 @@ model UserAuth {
|
|
| 67 |
// Relation to Notifications
|
| 68 |
notifications Notification[]
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
@@index([email])
|
| 71 |
@@index([phone])
|
| 72 |
@@map("user_auth")
|
|
@@ -280,3 +286,75 @@ model Notification {
|
|
| 280 |
@@index([createdAt])
|
| 281 |
@@map("notification")
|
| 282 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
// Relation to Notifications
|
| 68 |
notifications Notification[]
|
| 69 |
|
| 70 |
+
// Relation to MonitoringStateRecord
|
| 71 |
+
monitoringStateRecord MonitoringStateRecord?
|
| 72 |
+
|
| 73 |
+
// Relation to FollowUpTasks
|
| 74 |
+
followUpTasks FollowUpTask[]
|
| 75 |
+
|
| 76 |
@@index([email])
|
| 77 |
@@index([phone])
|
| 78 |
@@map("user_auth")
|
|
|
|
| 286 |
@@index([createdAt])
|
| 287 |
@@map("notification")
|
| 288 |
}
|
| 289 |
+
|
| 290 |
+
/**
|
| 291 |
+
* MonitoringState Enum
|
| 292 |
+
*
|
| 293 |
+
* Defines the possible states of the monitoring system for a user.
|
| 294 |
+
*/
|
| 295 |
+
enum MonitoringState {
|
| 296 |
+
NORMAL
|
| 297 |
+
MONITOR
|
| 298 |
+
URGENT
|
| 299 |
+
EMERGENCY
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
/**
|
| 303 |
+
* MonitoringStateRecord Entity
|
| 304 |
+
*
|
| 305 |
+
* Stores the current monitoring state for each user.
|
| 306 |
+
*/
|
| 307 |
+
model MonitoringStateRecord {
|
| 308 |
+
id String @id @default(uuid())
|
| 309 |
+
userId String @unique
|
| 310 |
+
currentState MonitoringState
|
| 311 |
+
lastUpdatedAt DateTime @default(now())
|
| 312 |
+
reason String?
|
| 313 |
+
|
| 314 |
+
userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 315 |
+
|
| 316 |
+
@@index([userId])
|
| 317 |
+
@@map("monitoring_state_record")
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
/**
|
| 321 |
+
* FollowUpTaskType Enum
|
| 322 |
+
*
|
| 323 |
+
* Defines the types of follow-up tasks.
|
| 324 |
+
*/
|
| 325 |
+
enum FollowUpTaskType {
|
| 326 |
+
RECHECK
|
| 327 |
+
SEEK_CARE
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
/**
|
| 331 |
+
* FollowUpTaskStatus Enum
|
| 332 |
+
*
|
| 333 |
+
* Defines the status of follow-up tasks.
|
| 334 |
+
*/
|
| 335 |
+
enum FollowUpTaskStatus {
|
| 336 |
+
PENDING
|
| 337 |
+
COMPLETED
|
| 338 |
+
MISSED
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
/**
|
| 342 |
+
* FollowUpTask Entity
|
| 343 |
+
*
|
| 344 |
+
* Stores details about each follow-up task.
|
| 345 |
+
*/
|
| 346 |
+
model FollowUpTask {
|
| 347 |
+
id String @id @default(uuid())
|
| 348 |
+
userId String
|
| 349 |
+
type FollowUpTaskType
|
| 350 |
+
dueAt DateTime
|
| 351 |
+
status FollowUpTaskStatus @default(PENDING)
|
| 352 |
+
relatedReadingId String? // Optional: Link to a specific BP reading that triggered the task
|
| 353 |
+
|
| 354 |
+
userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 355 |
+
|
| 356 |
+
@@index([userId])
|
| 357 |
+
@@index([dueAt])
|
| 358 |
+
@@index([status])
|
| 359 |
+
@@map("follow_up_task")
|
| 360 |
+
}
|
|
@@ -12,6 +12,7 @@ import { CarePriorityModule } from "./care-priority/care-priority.module";
|
|
| 12 |
import { NotificationsModule } from "./notifications/notifications.module";
|
| 13 |
import { EducationModule } from "./education/education.module";
|
| 14 |
import { ClinicFinderModule } from "./clinic-finder/clinic-finder.module";
|
|
|
|
| 15 |
import { AppController } from "./app.controller";
|
| 16 |
import { HealthController } from "./health.controller";
|
| 17 |
|
|
@@ -46,6 +47,7 @@ import { HealthController } from "./health.controller";
|
|
| 46 |
NotificationsModule,
|
| 47 |
EducationModule,
|
| 48 |
ClinicFinderModule,
|
|
|
|
| 49 |
],
|
| 50 |
controllers: [AppController, HealthController],
|
| 51 |
providers: [
|
|
|
|
| 12 |
import { NotificationsModule } from "./notifications/notifications.module";
|
| 13 |
import { EducationModule } from "./education/education.module";
|
| 14 |
import { ClinicFinderModule } from "./clinic-finder/clinic-finder.module";
|
| 15 |
+
import { MonitoringEngineModule } from "./monitoring-engine/monitoring-engine.module";
|
| 16 |
import { AppController } from "./app.controller";
|
| 17 |
import { HealthController } from "./health.controller";
|
| 18 |
|
|
|
|
| 47 |
NotificationsModule,
|
| 48 |
EducationModule,
|
| 49 |
ClinicFinderModule,
|
| 50 |
+
MonitoringEngineModule,
|
| 51 |
],
|
| 52 |
controllers: [AppController, HealthController],
|
| 53 |
providers: [
|
|
@@ -17,15 +17,14 @@ import {
|
|
| 17 |
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 {
|
| 21 |
|
| 22 |
@Controller("blood-pressure")
|
| 23 |
@UseGuards(JwtAuthGuard)
|
| 24 |
export class BloodPressureController {
|
| 25 |
constructor(
|
| 26 |
private readonly bloodPressureService: BloodPressureService,
|
| 27 |
-
|
| 28 |
-
private readonly healthAssessmentService: HealthAssessmentService
|
| 29 |
) {}
|
| 30 |
|
| 31 |
@Post()
|
|
@@ -40,10 +39,15 @@ export class BloodPressureController {
|
|
| 40 |
createBloodPressureDto
|
| 41 |
);
|
| 42 |
|
| 43 |
-
|
| 44 |
-
this.healthAssessmentService.assessAndNotify(userId);
|
| 45 |
|
| 46 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
/**
|
|
|
|
| 17 |
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)
|
| 24 |
export class BloodPressureController {
|
| 25 |
constructor(
|
| 26 |
private readonly bloodPressureService: BloodPressureService,
|
| 27 |
+
private readonly monitoringEngineService: MonitoringEngineService,
|
|
|
|
| 28 |
) {}
|
| 29 |
|
| 30 |
@Post()
|
|
|
|
| 39 |
createBloodPressureDto
|
| 40 |
);
|
| 41 |
|
| 42 |
+
const monitoringResult = await this.monitoringEngineService.evaluate(userId);
|
|
|
|
| 43 |
|
| 44 |
+
return {
|
| 45 |
+
reading,
|
| 46 |
+
monitoringState: monitoringResult.state,
|
| 47 |
+
message: monitoringResult.message,
|
| 48 |
+
nextAction: monitoringResult.nextAction,
|
| 49 |
+
followUp: monitoringResult.followUp,
|
| 50 |
+
};
|
| 51 |
}
|
| 52 |
|
| 53 |
/**
|
|
@@ -1,25 +1,10 @@
|
|
| 1 |
import { Module, forwardRef } from "@nestjs/common";
|
| 2 |
import { BloodPressureController } from "./blood-pressure.controller";
|
| 3 |
import { BloodPressureService } from "./blood-pressure.service";
|
| 4 |
-
import {
|
| 5 |
-
|
| 6 |
-
/**
|
| 7 |
-
* Blood Pressure Module
|
| 8 |
-
*
|
| 9 |
-
* RESPONSIBILITIES:
|
| 10 |
-
* - Store BP readings (systolic/diastolic)
|
| 11 |
-
* - Manual entry only
|
| 12 |
-
* - Timestamp tracking
|
| 13 |
-
*
|
| 14 |
-
* CLINICAL SAFETY CONSTRAINTS:
|
| 15 |
-
* - Do NOT label readings as normal/abnormal
|
| 16 |
-
* - Do NOT store interpretations
|
| 17 |
-
* - Neutral data handling only
|
| 18 |
-
* - Raw measurements for care priority engine
|
| 19 |
-
*/
|
| 20 |
|
| 21 |
@Module({
|
| 22 |
-
imports: [
|
| 23 |
controllers: [BloodPressureController],
|
| 24 |
providers: [BloodPressureService],
|
| 25 |
exports: [BloodPressureService],
|
|
|
|
| 1 |
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],
|
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export enum AppNotificationType {
|
| 2 |
+
BP_REMINDER = 'BP_REMINDER',
|
| 3 |
+
INACTIVITY_REMINDER = 'INACTIVITY_REMINDER',
|
| 4 |
+
URGENT_INACTIVITY_REMINDER = 'URGENT_INACTIVITY_REMINDER',
|
| 5 |
+
FOLLOW_UP_RECHECK = 'FOLLOW_UP_RECHECK',
|
| 6 |
+
FOLLOW_UP_SEEK_CARE = 'FOLLOW_UP_SEEK_CARE',
|
| 7 |
+
FOLLOW_UP_MISSED = 'FOLLOW_UP_MISSED',
|
| 8 |
+
TREND_ALERT = 'TREND_ALERT',
|
| 9 |
+
ESCALATION_ALERT = 'ESCALATION_ALERT',
|
| 10 |
+
SESSION_EXPIRY = 'SESSION_EXPIRY',
|
| 11 |
+
GENERIC = 'GENERIC',
|
| 12 |
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Controller,
|
| 3 |
+
Get,
|
| 4 |
+
Post,
|
| 5 |
+
Param,
|
| 6 |
+
UseGuards,
|
| 7 |
+
Request,
|
| 8 |
+
HttpCode,
|
| 9 |
+
HttpStatus,
|
| 10 |
+
} from '@nestjs/common';
|
| 11 |
+
import { MonitoringEngineService } from './monitoring-engine.service';
|
| 12 |
+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
| 13 |
+
|
| 14 |
+
@Controller('monitoring-engine')
|
| 15 |
+
@UseGuards(JwtAuthGuard)
|
| 16 |
+
export class MonitoringEngineController {
|
| 17 |
+
constructor(
|
| 18 |
+
private readonly monitoringEngineService: MonitoringEngineService,
|
| 19 |
+
) {}
|
| 20 |
+
|
| 21 |
+
@Get('state')
|
| 22 |
+
@HttpCode(HttpStatus.OK)
|
| 23 |
+
async getMonitoringState(@Request() req: any) {
|
| 24 |
+
const userId = req.user.id;
|
| 25 |
+
const state = await this.monitoringEngineService.getMonitoringState(userId);
|
| 26 |
+
return state;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
@Get('follow-ups')
|
| 30 |
+
@HttpCode(HttpStatus.OK)
|
| 31 |
+
async getPendingFollowUps(@Request() req: any) {
|
| 32 |
+
const userId = req.user.id;
|
| 33 |
+
const followUps = await this.monitoringEngineService.getPendingFollowUpTasks(
|
| 34 |
+
userId,
|
| 35 |
+
);
|
| 36 |
+
return followUps;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@Post('follow-ups/:id/complete')
|
| 40 |
+
@HttpCode(HttpStatus.OK)
|
| 41 |
+
async completeFollowUp(@Request() req: any, @Param('id') id: string) {
|
| 42 |
+
const userId = req.user.id; // Ensure the user owns the task
|
| 43 |
+
// In a real application, you'd add a check here to ensure the task belongs to the user
|
| 44 |
+
const completedTask = await this.monitoringEngineService.completeFollowUpTask(
|
| 45 |
+
id,
|
| 46 |
+
);
|
| 47 |
+
return completedTask;
|
| 48 |
+
}
|
| 49 |
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from '@nestjs/common';
|
| 2 |
+
import { MonitoringEngineService } from './monitoring-engine.service';
|
| 3 |
+
import { MonitoringEngineController } from './monitoring-engine.controller';
|
| 4 |
+
import { PrismaService } from '../database/prisma.service';
|
| 5 |
+
import { BloodPressureService } from '../blood-pressure/blood-pressure.service';
|
| 6 |
+
import { SymptomsService } from '../symptoms/symptoms.service';
|
| 7 |
+
import { UserProfileService } from '../user-profile/user-profile.service';
|
| 8 |
+
import { MonitoringSchedulerService } from './monitoring-scheduler.service';
|
| 9 |
+
import { NotificationsModule } from '../notifications/notifications.module';
|
| 10 |
+
import { UserProfileModule } from '../user-profile/user-profile.module';
|
| 11 |
+
import { ScheduleModule } from '@nestjs/schedule';
|
| 12 |
+
|
| 13 |
+
@Module({
|
| 14 |
+
imports: [NotificationsModule, UserProfileModule, ScheduleModule.forRoot()],
|
| 15 |
+
providers: [
|
| 16 |
+
MonitoringEngineService,
|
| 17 |
+
PrismaService,
|
| 18 |
+
BloodPressureService,
|
| 19 |
+
SymptomsService,
|
| 20 |
+
UserProfileService,
|
| 21 |
+
MonitoringSchedulerService,
|
| 22 |
+
],
|
| 23 |
+
controllers: [MonitoringEngineController],
|
| 24 |
+
exports: [MonitoringEngineService],
|
| 25 |
+
})
|
| 26 |
+
export class MonitoringEngineModule {}
|
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from '@nestjs/common';
|
| 2 |
+
import { PrismaService } from '../database/prisma.service';
|
| 3 |
+
import { BloodPressureService } from '../blood-pressure/blood-pressure.service';
|
| 4 |
+
import { SymptomsService } from '../symptoms/symptoms.service';
|
| 5 |
+
import { UserProfileService } from '../user-profile/user-profile.service';
|
| 6 |
+
import {
|
| 7 |
+
MonitoringState,
|
| 8 |
+
FollowUpTaskType,
|
| 9 |
+
FollowUpTaskStatus,
|
| 10 |
+
SymptomType,
|
| 11 |
+
} from '@prisma/client';
|
| 12 |
+
|
| 13 |
+
@Injectable()
|
| 14 |
+
export class MonitoringEngineService {
|
| 15 |
+
private readonly logger = new Logger(MonitoringEngineService.name);
|
| 16 |
+
|
| 17 |
+
constructor(
|
| 18 |
+
private readonly prisma: PrismaService,
|
| 19 |
+
private readonly bloodPressureService: BloodPressureService,
|
| 20 |
+
private readonly symptomsService: SymptomsService,
|
| 21 |
+
private readonly userProfileService: UserProfileService,
|
| 22 |
+
) {}
|
| 23 |
+
|
| 24 |
+
async evaluate(userId: string) {
|
| 25 |
+
const now = new Date();
|
| 26 |
+
let currentState: MonitoringState = MonitoringState.NORMAL;
|
| 27 |
+
let reason: string | null = null;
|
| 28 |
+
let nextAction: string | null = null;
|
| 29 |
+
let followUpTask = null;
|
| 30 |
+
|
| 31 |
+
// 1. Aggregate historical data
|
| 32 |
+
const last48Hours = new Date(now.getTime() - 48 * 60 * 60 * 1000);
|
| 33 |
+
const last72Hours = new Date(now.getTime() - 72 * 60 * 60 * 1000);
|
| 34 |
+
|
| 35 |
+
const recentBPs = await this.bloodPressureService.getRecentReadings(
|
| 36 |
+
userId,
|
| 37 |
+
48,
|
| 38 |
+
);
|
| 39 |
+
const latestBP = recentBPs.length > 0 ? recentBPs[0] : null;
|
| 40 |
+
const recentSymptoms = await this.symptomsService.getRecentSymptoms(
|
| 41 |
+
userId,
|
| 42 |
+
72,
|
| 43 |
+
);
|
| 44 |
+
const symptomTypes = new Set(recentSymptoms.map((s) => s.symptomType));
|
| 45 |
+
|
| 46 |
+
// 2. Detect patterns (TREND ENGINE)
|
| 47 |
+
// Rule B: Severe BP
|
| 48 |
+
if (latestBP && (latestBP.systolic >= 160 || latestBP.diastolic >= 110)) {
|
| 49 |
+
currentState = MonitoringState.EMERGENCY;
|
| 50 |
+
reason = 'Severe blood pressure reading detected.';
|
| 51 |
+
nextAction = 'Seek immediate medical attention.';
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Rule C: BP + symptom combination
|
| 55 |
+
if (
|
| 56 |
+
latestBP &&
|
| 57 |
+
(latestBP.systolic >= 140 || latestBP.diastolic >= 90) &&
|
| 58 |
+
(symptomTypes.has(SymptomType.HEADACHE) ||
|
| 59 |
+
symptomTypes.has(SymptomType.BLURRED_VISION) ||
|
| 60 |
+
symptomTypes.has(SymptomType.UPPER_ABDOMINAL_PAIN))
|
| 61 |
+
) {
|
| 62 |
+
currentState = MonitoringState.EMERGENCY;
|
| 63 |
+
reason = 'Elevated blood pressure with severe symptoms.';
|
| 64 |
+
nextAction = 'Seek immediate medical attention.';
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// If already EMERGENCY, we stop here for escalation
|
| 68 |
+
if (currentState === MonitoringState.EMERGENCY) {
|
| 69 |
+
await this.updateMonitoringState(userId, currentState, reason);
|
| 70 |
+
return {
|
| 71 |
+
state: currentState,
|
| 72 |
+
message: reason,
|
| 73 |
+
nextAction,
|
| 74 |
+
followUp: null,
|
| 75 |
+
};
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Rule A: Repeated high BP (>=3 readings where systolic >=140 OR diastolic >=90 within 48h)
|
| 79 |
+
const elevatedReadings = recentBPs.filter(
|
| 80 |
+
(bp) => bp.systolic >= 140 || bp.diastolic >= 90,
|
| 81 |
+
);
|
| 82 |
+
if (elevatedReadings.length >= 3) {
|
| 83 |
+
currentState = MonitoringState.URGENT;
|
| 84 |
+
reason = 'Repeated high blood pressure readings within 48 hours.';
|
| 85 |
+
nextAction = 'Contact your healthcare provider within 24 hours.';
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Rule D: Rising trend (basic version) - last 3 readings show increasing systolic or diastolic
|
| 89 |
+
if (recentBPs.length >= 3) {
|
| 90 |
+
const sortedBPs = recentBPs
|
| 91 |
+
.slice(0, 3)
|
| 92 |
+
.sort((a, b) => a.recordedAt.getTime() - b.recordedAt.getTime()); // Oldest first
|
| 93 |
+
|
| 94 |
+
const isSystolicRising =
|
| 95 |
+
sortedBPs[0].systolic < sortedBPs[1].systolic &&
|
| 96 |
+
sortedBPs[1].systolic < sortedBPs[2].systolic;
|
| 97 |
+
const isDiastolicRising =
|
| 98 |
+
sortedBPs[0].diastolic < sortedBPs[1].diastolic &&
|
| 99 |
+
sortedBPs[1].diastolic < sortedBPs[2].diastolic;
|
| 100 |
+
|
| 101 |
+
if (isSystolicRising || isDiastolicRising) {
|
| 102 |
+
// Cast to avoid TS narrowing issues when comparing against the "current" state
|
| 103 |
+
const checkState = currentState as MonitoringState;
|
| 104 |
+
if (checkState === MonitoringState.NORMAL) {
|
| 105 |
+
currentState = MonitoringState.MONITOR;
|
| 106 |
+
reason = 'Rising trend in blood pressure detected.';
|
| 107 |
+
nextAction = 'Continue monitoring closely.';
|
| 108 |
+
} else if (checkState === MonitoringState.MONITOR) {
|
| 109 |
+
currentState = MonitoringState.URGENT;
|
| 110 |
+
reason = 'Significant rising trend in blood pressure detected.';
|
| 111 |
+
nextAction = 'Contact your healthcare provider within 24 hours.';
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Initial high reading (NORMAL -> MONITOR)
|
| 117 |
+
if (
|
| 118 |
+
(currentState as MonitoringState) === MonitoringState.NORMAL &&
|
| 119 |
+
latestBP &&
|
| 120 |
+
(latestBP.systolic >= 140 || latestBP.diastolic >= 90)
|
| 121 |
+
) {
|
| 122 |
+
currentState = MonitoringState.MONITOR;
|
| 123 |
+
reason = 'First elevated blood pressure reading.';
|
| 124 |
+
nextAction = 'Recheck your blood pressure in 4 hours.';
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// 3. Introduce STATE MACHINE & 4. STATE TRANSITIONS
|
| 128 |
+
const previousStateRecord = await this.prisma.monitoringStateRecord.findUnique(
|
| 129 |
+
{
|
| 130 |
+
where: { userId },
|
| 131 |
+
},
|
| 132 |
+
);
|
| 133 |
+
|
| 134 |
+
if (previousStateRecord) {
|
| 135 |
+
// Transition from Any -> NORMAL (after consistent normal readings)
|
| 136 |
+
// This is a simplified check. A more robust solution would involve checking a longer history of normal readings.
|
| 137 |
+
const checkState = currentState as MonitoringState;
|
| 138 |
+
if (
|
| 139 |
+
previousStateRecord.currentState !== MonitoringState.NORMAL &&
|
| 140 |
+
checkState === MonitoringState.NORMAL &&
|
| 141 |
+
recentBPs.every((bp) => bp.systolic < 140) &&
|
| 142 |
+
recentBPs.every((bp) => bp.diastolic < 90) &&
|
| 143 |
+
recentSymptoms.length === 0
|
| 144 |
+
) {
|
| 145 |
+
currentState = MonitoringState.NORMAL;
|
| 146 |
+
reason = 'Consistent normal readings and no concerning symptoms.';
|
| 147 |
+
nextAction = 'Continue routine monitoring.';
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// 5. FOLLOW-UP SYSTEM (CRITICAL)
|
| 152 |
+
// When BP >= 140/90: Create RECHECK task: dueAt = now + 4 hours
|
| 153 |
+
if (
|
| 154 |
+
latestBP &&
|
| 155 |
+
(latestBP.systolic >= 140 || latestBP.diastolic >= 90) &&
|
| 156 |
+
(currentState as MonitoringState) !== MonitoringState.EMERGENCY
|
| 157 |
+
) {
|
| 158 |
+
followUpTask = await this.createFollowUpTask(
|
| 159 |
+
userId,
|
| 160 |
+
FollowUpTaskType.RECHECK,
|
| 161 |
+
new Date(now.getTime() + 4 * 60 * 60 * 1000), // 4 hours from now
|
| 162 |
+
latestBP.id,
|
| 163 |
+
);
|
| 164 |
+
nextAction = 'Please recheck your blood pressure in 4 hours.';
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// When state = URGENT: Create SEEK_CARE task: dueAt = now + 24 hours
|
| 168 |
+
if ((currentState as MonitoringState) === MonitoringState.URGENT) {
|
| 169 |
+
followUpTask = await this.createFollowUpTask(
|
| 170 |
+
userId,
|
| 171 |
+
FollowUpTaskType.SEEK_CARE,
|
| 172 |
+
new Date(now.getTime() + 24 * 60 * 60 * 1000), // 24 hours from now
|
| 173 |
+
latestBP?.id, // Link to the latest BP if available
|
| 174 |
+
);
|
| 175 |
+
nextAction = 'Contact your healthcare provider within 24 hours.';
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
await this.updateMonitoringState(userId, currentState, reason);
|
| 179 |
+
|
| 180 |
+
return {
|
| 181 |
+
state: currentState,
|
| 182 |
+
message: reason,
|
| 183 |
+
nextAction,
|
| 184 |
+
followUp: followUpTask,
|
| 185 |
+
};
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
async getMonitoringState(userId: string) {
|
| 189 |
+
return this.prisma.monitoringStateRecord.findUnique({
|
| 190 |
+
where: { userId },
|
| 191 |
+
});
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
async getPendingFollowUpTasks(userId: string) {
|
| 195 |
+
return this.prisma.followUpTask.findMany({
|
| 196 |
+
where: {
|
| 197 |
+
userId,
|
| 198 |
+
status: FollowUpTaskStatus.PENDING,
|
| 199 |
+
},
|
| 200 |
+
orderBy: {
|
| 201 |
+
dueAt: 'asc',
|
| 202 |
+
},
|
| 203 |
+
});
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
async completeFollowUpTask(taskId: string) {
|
| 207 |
+
return this.prisma.followUpTask.update({
|
| 208 |
+
where: { id: taskId },
|
| 209 |
+
data: { status: FollowUpTaskStatus.COMPLETED },
|
| 210 |
+
});
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
private async updateMonitoringState(
|
| 214 |
+
userId: string,
|
| 215 |
+
newState: MonitoringState,
|
| 216 |
+
reason: string | null,
|
| 217 |
+
) {
|
| 218 |
+
return this.prisma.monitoringStateRecord.upsert({
|
| 219 |
+
where: { userId },
|
| 220 |
+
update: {
|
| 221 |
+
currentState: newState,
|
| 222 |
+
reason,
|
| 223 |
+
lastUpdatedAt: new Date(),
|
| 224 |
+
},
|
| 225 |
+
create: {
|
| 226 |
+
userId,
|
| 227 |
+
currentState: newState,
|
| 228 |
+
reason,
|
| 229 |
+
lastUpdatedAt: new Date(),
|
| 230 |
+
},
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
private async createFollowUpTask(
|
| 235 |
+
userId: string,
|
| 236 |
+
type: FollowUpTaskType,
|
| 237 |
+
dueAt: Date,
|
| 238 |
+
relatedReadingId?: string,
|
| 239 |
+
) {
|
| 240 |
+
// Check for existing pending tasks of the same type to avoid duplicates
|
| 241 |
+
const existingTask = await this.prisma.followUpTask.findFirst({
|
| 242 |
+
where: {
|
| 243 |
+
userId,
|
| 244 |
+
type,
|
| 245 |
+
status: FollowUpTaskStatus.PENDING,
|
| 246 |
+
},
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
if (existingTask) {
|
| 250 |
+
// Update existing task if dueAt is earlier, or if it's a RECHECK and relatedReadingId is different
|
| 251 |
+
if (
|
| 252 |
+
existingTask.dueAt > dueAt ||
|
| 253 |
+
(type === FollowUpTaskType.RECHECK &&
|
| 254 |
+
existingTask.relatedReadingId !== relatedReadingId)
|
| 255 |
+
) {
|
| 256 |
+
return this.prisma.followUpTask.update({
|
| 257 |
+
where: { id: existingTask.id },
|
| 258 |
+
data: { dueAt, relatedReadingId },
|
| 259 |
+
});
|
| 260 |
+
}
|
| 261 |
+
return existingTask; // Return existing task if no update is needed
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
return this.prisma.followUpTask.create({
|
| 265 |
+
data: {
|
| 266 |
+
userId,
|
| 267 |
+
type,
|
| 268 |
+
dueAt,
|
| 269 |
+
relatedReadingId,
|
| 270 |
+
},
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from '@nestjs/common';
|
| 2 |
+
import { Cron, CronExpression } from '@nestjs/schedule';
|
| 3 |
+
import { PrismaService } from '../database/prisma.service';
|
| 4 |
+
import { MonitoringEngineService } from './monitoring-engine.service';
|
| 5 |
+
import {
|
| 6 |
+
FollowUpTaskStatus,
|
| 7 |
+
FollowUpTaskType,
|
| 8 |
+
MonitoringState,
|
| 9 |
+
} from '@prisma/client';
|
| 10 |
+
import { NotificationsService } from '../notifications/notifications.service';
|
| 11 |
+
import { UserProfileService } from '../user-profile/user-profile.service';
|
| 12 |
+
import { AppNotificationType } from '../common/enums/app-notification-type.enum';
|
| 13 |
+
|
| 14 |
+
@Injectable()
|
| 15 |
+
export class MonitoringSchedulerService {
|
| 16 |
+
private readonly logger = new Logger(MonitoringSchedulerService.name);
|
| 17 |
+
|
| 18 |
+
constructor(
|
| 19 |
+
private readonly prisma: PrismaService,
|
| 20 |
+
private readonly monitoringEngineService: MonitoringEngineService,
|
| 21 |
+
private readonly notificationsService: NotificationsService,
|
| 22 |
+
private readonly userProfileService: UserProfileService,
|
| 23 |
+
) {}
|
| 24 |
+
|
| 25 |
+
@Cron(CronExpression.EVERY_HOUR)
|
| 26 |
+
async handleOverdueFollowUps() {
|
| 27 |
+
this.logger.log('Checking for overdue follow-up tasks...');
|
| 28 |
+
const now = new Date();
|
| 29 |
+
|
| 30 |
+
const overdueTasks = await this.prisma.followUpTask.findMany({
|
| 31 |
+
where: {
|
| 32 |
+
dueAt: { lt: now },
|
| 33 |
+
status: FollowUpTaskStatus.PENDING,
|
| 34 |
+
},
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
for (const task of overdueTasks) {
|
| 38 |
+
await this.prisma.followUpTask.update({
|
| 39 |
+
where: { id: task.id },
|
| 40 |
+
data: { status: FollowUpTaskStatus.MISSED },
|
| 41 |
+
});
|
| 42 |
+
this.logger.warn(
|
| 43 |
+
`Follow-up task ${task.id} for user ${task.userId} missed.`,
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
// Trigger escalation (re-evaluate monitoring state)
|
| 47 |
+
const result = await this.monitoringEngineService.evaluate(task.userId);
|
| 48 |
+
this.logger.log(
|
| 49 |
+
`Escalated state for user ${task.userId} due to missed follow-up: ${result.state}`,
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
// Trigger notification
|
| 53 |
+
await this.notificationsService.sendPushNotification(
|
| 54 |
+
task.userId,
|
| 55 |
+
'Follow-up Missed',
|
| 56 |
+
`Your ${task.type} follow-up task was missed. Current state: ${result.state}.`,
|
| 57 |
+
{ type: AppNotificationType.FOLLOW_UP_MISSED },
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
this.logger.log(
|
| 61 |
+
`Finished checking overdue follow-up tasks. ${overdueTasks.length} tasks updated.`,
|
| 62 |
+
);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
@Cron('0 9 * * *') // Every day at 9 AM
|
| 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({
|
| 73 |
+
select: {
|
| 74 |
+
id: true,
|
| 75 |
+
bloodPressureReadings: {
|
| 76 |
+
orderBy: { recordedAt: 'desc' },
|
| 77 |
+
take: 1,
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
});
|
| 81 |
+
|
| 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 |
+
const userProfile = await this.userProfileService.findByUserId(user.id);
|
| 87 |
+
if (userProfile && userProfile.createdAt < fiveDaysAgo) {
|
| 88 |
+
await this.notificationsService.sendPushNotification(
|
| 89 |
+
user.id,
|
| 90 |
+
'BP Log Reminder',
|
| 91 |
+
"You haven't logged your blood pressure yet. Please log your first reading.",
|
| 92 |
+
{ type: AppNotificationType.INACTIVITY_REMINDER },
|
| 93 |
+
);
|
| 94 |
+
this.logger.log(
|
| 95 |
+
`Inactivity reminder sent to user ${user.id} (no BP ever logged).`,
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
continue;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if (lastBpReading.recordedAt < sevenDaysAgo) {
|
| 102 |
+
// No BP logged in >7 days -> escalate messaging urgency
|
| 103 |
+
await this.notificationsService.sendPushNotification(
|
| 104 |
+
user.id,
|
| 105 |
+
'Urgent BP Reminder',
|
| 106 |
+
"It's been over 7 days since your last blood pressure reading. Please log it now.",
|
| 107 |
+
{ type: AppNotificationType.URGENT_INACTIVITY_REMINDER },
|
| 108 |
+
);
|
| 109 |
+
this.logger.warn(
|
| 110 |
+
`Urgent inactivity reminder sent to user ${user.id} (>7 days).`,
|
| 111 |
+
);
|
| 112 |
+
} else if (lastBpReading.recordedAt < fiveDaysAgo) {
|
| 113 |
+
// No BP logged in >5 days -> send reminder
|
| 114 |
+
await this.notificationsService.sendPushNotification(
|
| 115 |
+
user.id,
|
| 116 |
+
'BP Log Reminder',
|
| 117 |
+
"It's been over 5 days since your last blood pressure reading. Please log it soon.",
|
| 118 |
+
{ type: AppNotificationType.INACTIVITY_REMINDER },
|
| 119 |
+
);
|
| 120 |
+
this.logger.log(
|
| 121 |
+
`Inactivity reminder sent to user ${user.id} (>5 days).`,
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
this.logger.log('Finished checking for user inactivity.');
|
| 126 |
+
}
|
| 127 |
+
}
|
|
@@ -64,7 +64,7 @@ export class NotificationsService {
|
|
| 64 |
/**
|
| 65 |
* Internal helper to send push notifications
|
| 66 |
*/
|
| 67 |
-
|
| 68 |
userId: string,
|
| 69 |
title: string,
|
| 70 |
body: string,
|
|
|
|
| 64 |
/**
|
| 65 |
* Internal helper to send push notifications
|
| 66 |
*/
|
| 67 |
+
public async sendPushNotification(
|
| 68 |
userId: string,
|
| 69 |
title: string,
|
| 70 |
body: string,
|