Auspicious14 commited on
Commit
395abdc
·
1 Parent(s): 6e43d92

feat: add ANC visit functionality and improve patient/referral workflows

Browse files

- add complete ANC visit module including controller, service, DTOs, risk calculation utility, and alert integration
- add patient list pagination, search, and risk level filtering
- add patient defaulter tracking endpoints
- add new risk evaluation service
- refactor referral service to use typed DTOs and facility-scoped patient validation
- update Prisma schema with new models, enums, and field updates
- fix minor formatting issues in existing services

prisma/schema.prisma CHANGED
@@ -1,8 +1,6 @@
1
  /**
2
  * Prisma Schema for Maternal Health Support Backend
3
- *
4
  * ORM CHOICE: Prisma
5
- *
6
  * JUSTIFICATION:
7
  * - Type-safe database client with excellent TypeScript support
8
  * - Intuitive schema definition language
@@ -10,7 +8,6 @@
10
  * - Built-in connection pooling
11
  * - Great developer experience with Prisma Studio
12
  * - Strong PostgreSQL support
13
- *
14
  * CLINICAL SAFETY PRINCIPLES:
15
  * - Data minimization enforced at schema level
16
  * - No free-text medical fields
@@ -47,6 +44,12 @@ enum HealthWorkerType {
47
  COMMUNITY_HEALTH_WORKER
48
  }
49
 
 
 
 
 
 
 
50
  /**
51
  * Organization Entity
52
  */
