Spaces:
Running
Running
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 +259 -163
- src/anc-visit/anc-visit.contoller.ts +43 -0
- src/anc-visit/anc-visit.module.ts +10 -0
- src/anc-visit/anc-visit.service.ts +399 -0
- src/anc-visit/dto/create-anc-visit.dto.ts +71 -0
- src/anc-visit/utils/calculate-risk.ts +47 -0
- src/app.module.ts +2 -0
- src/health-worker/health-worker.service.ts +1 -1
- src/patients/dto/get-patient-dto.ts +25 -0
- src/patients/patients.controller.ts +27 -6
- src/patients/patients.service.ts +121 -6
- src/referral/dto/create-referral-dto.ts +20 -0
- src/referral/referral.service.ts +57 -43
- src/risk/risk.service.ts +43 -0
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
|
| 82 |
healthcareWorkers HealthcareWorker[]
|
| 83 |
-
patients
|
| 84 |
-
appointments
|
| 85 |
-
alerts
|
| 86 |
-
referrals
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
@@index([organizationId])
|
| 89 |
@@index([facilityCode])
|
|
@@ -95,18 +101,20 @@ model Facility {
|
|
| 95 |
* Healthcare Worker Entity
|
| 96 |
*/
|
| 97 |
model HealthcareWorker {
|
| 98 |
-
id
|
| 99 |
-
userId
|
| 100 |
-
facilityId
|
| 101 |
-
type
|
| 102 |
-
firstName
|
| 103 |
-
lastName
|
| 104 |
-
isSuspended
|
| 105 |
-
createdAt
|
| 106 |
-
updatedAt
|
| 107 |
-
|
| 108 |
-
userAuth
|
| 109 |
-
facility
|
|
|
|
|
|
|
| 110 |
|
| 111 |
@@index([userId])
|
| 112 |
@@index([facilityId])
|
|
@@ -118,32 +126,40 @@ model HealthcareWorker {
|
|
| 118 |
* Patient Entity
|
| 119 |
*/
|
| 120 |
model Patient {
|
| 121 |
-
id
|
| 122 |
-
userId
|
| 123 |
-
facilityId
|
| 124 |
-
patientId
|
| 125 |
-
firstName
|
| 126 |
-
lastName
|
| 127 |
-
phone
|
| 128 |
-
age
|
| 129 |
-
address
|
| 130 |
-
gestationalAge
|
| 131 |
estimatedDeliveryDate DateTime?
|
| 132 |
-
qrCode
|
| 133 |
-
isActive
|
| 134 |
-
createdAt
|
| 135 |
-
updatedAt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 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
|
| 155 |
-
facilityId
|
| 156 |
-
patientId
|
| 157 |
-
type
|
| 158 |
-
message
|
| 159 |
-
isResolved
|
| 160 |
-
resolvedAt
|
| 161 |
-
createdAt
|
| 162 |
-
updatedAt
|
| 163 |
-
|
| 164 |
-
facility
|
| 165 |
-
patient
|
| 166 |
|
| 167 |
@@index([facilityId])
|
| 168 |
@@index([patientId])
|
|
@@ -173,54 +189,76 @@ model Alert {
|
|
| 173 |
/**
|
| 174 |
* Referral Entity
|
| 175 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
model Referral {
|
| 177 |
-
id
|
| 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 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 210 |
-
email
|
| 211 |
-
phone
|
| 212 |
-
passwordHash
|
| 213 |
-
role
|
| 214 |
-
refreshToken
|
| 215 |
-
pushToken
|
| 216 |
-
resetPasswordToken String?
|
| 217 |
resetPasswordExpires DateTime? // For forgot password
|
| 218 |
-
isActive
|
| 219 |
-
createdAt
|
| 220 |
-
updatedAt
|
| 221 |
|
| 222 |
// Relation to UserProfile (optional - created after registration)
|
| 223 |
-
profile
|
| 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
|
| 317 |
-
userId
|
| 318 |
-
ageRange
|
| 319 |
-
pregnancyWeeks
|
| 320 |
-
firstPregnancy
|
| 321 |
-
knownConditions
|
| 322 |
-
emergencyContactPhone
|
| 323 |
-
emergencyContactEmail
|
| 324 |
-
emergencyContactRelationship
|
| 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
|
| 339 |
-
updatedAt
|
| 340 |
|
| 341 |
// Relation to UserAuth
|
| 342 |
-
userAuth
|
| 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
|
| 369 |
-
userId
|
| 370 |
-
systolic
|
| 371 |
-
diastolic
|
| 372 |
-
recordedAt
|
| 373 |
-
createdAt
|
| 374 |
|
| 375 |
// Relation to UserAuth
|
| 376 |
-
userAuth
|
| 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
|
| 394 |
-
BLURRED_VISION
|
| 395 |
-
SWELLING
|
| 396 |
-
VAGINAL_BLEEDING
|
| 397 |
-
FEVER
|
| 398 |
-
REDUCED_FETAL_MOVEMENT
|
| 399 |
-
DIFFICULTY_BREATHING
|
| 400 |
-
CONVULSIONS
|
| 401 |
-
UPPER_ABDOMINAL_PAIN
|
| 402 |
-
REDUCED_URINE
|
| 403 |
-
NAUSEA_VOMITING
|
| 404 |
-
SHORTNESS_OF_BREATH
|
| 405 |
-
OTHER
|
| 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
|
| 424 |
-
userId
|
| 425 |
symptomType SymptomType
|
| 426 |
customSymptom String?
|
| 427 |
recordedAt DateTime @default(now())
|
| 428 |
-
createdAt
|
| 429 |
|
| 430 |
// Relation to UserAuth
|
| 431 |
-
userAuth
|
| 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
|
| 484 |
userId String
|
| 485 |
type NotificationType
|
| 486 |
-
priority NotificationPriority
|
| 487 |
category NotificationCategory?
|
| 488 |
title String
|
| 489 |
message String
|
| 490 |
-
read Boolean
|
| 491 |
-
createdAt DateTime
|
| 492 |
|
| 493 |
-
userAuth
|
| 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
|
| 519 |
-
userId String
|
| 520 |
currentState MonitoringState
|
| 521 |
-
lastUpdatedAt DateTime
|
| 522 |
reason String?
|
| 523 |
|
| 524 |
-
userAuth
|
| 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
|
| 558 |
-
userId
|
| 559 |
-
type
|
| 560 |
-
dueAt
|
| 561 |
-
status
|
| 562 |
relatedReadingId String? // Optional: Link to a specific BP reading that triggered the task
|
| 563 |
|
| 564 |
-
userAuth
|
| 565 |
|
| 566 |
@@index([userId])
|
| 567 |
@@index([dueAt])
|
|
@@ -581,23 +610,38 @@ enum AppointmentStatus {
|
|
| 581 |
MISSED
|
| 582 |
}
|
| 583 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
model Appointment {
|
| 585 |
-
id
|
| 586 |
-
userId
|
| 587 |
-
facilityId
|
| 588 |
-
patientId
|
| 589 |
-
type
|
| 590 |
-
title
|
| 591 |
-
dateTime
|
| 592 |
-
location
|
| 593 |
-
notes
|
| 594 |
-
status
|
| 595 |
-
createdAt
|
| 596 |
-
updatedAt
|
| 597 |
-
|
| 598 |
-
userAuth
|
| 599 |
-
facility
|
| 600 |
-
patient
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 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 |
-
|
| 7 |
-
|
| 8 |
-
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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 |
+
}
|