@@ -78,12 +81,15 @@ model Facility {
78
  createdAt DateTime @default(now())
79
  updatedAt DateTime @updatedAt
80
 
81
- organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
82
  healthcareWorkers HealthcareWorker[]
83
- patients Patient[]
84
- appointments Appointment[]
85
- alerts Alert[]
86
- referrals Referral[]
 
 
 
87
 
88
  @@index([organizationId])
89
  @@index([facilityCode])
@@ -95,18 +101,20 @@ model Facility {
95
  * Healthcare Worker Entity
96
  */
97
  model HealthcareWorker {
98
- id String @id @default(uuid())
99
- userId String @unique
100
- facilityId String
101
- type HealthWorkerType
102
- firstName String
103
- lastName String
104
- isSuspended Boolean @default(false)
105
- createdAt DateTime @default(now())
106
- updatedAt DateTime @updatedAt
107
-
108
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
109
- facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
 
 
110
 
111
  @@index([userId])
112
  @@index([facilityId])
@@ -118,32 +126,40 @@ model HealthcareWorker {
118
  * Patient Entity
119
  */
120
  model Patient {
121
- id String @id @default(uuid())
122
- userId String @unique
123
- facilityId String
124
- patientId String @unique
125
- firstName String
126
- lastName String
127
- phone String
128
- age Int
129
- address String?
130
- gestationalAge Int
131
  estimatedDeliveryDate DateTime?
132
- qrCode String?
133
- isActive Boolean @default(true)
134
- createdAt DateTime @default(now())
135
- updatedAt DateTime @updatedAt
 
 
 
 
 
 
136
 
137
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
138
- facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
139
- appointments Appointment[]
140
- alerts Alert[]
141
- referrals Referral[]
 
142
 
143
  @@index([userId])
144
  @@index([facilityId])
145
  @@index([patientId])
146
  @@index([isActive])
 
147
  @@map("patient")
148
  }
149
 
@@ -151,18 +167,18 @@ model Patient {
151
  * Alert Entity
152
  */
153
  model Alert {
154
- id String @id @default(uuid())
155
- facilityId String
156
- patientId String
157
- type String
158
- message String
159
- isResolved Boolean @default(false)
160
- resolvedAt DateTime?
161
- createdAt DateTime @default(now())
162
- updatedAt DateTime @updatedAt
163
-
164
- facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
165
- patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
166
 
167
  @@index([facilityId])
168
  @@index([patientId])
@@ -173,54 +189,76 @@ model Alert {
173
  /**
174
  * Referral Entity
175
  */
 
 
 
 
 
 
 
 
176
  model Referral {
177
- id String @id @default(uuid())
178
- facilityId String
179
- patientId String
180
- reason String
181
- status String @default("PENDING")
182
- createdAt DateTime @default(now())
183
- updatedAt DateTime @updatedAt
184
 
185
- facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
186
- patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
- @@index([facilityId])
189
  @@index([patientId])
 
 
190
  @@index([status])
191
- @@map("referral")
192
  }
193
 
194
  /**
195
  * UserAuth Entity
196
- *
197
  * CLINICAL SAFETY CONSTRAINTS:
198
  * - Minimal PII only (email OR phone, not both required)
199
  * - No health data in this table
200
  * - No logging of credentials
201
  * - Passwords are hashed (never stored in plain text)
202
- *
203
  * DATA MINIMIZATION:
204
  * - Only authentication-related fields
205
  * - No names, addresses, or demographic data
206
  * - Health data stored in separate UserProfile table
207
  */
208
  model UserAuth {
209
- id String @id @default(uuid())
210
- email String? @unique
211
- phone String? @unique
212
- passwordHash String // bcrypt hashed password
213
- role UserRole @default(PATIENT)
214
- refreshToken String? // For JWT refresh token rotation
215
- pushToken String? // For Expo push notifications
216
- resetPasswordToken String? // For forgot password
217
  resetPasswordExpires DateTime? // For forgot password
218
- isActive Boolean @default(true)
219
- createdAt DateTime @default(now())
220
- updatedAt DateTime @updatedAt
221
 
222
  // Relation to UserProfile (optional - created after registration)
223
- profile UserProfile?
224
 
225
  // Relation to BloodPressureReadings
226
  bloodPressureReadings BloodPressureReading[]
@@ -254,7 +292,6 @@ model UserAuth {
254
 
255
  /**
256
  * Age Range Enum
257
- *
258
  * DATA MINIMIZATION:
259
  * - Use age ranges instead of exact DOB
260
  * - Prevents unnecessary PII collection
@@ -267,7 +304,6 @@ enum AgeRange {
267
 
268
  /**
269
  * Emergency Contact Relationship Enum
270
- *
271
  * DATA MINIMIZATION:
272
  * - Relationship type only (no names stored)
273
  * - Supports midwife or closest relative
@@ -281,7 +317,6 @@ enum EmergencyContactRelationship {
281
 
282
  /**
283
  * Known Conditions Enum
284
- *
285
  * CLINICAL SAFETY:
286
  * - Enumerated list prevents free-text medical history
287
  * - Only high-risk pregnancy conditions relevant to hypertension
@@ -299,47 +334,45 @@ enum KnownCondition {
299
 
300
  /**
301
  * UserProfile Entity
302
- *
303
  * DATA MINIMIZATION PRINCIPLES:
304
  * - No DOB (age range only)
305
  * - No address
306
  * - No free-text medical history
307
  * - No name or demographic data
308
  * - Only pregnancy-relevant information
309
- *
310
  * CLINICAL SAFETY:
311
  * - All fields are enumerated or structured
312
  * - No diagnostic interpretations stored
313
  * - Links to UserAuth via userId
314
  */
315
  model UserProfile {
316
- id String @id @default(uuid())
317
- userId String @unique
318
- ageRange AgeRange
319
- pregnancyWeeks Int // Current week of pregnancy (0-42)
320
- firstPregnancy Boolean
321
- knownConditions KnownCondition[] // Array of enumerated conditions
322
- emergencyContactPhone String? // Phone number only (no name)
323
- emergencyContactEmail String? // Email address for emergency alerts
324
- emergencyContactRelationship EmergencyContactRelationship? // Relationship enum (e.g. MIDWIFE, PARTNER)
325
-
326
  // Clinic Information
327
  clinicName String?
328
  clinicAddress String?
329
  clinicPhone String?
330
-
331
  // Notification Preferences
332
  notifyCarePriority Boolean @default(true)
333
  notifyBpAlert Boolean @default(true)
334
  notifySymptomAlert Boolean @default(true)
335
  notifyReminders Boolean @default(true)
336
  reminderTime String? @default("09:00") // 24h format (HH:mm)
337
-
338
- createdAt DateTime @default(now())
339
- updatedAt DateTime @updatedAt
340
 
341
  // Relation to UserAuth
342
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
343
 
344
  @@index([userId])
345
  @@map("user_profile")
@@ -347,33 +380,30 @@ model UserProfile {
347
 
348
  /**
349
  * Blood Pressure Reading Entity
350
- *
351
  * CLINICAL SAFETY CONSTRAINTS:
352
  * - Manual entry only (no device integration)
353
  * - Do NOT label readings as normal/abnormal
354
  * - Do NOT store interpretations
355
  * - Neutral data handling only
356
- *
357
  * VALIDATION:
358
  * - Systolic: 60-260 mmHg (physiologically possible range)
359
  * - Diastolic: 40-160 mmHg (physiologically possible range)
360
  * - Timestamp for temporal tracking
361
- *
362
  * PURPOSE:
363
  * - Store raw BP measurements
364
  * - Used by care priority engine (Phase 3)
365
  * - No diagnostic labels attached to data
366
  */
367
  model BloodPressureReading {
368
- id String @id @default(uuid())
369
- userId String
370
- systolic Int // mmHg (60-260)
371
- diastolic Int // mmHg (40-160)
372
- recordedAt DateTime @default(now())
373
- createdAt DateTime @default(now())
374
 
375
  // Relation to UserAuth
376
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
377
 
378
  @@index([userId])
379
  @@index([recordedAt])
@@ -382,7 +412,6 @@ model BloodPressureReading {
382
 
383
  /**
384
  * Symptom Type Enum
385
- *
386
  * CLINICAL SAFETY:
387
  * - Enumerated list of pregnancy hypertension warning signs
388
  * - Free-text is only allowed with OTHER for user-entered symptoms
@@ -390,45 +419,43 @@ model BloodPressureReading {
390
  * - Based on clinical guidelines for preeclampsia/eclampsia
391
  */
392
  enum SymptomType {
393
- HEADACHE // Severe or persistent headache
394
- BLURRED_VISION // Visual disturbances
395
- SWELLING // Edema (face, hands, feet)
396
- VAGINAL_BLEEDING // Vaginal bleeding
397
- FEVER // Fever
398
- REDUCED_FETAL_MOVEMENT // Reduced fetal movement
399
- DIFFICULTY_BREATHING // Difficulty breathing
400
- CONVULSIONS // Convulsions
401
- UPPER_ABDOMINAL_PAIN // Right upper quadrant pain
402
- REDUCED_URINE // Decreased urine output
403
- NAUSEA_VOMITING // Persistent nausea/vomiting
404
- SHORTNESS_OF_BREATH // Difficulty breathing
405
- OTHER // User-entered symptom text stored separately
406
  }
407
 
408
  /**
409
  * Symptom Record Entity
410
- *
411
  * CLINICAL SAFETY CONSTRAINTS:
412
  * - Atomic recording (one symptom per record)
413
  * - No severity levels
414
  * - No numeric scoring
415
  * - No interpretation or diagnosis
416
- *
417
  * PURPOSE:
418
  * - Track presence of warning symptoms
419
  * - Used by care priority engine for escalation
420
  * - Each symptom is a separate, timestamped record
421
  */
422
  model SymptomRecord {
423
- id String @id @default(uuid())
424
- userId String
425
  symptomType SymptomType
426
  customSymptom String?
427
  recordedAt DateTime @default(now())
428
- createdAt DateTime @default(now())
429
 
430
  // Relation to UserAuth
431
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
432
 
433
  @@index([userId])
434
  @@index([recordedAt])
@@ -474,23 +501,22 @@ enum NotificationCategory {
474
 
475
  /**
476
  * Notification Entity
477
- *
478
  * PURPOSE:
479
  * - Store history of alerts and notifications
480
  * - Track read status
481
  */
482
  model Notification {
483
- id String @id @default(uuid())
484
  userId String
485
  type NotificationType
486
- priority NotificationPriority @default(REMINDER)
487
  category NotificationCategory?
488
  title String
489
  message String
490
- read Boolean @default(false)
491
- createdAt DateTime @default(now())
492
 
493
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
494
 
495
  @@index([userId])
496
  @@index([createdAt])
@@ -499,7 +525,6 @@ model Notification {
499
 
500
  /**
501
  * MonitoringState Enum
502
- *
503
  * Defines the possible states of the monitoring system for a user.
504
  */
505
  enum MonitoringState {
@@ -511,17 +536,16 @@ enum MonitoringState {
511
 
512
  /**
513
  * MonitoringStateRecord Entity
514
- *
515
  * Stores the current monitoring state for each user.
516
  */
517
  model MonitoringStateRecord {
518
- id String @id @default(uuid())
519
- userId String @unique
520
  currentState MonitoringState
521
- lastUpdatedAt DateTime @default(now())
522
  reason String?
523
 
524
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
525
 
526
  @@index([userId])
527
  @@map("monitoring_state_record")
@@ -529,7 +553,6 @@ model MonitoringStateRecord {
529
 
530
  /**
531
  * FollowUpTaskType Enum
532
- *
533
  * Defines the types of follow-up tasks.
534
  */
535
  enum FollowUpTaskType {
@@ -539,7 +562,6 @@ enum FollowUpTaskType {
539
 
540
  /**
541
  * FollowUpTaskStatus Enum
542
- *
543
  * Defines the status of follow-up tasks.
544
  */
545
  enum FollowUpTaskStatus {
@@ -548,20 +570,27 @@ enum FollowUpTaskStatus {
548
  MISSED
549
  }
550
 
 
 
 
 
 
 
 
 
551
  /**
552
  * FollowUpTask Entity
553
- *
554
  * Stores details about each follow-up task.
555
  */
556
  model FollowUpTask {
557
- id String @id @default(uuid())
558
- userId String
559
- type FollowUpTaskType
560
- dueAt DateTime
561
- status FollowUpTaskStatus @default(PENDING)
562
  relatedReadingId String? // Optional: Link to a specific BP reading that triggered the task
563
 
564
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
565
 
566
  @@index([userId])
567
  @@index([dueAt])
@@ -581,23 +610,38 @@ enum AppointmentStatus {
581
  MISSED
582
  }
583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
  model Appointment {
585
- id String @id @default(uuid())
586
- userId String
587
- facilityId String?
588
- patientId String?
589
- type AppointmentType
590
- title String
591
- dateTime DateTime
592
- location String?
593
- notes String?
594
- status AppointmentStatus @default(SCHEDULED)
595
- createdAt DateTime @default(now())
596
- updatedAt DateTime @updatedAt
597
-
598
- userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
599
- facility Facility? @relation(fields: [facilityId], references: [id], onDelete: Cascade)
600
- patient Patient? @relation(fields: [patientId], references: [id], onDelete: Cascade)
601
 
602
  @@index([userId])
603
  @@index([facilityId])
@@ -605,3 +649,55 @@ model Appointment {
605
  @@index([dateTime])
606
  @@map("appointment")
607
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /**
2
  * Prisma Schema for Maternal Health Support Backend
 
3
  * ORM CHOICE: Prisma
 
4
  * JUSTIFICATION:
5
  * - Type-safe database client with excellent TypeScript support
6
  * - Intuitive schema definition language
 
8
  * - Built-in connection pooling
9
  * - Great developer experience with Prisma Studio
10
  * - Strong PostgreSQL support
 
11
  * CLINICAL SAFETY PRINCIPLES:
12
  * - Data minimization enforced at schema level
13
  * - No free-text medical fields
 
44
  COMMUNITY_HEALTH_WORKER
45
  }
46
 
47
+ enum PatientRiskLevel {
48
+ NORMAL
49
+ HIGH
50
+ CRITICAL
51
+ }
52
+
53
  /**
54
  * Organization Entity
55
  */
 
81
  createdAt DateTime @default(now())
82
  updatedAt DateTime @updatedAt
83
 
84
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
85
  healthcareWorkers HealthcareWorker[]
86
+ patients Patient[]
87
+ appointments Appointment[]
88
+ alerts Alert[]
89
+ referrals Referral[]
90
+ ancVisits ANCVisit[]
91
+ outgoingReferrals Referral[] @relation("OutgoingReferrals")
92
+ incomingReferrals Referral[] @relation("IncomingReferrals")
93
 
94
  @@index([organizationId])
95
  @@index([facilityCode])
 
101
  * Healthcare Worker Entity
102
  */
103
  model HealthcareWorker {
104
+ id String @id @default(uuid())
105
+ userId String @unique
106
+ facilityId String
107
+ type HealthWorkerType
108
+ firstName String
109
+ lastName String
110
+ isSuspended Boolean @default(false)
111
+ createdAt DateTime @default(now())
112
+ updatedAt DateTime @updatedAt
113
+
114
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
115
+ facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
116
+
117
+ ancVisits ANCVisit[]
118
 
119
  @@index([userId])
120
  @@index([facilityId])
 
126
  * Patient Entity
127
  */
128
  model Patient {
129
+ id String @id @default(uuid())
130
+ userId String @unique
131
+ facilityId String
132
+ patientId String @unique
133
+ firstName String
134
+ lastName String
135
+ phone String
136
+ age Int
137
+ address String?
138
+ gestationalAge Int
139
  estimatedDeliveryDate DateTime?
140
+ qrCode String?
141
+ isActive Boolean @default(true)
142
+ createdAt DateTime @default(now())
143
+ updatedAt DateTime @updatedAt
144
+
145
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
146
+ facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
147
+ appointments Appointment[]
148
+ alerts Alert[]
149
+ referrals Referral[]
150
 
151
+ ancVisits ANCVisit[]
152
+ status PatientStatus @default(ACTIVE)
153
+ riskLevel PatientRiskLevel @default(NORMAL)
154
+ riskReason String?
155
+ latestRiskLevel RiskLevel @default(LOW)
156
+ lastVisitDate DateTime?
157
 
158
  @@index([userId])
159
  @@index([facilityId])
160
  @@index([patientId])
161
  @@index([isActive])
162
+ @@index([status])
163
  @@map("patient")
164
  }
165
 
 
167
  * Alert Entity
168
  */
169
  model Alert {
170
+ id String @id @default(uuid())
171
+ facilityId String
172
+ patientId String
173
+ type String
174
+ message String
175
+ isResolved Boolean @default(false)
176
+ resolvedAt DateTime?
177
+ createdAt DateTime @default(now())
178
+ updatedAt DateTime @updatedAt
179
+
180
+ facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
181
+ patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
182
 
183
  @@index([facilityId])
184
  @@index([patientId])
 
189
  /**
190
  * Referral Entity
191
  */
192
+
193
+ enum ReferralStatus {
194
+ PENDING
195
+ ACCEPTED
196
+ COMPLETED
197
+ CANCELLED
198
+ }
199
+
200
  model Referral {
201
+ id String @id @default(uuid())
 
 
 
 
 
 
202
 
203
+ patientId String
204
+
205
+ fromFacilityId String
206
+ toFacilityId String?
207
+
208
+ reason String
209
+ notes String?
210
+
211
+ status ReferralStatus @default(PENDING)
212
+
213
+ referredAt DateTime @default(now())
214
+ acceptedAt DateTime?
215
+ completedAt DateTime?
216
+
217
+ patient Patient @relation(fields: [patientId], references: [id])
218
+
219
+ fromFacility Facility @relation("OutgoingReferrals", fields: [fromFacilityId], references: [id])
220
+
221
+ toFacility Facility? @relation("IncomingReferrals", fields: [toFacilityId], references: [id])
222
+
223
+ createdAt DateTime @default(now())
224
+ updatedAt DateTime @updatedAt
225
+ facility Facility? @relation(fields: [facilityId], references: [id])
226
+ facilityId String?
227
 
 
228
  @@index([patientId])
229
+ @@index([fromFacilityId])
230
+ @@index([toFacilityId])
231
  @@index([status])
 
232
  }
233
 
234
  /**
235
  * UserAuth Entity
 
236
  * CLINICAL SAFETY CONSTRAINTS:
237
  * - Minimal PII only (email OR phone, not both required)
238
  * - No health data in this table
239
  * - No logging of credentials
240
  * - Passwords are hashed (never stored in plain text)
 
241
  * DATA MINIMIZATION:
242
  * - Only authentication-related fields
243
  * - No names, addresses, or demographic data
244
  * - Health data stored in separate UserProfile table
245
  */
246
  model UserAuth {
247
+ id String @id @default(uuid())
248
+ email String? @unique
249
+ phone String? @unique
250
+ passwordHash String // bcrypt hashed password
251
+ role UserRole @default(PATIENT)
252
+ refreshToken String? // For JWT refresh token rotation
253
+ pushToken String? // For Expo push notifications
254
+ resetPasswordToken String? // For forgot password
255
  resetPasswordExpires DateTime? // For forgot password
256
+ isActive Boolean @default(true)
257
+ createdAt DateTime @default(now())
258
+ updatedAt DateTime @updatedAt
259
 
260
  // Relation to UserProfile (optional - created after registration)
261
+ profile UserProfile?
262
 
263
  // Relation to BloodPressureReadings
264
  bloodPressureReadings BloodPressureReading[]
 
292
 
293
  /**
294
  * Age Range Enum
 
295
  * DATA MINIMIZATION:
296
  * - Use age ranges instead of exact DOB
297
  * - Prevents unnecessary PII collection
 
304
 
305
  /**
306
  * Emergency Contact Relationship Enum
 
307
  * DATA MINIMIZATION:
308
  * - Relationship type only (no names stored)
309
  * - Supports midwife or closest relative
 
317
 
318
  /**
319
  * Known Conditions Enum
 
320
  * CLINICAL SAFETY:
321
  * - Enumerated list prevents free-text medical history
322
  * - Only high-risk pregnancy conditions relevant to hypertension
 
334
 
335
  /**
336
  * UserProfile Entity
 
337
  * DATA MINIMIZATION PRINCIPLES:
338
  * - No DOB (age range only)
339
  * - No address
340
  * - No free-text medical history
341
  * - No name or demographic data
342
  * - Only pregnancy-relevant information
 
343
  * CLINICAL SAFETY:
344
  * - All fields are enumerated or structured
345
  * - No diagnostic interpretations stored
346
  * - Links to UserAuth via userId
347
  */
348
  model UserProfile {
349
+ id String @id @default(uuid())
350
+ userId String @unique
351
+ ageRange AgeRange
352
+ pregnancyWeeks Int // Current week of pregnancy (0-42)
353
+ firstPregnancy Boolean
354
+ knownConditions KnownCondition[] // Array of enumerated conditions
355
+ emergencyContactPhone String? // Phone number only (no name)
356
+ emergencyContactEmail String? // Email address for emergency alerts
357
+ emergencyContactRelationship EmergencyContactRelationship? // Relationship enum (e.g. MIDWIFE, PARTNER)
358
+
359
  // Clinic Information
360
  clinicName String?
361
  clinicAddress String?
362
  clinicPhone String?
363
+
364
  // Notification Preferences
365
  notifyCarePriority Boolean @default(true)
366
  notifyBpAlert Boolean @default(true)
367
  notifySymptomAlert Boolean @default(true)
368
  notifyReminders Boolean @default(true)
369
  reminderTime String? @default("09:00") // 24h format (HH:mm)
370
+
371
+ createdAt DateTime @default(now())
372
+ updatedAt DateTime @updatedAt
373
 
374
  // Relation to UserAuth
375
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
376
 
377
  @@index([userId])
378
  @@map("user_profile")
 
380
 
381
  /**
382
  * Blood Pressure Reading Entity
 
383
  * CLINICAL SAFETY CONSTRAINTS:
384
  * - Manual entry only (no device integration)
385
  * - Do NOT label readings as normal/abnormal
386
  * - Do NOT store interpretations
387
  * - Neutral data handling only
 
388
  * VALIDATION:
389
  * - Systolic: 60-260 mmHg (physiologically possible range)
390
  * - Diastolic: 40-160 mmHg (physiologically possible range)
391
  * - Timestamp for temporal tracking
 
392
  * PURPOSE:
393
  * - Store raw BP measurements
394
  * - Used by care priority engine (Phase 3)
395
  * - No diagnostic labels attached to data
396
  */
397
  model BloodPressureReading {
398
+ id String @id @default(uuid())
399
+ userId String
400
+ systolic Int // mmHg (60-260)
401
+ diastolic Int // mmHg (40-160)
402
+ recordedAt DateTime @default(now())
403
+ createdAt DateTime @default(now())
404
 
405
  // Relation to UserAuth
406
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
407
 
408
  @@index([userId])
409
  @@index([recordedAt])
 
412
 
413
  /**
414
  * Symptom Type Enum
 
415
  * CLINICAL SAFETY:
416
  * - Enumerated list of pregnancy hypertension warning signs
417
  * - Free-text is only allowed with OTHER for user-entered symptoms
 
419
  * - Based on clinical guidelines for preeclampsia/eclampsia
420
  */
421
  enum SymptomType {
422
+ HEADACHE // Severe or persistent headache
423
+ BLURRED_VISION // Visual disturbances
424
+ SWELLING // Edema (face, hands, feet)
425
+ VAGINAL_BLEEDING // Vaginal bleeding
426
+ FEVER // Fever
427
+ REDUCED_FETAL_MOVEMENT // Reduced fetal movement
428
+ DIFFICULTY_BREATHING // Difficulty breathing
429
+ CONVULSIONS // Convulsions
430
+ UPPER_ABDOMINAL_PAIN // Right upper quadrant pain
431
+ REDUCED_URINE // Decreased urine output
432
+ NAUSEA_VOMITING // Persistent nausea/vomiting
433
+ SHORTNESS_OF_BREATH // Difficulty breathing
434
+ OTHER // User-entered symptom text stored separately
435
  }
436
 
437
  /**
438
  * Symptom Record Entity
 
439
  * CLINICAL SAFETY CONSTRAINTS:
440
  * - Atomic recording (one symptom per record)
441
  * - No severity levels
442
  * - No numeric scoring
443
  * - No interpretation or diagnosis
 
444
  * PURPOSE:
445
  * - Track presence of warning symptoms
446
  * - Used by care priority engine for escalation
447
  * - Each symptom is a separate, timestamped record
448
  */
449
  model SymptomRecord {
450
+ id String @id @default(uuid())
451
+ userId String
452
  symptomType SymptomType
453
  customSymptom String?
454
  recordedAt DateTime @default(now())
455
+ createdAt DateTime @default(now())
456
 
457
  // Relation to UserAuth
458
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
459
 
460
  @@index([userId])
461
  @@index([recordedAt])
 
501
 
502
  /**
503
  * Notification Entity
 
504
  * PURPOSE:
505
  * - Store history of alerts and notifications
506
  * - Track read status
507
  */
508
  model Notification {
509
+ id String @id @default(uuid())
510
  userId String
511
  type NotificationType
512
+ priority NotificationPriority @default(REMINDER)
513
  category NotificationCategory?
514
  title String
515
  message String
516
+ read Boolean @default(false)
517
+ createdAt DateTime @default(now())
518
 
519
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
520
 
521
  @@index([userId])
522
  @@index([createdAt])
 
525
 
526
  /**
527
  * MonitoringState Enum
 
528
  * Defines the possible states of the monitoring system for a user.
529
  */
530
  enum MonitoringState {
 
536
 
537
  /**
538
  * MonitoringStateRecord Entity
 
539
  * Stores the current monitoring state for each user.
540
  */
541
  model MonitoringStateRecord {
542
+ id String @id @default(uuid())
543
+ userId String @unique
544
  currentState MonitoringState
545
+ lastUpdatedAt DateTime @default(now())
546
  reason String?
547
 
548
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
549
 
550
  @@index([userId])
551
  @@map("monitoring_state_record")
 
553
 
554
  /**
555
  * FollowUpTaskType Enum
 
556
  * Defines the types of follow-up tasks.
557
  */
558
  enum FollowUpTaskType {
 
562
 
563
  /**
564
  * FollowUpTaskStatus Enum
 
565
  * Defines the status of follow-up tasks.
566
  */
567
  enum FollowUpTaskStatus {
 
570
  MISSED
571
  }
572
 
573
+ enum PatientStatus {
574
+ ACTIVE
575
+ DEFAULTER
576
+ DELIVERED
577
+ TRANSFERRED
578
+ LOST_TO_FOLLOW_UP
579
+ }
580
+
581
  /**
582
  * FollowUpTask Entity
 
583
  * Stores details about each follow-up task.
584
  */
585
  model FollowUpTask {
586
+ id String @id @default(uuid())
587
+ userId String
588
+ type FollowUpTaskType
589
+ dueAt DateTime
590
+ status FollowUpTaskStatus @default(PENDING)
591
  relatedReadingId String? // Optional: Link to a specific BP reading that triggered the task
592
 
593
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
594
 
595
  @@index([userId])
596
  @@index([dueAt])
 
610
  MISSED
611
  }
612
 
613
+ enum UrineProtein {
614
+ NEGATIVE
615
+ TRACE
616
+ PLUS_1
617
+ PLUS_2
618
+ PLUS_3
619
+ }
620
+
621
+ enum RiskLevel {
622
+ LOW
623
+ MODERATE
624
+ HIGH
625
+ EMERGENCY
626
+ }
627
+
628
  model Appointment {
629
+ id String @id @default(uuid())
630
+ userId String
631
+ facilityId String?
632
+ patientId String?
633
+ type AppointmentType
634
+ title String
635
+ dateTime DateTime
636
+ location String?
637
+ notes String?
638
+ status AppointmentStatus @default(SCHEDULED)
639
+ createdAt DateTime @default(now())
640
+ updatedAt DateTime @updatedAt
641
+
642
+ userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
643
+ facility Facility? @relation(fields: [facilityId], references: [id], onDelete: Cascade)
644
+ patient Patient? @relation(fields: [patientId], references: [id], onDelete: Cascade)
645
 
646
  @@index([userId])
647
  @@index([facilityId])
 
649
  @@index([dateTime])
650
  @@map("appointment")
651
  }
652
+
653
+ model ANCVisit {
654
+ id String @id @default(uuid())
655
+
656
+ patientId String
657
+ patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
658
+
659
+ facilityId String
660
+ facility Facility @relation(fields: [facilityId], references: [id], onDelete: Cascade)
661
+
662
+ healthcareWorkerId String
663
+ healthcareWorker HealthcareWorker @relation(fields: [healthcareWorkerId], references: [id], onDelete: Cascade)
664
+
665
+ visitDate DateTime @default(now())
666
+
667
+ gestationalAge Int?
668
+
669
+ weight Float?
670
+
671
+ bloodPressureSystolic Int?
672
+ bloodPressureDiastolic Int?
673
+
674
+ urineProtein UrineProtein?
675
+
676
+ fundalHeight Float?
677
+
678
+ fetalHeartRate Int?
679
+
680
+ fetalMovement Boolean?
681
+
682
+ haemoglobin Float?
683
+
684
+ temperature Float?
685
+
686
+ remarks String?
687
+
688
+ nextAppointmentDate DateTime?
689
+
690
+ riskLevel RiskLevel @default(LOW)
691
+
692
+ createdAt DateTime @default(now())
693
+
694
+ updatedAt DateTime @updatedAt
695
+
696
+ @@index([patientId])
697
+ @@index([facilityId])
698
+ @@index([healthcareWorkerId])
699
+ @@index([visitDate])
700
+ @@index([nextAppointmentDate])
701
+ @@index([riskLevel])
702
+ @@map("anc_visit")
703
+ }
src/anc-visit/anc-visit.contoller.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Body, Controller, Get, Logger, Param, Post, Req, UseGuards } from "@nestjs/common";
2
+ import { Request } from "express";
3
+ import { AncVisitService } from "./anc-visit.service";
4
+ import { CreateAncVisitDto } from "./dto/create-anc-visit.dto";
5
+ import { AuthGuard } from "@nestjs/passport";
6
+
7
+ @UseGuards(AuthGuard('jwt'))
8
+ @Controller("anc-visits")
9
+ export class AncVisitController {
10
+ private readonly Logger = new Logger(AncVisitController.name);
11
+ constructor(private readonly ancVisitService: AncVisitService) {}
12
+
13
+ @Post()
14
+ async create(@Body() dto: CreateAncVisitDto, @Req() req: Request) {
15
+ const healthcareWorkerId = (req.user as any).healthcareWorker.id;
16
+ return this.ancVisitService.create(dto, healthcareWorkerId);
17
+ }
18
+
19
+ @Get("patient/:patientId")
20
+ async getPatientVisits(
21
+ @Param("patientId")
22
+ patientId: string,
23
+ ) {
24
+ return this.ancVisitService.getPatientVisits(patientId);
25
+ }
26
+
27
+ @Get("defaulters")
28
+ async getDefaulters(@Req() req: Request) {
29
+ const healthcareWorkerId = (req.user as any).healthcareWorker.id;
30
+
31
+ return this.ancVisitService.getDefaulters(healthcareWorkerId);
32
+ }
33
+
34
+ @Get("patient/:patientId/summary")
35
+ async getPatientSummary(
36
+ @Param("patientId")
37
+ patientId: string,
38
+ ) {
39
+ return this.ancVisitService.getPatientSummary(
40
+ patientId,
41
+ );
42
+ }
43
+ }
src/anc-visit/anc-visit.module.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from "@nestjs/common";
2
+ import { PrismaService } from "src/database/prisma.service";
3
+ import { AncVisitController } from "./anc-visit.contoller";
4
+ import { AncVisitService } from "./anc-visit.service";
5
+
6
+ @Module({
7
+ controllers: [AncVisitController],
8
+ providers: [AncVisitService, PrismaService],
9
+ })
10
+ export class AncVisitModule {}
src/anc-visit/anc-visit.service.ts ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, NotFoundException } from "@nestjs/common";
2
+ import { CreateAncVisitDto } from "./dto/create-anc-visit.dto";
3
+ import { PrismaService } from "src/database/prisma.service";
4
+ import { calculateRisk } from "./utils/calculate-risk";
5
+
6
+ @Injectable()
7
+ export class AncVisitService {
8
+ constructor(private readonly prisma: PrismaService) {}
9
+
10
+ async create(
11
+ dto: CreateAncVisitDto,
12
+ healthcareWorkerId: string,
13
+ ) {
14
+ const healthcareWorker =
15
+ await this.prisma.healthcareWorker.findUnique({
16
+ where: {
17
+ id: healthcareWorkerId,
18
+ },
19
+ });
20
+
21
+ if (!healthcareWorker) {
22
+ throw new NotFoundException(
23
+ "Healthcare worker not found",
24
+ );
25
+ }
26
+
27
+ const patient =
28
+ await this.prisma.patient.findFirst({
29
+ where: {
30
+ id: dto.patientId,
31
+ facilityId:
32
+ healthcareWorker.facilityId,
33
+ },
34
+ });
35
+
36
+ if (!patient) {
37
+ throw new NotFoundException(
38
+ "Patient not found",
39
+ );
40
+ }
41
+
42
+ const riskLevel = calculateRisk({
43
+ systolic:
44
+ dto.bloodPressureSystolic,
45
+ diastolic:
46
+ dto.bloodPressureDiastolic,
47
+ urineProtein:
48
+ dto.urineProtein,
49
+ });
50
+
51
+ const visit =
52
+ await this.prisma.aNCVisit.create({
53
+ data: {
54
+ patientId: patient.id,
55
+ facilityId:
56
+ patient.facilityId,
57
+ healthcareWorkerId,
58
+
59
+ visitDate: dto.visitDate
60
+ ? new Date(dto.visitDate)
61
+ : new Date(),
62
+
63
+ gestationalAge:
64
+ dto.gestationalAge,
65
+ weight: dto.weight,
66
+
67
+ bloodPressureSystolic:
68
+ dto.bloodPressureSystolic,
69
+
70
+ bloodPressureDiastolic:
71
+ dto.bloodPressureDiastolic,
72
+
73
+ urineProtein:
74
+ dto.urineProtein,
75
+
76
+ fundalHeight:
77
+ dto.fundalHeight,
78
+
79
+ fetalHeartRate:
80
+ dto.fetalHeartRate,
81
+
82
+ fetalMovement:
83
+ dto.fetalMovement,
84
+
85
+ haemoglobin:
86
+ dto.haemoglobin,
87
+
88
+ temperature:
89
+ dto.temperature,
90
+
91
+ remarks:
92
+ dto.remarks,
93
+
94
+ nextAppointmentDate:
95
+ dto.nextAppointmentDate
96
+ ? new Date(
97
+ dto.nextAppointmentDate,
98
+ )
99
+ : null,
100
+
101
+ riskLevel,
102
+ },
103
+ });
104
+
105
+ /**
106
+ * Update patient's latest risk snapshot
107
+ */
108
+ await this.prisma.patient.update({
109
+ where: {
110
+ id: patient.id,
111
+ },
112
+ data: {
113
+ latestRiskLevel: riskLevel,
114
+ lastVisitDate:
115
+ visit.visitDate,
116
+ },
117
+ });
118
+
119
+ /**
120
+ * Create appointment
121
+ */
122
+ if (dto.nextAppointmentDate) {
123
+ await this.prisma.appointment.create({
124
+ data: {
125
+ userId: patient.userId,
126
+ patientId: patient.id,
127
+ facilityId:
128
+ patient.facilityId,
129
+ title:
130
+ "Antenatal Care Visit",
131
+ type: "ANC",
132
+ dateTime: new Date(
133
+ dto.nextAppointmentDate,
134
+ ),
135
+ },
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Close old unresolved alerts
141
+ * if patient is no longer high risk.
142
+ */
143
+ if (
144
+ riskLevel === "LOW" ||
145
+ riskLevel === "MODERATE"
146
+ ) {
147
+ await this.prisma.alert.updateMany({
148
+ where: {
149
+ patientId: patient.id,
150
+ isResolved: false,
151
+ },
152
+ data: {
153
+ isResolved: true,
154
+ resolvedAt: new Date(),
155
+ },
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Create new alert
161
+ */
162
+ if (
163
+ riskLevel === "HIGH" ||
164
+ riskLevel === "EMERGENCY"
165
+ ) {
166
+ const existingAlert =
167
+ await this.prisma.alert.findFirst({
168
+ where: {
169
+ patientId: patient.id,
170
+ isResolved: false,
171
+ type: riskLevel,
172
+ },
173
+ });
174
+
175
+ if (!existingAlert) {
176
+ await this.prisma.alert.create({
177
+ data: {
178
+ facilityId:
179
+ patient.facilityId,
180
+ patientId:
181
+ patient.id,
182
+ type: riskLevel,
183
+ message:
184
+ riskLevel === "EMERGENCY"
185
+ ? "Severely elevated blood pressure detected."
186
+ : "High-risk pregnancy detected during ANC visit.",
187
+ },
188
+ });
189
+ }
190
+ }
191
+
192
+ return visit;
193
+ }
194
+
195
+ async getPatientVisits(patientId: string) {
196
+ const patient = await this.prisma.patient.findUnique({
197
+ where: {
198
+ id: patientId,
199
+ },
200
+ });
201
+
202
+ if (!patient) {
203
+ throw new NotFoundException("Patient not found");
204
+ }
205
+
206
+ return this.prisma.aNCVisit.findMany({
207
+ where: {
208
+ patientId,
209
+ },
210
+ include: {
211
+ healthcareWorker: {
212
+ select: {
213
+ firstName: true,
214
+ lastName: true,
215
+ type: true,
216
+ },
217
+ },
218
+ },
219
+ orderBy: {
220
+ visitDate: "desc",
221
+ },
222
+ });
223
+ }
224
+
225
+ async getDefaulters(healthcareWorkerId: string) {
226
+ const worker = await this.prisma.healthcareWorker.findUnique({
227
+ where: {
228
+ id: healthcareWorkerId,
229
+ },
230
+ });
231
+
232
+ if (!worker) {
233
+ throw new NotFoundException("Healthcare worker not found");
234
+ }
235
+
236
+ const today = new Date();
237
+
238
+ const visits = await this.prisma.aNCVisit.findMany({
239
+ where: {
240
+ facilityId: worker.facilityId,
241
+ nextAppointmentDate: {
242
+ lt: today,
243
+ },
244
+ },
245
+ include: {
246
+ patient: true,
247
+ },
248
+ orderBy: {
249
+ nextAppointmentDate: "asc",
250
+ },
251
+ });
252
+
253
+ const latestVisits = new Map<string, (typeof visits)[0]>();
254
+
255
+ for (const visit of visits) {
256
+ const existing = latestVisits.get(visit.patientId);
257
+
258
+ if (!existing || visit.visitDate > existing.visitDate) {
259
+ latestVisits.set(visit.patientId, visit);
260
+ }
261
+ }
262
+
263
+ const defaulters = [];
264
+
265
+ for (const visit of latestVisits.values()) {
266
+ const hasRecentVisit = await this.prisma.aNCVisit.findFirst({
267
+ where: {
268
+ patientId: visit.patientId,
269
+ visitDate: {
270
+ gt: visit.nextAppointmentDate ?? new Date(0),
271
+ },
272
+ },
273
+ });
274
+
275
+ if (hasRecentVisit) {
276
+ continue;
277
+ }
278
+
279
+ const daysOverdue = Math.floor(
280
+ (today.getTime() -
281
+ (visit.nextAppointmentDate?.getTime() ?? today.getTime())) /
282
+ (1000 * 60 * 60 * 24),
283
+ );
284
+
285
+ defaulters.push({
286
+ patientId: visit.patient.patientId,
287
+ patientDbId: visit.patient.id,
288
+ firstName: visit.patient.firstName,
289
+ lastName: visit.patient.lastName,
290
+ phone: visit.patient.phone,
291
+ daysOverdue,
292
+ riskLevel: visit.riskLevel,
293
+ lastVisitDate: visit.visitDate,
294
+ missedAppointmentDate: visit.nextAppointmentDate,
295
+ });
296
+ }
297
+
298
+ return defaulters.sort((a, b) => b.daysOverdue - a.daysOverdue);
299
+ }
300
+
301
+ async getPatientSummary(
302
+ patientId: string,
303
+ ) {
304
+ const patient =
305
+ await this.prisma.patient.findUnique({
306
+ where: {
307
+ id: patientId,
308
+ },
309
+ });
310
+
311
+ if (!patient) {
312
+ throw new NotFoundException(
313
+ "Patient not found",
314
+ );
315
+ }
316
+
317
+ const visits =
318
+ await this.prisma.aNCVisit.findMany({
319
+ where: {
320
+ patientId,
321
+ },
322
+ orderBy: {
323
+ visitDate: "asc",
324
+ },
325
+ });
326
+
327
+ if (!visits.length) {
328
+ return {
329
+ totalVisits: 0,
330
+ lastVisitDate: null,
331
+ nextAppointmentDate: null,
332
+ riskLevel: "LOW",
333
+ missedAppointments: 0,
334
+ bpTrend: "UNKNOWN",
335
+ lastBloodPressure: null,
336
+ };
337
+ }
338
+
339
+ const latestVisit =
340
+ visits[visits.length - 1];
341
+
342
+ const today = new Date();
343
+
344
+ const missedAppointments =
345
+ visits.filter(
346
+ (v) =>
347
+ v.nextAppointmentDate &&
348
+ v.nextAppointmentDate < today,
349
+ ).length;
350
+
351
+ let bpTrend = "STABLE";
352
+
353
+ const bpVisits = visits.filter(
354
+ (v) =>
355
+ v.bloodPressureSystolic &&
356
+ v.bloodPressureDiastolic,
357
+ );
358
+
359
+ if (bpVisits.length >= 2) {
360
+ const last =
361
+ bpVisits[bpVisits.length - 1];
362
+
363
+ const previous =
364
+ bpVisits[bpVisits.length - 2];
365
+
366
+ if (
367
+ last.bloodPressureSystolic! >
368
+ previous.bloodPressureSystolic!
369
+ ) {
370
+ bpTrend = "RISING";
371
+ } else if (
372
+ last.bloodPressureSystolic! <
373
+ previous.bloodPressureSystolic!
374
+ ) {
375
+ bpTrend = "FALLING";
376
+ }
377
+ }
378
+
379
+ return {
380
+ totalVisits: visits.length,
381
+ lastVisitDate:
382
+ latestVisit.visitDate,
383
+ nextAppointmentDate:
384
+ latestVisit.nextAppointmentDate,
385
+ riskLevel:
386
+ latestVisit.riskLevel,
387
+ missedAppointments,
388
+ bpTrend,
389
+ lastBloodPressure: {
390
+ systolic:
391
+ latestVisit
392
+ .bloodPressureSystolic,
393
+ diastolic:
394
+ latestVisit
395
+ .bloodPressureDiastolic,
396
+ },
397
+ };
398
+ }
399
+ }
src/anc-visit/dto/create-anc-visit.dto.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ IsBoolean,
3
+ IsDateString,
4
+ IsEnum,
5
+ IsInt,
6
+ IsNumber,
7
+ IsOptional,
8
+ IsString,
9
+ } from "class-validator";
10
+ import { RiskLevel, UrineProtein } from "@prisma/client";
11
+
12
+ export class CreateAncVisitDto {
13
+ @IsString()
14
+ patientId!: string;
15
+
16
+ @IsInt()
17
+ @IsOptional()
18
+ gestationalAge?: number;
19
+
20
+ @IsNumber()
21
+ @IsOptional()
22
+ weight?: number;
23
+
24
+ @IsInt()
25
+ @IsOptional()
26
+ bloodPressureSystolic?: number;
27
+
28
+ @IsInt()
29
+ @IsOptional()
30
+ bloodPressureDiastolic?: number;
31
+
32
+ @IsEnum(UrineProtein)
33
+ @IsOptional()
34
+ urineProtein?: UrineProtein;
35
+
36
+ @IsNumber()
37
+ @IsOptional()
38
+ fundalHeight?: number;
39
+
40
+ @IsInt()
41
+ @IsOptional()
42
+ fetalHeartRate?: number;
43
+
44
+ @IsBoolean()
45
+ @IsOptional()
46
+ fetalMovement?: boolean;
47
+
48
+ @IsNumber()
49
+ @IsOptional()
50
+ haemoglobin?: number;
51
+
52
+ @IsNumber()
53
+ @IsOptional()
54
+ temperature?: number;
55
+
56
+ @IsString()
57
+ @IsOptional()
58
+ remarks?: string;
59
+
60
+ @IsDateString()
61
+ @IsOptional()
62
+ nextAppointmentDate?: string;
63
+
64
+ @IsEnum(RiskLevel)
65
+ @IsOptional()
66
+ riskLevel?: RiskLevel;
67
+
68
+ @IsDateString()
69
+ @IsOptional()
70
+ visitDate?: string;
71
+ }
src/anc-visit/utils/calculate-risk.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { RiskLevel, UrineProtein } from "@prisma/client";
2
+
3
+ export function calculateRisk({
4
+ systolic,
5
+ diastolic,
6
+ urineProtein,
7
+ }: {
8
+ systolic?: number;
9
+ diastolic?: number;
10
+ urineProtein?: UrineProtein;
11
+ }): RiskLevel {
12
+ if (
13
+ systolic &&
14
+ diastolic &&
15
+ systolic >= 160 &&
16
+ diastolic >= 110
17
+ ) {
18
+ return "EMERGENCY";
19
+ }
20
+
21
+ if (
22
+ systolic &&
23
+ diastolic &&
24
+ systolic >= 140 &&
25
+ diastolic >= 90
26
+ ) {
27
+ return "HIGH";
28
+ }
29
+
30
+ if (
31
+ urineProtein === "PLUS_2" ||
32
+ urineProtein === "PLUS_3"
33
+ ) {
34
+ return "HIGH";
35
+ }
36
+
37
+ if (
38
+ systolic &&
39
+ diastolic &&
40
+ systolic >= 130 &&
41
+ diastolic >= 80
42
+ ) {
43
+ return "MODERATE";
44
+ }
45
+
46
+ return "LOW";
47
+ }
src/app.module.ts CHANGED
@@ -25,6 +25,7 @@ import { AppController } from "./app.controller";
25
  import { HealthController } from "./health.controller";
26
  import { ReferralModule } from "./referral/referral.module";
27
  import { EmergencyModule } from "./emergency/emergency.module";
 
28
 
29
  @Module({
30
  imports: [
@@ -68,6 +69,7 @@ import { EmergencyModule } from "./emergency/emergency.module";
68
  PatientsModule,
69
  ReferralModule,
70
  EmergencyModule,
 
71
  ],
72
  controllers: [AppController, HealthController],
73
  providers: [
 
25
  import { HealthController } from "./health.controller";
26
  import { ReferralModule } from "./referral/referral.module";
27
  import { EmergencyModule } from "./emergency/emergency.module";
28
+ import { AncVisitModule } from "./anc-visit/anc-visit.module";
29
 
30
  @Module({
31
  imports: [
 
69
  PatientsModule,
70
  ReferralModule,
71
  EmergencyModule,
72
+ AncVisitModule
73
  ],
74
  controllers: [AppController, HealthController],
75
  providers: [
src/health-worker/health-worker.service.ts CHANGED
@@ -19,7 +19,7 @@ export class HealthWorkerService {
19
  async getDashboardStats() {
20
  // Count only PATIENT-role users, not all userAuth records
21
  const totalPatients = await this.prisma.userAuth.count({
22
- where: { role: "PATIENT", isActive: true },
23
  });
24
 
25
  const highRiskPatients = await this.prisma.monitoringStateRecord.count({
 
19
  async getDashboardStats() {
20
  // Count only PATIENT-role users, not all userAuth records
21
  const totalPatients = await this.prisma.userAuth.count({
22
+ where: { role: "PATIENT", isActive: true, },
23
  });
24
 
25
  const highRiskPatients = await this.prisma.monitoringStateRecord.count({
src/patients/dto/get-patient-dto.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ IsEnum,
3
+ IsNumberString,
4
+ IsOptional,
5
+ IsString,
6
+ } from 'class-validator';
7
+ import { RiskLevel } from '@prisma/client';
8
+
9
+ export class GetPatientsDto {
10
+ @IsOptional()
11
+ @IsNumberString()
12
+ page?: string;
13
+
14
+ @IsOptional()
15
+ @IsNumberString()
16
+ limit?: string;
17
+
18
+ @IsOptional()
19
+ @IsString()
20
+ search?: string;
21
+
22
+ @IsOptional()
23
+ @IsEnum(RiskLevel)
24
+ risk?: RiskLevel;
25
+ }
src/patients/patients.controller.ts CHANGED
@@ -6,19 +6,26 @@ import {
6
  Param,
7
  UseGuards,
8
  Req,
 
 
 
9
  } from "@nestjs/common";
10
  import { PatientsService } from "./patients.service";
11
  import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
12
  import { RolesGuard } from "../auth/guards/roles.guard";
13
  import { SetMetadata } from "@nestjs/common";
14
  import { Request } from "express";
 
15
 
16
  const Roles = (...roles: string[]) => SetMetadata("roles", roles);
17
 
18
  @Controller("patients")
19
  @UseGuards(JwtAuthGuard, RolesGuard)
20
  export class PatientsController {
21
- constructor(private readonly patientsService: PatientsService) {}
 
 
 
22
 
23
  @Post()
24
  @Roles("SUPER_ADMIN", "HEALTH_WORKER")
@@ -35,18 +42,30 @@ export class PatientsController {
35
  facilityId: string;
36
  email?: string;
37
  password: string;
38
- }
39
  ) {
40
  return this.patientsService.create(data);
41
  }
42
 
43
  @Get()
44
  @Roles("SUPER_ADMIN", "HEALTH_WORKER")
45
- findAll(@Req() req: Request & { user: any }) {
46
  if (req.user.role === "HEALTH_WORKER") {
47
- return this.patientsService.findAll(req.user.healthcareWorker?.facilityId);
 
 
 
48
  }
49
- return this.patientsService.findAll();
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
  @Get(":id")
@@ -54,4 +73,6 @@ export class PatientsController {
54
  findOne(@Param("id") id: string) {
55
  return this.patientsService.findOne(id);
56
  }
57
- }
 
 
 
6
  Param,
7
  UseGuards,
8
  Req,
9
+ LoggerService,
10
+ Logger,
11
+ Query,
12
  } from "@nestjs/common";
13
  import { PatientsService } from "./patients.service";
14
  import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
15
  import { RolesGuard } from "../auth/guards/roles.guard";
16
  import { SetMetadata } from "@nestjs/common";
17
  import { Request } from "express";
18
+ import { GetPatientsDto } from "./dto/get-patient-dto";
19
 
20
  const Roles = (...roles: string[]) => SetMetadata("roles", roles);
21
 
22
  @Controller("patients")
23
  @UseGuards(JwtAuthGuard, RolesGuard)
24
  export class PatientsController {
25
+ private readonly Logger = new Logger(PatientsController.name);
26
+
27
+
28
+ constructor(private readonly patientsService: PatientsService,) {}
29
 
30
  @Post()
31
  @Roles("SUPER_ADMIN", "HEALTH_WORKER")
 
42
  facilityId: string;
43
  email?: string;
44
  password: string;
45
+ },
46
  ) {
47
  return this.patientsService.create(data);
48
  }
49
 
50
  @Get()
51
  @Roles("SUPER_ADMIN", "HEALTH_WORKER")
52
+ findAll(@Req() req: Request & { user: any }, @Query() query: GetPatientsDto) {
53
  if (req.user.role === "HEALTH_WORKER") {
54
+ return this.patientsService.findAll(
55
+ req.user.healthcareWorker?.facilityId || "",
56
+ query,
57
+ );
58
  }
59
+ return this.patientsService.findAll("", query);
60
+ }
61
+
62
+ @Get("defaulters")
63
+ @Roles("HEALTH_WORKER")
64
+ getDefaulters(@Req() req: Request) {
65
+ this.Logger.log(req.user);
66
+ return this.patientsService.getDefaulters(
67
+ (req.user as any).healthcareWorker?.facilityId,
68
+ );
69
  }
70
 
71
  @Get(":id")
 
73
  findOne(@Param("id") id: string) {
74
  return this.patientsService.findOne(id);
75
  }
76
+
77
+
78
+ }
src/patients/patients.service.ts CHANGED
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from "@nestjs/common";
2
  import { PrismaService } from "../database/prisma.service";
3
  import { v4 as uuidv4 } from "uuid";
4
  import * as bcrypt from "bcrypt";
 
 
5
 
6
  @Injectable()
7
  export class PatientsService {
@@ -52,14 +54,88 @@ export class PatientsService {
52
  });
53
  }
54
 
55
- async findAll(facilityId?: string) {
56
- return this.prisma.patient.findMany({
57
- where: facilityId ? { facilityId } : undefined,
58
- include: { userAuth: true, facility: true },
59
- orderBy: { createdAt: "desc" },
60
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  async findOne(id: string) {
64
  const patient = await this.prisma.patient.findUnique({
65
  where: { id },
@@ -70,4 +146,43 @@ export class PatientsService {
70
  }
71
  return patient;
72
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  }
 
2
  import { PrismaService } from "../database/prisma.service";
3
  import { v4 as uuidv4 } from "uuid";
4
  import * as bcrypt from "bcrypt";
5
+ import { GetPatientsDto } from "./dto/get-patient-dto";
6
+ import { Prisma } from "@prisma/client";
7
 
8
  @Injectable()
9
  export class PatientsService {
 
54
  });
55
  }
56
 
57
+ async findAll(
58
+ facilityId: string,
59
+ query: GetPatientsDto,
60
+ ) {
61
+ const page =
62
+ Number(query.page) || 1;
63
+
64
+ const limit =
65
+ Number(query.limit) || 20;
66
+
67
+ const skip =
68
+ (page - 1) * limit;
69
+
70
+ const where: Prisma.PatientWhereInput = {
71
+ facilityId,
72
+ };
73
+
74
+ if (query.risk) {
75
+ where.latestRiskLevel =
76
+ query.risk;
77
  }
78
 
79
+ if (query.search) {
80
+ where.OR = [
81
+ {
82
+ firstName: {
83
+ contains: query.search,
84
+ mode: 'insensitive',
85
+ },
86
+ },
87
+ {
88
+ lastName: {
89
+ contains: query.search,
90
+ mode: 'insensitive',
91
+ },
92
+ },
93
+ {
94
+ patientId: {
95
+ contains: query.search,
96
+ mode: 'insensitive',
97
+ },
98
+ },
99
+ {
100
+ phone: {
101
+ contains: query.search,
102
+ },
103
+ },
104
+ ];
105
+ }
106
+
107
+ const [patients, total] =
108
+ await Promise.all([
109
+ this.prisma.patient.findMany({
110
+ where,
111
+ skip,
112
+ take: limit,
113
+ include: {
114
+ facility: true,
115
+ },
116
+ orderBy: {
117
+ updatedAt: 'desc',
118
+ },
119
+ }),
120
+
121
+ this.prisma.patient.count({
122
+ where,
123
+ }),
124
+ ]);
125
+
126
+ return {
127
+ data: patients,
128
+ meta: {
129
+ page,
130
+ limit,
131
+ total,
132
+ totalPages: Math.ceil(
133
+ total / limit,
134
+ ),
135
+ },
136
+ };
137
+ }
138
+
139
  async findOne(id: string) {
140
  const patient = await this.prisma.patient.findUnique({
141
  where: { id },
 
146
  }
147
  return patient;
148
  }
149
+
150
+ async getDefaulters(facilityId: string) {
151
+ const sevenDaysAgo = new Date();
152
+
153
+ sevenDaysAgo.setDate(
154
+ sevenDaysAgo.getDate() - 7,
155
+ );
156
+
157
+ const patients = await this.prisma.patient.findMany({
158
+ where: {
159
+ facilityId,
160
+ appointments: {
161
+ some: {
162
+ status: 'SCHEDULED',
163
+ dateTime: {
164
+ lte: sevenDaysAgo,
165
+ },
166
+ },
167
+ },
168
+ },
169
+ include: {
170
+ appointments: {
171
+ where: {
172
+ status: 'SCHEDULED',
173
+ },
174
+ orderBy: {
175
+ dateTime: 'desc',
176
+ },
177
+ take: 1,
178
+ },
179
+ },
180
+ orderBy: {
181
+ updatedAt: 'desc',
182
+ },
183
+ });
184
+ console.log(patients);
185
+
186
+ return patients;
187
+ }
188
  }
src/referral/dto/create-referral-dto.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ IsOptional,
3
+ IsString,
4
+ } from 'class-validator';
5
+
6
+ export class CreateReferralDto {
7
+ @IsString()
8
+ patientId!: string;
9
+
10
+ @IsString()
11
+ reason!: string;
12
+
13
+ @IsOptional()
14
+ @IsString()
15
+ notes?: string;
16
+
17
+ @IsOptional()
18
+ @IsString()
19
+ toFacilityId?: string;
20
+ }
src/referral/referral.service.ts CHANGED
@@ -3,11 +3,9 @@
3
  import { Injectable, NotFoundException } from "@nestjs/common";
4
  import { PrismaService } from "../database/prisma.service";
5
 
6
- export interface CreateReferralDto {
7
- patientId: string; // Patient.id (HW registered) or UserAuth.id
8
- reason: string;
9
- facilityId: string;
10
- }
11
 
12
  export interface UpdateReferralDto {
13
  status: "PENDING" | "ACCEPTED" | "COMPLETED" | "CANCELLED";
@@ -17,48 +15,64 @@ export interface UpdateReferralDto {
17
  export class ReferralsService {
18
  constructor(private readonly prisma: PrismaService) {}
19
 
20
- async create(dto: CreateReferralDto) {
21
- // Resolve patient — could be Patient.id or UserAuth.id
22
- let patient = await this.prisma.patient.findUnique({
23
- where: { id: dto.patientId },
24
- });
25
-
26
- // If not found by Patient.id, try treating it as userId
27
- if (!patient) {
28
- patient = await this.prisma.patient.findUnique({
29
- where: { userId: dto.patientId },
30
- });
31
- }
32
-
33
- if (!patient) {
34
- throw new NotFoundException(
35
- `No registered patient found for id ${dto.patientId}. ` +
36
- `Self-registered (app-only) patients cannot yet be referred — ` +
37
- `please register them at your facility first.`
38
- );
39
- }
40
-
41
- return this.prisma.referral.create({
42
- data: {
43
- patientId: patient.id,
44
- facilityId: dto.facilityId,
45
- reason: dto.reason,
46
- status: "PENDING",
47
- },
48
- include: {
49
- patient: {
50
- select: {
51
- firstName: true,
52
- lastName: true,
53
- patientId: true,
54
- phone: true,
55
- },
56
- },
57
- facility: { select: { name: true, address: true, phone: true } },
58
  },
59
  });
 
 
 
 
 
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  async findAll(facilityId?: string) {
63
  return this.prisma.referral.findMany({
64
  where: facilityId ? { facilityId } : undefined,
 
3
  import { Injectable, NotFoundException } from "@nestjs/common";
4
  import { PrismaService } from "../database/prisma.service";
5
 
6
+ import { CreateReferralDto } from "./dto/create-referral-dto";
7
+
8
+
 
 
9
 
10
  export interface UpdateReferralDto {
11
  status: "PENDING" | "ACCEPTED" | "COMPLETED" | "CANCELLED";
 
15
  export class ReferralsService {
16
  constructor(private readonly prisma: PrismaService) {}
17
 
18
+ async create(
19
+ dto: CreateReferralDto,
20
+ facilityId: string,
21
+ ) {
22
+ const patient =
23
+ await this.prisma.patient.findFirst({
24
+ where: {
25
+ id: dto.patientId,
26
+ facilityId,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  },
28
  });
29
+
30
+ if (!patient) {
31
+ throw new NotFoundException(
32
+ 'Patient not found',
33
+ );
34
  }
35
 
36
+ return this.prisma.referral.create({
37
+ data: {
38
+ patientId: patient.id,
39
+ fromFacilityId: facilityId,
40
+ toFacilityId:
41
+ dto.toFacilityId,
42
+ reason: dto.reason,
43
+ notes: dto.notes,
44
+ },
45
+ });
46
+ }
47
+
48
+ async accept(
49
+ referralId: string,
50
+ ) {
51
+ return this.prisma.referral.update({
52
+ where: {
53
+ id: referralId,
54
+ },
55
+ data: {
56
+ status: 'ACCEPTED',
57
+ acceptedAt: new Date(),
58
+ },
59
+ });
60
+ }
61
+
62
+ async complete(
63
+ referralId: string,
64
+ ) {
65
+ return this.prisma.referral.update({
66
+ where: {
67
+ id: referralId,
68
+ },
69
+ data: {
70
+ status: 'COMPLETED',
71
+ completedAt: new Date(),
72
+ },
73
+ });
74
+ }
75
+
76
  async findAll(facilityId?: string) {
77
  return this.prisma.referral.findMany({
78
  where: facilityId ? { facilityId } : undefined,
src/risk/risk.service.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable } from '@nestjs/common';
2
+ import { PatientRiskLevel } from '@prisma/client';
3
+
4
+ @Injectable()
5
+ export class RiskService {
6
+ evaluate(
7
+ systolic?: number,
8
+ diastolic?: number,
9
+ urineProtein?: string,
10
+ ) {
11
+ if (
12
+ (systolic && systolic >= 160) ||
13
+ (diastolic && diastolic >= 110)
14
+ ) {
15
+ return {
16
+ riskLevel:
17
+ PatientRiskLevel.CRITICAL,
18
+ reason:
19
+ 'Severely elevated blood pressure',
20
+ };
21
+ }
22
+
23
+ if (
24
+ (systolic && systolic >= 140) ||
25
+ (diastolic && diastolic >= 90) ||
26
+ urineProtein === 'PLUS_2' ||
27
+ urineProtein === 'PLUS_3'
28
+ ) {
29
+ return {
30
+ riskLevel:
31
+ PatientRiskLevel.HIGH,
32
+ reason:
33
+ 'Elevated blood pressure or proteinuria',
34
+ };
35
+ }
36
+
37
+ return {
38
+ riskLevel:
39
+ PatientRiskLevel.NORMAL,
40
+ reason: null,
41
+ };
42
+ }
43
+ }