Spaces:
Running
Running
Commit ·
f78b36a
1
Parent(s): 2b70e7f
Initial backend deployment with Docker and Cron jobs
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +11 -0
- .env.example +23 -0
- .gitignore +46 -0
- DEPLOYMENT.md +40 -0
- Dockerfile +40 -0
- README.md +167 -9
- nest-cli.json +8 -0
- package-lock.json +0 -0
- package.json +75 -0
- prisma.config.ts +14 -0
- prisma/schema.prisma +282 -0
- src/app.controller.ts +30 -0
- src/app.module.ts +50 -0
- src/auth/auth.controller.ts +129 -0
- src/auth/auth.module.ts +46 -0
- src/auth/auth.service.ts +343 -0
- src/auth/dto/auth-response.dto.ts +10 -0
- src/auth/dto/forgot-password.dto.ts +17 -0
- src/auth/dto/login.dto.ts +23 -0
- src/auth/dto/refresh-token.dto.ts +9 -0
- src/auth/dto/register.dto.ts +31 -0
- src/auth/dto/reset-password.dto.ts +17 -0
- src/auth/guards/jwt-auth.guard.ts +14 -0
- src/auth/strategies/jwt.strategy.ts +39 -0
- src/blood-pressure/blood-pressure.controller.ts +92 -0
- src/blood-pressure/blood-pressure.module.ts +27 -0
- src/blood-pressure/blood-pressure.service.ts +151 -0
- src/blood-pressure/dto/create-blood-pressure.dto.ts +30 -0
- src/care-priority/care-priority.controller.ts +71 -0
- src/care-priority/care-priority.module.ts +21 -0
- src/care-priority/care-priority.service.ts +298 -0
- src/care-priority/health-assessment.service.ts +104 -0
- src/care-priority/types/care-priority.types.ts +32 -0
- src/clinic-finder/clinic-finder.controller.ts +33 -0
- src/clinic-finder/clinic-finder.module.ts +11 -0
- src/clinic-finder/clinic-finder.service.ts +73 -0
- src/clinic-finder/data/clinics.data.ts +125 -0
- src/database/database.module.ts +33 -0
- src/database/prisma.service.ts +102 -0
- src/education/data/data.txt +123 -0
- src/education/data/education.content.ts +177 -0
- src/education/education.controller.ts +43 -0
- src/education/education.module.ts +22 -0
- src/education/education.service.ts +44 -0
- src/health.controller.ts +18 -0
- src/main.ts +19 -0
- src/notifications/dto/register-push-token.dto.ts +7 -0
- src/notifications/email.service.ts +41 -0
- src/notifications/notifications.controller.ts +25 -0
- src/notifications/notifications.module.ts +31 -0
.dockerignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
npm-debug.log
|
| 4 |
+
Dockerfile
|
| 5 |
+
.dockerignore
|
| 6 |
+
.git
|
| 7 |
+
.env
|
| 8 |
+
README.md
|
| 9 |
+
test
|
| 10 |
+
coverage
|
| 11 |
+
prisma/migrations
|
.env.example
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Application
|
| 2 |
+
NODE_ENV=development
|
| 3 |
+
PORT=3000
|
| 4 |
+
|
| 5 |
+
# Database (PostgreSQL) - Prisma Connection String
|
| 6 |
+
DATABASE_URL="postgresql://postgres:your_password_here@localhost:5432/maternal_health?schema=public"
|
| 7 |
+
|
| 8 |
+
# JWT Authentication
|
| 9 |
+
JWT_SECRET=your_jwt_secret_here_change_in_production
|
| 10 |
+
JWT_EXPIRATION=1h
|
| 11 |
+
JWT_REFRESH_SECRET=your_refresh_secret_here_change_in_production
|
| 12 |
+
JWT_REFRESH_EXPIRATION=7d
|
| 13 |
+
|
| 14 |
+
# Frontend Configuration
|
| 15 |
+
FRONTEND_URL=http://localhost:8081
|
| 16 |
+
|
| 17 |
+
# Email Configuration (SMTP)
|
| 18 |
+
SMTP_HOST=smtp.gmail.com
|
| 19 |
+
SMTP_PORT=587
|
| 20 |
+
SMTP_USER=your-email@gmail.com
|
| 21 |
+
SMTP_PASS=your-app-password
|
| 22 |
+
SMTP_FROM=MaternAlert <no-reply@maternalert.com>
|
| 23 |
+
|
.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# compiled output
|
| 2 |
+
/dist
|
| 3 |
+
/node_modules
|
| 4 |
+
|
| 5 |
+
# Logs
|
| 6 |
+
logs
|
| 7 |
+
*.log
|
| 8 |
+
npm-debug.log*
|
| 9 |
+
pnpm-debug.log*
|
| 10 |
+
yarn-debug.log*
|
| 11 |
+
yarn-error.log*
|
| 12 |
+
lerna-debug.log*
|
| 13 |
+
|
| 14 |
+
# OS
|
| 15 |
+
.DS_Store
|
| 16 |
+
|
| 17 |
+
# Tests
|
| 18 |
+
/coverage
|
| 19 |
+
/.nyc_output
|
| 20 |
+
|
| 21 |
+
# IDEs and editors
|
| 22 |
+
/.idea
|
| 23 |
+
.project
|
| 24 |
+
.classpath
|
| 25 |
+
.c9/
|
| 26 |
+
*.launch
|
| 27 |
+
.settings/
|
| 28 |
+
*.sublime-workspace
|
| 29 |
+
|
| 30 |
+
# IDE - VSCode
|
| 31 |
+
.vscode/*
|
| 32 |
+
!.vscode/settings.json
|
| 33 |
+
!.vscode/tasks.json
|
| 34 |
+
!.vscode/launch.json
|
| 35 |
+
!.vscode/extensions.json
|
| 36 |
+
|
| 37 |
+
# Environment
|
| 38 |
+
# .env
|
| 39 |
+
.env.local
|
| 40 |
+
.env.*.local
|
| 41 |
+
|
| 42 |
+
/generated/prisma
|
| 43 |
+
|
| 44 |
+
# Firebase Service Account
|
| 45 |
+
*-firebase-adminsdk-*.json
|
| 46 |
+
google-services.json
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MaternAlert Backend Deployment Guide (Hugging Face Spaces)
|
| 2 |
+
|
| 3 |
+
This guide covers the deployment of the MaternAlert backend as a persistent, Docker-based environment on Hugging Face Spaces. This setup ensures 99.9% uptime for background tasks and cron jobs.
|
| 4 |
+
|
| 5 |
+
## 🚀 Deployment Steps
|
| 6 |
+
|
| 7 |
+
### 1. Prepare Hugging Face Space
|
| 8 |
+
1. Create a new Space on [Hugging Face](https://huggingface.co/new-space).
|
| 9 |
+
2. Choose **Docker** as the SDK.
|
| 10 |
+
3. Select the **Blank** template or **NestJS** if available.
|
| 11 |
+
4. Choose your hardware (the free tier is sufficient for this backend).
|
| 12 |
+
|
| 13 |
+
### 2. Configure Environment Variables
|
| 14 |
+
In your Space settings, add the following secrets:
|
| 15 |
+
- `DATABASE_URL`: Your production PostgreSQL connection string.
|
| 16 |
+
- `JWT_SECRET`: A strong secret for token signing.
|
| 17 |
+
- `EXPO_ACCESS_TOKEN`: Your Expo access token for push notifications.
|
| 18 |
+
- `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USER`, `EMAIL_PASS`: SMTP credentials for email alerts.
|
| 19 |
+
- `FRONTEND_URL`: The URL of your deployed frontend.
|
| 20 |
+
|
| 21 |
+
### 3. Deploy Code
|
| 22 |
+
1. Clone the Space repository.
|
| 23 |
+
2. Copy the contents of the `maternalert-be` directory into the Space repository.
|
| 24 |
+
3. Ensure the `Dockerfile` and `.dockerignore` are in the root of the Space repository.
|
| 25 |
+
4. Push your changes to the Space.
|
| 26 |
+
|
| 27 |
+
## 🕒 Scheduled Tasks (Cron Jobs)
|
| 28 |
+
The backend is configured with `@nestjs/schedule` to run the following persistent tasks:
|
| 29 |
+
- **Daily Inactivity Check**: Runs at midnight to remind users who haven't logged BP in 5 days.
|
| 30 |
+
- **Trend Analysis**: Runs daily at 1 AM to detect dangerous BP patterns.
|
| 31 |
+
- **4-Hour Follow-Up**: Runs every 30 minutes to check for missed rechecks after elevated readings.
|
| 32 |
+
|
| 33 |
+
## 🏥 Health & Monitoring
|
| 34 |
+
- **Health Endpoint**: Access `https://your-space-url/api/v1/health` to verify service status and uptime.
|
| 35 |
+
- **Logs**: Monitor execution logs directly in the Hugging Face Space "Logs" tab.
|
| 36 |
+
|
| 37 |
+
## 🛠 Troubleshooting
|
| 38 |
+
- **Cold Starts**: Hugging Face Spaces stay active as long as they receive traffic or are configured as "Persistent." Ensure the Space is set to "Public" or "Private" with persistence enabled.
|
| 39 |
+
- **Database Connectivity**: Verify that your database allows connections from the Hugging Face IP range.
|
| 40 |
+
- **Permissions**: The Dockerfile is configured to run as a non-root user (UID 1000) as required by Hugging Face.
|
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build stage
|
| 2 |
+
FROM node:20-alpine AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install dependencies first (better caching)
|
| 7 |
+
COPY package*.json ./
|
| 8 |
+
COPY prisma ./prisma/
|
| 9 |
+
|
| 10 |
+
RUN npm install
|
| 11 |
+
|
| 12 |
+
# Copy source and build
|
| 13 |
+
COPY . .
|
| 14 |
+
RUN npx prisma generate
|
| 15 |
+
RUN npm run build
|
| 16 |
+
|
| 17 |
+
# Production stage
|
| 18 |
+
FROM node:20-alpine
|
| 19 |
+
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Create a non-root user for security (Hugging Face requirement)
|
| 23 |
+
RUN adduser -D -u 1000 appuser
|
| 24 |
+
USER appuser
|
| 25 |
+
|
| 26 |
+
# Copy built assets and production dependencies
|
| 27 |
+
COPY --from=builder /app/package*.json ./
|
| 28 |
+
COPY --from=builder /app/node_modules ./node_modules
|
| 29 |
+
COPY --from=builder /app/dist ./dist
|
| 30 |
+
COPY --from=builder /app/prisma ./prisma
|
| 31 |
+
|
| 32 |
+
# Set production environment
|
| 33 |
+
ENV NODE_ENV=production
|
| 34 |
+
ENV PORT=7860
|
| 35 |
+
|
| 36 |
+
# Expose the port (Hugging Face default is 7860)
|
| 37 |
+
EXPOSE 7860
|
| 38 |
+
|
| 39 |
+
# Run the app
|
| 40 |
+
CMD ["npm", "run", "start:prod"]
|
README.md
CHANGED
|
@@ -1,11 +1,169 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
| 1 |
+
# Maternal Health Support Backend
|
| 2 |
+
|
| 3 |
+
⚠️ **CLINICAL SAFETY NOTICE**
|
| 4 |
+
|
| 5 |
+
This application is a **CARE-SUPPORT and ESCALATION TOOL**.
|
| 6 |
+
|
| 7 |
+
- ❌ This is **NOT** a diagnostic system
|
| 8 |
+
- ❌ This is **NOT** a medical device
|
| 9 |
+
- ✅ This **IS** a care-support and escalation tool for early warning awareness
|
| 10 |
+
|
| 11 |
+
## Purpose
|
| 12 |
+
|
| 13 |
+
This backend supports a maternal health application focused on:
|
| 14 |
+
- Early warning awareness for high blood pressure disorders in pregnancy
|
| 15 |
+
- Care escalation based on objective measurements
|
| 16 |
+
- Educational content delivery
|
| 17 |
+
- Connection to healthcare providers
|
| 18 |
+
|
| 19 |
+
## Non-Diagnostic Design Principles
|
| 20 |
+
|
| 21 |
+
This system is built with strict clinical safety constraints:
|
| 22 |
+
|
| 23 |
+
1. **No Diagnosis**: The system never diagnoses conditions
|
| 24 |
+
2. **No Medical Advice**: No treatment recommendations or medical guidance
|
| 25 |
+
3. **No Risk Scoring**: No probability calculations or risk percentages
|
| 26 |
+
4. **Data Minimization**: Only essential data is collected
|
| 27 |
+
5. **Escalation Bias**: When uncertain, always escalate to higher priority
|
| 28 |
+
6. **Template-Only Messaging**: No dynamic medical text generation
|
| 29 |
+
|
| 30 |
+
## Architecture
|
| 31 |
+
|
| 32 |
+
### Modular Domain-Driven Design
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
src/
|
| 36 |
+
├── auth/ # Authentication (minimal PII)
|
| 37 |
+
├── user-profile/ # User profile (data minimization)
|
| 38 |
+
├── blood-pressure/ # BP readings (neutral storage)
|
| 39 |
+
├── symptoms/ # Symptom tracking (atomic, non-scored)
|
| 40 |
+
├── care-priority/ # Care priority engine (rule-based)
|
| 41 |
+
├── notifications/ # Alerts (template-only)
|
| 42 |
+
└── education/ # Educational content
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### Technology Stack
|
| 46 |
+
|
| 47 |
+
- **Framework**: NestJS with TypeScript (strict mode)
|
| 48 |
+
- **Database**: PostgreSQL (to be configured in STEP 2)
|
| 49 |
+
- **Authentication**: JWT-based
|
| 50 |
+
- **Validation**: class-validator with strict settings
|
| 51 |
+
|
| 52 |
+
## Getting Started
|
| 53 |
+
|
| 54 |
+
### Prerequisites
|
| 55 |
+
|
| 56 |
+
- Node.js 18+
|
| 57 |
+
- PostgreSQL 14+
|
| 58 |
+
- npm or yarn
|
| 59 |
+
|
| 60 |
+
### Installation
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
# Install dependencies
|
| 64 |
+
npm install
|
| 65 |
+
|
| 66 |
+
# Copy environment template
|
| 67 |
+
cp .env.example .env
|
| 68 |
+
|
| 69 |
+
# Edit .env with your configuration
|
| 70 |
+
# (Database credentials, JWT secrets, etc.)
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### Development
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
# Start in development mode with hot reload
|
| 77 |
+
npm run start:dev
|
| 78 |
+
|
| 79 |
+
# Build for production
|
| 80 |
+
npm run build
|
| 81 |
+
|
| 82 |
+
# Start production server
|
| 83 |
+
npm run start:prod
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### Testing
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
# Run unit tests
|
| 90 |
+
npm test
|
| 91 |
+
|
| 92 |
+
# Run tests in watch mode
|
| 93 |
+
npm test:watch
|
| 94 |
+
|
| 95 |
+
# Generate coverage report
|
| 96 |
+
npm test:cov
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
## Deployment
|
| 100 |
+
|
| 101 |
+
For persistent background tasks and cron jobs, the backend is optimized for deployment on **Hugging Face Spaces** using Docker.
|
| 102 |
+
|
| 103 |
+
- [Deployment Guide](./DEPLOYMENT.md)
|
| 104 |
+
- [Health Check API](http://localhost:3000/api/v1/health) (when running locally)
|
| 105 |
+
|
| 106 |
+
## Implementation Status
|
| 107 |
+
|
| 108 |
+
This project follows a 12-step implementation process:
|
| 109 |
+
|
| 110 |
+
### Phase 1: Foundation
|
| 111 |
+
- [x] **STEP 1**: Project Bootstrap & Architecture ✅
|
| 112 |
+
- [ ] **STEP 2**: Database & ORM Setup
|
| 113 |
+
- [ ] **STEP 3**: Authentication Module
|
| 114 |
+
|
| 115 |
+
### Phase 2: Core Data Modules
|
| 116 |
+
- [ ] **STEP 4**: User Profile Module (Data Minimization)
|
| 117 |
+
- [ ] **STEP 5**: Blood Pressure Module (Neutral Handling)
|
| 118 |
+
- [ ] **STEP 6**: Symptom Module (Atomic, Non-Scored)
|
| 119 |
+
|
| 120 |
+
### Phase 3: Care Logic & Safety
|
| 121 |
+
- [ ] **STEP 7**: Care Priority Engine (Most Critical)
|
| 122 |
+
- [ ] **STEP 8**: Care Priority API Endpoint
|
| 123 |
+
- [ ] **STEP 9**: Notifications & Alerts
|
| 124 |
+
|
| 125 |
+
### Phase 4: Support & Security
|
| 126 |
+
- [ ] **STEP 10**: Education Content Module
|
| 127 |
+
- [ ] **STEP 11**: Security, Logging & Rate Limiting
|
| 128 |
+
- [ ] **STEP 12**: Final Safety Audit
|
| 129 |
+
|
| 130 |
+
## API Documentation
|
| 131 |
+
|
| 132 |
+
Once running, API documentation will be available at:
|
| 133 |
+
- Development: `http://localhost:3000/api/v1`
|
| 134 |
+
- Swagger docs: (to be added in later steps)
|
| 135 |
+
|
| 136 |
+
## Clinical Safety Commitments
|
| 137 |
+
|
| 138 |
+
Every module in this system adheres to:
|
| 139 |
+
|
| 140 |
+
1. **Deterministic Logic**: No AI/ML for medical decisions
|
| 141 |
+
2. **Transparent Rules**: All escalation rules are documented
|
| 142 |
+
3. **Conservative Defaults**: Favor care-seeking when uncertain
|
| 143 |
+
4. **Audit Trail**: All decisions are logged (without PII)
|
| 144 |
+
5. **No Diagnostic Language**: Careful wording throughout
|
| 145 |
+
|
| 146 |
+
## Regulatory Considerations
|
| 147 |
+
|
| 148 |
+
This system is designed for:
|
| 149 |
+
- NGO deployments
|
| 150 |
+
- Public health pilots
|
| 151 |
+
- Clinical partnerships
|
| 152 |
+
- Research studies
|
| 153 |
+
|
| 154 |
+
**Not intended for**:
|
| 155 |
+
- Direct-to-consumer medical diagnosis
|
| 156 |
+
- Replacement of clinical judgment
|
| 157 |
+
- Unsupervised medical decision-making
|
| 158 |
+
|
| 159 |
+
## License
|
| 160 |
+
|
| 161 |
+
ISC
|
| 162 |
+
|
| 163 |
+
## Support
|
| 164 |
+
|
| 165 |
+
For questions about clinical safety design or implementation, please review the inline documentation in each module.
|
| 166 |
+
|
| 167 |
---
|
| 168 |
|
| 169 |
+
**Remember**: This is a care-support tool. Always encourage users to seek professional medical advice.
|
nest-cli.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json.schemastore.org/nest-cli",
|
| 3 |
+
"collection": "@nestjs/schematics",
|
| 4 |
+
"sourceRoot": "src",
|
| 5 |
+
"compilerOptions": {
|
| 6 |
+
"deleteOutDir": true
|
| 7 |
+
}
|
| 8 |
+
}
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "maternal-health-backend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Clinical-safe maternal health support backend - Care escalation tool (NOT a diagnostic system)",
|
| 5 |
+
"main": "dist/main.js",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"build": "prisma generate && prisma migrate deploy && nest build",
|
| 8 |
+
"postinstall": "prisma generate",
|
| 9 |
+
"format": "prettier --write \"src/**/*.ts\"",
|
| 10 |
+
"start": "nest start",
|
| 11 |
+
"start:dev": "nest start --watch",
|
| 12 |
+
"start:debug": "nest start --debug --watch",
|
| 13 |
+
"start:prod": "node dist/main",
|
| 14 |
+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
| 15 |
+
"test": "jest",
|
| 16 |
+
"test:watch": "jest --watch",
|
| 17 |
+
"test:cov": "jest --coverage",
|
| 18 |
+
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
| 19 |
+
"prisma:generate": "prisma generate",
|
| 20 |
+
"prisma:migrate": "prisma migrate dev",
|
| 21 |
+
"prisma:studio": "prisma studio",
|
| 22 |
+
"prisma:push": "prisma db push"
|
| 23 |
+
},
|
| 24 |
+
"keywords": [
|
| 25 |
+
"maternal-health",
|
| 26 |
+
"pregnancy",
|
| 27 |
+
"hypertension",
|
| 28 |
+
"care-escalation",
|
| 29 |
+
"clinical-safety"
|
| 30 |
+
],
|
| 31 |
+
"author": "",
|
| 32 |
+
"license": "ISC",
|
| 33 |
+
"type": "commonjs",
|
| 34 |
+
"devDependencies": {
|
| 35 |
+
"@nestjs/cli": "^11.0.14",
|
| 36 |
+
"@nestjs/schematics": "^11.0.9",
|
| 37 |
+
"@nestjs/testing": "^11.1.11",
|
| 38 |
+
"@types/bcrypt": "^6.0.0",
|
| 39 |
+
"@types/compression": "^1.8.1",
|
| 40 |
+
"@types/express": "^5.0.6",
|
| 41 |
+
"@types/node": "^25.0.6",
|
| 42 |
+
"@types/nodemailer": "^7.0.11",
|
| 43 |
+
"@types/passport-jwt": "^4.0.1",
|
| 44 |
+
"@types/pg": "^8.16.0",
|
| 45 |
+
"dotenv": "^17.2.3",
|
| 46 |
+
"prisma": "^7.2.0",
|
| 47 |
+
"ts-loader": "^9.5.4",
|
| 48 |
+
"ts-node": "^10.9.2",
|
| 49 |
+
"typescript": "^5.9.3"
|
| 50 |
+
},
|
| 51 |
+
"dependencies": {
|
| 52 |
+
"@googlemaps/google-maps-services-js": "^3.4.2",
|
| 53 |
+
"@nestjs/config": "^4.0.2",
|
| 54 |
+
"@nestjs/jwt": "^11.0.2",
|
| 55 |
+
"@nestjs/passport": "^11.0.5",
|
| 56 |
+
"@nestjs/platform-express": "^11.1.11",
|
| 57 |
+
"@nestjs/schedule": "^6.1.1",
|
| 58 |
+
"@nestjs/swagger": "^11.2.4",
|
| 59 |
+
"@nestjs/throttler": "^6.5.0",
|
| 60 |
+
"@prisma/adapter-pg": "^7.2.0",
|
| 61 |
+
"@prisma/client": "^7.2.0",
|
| 62 |
+
"axios": "^1.13.5",
|
| 63 |
+
"bcrypt": "^6.0.0",
|
| 64 |
+
"class-transformer": "^0.5.1",
|
| 65 |
+
"class-validator": "^0.14.3",
|
| 66 |
+
"compression": "^1.8.1",
|
| 67 |
+
"expo-server-sdk": "^6.1.0",
|
| 68 |
+
"helmet": "^8.1.0",
|
| 69 |
+
"nodemailer": "^8.0.1",
|
| 70 |
+
"passport": "^0.7.0",
|
| 71 |
+
"passport-jwt": "^4.0.1",
|
| 72 |
+
"pg": "^8.16.3",
|
| 73 |
+
"swagger-ui-express": "^5.0.1"
|
| 74 |
+
}
|
| 75 |
+
}
|
prisma.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file was generated by Prisma, and assumes you have installed the following:
|
| 2 |
+
// npm install --save-dev prisma dotenv
|
| 3 |
+
import "dotenv/config";
|
| 4 |
+
import { defineConfig } from "prisma/config";
|
| 5 |
+
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
schema: "prisma/schema.prisma",
|
| 8 |
+
migrations: {
|
| 9 |
+
path: "prisma/migrations",
|
| 10 |
+
},
|
| 11 |
+
datasource: {
|
| 12 |
+
url: process.env["DATABASE_URL"],
|
| 13 |
+
},
|
| 14 |
+
});
|
prisma/schema.prisma
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 9 |
+
* - Automatic migration generation
|
| 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
|
| 17 |
+
* - Enums for controlled vocabularies
|
| 18 |
+
* - Timestamps for audit trails
|
| 19 |
+
* - Strict validation rules
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
generator client {
|
| 23 |
+
provider = "prisma-client-js"
|
| 24 |
+
engineType = "library"
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
datasource db {
|
| 28 |
+
provider = "postgresql"
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* UserAuth Entity
|
| 33 |
+
*
|
| 34 |
+
* CLINICAL SAFETY CONSTRAINTS:
|
| 35 |
+
* - Minimal PII only (email OR phone, not both required)
|
| 36 |
+
* - No health data in this table
|
| 37 |
+
* - No logging of credentials
|
| 38 |
+
* - Passwords are hashed (never stored in plain text)
|
| 39 |
+
*
|
| 40 |
+
* DATA MINIMIZATION:
|
| 41 |
+
* - Only authentication-related fields
|
| 42 |
+
* - No names, addresses, or demographic data
|
| 43 |
+
* - Health data stored in separate UserProfile table
|
| 44 |
+
*/
|
| 45 |
+
model UserAuth {
|
| 46 |
+
id String @id @default(uuid())
|
| 47 |
+
email String? @unique
|
| 48 |
+
phone String? @unique
|
| 49 |
+
passwordHash String // bcrypt hashed password
|
| 50 |
+
refreshToken String? // For JWT refresh token rotation
|
| 51 |
+
pushToken String? // For Expo push notifications
|
| 52 |
+
resetPasswordToken String? // For forgot password
|
| 53 |
+
resetPasswordExpires DateTime? // For forgot password
|
| 54 |
+
isActive Boolean @default(true)
|
| 55 |
+
createdAt DateTime @default(now())
|
| 56 |
+
updatedAt DateTime @updatedAt
|
| 57 |
+
|
| 58 |
+
// Relation to UserProfile (optional - created after registration)
|
| 59 |
+
profile UserProfile?
|
| 60 |
+
|
| 61 |
+
// Relation to BloodPressureReadings
|
| 62 |
+
bloodPressureReadings BloodPressureReading[]
|
| 63 |
+
|
| 64 |
+
// Relation to SymptomRecords
|
| 65 |
+
symptomRecords SymptomRecord[]
|
| 66 |
+
|
| 67 |
+
// Relation to Notifications
|
| 68 |
+
notifications Notification[]
|
| 69 |
+
|
| 70 |
+
@@index([email])
|
| 71 |
+
@@index([phone])
|
| 72 |
+
@@map("user_auth")
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Age Range Enum
|
| 77 |
+
*
|
| 78 |
+
* DATA MINIMIZATION:
|
| 79 |
+
* - Use age ranges instead of exact DOB
|
| 80 |
+
* - Prevents unnecessary PII collection
|
| 81 |
+
*/
|
| 82 |
+
enum AgeRange {
|
| 83 |
+
UNDER_18
|
| 84 |
+
AGE_18_34
|
| 85 |
+
AGE_35_PLUS
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Emergency Contact Relationship Enum
|
| 90 |
+
*
|
| 91 |
+
* DATA MINIMIZATION:
|
| 92 |
+
* - Relationship type only (no names stored)
|
| 93 |
+
* - Supports midwife or closest relative
|
| 94 |
+
*/
|
| 95 |
+
enum EmergencyContactRelationship {
|
| 96 |
+
MIDWIFE
|
| 97 |
+
PARTNER
|
| 98 |
+
FAMILY_MEMBER
|
| 99 |
+
OTHER
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Known Conditions Enum
|
| 104 |
+
*
|
| 105 |
+
* CLINICAL SAFETY:
|
| 106 |
+
* - Enumerated list prevents free-text medical history
|
| 107 |
+
* - Only high-risk pregnancy conditions relevant to hypertension
|
| 108 |
+
* - No diagnostic information stored
|
| 109 |
+
*/
|
| 110 |
+
enum KnownCondition {
|
| 111 |
+
CHRONIC_HYPERTENSION
|
| 112 |
+
GESTATIONAL_DIABETES
|
| 113 |
+
PREECLAMPSIA_HISTORY
|
| 114 |
+
KIDNEY_DISEASE
|
| 115 |
+
AUTOIMMUNE_DISORDER
|
| 116 |
+
MULTIPLE_PREGNANCY
|
| 117 |
+
NONE
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* UserProfile Entity
|
| 122 |
+
*
|
| 123 |
+
* DATA MINIMIZATION PRINCIPLES:
|
| 124 |
+
* - No DOB (age range only)
|
| 125 |
+
* - No address
|
| 126 |
+
* - No free-text medical history
|
| 127 |
+
* - No name or demographic data
|
| 128 |
+
* - Only pregnancy-relevant information
|
| 129 |
+
*
|
| 130 |
+
* CLINICAL SAFETY:
|
| 131 |
+
* - All fields are enumerated or structured
|
| 132 |
+
* - No diagnostic interpretations stored
|
| 133 |
+
* - Links to UserAuth via userId
|
| 134 |
+
*/
|
| 135 |
+
model UserProfile {
|
| 136 |
+
id String @id @default(uuid())
|
| 137 |
+
userId String @unique
|
| 138 |
+
ageRange AgeRange
|
| 139 |
+
pregnancyWeeks Int // Current week of pregnancy (0-42)
|
| 140 |
+
firstPregnancy Boolean
|
| 141 |
+
knownConditions KnownCondition[] // Array of enumerated conditions
|
| 142 |
+
emergencyContactPhone String? // Phone number only (no name)
|
| 143 |
+
emergencyContactRelationship EmergencyContactRelationship? // Relationship enum (e.g. MIDWIFE, PARTNER)
|
| 144 |
+
|
| 145 |
+
// Clinic Information
|
| 146 |
+
clinicName String?
|
| 147 |
+
clinicAddress String?
|
| 148 |
+
clinicPhone String?
|
| 149 |
+
|
| 150 |
+
// Notification Preferences
|
| 151 |
+
notifyCarePriority Boolean @default(true)
|
| 152 |
+
notifyBpAlert Boolean @default(true)
|
| 153 |
+
notifySymptomAlert Boolean @default(true)
|
| 154 |
+
notifyReminders Boolean @default(true)
|
| 155 |
+
reminderTime String? @default("09:00") // 24h format (HH:mm)
|
| 156 |
+
|
| 157 |
+
createdAt DateTime @default(now())
|
| 158 |
+
updatedAt DateTime @updatedAt
|
| 159 |
+
|
| 160 |
+
// Relation to UserAuth
|
| 161 |
+
userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 162 |
+
|
| 163 |
+
@@index([userId])
|
| 164 |
+
@@map("user_profile")
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/**
|
| 168 |
+
* Blood Pressure Reading Entity
|
| 169 |
+
*
|
| 170 |
+
* CLINICAL SAFETY CONSTRAINTS:
|
| 171 |
+
* - Manual entry only (no device integration)
|
| 172 |
+
* - Do NOT label readings as normal/abnormal
|
| 173 |
+
* - Do NOT store interpretations
|
| 174 |
+
* - Neutral data handling only
|
| 175 |
+
*
|
| 176 |
+
* VALIDATION:
|
| 177 |
+
* - Systolic: 60-260 mmHg (physiologically possible range)
|
| 178 |
+
* - Diastolic: 40-160 mmHg (physiologically possible range)
|
| 179 |
+
* - Timestamp for temporal tracking
|
| 180 |
+
*
|
| 181 |
+
* PURPOSE:
|
| 182 |
+
* - Store raw BP measurements
|
| 183 |
+
* - Used by care priority engine (Phase 3)
|
| 184 |
+
* - No diagnostic labels attached to data
|
| 185 |
+
*/
|
| 186 |
+
model BloodPressureReading {
|
| 187 |
+
id String @id @default(uuid())
|
| 188 |
+
userId String
|
| 189 |
+
systolic Int // mmHg (60-260)
|
| 190 |
+
diastolic Int // mmHg (40-160)
|
| 191 |
+
recordedAt DateTime @default(now())
|
| 192 |
+
createdAt DateTime @default(now())
|
| 193 |
+
|
| 194 |
+
// Relation to UserAuth
|
| 195 |
+
userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 196 |
+
|
| 197 |
+
@@index([userId])
|
| 198 |
+
@@index([recordedAt])
|
| 199 |
+
@@map("blood_pressure_reading")
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/**
|
| 203 |
+
* Symptom Type Enum
|
| 204 |
+
*
|
| 205 |
+
* CLINICAL SAFETY:
|
| 206 |
+
* - Enumerated list of pregnancy hypertension warning signs
|
| 207 |
+
* - No free-text symptoms
|
| 208 |
+
* - No severity levels
|
| 209 |
+
* - Based on clinical guidelines for preeclampsia/eclampsia
|
| 210 |
+
*/
|
| 211 |
+
enum SymptomType {
|
| 212 |
+
HEADACHE // Severe or persistent headache
|
| 213 |
+
BLURRED_VISION // Visual disturbances
|
| 214 |
+
SWELLING // Edema (face, hands, feet)
|
| 215 |
+
UPPER_ABDOMINAL_PAIN // Right upper quadrant pain
|
| 216 |
+
REDUCED_URINE // Decreased urine output
|
| 217 |
+
NAUSEA_VOMITING // Persistent nausea/vomiting
|
| 218 |
+
SHORTNESS_OF_BREATH // Difficulty breathing
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* Symptom Record Entity
|
| 223 |
+
*
|
| 224 |
+
* CLINICAL SAFETY CONSTRAINTS:
|
| 225 |
+
* - Atomic recording (one symptom per record)
|
| 226 |
+
* - No severity levels
|
| 227 |
+
* - No numeric scoring
|
| 228 |
+
* - No interpretation or diagnosis
|
| 229 |
+
*
|
| 230 |
+
* PURPOSE:
|
| 231 |
+
* - Track presence of warning symptoms
|
| 232 |
+
* - Used by care priority engine for escalation
|
| 233 |
+
* - Each symptom is a separate, timestamped record
|
| 234 |
+
*/
|
| 235 |
+
model SymptomRecord {
|
| 236 |
+
id String @id @default(uuid())
|
| 237 |
+
userId String
|
| 238 |
+
symptomType SymptomType
|
| 239 |
+
recordedAt DateTime @default(now())
|
| 240 |
+
createdAt DateTime @default(now())
|
| 241 |
+
|
| 242 |
+
// Relation to UserAuth
|
| 243 |
+
userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 244 |
+
|
| 245 |
+
@@index([userId])
|
| 246 |
+
@@index([recordedAt])
|
| 247 |
+
@@index([symptomType])
|
| 248 |
+
@@map("symptom_record")
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/**
|
| 252 |
+
* Notification Type Enum
|
| 253 |
+
*/
|
| 254 |
+
enum NotificationType {
|
| 255 |
+
CARE_PRIORITY
|
| 256 |
+
BP_ALERT
|
| 257 |
+
SYMPTOM_ALERT
|
| 258 |
+
REMINDER
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
/**
|
| 262 |
+
* Notification Entity
|
| 263 |
+
*
|
| 264 |
+
* PURPOSE:
|
| 265 |
+
* - Store history of alerts and notifications
|
| 266 |
+
* - Track read status
|
| 267 |
+
*/
|
| 268 |
+
model Notification {
|
| 269 |
+
id String @id @default(uuid())
|
| 270 |
+
userId String
|
| 271 |
+
type NotificationType
|
| 272 |
+
title String
|
| 273 |
+
message String
|
| 274 |
+
read Boolean @default(false)
|
| 275 |
+
createdAt DateTime @default(now())
|
| 276 |
+
|
| 277 |
+
userAuth UserAuth @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 278 |
+
|
| 279 |
+
@@index([userId])
|
| 280 |
+
@@index([createdAt])
|
| 281 |
+
@@map("notification")
|
| 282 |
+
}
|
src/app.controller.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Controller, Get } from '@nestjs/common';
|
| 2 |
+
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
| 3 |
+
|
| 4 |
+
@ApiTags('Health')
|
| 5 |
+
@Controller()
|
| 6 |
+
export class AppController {
|
| 7 |
+
@Get('health')
|
| 8 |
+
@ApiOperation({ summary: 'Check API health status' })
|
| 9 |
+
@ApiResponse({ status: 200, description: 'API is healthy' })
|
| 10 |
+
getHealth() {
|
| 11 |
+
return {
|
| 12 |
+
status: 'ok',
|
| 13 |
+
timestamp: new Date().toISOString(),
|
| 14 |
+
service: 'Maternal Health Support API',
|
| 15 |
+
clinical_safety_notice: 'CARE-SUPPORT TOOL - NOT A DIAGNOSTIC SYSTEM',
|
| 16 |
+
};
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
@Get()
|
| 20 |
+
@ApiOperation({ summary: 'API Root' })
|
| 21 |
+
@ApiResponse({ status: 200, description: 'Welcome message' })
|
| 22 |
+
getHello() {
|
| 23 |
+
return {
|
| 24 |
+
message: 'Welcome to Maternal Health Support API',
|
| 25 |
+
version: '1.0.0',
|
| 26 |
+
docs: '/api/v1/docs',
|
| 27 |
+
health: '/api/v1/health',
|
| 28 |
+
};
|
| 29 |
+
}
|
| 30 |
+
}
|
src/app.module.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from "@nestjs/common";
|
| 2 |
+
import { ConfigModule, ConfigService } from "@nestjs/config";
|
| 3 |
+
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
|
| 4 |
+
import { APP_GUARD } from "@nestjs/core";
|
| 5 |
+
import { ScheduleModule } from "@nestjs/schedule";
|
| 6 |
+
import { DatabaseModule } from "./database/database.module";
|
| 7 |
+
import { AuthModule } from "./auth/auth.module";
|
| 8 |
+
import { UserProfileModule } from "./user-profile/user-profile.module";
|
| 9 |
+
import { BloodPressureModule } from "./blood-pressure/blood-pressure.module";
|
| 10 |
+
import { SymptomsModule } from "./symptoms/symptoms.module";
|
| 11 |
+
import { CarePriorityModule } from "./care-priority/care-priority.module";
|
| 12 |
+
import { NotificationsModule } from "./notifications/notifications.module";
|
| 13 |
+
import { EducationModule } from "./education/education.module";
|
| 14 |
+
import { ClinicFinderModule } from "./clinic-finder/clinic-finder.module";
|
| 15 |
+
import { AppController } from "./app.controller";
|
| 16 |
+
import { HealthController } from "./health.controller";
|
| 17 |
+
|
| 18 |
+
@Module({
|
| 19 |
+
imports: [
|
| 20 |
+
// Configuration
|
| 21 |
+
ConfigModule.forRoot({
|
| 22 |
+
isGlobal: true,
|
| 23 |
+
envFilePath: ".env",
|
| 24 |
+
}),
|
| 25 |
+
|
| 26 |
+
// Scheduling
|
| 27 |
+
ScheduleModule.forRoot(),
|
| 28 |
+
|
| 29 |
+
// Database (Prisma) - Global module
|
| 30 |
+
DatabaseModule,
|
| 31 |
+
|
| 32 |
+
// Domain modules
|
| 33 |
+
AuthModule,
|
| 34 |
+
UserProfileModule,
|
| 35 |
+
BloodPressureModule,
|
| 36 |
+
SymptomsModule,
|
| 37 |
+
CarePriorityModule,
|
| 38 |
+
NotificationsModule,
|
| 39 |
+
EducationModule,
|
| 40 |
+
ClinicFinderModule,
|
| 41 |
+
],
|
| 42 |
+
controllers: [AppController, HealthController],
|
| 43 |
+
providers: [
|
| 44 |
+
{
|
| 45 |
+
provide: APP_GUARD,
|
| 46 |
+
useClass: ThrottlerGuard,
|
| 47 |
+
},
|
| 48 |
+
],
|
| 49 |
+
})
|
| 50 |
+
export class AppModule {}
|
src/auth/auth.controller.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Body,
|
| 3 |
+
Controller,
|
| 4 |
+
HttpCode,
|
| 5 |
+
HttpStatus,
|
| 6 |
+
Post,
|
| 7 |
+
Req,
|
| 8 |
+
UseGuards,
|
| 9 |
+
} from "@nestjs/common";
|
| 10 |
+
import { AuthService } from "./auth.service";
|
| 11 |
+
import { RegisterDto } from "./dto/register.dto";
|
| 12 |
+
import { LoginDto } from "./dto/login.dto";
|
| 13 |
+
import { RefreshTokenDto } from "./dto/refresh-token.dto";
|
| 14 |
+
import { AuthResponseDto } from "./dto/auth-response.dto";
|
| 15 |
+
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
| 16 |
+
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
| 17 |
+
import { JwtAuthGuard } from "./guards/jwt-auth.guard";
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Authentication Controller
|
| 21 |
+
*
|
| 22 |
+
* ENDPOINTS:
|
| 23 |
+
* - POST /auth/register - Register new user
|
| 24 |
+
* - POST /auth/login - Login user
|
| 25 |
+
* - POST /auth/refresh - Refresh access token
|
| 26 |
+
* - POST /auth/forgot-password - Forgot password request
|
| 27 |
+
* - POST /auth/reset-password - Reset password with token
|
| 28 |
+
*
|
| 29 |
+
* CLINICAL SAFETY:
|
| 30 |
+
* - No health data collected
|
| 31 |
+
* - Minimal PII only
|
| 32 |
+
* - No credential logging
|
| 33 |
+
*/
|
| 34 |
+
|
| 35 |
+
@Controller("auth")
|
| 36 |
+
export class AuthController {
|
| 37 |
+
constructor(private readonly authService: AuthService) {}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Register a new user
|
| 41 |
+
*
|
| 42 |
+
* @param registerDto - Email/phone and password
|
| 43 |
+
* @returns Access token, refresh token, and user ID
|
| 44 |
+
*/
|
| 45 |
+
@Post("register")
|
| 46 |
+
@HttpCode(HttpStatus.CREATED)
|
| 47 |
+
async register(@Body() registerDto: RegisterDto): Promise<AuthResponseDto> {
|
| 48 |
+
return this.authService.register(registerDto);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Login user
|
| 53 |
+
*
|
| 54 |
+
* @param loginDto - Email/phone and password
|
| 55 |
+
* @returns Access token, refresh token, and user ID
|
| 56 |
+
*/
|
| 57 |
+
@Post("login")
|
| 58 |
+
@HttpCode(HttpStatus.OK)
|
| 59 |
+
async login(@Body() loginDto: LoginDto): Promise<AuthResponseDto> {
|
| 60 |
+
return this.authService.login(loginDto);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Forgot password request
|
| 65 |
+
*
|
| 66 |
+
* @param forgotPasswordDto - Email or phone
|
| 67 |
+
*/
|
| 68 |
+
@Post("forgot-password")
|
| 69 |
+
@HttpCode(HttpStatus.OK)
|
| 70 |
+
async forgotPassword(
|
| 71 |
+
@Body() forgotPasswordDto: ForgotPasswordDto
|
| 72 |
+
): Promise<void> {
|
| 73 |
+
return this.authService.forgotPassword(forgotPasswordDto);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Reset password with token
|
| 78 |
+
*
|
| 79 |
+
* @param resetPasswordDto - Token and new password
|
| 80 |
+
*/
|
| 81 |
+
@Post("reset-password")
|
| 82 |
+
@HttpCode(HttpStatus.OK)
|
| 83 |
+
async resetPassword(
|
| 84 |
+
@Body() resetPasswordDto: ResetPasswordDto
|
| 85 |
+
): Promise<void> {
|
| 86 |
+
return this.authService.resetPassword(resetPasswordDto);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Refresh access token
|
| 91 |
+
*
|
| 92 |
+
* @param refreshTokenDto - Refresh token
|
| 93 |
+
* @returns New access token and refresh token
|
| 94 |
+
*/
|
| 95 |
+
@Post("refresh")
|
| 96 |
+
@HttpCode(HttpStatus.OK)
|
| 97 |
+
async refresh(
|
| 98 |
+
@Body() refreshTokenDto: RefreshTokenDto
|
| 99 |
+
): Promise<AuthResponseDto> {
|
| 100 |
+
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* Logout user
|
| 105 |
+
*
|
| 106 |
+
* SECURITY:
|
| 107 |
+
* - Requires valid access token
|
| 108 |
+
* - Clears stored refresh token so it cannot be reused
|
| 109 |
+
*/
|
| 110 |
+
@Post("logout")
|
| 111 |
+
@UseGuards(JwtAuthGuard)
|
| 112 |
+
@HttpCode(HttpStatus.OK)
|
| 113 |
+
async logout(@Req() req: any): Promise<void> {
|
| 114 |
+
await this.authService.logout(req.user.id);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* Get current authenticated user
|
| 119 |
+
*
|
| 120 |
+
* @param req - Request with authenticated user
|
| 121 |
+
* @returns Current user details
|
| 122 |
+
*/
|
| 123 |
+
@Post("me")
|
| 124 |
+
@UseGuards(JwtAuthGuard)
|
| 125 |
+
@HttpCode(HttpStatus.OK)
|
| 126 |
+
async getMe(@Req() req: any) {
|
| 127 |
+
return req.user;
|
| 128 |
+
}
|
| 129 |
+
}
|
src/auth/auth.module.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from "@nestjs/common";
|
| 2 |
+
import { JwtModule } from "@nestjs/jwt";
|
| 3 |
+
import { PassportModule } from "@nestjs/passport";
|
| 4 |
+
import { ConfigModule, ConfigService } from "@nestjs/config";
|
| 5 |
+
import { AuthController } from "./auth.controller";
|
| 6 |
+
import { AuthService } from "./auth.service";
|
| 7 |
+
import { JwtStrategy } from "./strategies/jwt.strategy";
|
| 8 |
+
import { NotificationsModule } from "../notifications/notifications.module";
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Authentication Module
|
| 12 |
+
*
|
| 13 |
+
* RESPONSIBILITIES:
|
| 14 |
+
* - User authentication (JWT-based)
|
| 15 |
+
* - Minimal PII storage
|
| 16 |
+
* - No health data in auth tables
|
| 17 |
+
* - No credential logging
|
| 18 |
+
*
|
| 19 |
+
* FEATURES:
|
| 20 |
+
* - Email OR phone login
|
| 21 |
+
* - Password hashing with bcrypt
|
| 22 |
+
* - JWT access tokens (short-lived)
|
| 23 |
+
* - Refresh token rotation
|
| 24 |
+
* - Secure authentication flow
|
| 25 |
+
*/
|
| 26 |
+
|
| 27 |
+
@Module({
|
| 28 |
+
imports: [
|
| 29 |
+
NotificationsModule,
|
| 30 |
+
PassportModule.register({ defaultStrategy: "jwt" }),
|
| 31 |
+
JwtModule.registerAsync({
|
| 32 |
+
imports: [ConfigModule],
|
| 33 |
+
useFactory: (configService: ConfigService) => ({
|
| 34 |
+
secret: configService.get("JWT_SECRET"),
|
| 35 |
+
signOptions: {
|
| 36 |
+
expiresIn: configService.get("JWT_EXPIRATION"),
|
| 37 |
+
},
|
| 38 |
+
}),
|
| 39 |
+
inject: [ConfigService],
|
| 40 |
+
}),
|
| 41 |
+
],
|
| 42 |
+
controllers: [AuthController],
|
| 43 |
+
providers: [AuthService, JwtStrategy],
|
| 44 |
+
exports: [AuthService, JwtStrategy, PassportModule],
|
| 45 |
+
})
|
| 46 |
+
export class AuthModule {}
|
src/auth/auth.service.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Injectable,
|
| 3 |
+
UnauthorizedException,
|
| 4 |
+
ConflictException,
|
| 5 |
+
Logger,
|
| 6 |
+
NotFoundException,
|
| 7 |
+
} from "@nestjs/common";
|
| 8 |
+
import { JwtService } from "@nestjs/jwt";
|
| 9 |
+
import { ConfigService } from "@nestjs/config";
|
| 10 |
+
import * as bcrypt from "bcrypt";
|
| 11 |
+
import * as crypto from "crypto";
|
| 12 |
+
import { PrismaService } from "../database/prisma.service";
|
| 13 |
+
import { RegisterDto } from "./dto/register.dto";
|
| 14 |
+
import { LoginDto } from "./dto/login.dto";
|
| 15 |
+
import { AuthResponseDto } from "./dto/auth-response.dto";
|
| 16 |
+
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
| 17 |
+
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
| 18 |
+
import { NotificationsService } from "../notifications/notifications.service";
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Authentication Service
|
| 22 |
+
*
|
| 23 |
+
* CLINICAL SAFETY PRINCIPLES:
|
| 24 |
+
* - Minimal PII storage
|
| 25 |
+
* - No health data in auth tables
|
| 26 |
+
* - No credential logging
|
| 27 |
+
* - Secure password hashing (bcrypt)
|
| 28 |
+
* - JWT-based stateless authentication
|
| 29 |
+
* - Refresh token rotation for security
|
| 30 |
+
*/
|
| 31 |
+
|
| 32 |
+
@Injectable()
|
| 33 |
+
export class AuthService {
|
| 34 |
+
private readonly logger = new Logger(AuthService.name);
|
| 35 |
+
private readonly SALT_ROUNDS = 10;
|
| 36 |
+
|
| 37 |
+
constructor(
|
| 38 |
+
private readonly prisma: PrismaService,
|
| 39 |
+
private readonly jwtService: JwtService,
|
| 40 |
+
private readonly configService: ConfigService,
|
| 41 |
+
private readonly notificationsService: NotificationsService,
|
| 42 |
+
) {}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Forgot password request
|
| 46 |
+
*/
|
| 47 |
+
async forgotPassword(forgotPasswordDto: ForgotPasswordDto): Promise<void> {
|
| 48 |
+
const { email, phone } = forgotPasswordDto;
|
| 49 |
+
|
| 50 |
+
if (!email && !phone) {
|
| 51 |
+
this.logger.warn("Forgot password attempt with no email or phone");
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Find user — build conditions dynamically to avoid empty {} matching all rows
|
| 56 |
+
const conditions: any[] = [];
|
| 57 |
+
if (email) conditions.push({ email });
|
| 58 |
+
if (phone) conditions.push({ phone });
|
| 59 |
+
|
| 60 |
+
const user = await this.prisma.userAuth.findFirst({
|
| 61 |
+
where: conditions.length === 1 ? conditions[0] : { OR: conditions },
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
if (!user) {
|
| 65 |
+
// Don't reveal if user exists for security
|
| 66 |
+
this.logger.warn(
|
| 67 |
+
`Forgot password attempt for non-existent user: ${email || phone}`,
|
| 68 |
+
);
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Generate token (valid for 1 hour)
|
| 73 |
+
const token = crypto.randomBytes(32).toString("hex");
|
| 74 |
+
const expires = new Date();
|
| 75 |
+
expires.setHours(expires.getHours() + 1);
|
| 76 |
+
|
| 77 |
+
// Save token
|
| 78 |
+
await this.prisma.userAuth.update({
|
| 79 |
+
where: { id: user.id },
|
| 80 |
+
data: {
|
| 81 |
+
resetPasswordToken: token,
|
| 82 |
+
resetPasswordExpires: expires,
|
| 83 |
+
},
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// Send reset message (via notification service)
|
| 87 |
+
await this.notificationsService.sendResetPasswordNotification(
|
| 88 |
+
user.id,
|
| 89 |
+
token,
|
| 90 |
+
);
|
| 91 |
+
|
| 92 |
+
this.logger.log(`Password reset token generated for user: ${user.id}`);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Reset password with token
|
| 97 |
+
*/
|
| 98 |
+
async resetPassword(resetPasswordDto: ResetPasswordDto): Promise<void> {
|
| 99 |
+
const { token, newPassword } = resetPasswordDto;
|
| 100 |
+
|
| 101 |
+
// Find user with valid token
|
| 102 |
+
const user = await this.prisma.userAuth.findFirst({
|
| 103 |
+
where: {
|
| 104 |
+
resetPasswordToken: token,
|
| 105 |
+
resetPasswordExpires: { gt: new Date() },
|
| 106 |
+
},
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
if (!user) {
|
| 110 |
+
throw new UnauthorizedException("Invalid or expired reset token");
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Hash new password
|
| 114 |
+
const passwordHash = await bcrypt.hash(newPassword, this.SALT_ROUNDS);
|
| 115 |
+
|
| 116 |
+
// Update user
|
| 117 |
+
await this.prisma.userAuth.update({
|
| 118 |
+
where: { id: user.id },
|
| 119 |
+
data: {
|
| 120 |
+
passwordHash,
|
| 121 |
+
resetPasswordToken: null,
|
| 122 |
+
resetPasswordExpires: null,
|
| 123 |
+
refreshToken: null, // Force logout from all devices
|
| 124 |
+
},
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
this.logger.log(`Password reset successful for user: ${user.id}`);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Register a new user
|
| 132 |
+
*
|
| 133 |
+
* CONSTRAINTS:
|
| 134 |
+
* - Email OR phone required (not both)
|
| 135 |
+
* - Password must be at least 8 characters
|
| 136 |
+
* - No duplicate email/phone
|
| 137 |
+
*/
|
| 138 |
+
async register(registerDto: RegisterDto): Promise<AuthResponseDto> {
|
| 139 |
+
this.logger.log(
|
| 140 |
+
`Registration attempt for: ${registerDto.email || registerDto.phone}`,
|
| 141 |
+
);
|
| 142 |
+
const { email, phone, password } = registerDto;
|
| 143 |
+
|
| 144 |
+
// Validate that at least one identifier is provided
|
| 145 |
+
if (!email && !phone) {
|
| 146 |
+
throw new ConflictException("Either email or phone must be provided");
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// Check for existing user — build conditions dynamically
|
| 150 |
+
const conditions: any[] = [];
|
| 151 |
+
if (email) conditions.push({ email });
|
| 152 |
+
if (phone) conditions.push({ phone });
|
| 153 |
+
|
| 154 |
+
const existingUser = await this.prisma.userAuth.findFirst({
|
| 155 |
+
where: conditions.length === 1 ? conditions[0] : { OR: conditions },
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
if (existingUser) {
|
| 159 |
+
throw new ConflictException(
|
| 160 |
+
"User with this email or phone already exists",
|
| 161 |
+
);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// Hash password (NEVER log the password)
|
| 165 |
+
const passwordHash = await bcrypt.hash(password, this.SALT_ROUNDS);
|
| 166 |
+
|
| 167 |
+
// Create user
|
| 168 |
+
try {
|
| 169 |
+
const user = await this.prisma.userAuth.create({
|
| 170 |
+
data: {
|
| 171 |
+
email,
|
| 172 |
+
phone,
|
| 173 |
+
passwordHash,
|
| 174 |
+
},
|
| 175 |
+
});
|
| 176 |
+
this.logger.log(`New user registered: ${user.id}`);
|
| 177 |
+
return this.generateTokens(user.id);
|
| 178 |
+
} catch (error: any) {
|
| 179 |
+
this.logger.error(
|
| 180 |
+
`Database error during registration: ${error.message}`,
|
| 181 |
+
error.stack,
|
| 182 |
+
);
|
| 183 |
+
throw error;
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* Login user
|
| 189 |
+
*
|
| 190 |
+
* CONSTRAINTS:
|
| 191 |
+
* - Email OR phone required
|
| 192 |
+
* - Password verification
|
| 193 |
+
* - No credential logging
|
| 194 |
+
*/
|
| 195 |
+
async login(loginDto: LoginDto): Promise<AuthResponseDto> {
|
| 196 |
+
const { email, phone, password } = loginDto;
|
| 197 |
+
|
| 198 |
+
// Validate that at least one identifier is provided
|
| 199 |
+
if (!email && !phone) {
|
| 200 |
+
throw new UnauthorizedException("Either email or phone must be provided");
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Find user — build conditions dynamically
|
| 204 |
+
const loginConditions: any[] = [];
|
| 205 |
+
if (email) loginConditions.push({ email });
|
| 206 |
+
if (phone) loginConditions.push({ phone });
|
| 207 |
+
|
| 208 |
+
const user = await this.prisma.userAuth.findFirst({
|
| 209 |
+
where:
|
| 210 |
+
loginConditions.length === 1
|
| 211 |
+
? loginConditions[0]
|
| 212 |
+
: { OR: loginConditions },
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
if (!user) {
|
| 216 |
+
// Generic error to prevent user enumeration
|
| 217 |
+
throw new UnauthorizedException("Invalid credentials");
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// Verify password (NEVER log the password)
|
| 221 |
+
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
|
| 222 |
+
|
| 223 |
+
if (!isPasswordValid) {
|
| 224 |
+
throw new UnauthorizedException("Invalid credentials");
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// Check if user is active
|
| 228 |
+
if (!user.isActive) {
|
| 229 |
+
throw new UnauthorizedException("Account is inactive");
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
this.logger.log(`User logged in: ${user.id}`);
|
| 233 |
+
|
| 234 |
+
// Generate tokens
|
| 235 |
+
return this.generateTokens(user.id);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/**
|
| 239 |
+
* Refresh access token
|
| 240 |
+
*/
|
| 241 |
+
async refreshToken(refreshToken: string): Promise<AuthResponseDto> {
|
| 242 |
+
try {
|
| 243 |
+
// Verify refresh token
|
| 244 |
+
const payload = this.jwtService.verify(refreshToken, {
|
| 245 |
+
secret: this.configService.get("JWT_REFRESH_SECRET"),
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
// Find user
|
| 249 |
+
const user = await this.prisma.userAuth.findUnique({
|
| 250 |
+
where: { id: payload.sub },
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
if (!user || !user.isActive) {
|
| 254 |
+
throw new UnauthorizedException("Invalid refresh token");
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// Verify stored refresh token matches
|
| 258 |
+
if (user.refreshToken !== refreshToken) {
|
| 259 |
+
throw new UnauthorizedException("Invalid refresh token");
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// Generate new tokens
|
| 263 |
+
return this.generateTokens(user.id);
|
| 264 |
+
} catch (error) {
|
| 265 |
+
throw new UnauthorizedException("Invalid refresh token");
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/**
|
| 270 |
+
* Logout user by invalidating stored refresh token
|
| 271 |
+
*
|
| 272 |
+
* SECURITY:
|
| 273 |
+
* - Clears refresh token from database so it can no longer be used
|
| 274 |
+
*/
|
| 275 |
+
async logout(userId: string): Promise<void> {
|
| 276 |
+
await this.prisma.userAuth.update({
|
| 277 |
+
where: { id: userId },
|
| 278 |
+
data: {
|
| 279 |
+
refreshToken: null,
|
| 280 |
+
pushToken: null, // Clear push token on logout for security
|
| 281 |
+
},
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
this.logger.log(`User logged out: ${userId}`);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/**
|
| 288 |
+
* Generate JWT access and refresh tokens
|
| 289 |
+
*
|
| 290 |
+
* SECURITY:
|
| 291 |
+
* - Access token: short-lived (1 hour)
|
| 292 |
+
* - Refresh token: longer-lived (7 days)
|
| 293 |
+
* - Refresh token stored in database for rotation
|
| 294 |
+
*/
|
| 295 |
+
private async generateTokens(userId: string): Promise<AuthResponseDto> {
|
| 296 |
+
const payload = { sub: userId };
|
| 297 |
+
|
| 298 |
+
// Generate access token
|
| 299 |
+
const accessToken = this.jwtService.sign(payload, {
|
| 300 |
+
secret: this.configService.get("JWT_SECRET"),
|
| 301 |
+
expiresIn: this.configService.get("JWT_EXPIRATION"),
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
// Generate refresh token
|
| 305 |
+
const refreshToken = this.jwtService.sign(payload, {
|
| 306 |
+
secret: this.configService.get("JWT_REFRESH_SECRET"),
|
| 307 |
+
expiresIn: this.configService.get("JWT_REFRESH_EXPIRATION"),
|
| 308 |
+
});
|
| 309 |
+
|
| 310 |
+
// Store refresh token in database
|
| 311 |
+
await this.prisma.userAuth.update({
|
| 312 |
+
where: { id: userId },
|
| 313 |
+
data: { refreshToken },
|
| 314 |
+
});
|
| 315 |
+
|
| 316 |
+
return {
|
| 317 |
+
accessToken,
|
| 318 |
+
refreshToken,
|
| 319 |
+
userId,
|
| 320 |
+
};
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/**
|
| 324 |
+
* Validate user by ID (used by JWT strategy)
|
| 325 |
+
*/
|
| 326 |
+
async validateUser(userId: string) {
|
| 327 |
+
const user = await this.prisma.userAuth.findUnique({
|
| 328 |
+
where: { id: userId },
|
| 329 |
+
select: {
|
| 330 |
+
id: true,
|
| 331 |
+
email: true,
|
| 332 |
+
phone: true,
|
| 333 |
+
isActive: true,
|
| 334 |
+
},
|
| 335 |
+
});
|
| 336 |
+
|
| 337 |
+
if (!user || !user.isActive) {
|
| 338 |
+
return null;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
return user;
|
| 342 |
+
}
|
| 343 |
+
}
|
src/auth/dto/auth-response.dto.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Auth Response DTO
|
| 3 |
+
*
|
| 4 |
+
* Returned after successful login or registration
|
| 5 |
+
*/
|
| 6 |
+
export class AuthResponseDto {
|
| 7 |
+
accessToken!: string;
|
| 8 |
+
refreshToken!: string;
|
| 9 |
+
userId!: string;
|
| 10 |
+
}
|
src/auth/dto/forgot-password.dto.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Forgot Password DTO
|
| 5 |
+
*
|
| 6 |
+
* Supports both email and phone for reset
|
| 7 |
+
*/
|
| 8 |
+
export class ForgotPasswordDto {
|
| 9 |
+
@IsEmail()
|
| 10 |
+
@IsOptional()
|
| 11 |
+
email?: string;
|
| 12 |
+
|
| 13 |
+
@IsString()
|
| 14 |
+
@IsNotEmpty()
|
| 15 |
+
@IsOptional()
|
| 16 |
+
phone?: string;
|
| 17 |
+
}
|
src/auth/dto/login.dto.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IsEmail, IsOptional, IsString, ValidateIf } from "class-validator";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Login DTO
|
| 5 |
+
*
|
| 6 |
+
* CLINICAL SAFETY:
|
| 7 |
+
* - Email OR phone for login
|
| 8 |
+
* - No credential logging
|
| 9 |
+
*/
|
| 10 |
+
export class LoginDto {
|
| 11 |
+
@IsOptional()
|
| 12 |
+
@IsEmail()
|
| 13 |
+
@ValidateIf((o) => !o.phone)
|
| 14 |
+
email?: string;
|
| 15 |
+
|
| 16 |
+
@IsOptional()
|
| 17 |
+
@IsString()
|
| 18 |
+
@ValidateIf((o) => !o.email)
|
| 19 |
+
phone?: string;
|
| 20 |
+
|
| 21 |
+
@IsString()
|
| 22 |
+
password!: string;
|
| 23 |
+
}
|
src/auth/dto/refresh-token.dto.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IsString } from "class-validator";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Refresh Token DTO
|
| 5 |
+
*/
|
| 6 |
+
export class RefreshTokenDto {
|
| 7 |
+
@IsString()
|
| 8 |
+
refreshToken!: string;
|
| 9 |
+
}
|
src/auth/dto/register.dto.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
IsEmail,
|
| 3 |
+
IsOptional,
|
| 4 |
+
IsString,
|
| 5 |
+
MinLength,
|
| 6 |
+
ValidateIf,
|
| 7 |
+
} from "class-validator";
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Register DTO
|
| 11 |
+
*
|
| 12 |
+
* CLINICAL SAFETY:
|
| 13 |
+
* - Email OR phone required (not both)
|
| 14 |
+
* - No health data collected during registration
|
| 15 |
+
* - Minimal PII only
|
| 16 |
+
*/
|
| 17 |
+
export class RegisterDto {
|
| 18 |
+
@IsOptional()
|
| 19 |
+
@IsEmail()
|
| 20 |
+
@ValidateIf((o) => !o.phone) // Email required if no phone
|
| 21 |
+
email?: string;
|
| 22 |
+
|
| 23 |
+
@IsOptional()
|
| 24 |
+
@IsString()
|
| 25 |
+
@ValidateIf((o) => !o.email) // Phone required if no email
|
| 26 |
+
phone?: string;
|
| 27 |
+
|
| 28 |
+
@IsString()
|
| 29 |
+
@MinLength(8, { message: "Password must be at least 8 characters long" })
|
| 30 |
+
password!: string;
|
| 31 |
+
}
|
src/auth/dto/reset-password.dto.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Reset Password DTO
|
| 5 |
+
*
|
| 6 |
+
* Used with a reset token to set a new password
|
| 7 |
+
*/
|
| 8 |
+
export class ResetPasswordDto {
|
| 9 |
+
@IsString()
|
| 10 |
+
@IsNotEmpty()
|
| 11 |
+
token!: string;
|
| 12 |
+
|
| 13 |
+
@IsString()
|
| 14 |
+
@IsNotEmpty()
|
| 15 |
+
@MinLength(8, { message: 'Password must be at least 8 characters' })
|
| 16 |
+
newPassword!: string;
|
| 17 |
+
}
|
src/auth/guards/jwt-auth.guard.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable } from "@nestjs/common";
|
| 2 |
+
import { AuthGuard } from "@nestjs/passport";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* JWT Auth Guard
|
| 6 |
+
*
|
| 7 |
+
* Use this guard to protect routes that require authentication
|
| 8 |
+
*
|
| 9 |
+
* Usage:
|
| 10 |
+
* @UseGuards(JwtAuthGuard)
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
@Injectable()
|
| 14 |
+
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
src/auth/strategies/jwt.strategy.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
| 2 |
+
import { PassportStrategy } from "@nestjs/passport";
|
| 3 |
+
import { ExtractJwt, Strategy } from "passport-jwt";
|
| 4 |
+
import { ConfigService } from "@nestjs/config";
|
| 5 |
+
import { AuthService } from "../auth.service";
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* JWT Strategy
|
| 9 |
+
*
|
| 10 |
+
* Validates JWT tokens and attaches user to request
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
@Injectable()
|
| 14 |
+
export class JwtStrategy extends PassportStrategy(Strategy) {
|
| 15 |
+
constructor(
|
| 16 |
+
private readonly configService: ConfigService,
|
| 17 |
+
private readonly authService: AuthService
|
| 18 |
+
) {
|
| 19 |
+
super({
|
| 20 |
+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
| 21 |
+
ignoreExpiration: false,
|
| 22 |
+
secretOrKey: configService.get<string>("JWT_SECRET") || "fallback_secret",
|
| 23 |
+
});
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Validate JWT payload
|
| 28 |
+
* Called automatically by Passport after token verification
|
| 29 |
+
*/
|
| 30 |
+
async validate(payload: any) {
|
| 31 |
+
const user = await this.authService.validateUser(payload.sub);
|
| 32 |
+
|
| 33 |
+
if (!user) {
|
| 34 |
+
throw new UnauthorizedException();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
return user;
|
| 38 |
+
}
|
| 39 |
+
}
|
src/blood-pressure/blood-pressure.controller.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Controller,
|
| 3 |
+
Get,
|
| 4 |
+
Post,
|
| 5 |
+
Delete,
|
| 6 |
+
Body,
|
| 7 |
+
Param,
|
| 8 |
+
Query,
|
| 9 |
+
UseGuards,
|
| 10 |
+
Request,
|
| 11 |
+
HttpCode,
|
| 12 |
+
HttpStatus,
|
| 13 |
+
ParseIntPipe,
|
| 14 |
+
Inject,
|
| 15 |
+
forwardRef,
|
| 16 |
+
} from "@nestjs/common";
|
| 17 |
+
import { BloodPressureService } from "./blood-pressure.service";
|
| 18 |
+
import { CreateBloodPressureDto } from "./dto/create-blood-pressure.dto";
|
| 19 |
+
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
| 20 |
+
import { HealthAssessmentService } from "../care-priority/health-assessment.service";
|
| 21 |
+
|
| 22 |
+
@Controller("blood-pressure")
|
| 23 |
+
@UseGuards(JwtAuthGuard)
|
| 24 |
+
export class BloodPressureController {
|
| 25 |
+
constructor(
|
| 26 |
+
private readonly bloodPressureService: BloodPressureService,
|
| 27 |
+
@Inject(forwardRef(() => HealthAssessmentService))
|
| 28 |
+
private readonly healthAssessmentService: HealthAssessmentService
|
| 29 |
+
) {}
|
| 30 |
+
|
| 31 |
+
@Post()
|
| 32 |
+
@HttpCode(HttpStatus.CREATED)
|
| 33 |
+
async create(
|
| 34 |
+
@Request() req: any,
|
| 35 |
+
@Body() createBloodPressureDto: CreateBloodPressureDto
|
| 36 |
+
) {
|
| 37 |
+
const userId = req.user.id;
|
| 38 |
+
const reading = await this.bloodPressureService.create(
|
| 39 |
+
userId,
|
| 40 |
+
createBloodPressureDto
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
// Trigger health assessment and notifications (don't await to avoid blocking response)
|
| 44 |
+
this.healthAssessmentService.assessAndNotify(userId);
|
| 45 |
+
|
| 46 |
+
return reading;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Get all BP readings for authenticated user
|
| 51 |
+
*
|
| 52 |
+
* @param req - Request with authenticated user
|
| 53 |
+
* @param limit - Optional limit (default: 50)
|
| 54 |
+
* @returns Array of BP readings
|
| 55 |
+
*/
|
| 56 |
+
@Get()
|
| 57 |
+
@HttpCode(HttpStatus.OK)
|
| 58 |
+
async getReadings(
|
| 59 |
+
@Request() req: any,
|
| 60 |
+
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number
|
| 61 |
+
) {
|
| 62 |
+
const userId = req.user.id;
|
| 63 |
+
return this.bloodPressureService.findByUserId(userId, limit);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* Get latest BP reading
|
| 68 |
+
*
|
| 69 |
+
* @param req - Request with authenticated user
|
| 70 |
+
* @returns Latest BP reading or null
|
| 71 |
+
*/
|
| 72 |
+
@Get("latest")
|
| 73 |
+
@HttpCode(HttpStatus.OK)
|
| 74 |
+
async getLatest(@Request() req: any) {
|
| 75 |
+
const userId = req.user.id;
|
| 76 |
+
return this.bloodPressureService.getLatestReading(userId);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Delete a BP reading
|
| 81 |
+
*
|
| 82 |
+
* @param req - Request with authenticated user
|
| 83 |
+
* @param id - Reading ID
|
| 84 |
+
* @returns Success message
|
| 85 |
+
*/
|
| 86 |
+
@Delete(":id")
|
| 87 |
+
@HttpCode(HttpStatus.OK)
|
| 88 |
+
async delete(@Request() req: any, @Param("id") id: string) {
|
| 89 |
+
const userId = req.user.id;
|
| 90 |
+
return this.bloodPressureService.delete(id, userId);
|
| 91 |
+
}
|
| 92 |
+
}
|
src/blood-pressure/blood-pressure.module.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module, forwardRef } from "@nestjs/common";
|
| 2 |
+
import { BloodPressureController } from "./blood-pressure.controller";
|
| 3 |
+
import { BloodPressureService } from "./blood-pressure.service";
|
| 4 |
+
import { CarePriorityModule } from "../care-priority/care-priority.module";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Blood Pressure Module
|
| 8 |
+
*
|
| 9 |
+
* RESPONSIBILITIES:
|
| 10 |
+
* - Store BP readings (systolic/diastolic)
|
| 11 |
+
* - Manual entry only
|
| 12 |
+
* - Timestamp tracking
|
| 13 |
+
*
|
| 14 |
+
* CLINICAL SAFETY CONSTRAINTS:
|
| 15 |
+
* - Do NOT label readings as normal/abnormal
|
| 16 |
+
* - Do NOT store interpretations
|
| 17 |
+
* - Neutral data handling only
|
| 18 |
+
* - Raw measurements for care priority engine
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
@Module({
|
| 22 |
+
imports: [forwardRef(() => CarePriorityModule)],
|
| 23 |
+
controllers: [BloodPressureController],
|
| 24 |
+
providers: [BloodPressureService],
|
| 25 |
+
exports: [BloodPressureService],
|
| 26 |
+
})
|
| 27 |
+
export class BloodPressureModule {}
|
src/blood-pressure/blood-pressure.service.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from "@nestjs/common";
|
| 2 |
+
import { PrismaService } from "../database/prisma.service";
|
| 3 |
+
import { CreateBloodPressureDto } from "./dto/create-blood-pressure.dto";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Blood Pressure Service
|
| 7 |
+
*
|
| 8 |
+
* CLINICAL SAFETY PRINCIPLES:
|
| 9 |
+
* - Manual entry only (no device integration)
|
| 10 |
+
* - Do NOT label readings as normal/abnormal
|
| 11 |
+
* - Do NOT store interpretations
|
| 12 |
+
* - Neutral data handling only
|
| 13 |
+
*
|
| 14 |
+
* RESPONSIBILITIES:
|
| 15 |
+
* - Store BP readings with validation
|
| 16 |
+
* - Retrieve readings for user
|
| 17 |
+
* - Provide latest reading for care priority engine
|
| 18 |
+
* - Query readings by date range
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
@Injectable()
|
| 22 |
+
export class BloodPressureService {
|
| 23 |
+
private readonly logger = new Logger(BloodPressureService.name);
|
| 24 |
+
|
| 25 |
+
constructor(private readonly prisma: PrismaService) {}
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Create a new blood pressure reading
|
| 29 |
+
*
|
| 30 |
+
* CONSTRAINTS:
|
| 31 |
+
* - Systolic: 60-260 mmHg
|
| 32 |
+
* - Diastolic: 40-160 mmHg
|
| 33 |
+
* - No interpretation stored
|
| 34 |
+
*/
|
| 35 |
+
async create(userId: string, createBloodPressureDto: CreateBloodPressureDto) {
|
| 36 |
+
const { systolic, diastolic, recordedAt } = createBloodPressureDto;
|
| 37 |
+
|
| 38 |
+
const reading = await this.prisma.bloodPressureReading.create({
|
| 39 |
+
data: {
|
| 40 |
+
userId,
|
| 41 |
+
systolic,
|
| 42 |
+
diastolic,
|
| 43 |
+
recordedAt: recordedAt ? new Date(recordedAt) : new Date(),
|
| 44 |
+
},
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
this.logger.log(
|
| 48 |
+
`BP reading created for user: ${userId} (${systolic}/${diastolic})`
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
return reading;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Get all BP readings for a user
|
| 56 |
+
*
|
| 57 |
+
* @param userId - User ID
|
| 58 |
+
* @param limit - Optional limit (default: 50)
|
| 59 |
+
* @returns Array of BP readings, newest first
|
| 60 |
+
*/
|
| 61 |
+
async findByUserId(userId: string, limit: number = 50) {
|
| 62 |
+
const readings = await this.prisma.bloodPressureReading.findMany({
|
| 63 |
+
where: { userId },
|
| 64 |
+
orderBy: { recordedAt: "desc" },
|
| 65 |
+
take: limit,
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
return readings;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Get latest BP reading for a user
|
| 73 |
+
*
|
| 74 |
+
* Used by care priority engine
|
| 75 |
+
*/
|
| 76 |
+
async getLatestReading(userId: string) {
|
| 77 |
+
const reading = await this.prisma.bloodPressureReading.findFirst({
|
| 78 |
+
where: { userId },
|
| 79 |
+
orderBy: { recordedAt: "desc" },
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
return reading;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Get BP readings within a date range
|
| 87 |
+
*
|
| 88 |
+
* @param userId - User ID
|
| 89 |
+
* @param startDate - Start date
|
| 90 |
+
* @param endDate - End date
|
| 91 |
+
* @returns Array of BP readings in range
|
| 92 |
+
*/
|
| 93 |
+
async getReadingsInRange(userId: string, startDate: Date, endDate: Date) {
|
| 94 |
+
const readings = await this.prisma.bloodPressureReading.findMany({
|
| 95 |
+
where: {
|
| 96 |
+
userId,
|
| 97 |
+
recordedAt: {
|
| 98 |
+
gte: startDate,
|
| 99 |
+
lte: endDate,
|
| 100 |
+
},
|
| 101 |
+
},
|
| 102 |
+
orderBy: { recordedAt: "desc" },
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
return readings;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* Get readings from last N hours
|
| 110 |
+
*
|
| 111 |
+
* Used by care priority engine to check recent trends
|
| 112 |
+
*/
|
| 113 |
+
async getRecentReadings(userId: string, hours: number = 48) {
|
| 114 |
+
const cutoffDate = new Date();
|
| 115 |
+
cutoffDate.setHours(cutoffDate.getHours() - hours);
|
| 116 |
+
|
| 117 |
+
const readings = await this.prisma.bloodPressureReading.findMany({
|
| 118 |
+
where: {
|
| 119 |
+
userId,
|
| 120 |
+
recordedAt: {
|
| 121 |
+
gte: cutoffDate,
|
| 122 |
+
},
|
| 123 |
+
},
|
| 124 |
+
orderBy: { recordedAt: "desc" },
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
return readings;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Delete a BP reading
|
| 132 |
+
*/
|
| 133 |
+
async delete(id: string, userId: string) {
|
| 134 |
+
// Ensure user owns the reading
|
| 135 |
+
const reading = await this.prisma.bloodPressureReading.findFirst({
|
| 136 |
+
where: { id, userId },
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
if (!reading) {
|
| 140 |
+
throw new Error("Reading not found or unauthorized");
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
await this.prisma.bloodPressureReading.delete({
|
| 144 |
+
where: { id },
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
this.logger.log(`BP reading deleted: ${id}`);
|
| 148 |
+
|
| 149 |
+
return { message: "Reading deleted successfully" };
|
| 150 |
+
}
|
| 151 |
+
}
|
src/blood-pressure/dto/create-blood-pressure.dto.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IsInt, Min, Max, IsOptional, IsDateString } from "class-validator";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Create Blood Pressure Reading DTO
|
| 5 |
+
*
|
| 6 |
+
* VALIDATION RULES:
|
| 7 |
+
* - Systolic: 60-260 mmHg (physiologically possible range)
|
| 8 |
+
* - Diastolic: 40-160 mmHg (physiologically possible range)
|
| 9 |
+
* - Optional recordedAt (defaults to now)
|
| 10 |
+
*
|
| 11 |
+
* CLINICAL SAFETY:
|
| 12 |
+
* - No interpretation or labeling
|
| 13 |
+
* - Neutral data collection only
|
| 14 |
+
* - Manual entry only
|
| 15 |
+
*/
|
| 16 |
+
export class CreateBloodPressureDto {
|
| 17 |
+
@IsInt()
|
| 18 |
+
@Min(60, { message: "Systolic must be at least 60 mmHg" })
|
| 19 |
+
@Max(260, { message: "Systolic must not exceed 260 mmHg" })
|
| 20 |
+
systolic!: number;
|
| 21 |
+
|
| 22 |
+
@IsInt()
|
| 23 |
+
@Min(40, { message: "Diastolic must be at least 40 mmHg" })
|
| 24 |
+
@Max(160, { message: "Diastolic must not exceed 160 mmHg" })
|
| 25 |
+
diastolic!: number;
|
| 26 |
+
|
| 27 |
+
@IsOptional()
|
| 28 |
+
@IsDateString()
|
| 29 |
+
recordedAt?: string;
|
| 30 |
+
}
|
src/care-priority/care-priority.controller.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Controller,
|
| 3 |
+
Get,
|
| 4 |
+
UseGuards,
|
| 5 |
+
Request,
|
| 6 |
+
HttpCode,
|
| 7 |
+
HttpStatus,
|
| 8 |
+
} from "@nestjs/common";
|
| 9 |
+
import { CarePriorityService } from "./care-priority.service";
|
| 10 |
+
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Care Priority Controller
|
| 14 |
+
*
|
| 15 |
+
* ENDPOINT:
|
| 16 |
+
* - GET /care-priority - Get current care priority (authenticated, read-only)
|
| 17 |
+
*
|
| 18 |
+
* CLINICAL SAFETY:
|
| 19 |
+
* - Read-only endpoint (no POST/PUT/DELETE)
|
| 20 |
+
* - Returns priority level and safe next step message
|
| 21 |
+
* - Non-diagnostic language only
|
| 22 |
+
* - No explanation of medical reasoning
|
| 23 |
+
* - Template-based messaging
|
| 24 |
+
*
|
| 25 |
+
* RESPONSE FORMAT:
|
| 26 |
+
* {
|
| 27 |
+
* priority: 'ROUTINE' | 'INCREASED_MONITORING' | 'URGENT_REVIEW' | 'EMERGENCY',
|
| 28 |
+
* message: 'Safe next step message',
|
| 29 |
+
* reasons: ['Factor 1', 'Factor 2'],
|
| 30 |
+
* timestamp: '2024-01-01T00:00:00.000Z'
|
| 31 |
+
* }
|
| 32 |
+
*/
|
| 33 |
+
|
| 34 |
+
@Controller("care-priority")
|
| 35 |
+
@UseGuards(JwtAuthGuard)
|
| 36 |
+
export class CarePriorityController {
|
| 37 |
+
constructor(private readonly carePriorityService: CarePriorityService) {}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Get current care priority for authenticated user
|
| 41 |
+
*
|
| 42 |
+
* CONSTRAINTS:
|
| 43 |
+
* - Read-only operation
|
| 44 |
+
* - Calculates priority based on latest data
|
| 45 |
+
* - Returns predefined safe message
|
| 46 |
+
* - No diagnostic information
|
| 47 |
+
*
|
| 48 |
+
* @param req - Request with authenticated user
|
| 49 |
+
* @returns Care priority result with safe message
|
| 50 |
+
*/
|
| 51 |
+
@Get()
|
| 52 |
+
@HttpCode(HttpStatus.OK)
|
| 53 |
+
async getCarePriority(@Request() req: any) {
|
| 54 |
+
const userId = req.user.id;
|
| 55 |
+
|
| 56 |
+
// Calculate care priority
|
| 57 |
+
const result = await this.carePriorityService.calculateCarePriority(userId);
|
| 58 |
+
|
| 59 |
+
// Get safe next step message
|
| 60 |
+
const message = this.carePriorityService.getSafeNextStepMessage(
|
| 61 |
+
result.priority
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
priority: result.priority,
|
| 66 |
+
message,
|
| 67 |
+
reasons: result.reasons,
|
| 68 |
+
timestamp: result.timestamp,
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
}
|
src/care-priority/care-priority.module.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module, forwardRef } from "@nestjs/common";
|
| 2 |
+
import { CarePriorityService } from "./care-priority.service";
|
| 3 |
+
import { CarePriorityController } from "./care-priority.controller";
|
| 4 |
+
import { BloodPressureModule } from "../blood-pressure/blood-pressure.module";
|
| 5 |
+
import { SymptomsModule } from "../symptoms/symptoms.module";
|
| 6 |
+
import { UserProfileModule } from "../user-profile/user-profile.module";
|
| 7 |
+
import { NotificationsModule } from "../notifications/notifications.module";
|
| 8 |
+
import { HealthAssessmentService } from "./health-assessment.service";
|
| 9 |
+
|
| 10 |
+
@Module({
|
| 11 |
+
imports: [
|
| 12 |
+
forwardRef(() => BloodPressureModule),
|
| 13 |
+
forwardRef(() => SymptomsModule),
|
| 14 |
+
UserProfileModule,
|
| 15 |
+
NotificationsModule,
|
| 16 |
+
],
|
| 17 |
+
controllers: [CarePriorityController],
|
| 18 |
+
providers: [CarePriorityService, HealthAssessmentService],
|
| 19 |
+
exports: [CarePriorityService, HealthAssessmentService],
|
| 20 |
+
})
|
| 21 |
+
export class CarePriorityModule {}
|
src/care-priority/care-priority.service.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from "@nestjs/common";
|
| 2 |
+
import { BloodPressureService } from "../blood-pressure/blood-pressure.service";
|
| 3 |
+
import { SymptomsService } from "../symptoms/symptoms.service";
|
| 4 |
+
import { UserProfileService } from "../user-profile/user-profile.service";
|
| 5 |
+
import { CarePriority, CarePriorityResult } from "./types/care-priority.types";
|
| 6 |
+
import { KnownCondition, SymptomType } from "@prisma/client";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Care Priority Engine Service
|
| 10 |
+
*
|
| 11 |
+
* ⚠️ MOST CRITICAL MODULE - CLINICAL SAFETY ⚠️
|
| 12 |
+
*
|
| 13 |
+
* PRINCIPLES:
|
| 14 |
+
* - Deterministic, rule-based logic (NO AI/ML)
|
| 15 |
+
* - NO diagnosis
|
| 16 |
+
* - NO medical advice
|
| 17 |
+
* - NO risk scoring or probability calculations
|
| 18 |
+
* - Default to HIGHER priority when uncertain
|
| 19 |
+
* - Based on clinical guidelines for hypertensive disorders in pregnancy
|
| 20 |
+
*
|
| 21 |
+
* RULES SOURCE:
|
| 22 |
+
* - ACOG (American College of Obstetricians and Gynecologists) guidelines
|
| 23 |
+
* - Preeclampsia Foundation recommendations
|
| 24 |
+
* - NHS (UK) hypertension in pregnancy guidelines
|
| 25 |
+
*
|
| 26 |
+
* PURPOSE:
|
| 27 |
+
* - Provide care escalation recommendations
|
| 28 |
+
* - Guide users to seek appropriate level of care
|
| 29 |
+
* - NOT to replace clinical judgment
|
| 30 |
+
*/
|
| 31 |
+
|
| 32 |
+
@Injectable()
|
| 33 |
+
export class CarePriorityService {
|
| 34 |
+
private readonly logger = new Logger(CarePriorityService.name);
|
| 35 |
+
|
| 36 |
+
constructor(
|
| 37 |
+
private readonly bloodPressureService: BloodPressureService,
|
| 38 |
+
private readonly symptomsService: SymptomsService,
|
| 39 |
+
private readonly userProfileService: UserProfileService
|
| 40 |
+
) {}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Calculate care priority for a user
|
| 44 |
+
*
|
| 45 |
+
* ALGORITHM:
|
| 46 |
+
* 1. Check for EMERGENCY conditions (immediate medical attention)
|
| 47 |
+
* 2. Check for URGENT_REVIEW conditions (contact provider within 24h)
|
| 48 |
+
* 3. Check for INCREASED_MONITORING conditions (more frequent monitoring)
|
| 49 |
+
* 4. Default to ROUTINE if no concerning factors
|
| 50 |
+
*
|
| 51 |
+
* CONSERVATIVE APPROACH:
|
| 52 |
+
* - If multiple factors present, use highest priority
|
| 53 |
+
* - If uncertain, escalate to higher priority
|
| 54 |
+
* - Better to over-escalate than under-escalate
|
| 55 |
+
*/
|
| 56 |
+
async calculateCarePriority(userId: string): Promise<CarePriorityResult> {
|
| 57 |
+
const reasons: string[] = [];
|
| 58 |
+
let priority: CarePriority = CarePriority.ROUTINE;
|
| 59 |
+
|
| 60 |
+
try {
|
| 61 |
+
// Get user data
|
| 62 |
+
const profile = await this.userProfileService.findByUserId(userId);
|
| 63 |
+
const latestBP = await this.bloodPressureService.getLatestReading(userId);
|
| 64 |
+
const recentBPs = await this.bloodPressureService.getRecentReadings(
|
| 65 |
+
userId,
|
| 66 |
+
48
|
| 67 |
+
);
|
| 68 |
+
const recentSymptoms = await this.symptomsService.getRecentSymptoms(
|
| 69 |
+
userId,
|
| 70 |
+
72
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
// ========================================
|
| 74 |
+
// EMERGENCY CHECKS
|
| 75 |
+
// ========================================
|
| 76 |
+
|
| 77 |
+
// EMERGENCY: Severe hypertension (BP ≥160/110)
|
| 78 |
+
if (latestBP && (latestBP.systolic >= 160 || latestBP.diastolic >= 110)) {
|
| 79 |
+
priority = CarePriority.EMERGENCY;
|
| 80 |
+
reasons.push("Blood pressure reading indicates severe hypertension");
|
| 81 |
+
this.logger.warn(
|
| 82 |
+
`EMERGENCY priority for user ${userId}: BP ${latestBP.systolic}/${latestBP.diastolic}`
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// EMERGENCY: Dangerous symptom combinations
|
| 87 |
+
const symptomTypes = new Set(
|
| 88 |
+
recentSymptoms.map((s: any) => s.symptomType)
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
// Severe preeclampsia warning signs
|
| 92 |
+
if (
|
| 93 |
+
symptomTypes.has(SymptomType.HEADACHE) &&
|
| 94 |
+
symptomTypes.has(SymptomType.BLURRED_VISION)
|
| 95 |
+
) {
|
| 96 |
+
priority = CarePriority.EMERGENCY;
|
| 97 |
+
reasons.push("Combination of severe headache and vision changes");
|
| 98 |
+
this.logger.warn(
|
| 99 |
+
`EMERGENCY priority for user ${userId}: Headache + Vision changes`
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
if (
|
| 104 |
+
symptomTypes.has(SymptomType.UPPER_ABDOMINAL_PAIN) &&
|
| 105 |
+
symptomTypes.has(SymptomType.NAUSEA_VOMITING)
|
| 106 |
+
) {
|
| 107 |
+
priority = CarePriority.EMERGENCY;
|
| 108 |
+
reasons.push("Upper abdominal pain with nausea/vomiting");
|
| 109 |
+
this.logger.warn(
|
| 110 |
+
`EMERGENCY priority for user ${userId}: Abdominal pain + Nausea`
|
| 111 |
+
);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if (symptomTypes.has(SymptomType.SHORTNESS_OF_BREATH)) {
|
| 115 |
+
priority = CarePriority.EMERGENCY;
|
| 116 |
+
reasons.push("Difficulty breathing reported");
|
| 117 |
+
this.logger.warn(
|
| 118 |
+
`EMERGENCY priority for user ${userId}: Shortness of breath`
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// If already EMERGENCY, return immediately
|
| 123 |
+
if (priority === CarePriority.EMERGENCY) {
|
| 124 |
+
return {
|
| 125 |
+
priority,
|
| 126 |
+
reasons,
|
| 127 |
+
timestamp: new Date(),
|
| 128 |
+
};
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// ========================================
|
| 132 |
+
// URGENT_REVIEW CHECKS
|
| 133 |
+
// ========================================
|
| 134 |
+
|
| 135 |
+
// URGENT: Confirmed hypertension (BP ≥140/90 on two occasions)
|
| 136 |
+
if (recentBPs.length >= 2) {
|
| 137 |
+
const elevatedReadings = recentBPs.filter(
|
| 138 |
+
(bp: any) => bp.systolic >= 140 || bp.diastolic >= 90
|
| 139 |
+
);
|
| 140 |
+
|
| 141 |
+
if (elevatedReadings.length >= 2) {
|
| 142 |
+
priority = CarePriority.URGENT_REVIEW;
|
| 143 |
+
reasons.push("Multiple elevated blood pressure readings");
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// URGENT: High-risk conditions + elevated BP
|
| 148 |
+
const highRiskConditions = [
|
| 149 |
+
KnownCondition.CHRONIC_HYPERTENSION,
|
| 150 |
+
KnownCondition.PREECLAMPSIA_HISTORY,
|
| 151 |
+
KnownCondition.KIDNEY_DISEASE,
|
| 152 |
+
];
|
| 153 |
+
|
| 154 |
+
const hasHighRiskCondition = profile.knownConditions.some(
|
| 155 |
+
(condition: any) => highRiskConditions.includes(condition)
|
| 156 |
+
);
|
| 157 |
+
|
| 158 |
+
if (
|
| 159 |
+
hasHighRiskCondition &&
|
| 160 |
+
latestBP &&
|
| 161 |
+
(latestBP.systolic >= 130 || latestBP.diastolic >= 85)
|
| 162 |
+
) {
|
| 163 |
+
priority = CarePriority.URGENT_REVIEW;
|
| 164 |
+
reasons.push("High-risk condition with elevated blood pressure");
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// URGENT: Any severe symptoms individually
|
| 168 |
+
if (
|
| 169 |
+
symptomTypes.has(SymptomType.HEADACHE) ||
|
| 170 |
+
symptomTypes.has(SymptomType.BLURRED_VISION) ||
|
| 171 |
+
symptomTypes.has(SymptomType.UPPER_ABDOMINAL_PAIN)
|
| 172 |
+
) {
|
| 173 |
+
if (priority !== CarePriority.URGENT_REVIEW) {
|
| 174 |
+
priority = CarePriority.URGENT_REVIEW;
|
| 175 |
+
}
|
| 176 |
+
reasons.push("Warning symptoms present");
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// URGENT: Reduced urine output
|
| 180 |
+
if (symptomTypes.has(SymptomType.REDUCED_URINE)) {
|
| 181 |
+
priority = CarePriority.URGENT_REVIEW;
|
| 182 |
+
reasons.push("Reduced urine output reported");
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// If URGENT_REVIEW, return
|
| 186 |
+
if (priority === CarePriority.URGENT_REVIEW) {
|
| 187 |
+
return {
|
| 188 |
+
priority,
|
| 189 |
+
reasons,
|
| 190 |
+
timestamp: new Date(),
|
| 191 |
+
};
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// ========================================
|
| 195 |
+
// INCREASED_MONITORING CHECKS
|
| 196 |
+
// ========================================
|
| 197 |
+
|
| 198 |
+
// INCREASED: Borderline BP (130-159 / 85-109)
|
| 199 |
+
if (
|
| 200 |
+
latestBP &&
|
| 201 |
+
(latestBP.systolic >= 130 || latestBP.diastolic >= 85)
|
| 202 |
+
) {
|
| 203 |
+
priority = CarePriority.INCREASED_MONITORING;
|
| 204 |
+
reasons.push("Blood pressure reading is above normal range");
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// INCREASED: High-risk conditions present
|
| 208 |
+
if (hasHighRiskCondition) {
|
| 209 |
+
priority = CarePriority.INCREASED_MONITORING;
|
| 210 |
+
reasons.push("High-risk pregnancy condition present");
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// INCREASED: Advanced maternal age (35+)
|
| 214 |
+
if (profile.ageRange === "AGE_35_PLUS") {
|
| 215 |
+
if (priority !== CarePriority.INCREASED_MONITORING) {
|
| 216 |
+
priority = CarePriority.INCREASED_MONITORING;
|
| 217 |
+
}
|
| 218 |
+
reasons.push("Advanced maternal age");
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// INCREASED: Multiple pregnancy
|
| 222 |
+
if (profile.knownConditions.includes(KnownCondition.MULTIPLE_PREGNANCY)) {
|
| 223 |
+
priority = CarePriority.INCREASED_MONITORING;
|
| 224 |
+
reasons.push("Multiple pregnancy");
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// INCREASED: Any symptoms present (even mild)
|
| 228 |
+
if (recentSymptoms.length > 0) {
|
| 229 |
+
if (priority !== CarePriority.INCREASED_MONITORING) {
|
| 230 |
+
priority = CarePriority.INCREASED_MONITORING;
|
| 231 |
+
}
|
| 232 |
+
reasons.push("Symptoms reported");
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// If INCREASED_MONITORING, return
|
| 236 |
+
if (priority === CarePriority.INCREASED_MONITORING) {
|
| 237 |
+
return {
|
| 238 |
+
priority,
|
| 239 |
+
reasons,
|
| 240 |
+
timestamp: new Date(),
|
| 241 |
+
};
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
// ========================================
|
| 245 |
+
// ROUTINE (Default)
|
| 246 |
+
// ========================================
|
| 247 |
+
|
| 248 |
+
reasons.push("No concerning factors identified");
|
| 249 |
+
|
| 250 |
+
return {
|
| 251 |
+
priority: CarePriority.ROUTINE,
|
| 252 |
+
reasons,
|
| 253 |
+
timestamp: new Date(),
|
| 254 |
+
};
|
| 255 |
+
} catch (error) {
|
| 256 |
+
// SAFETY: If calculation fails, default to INCREASED_MONITORING
|
| 257 |
+
this.logger.error(
|
| 258 |
+
`Error calculating care priority for user ${userId}:`,
|
| 259 |
+
error
|
| 260 |
+
);
|
| 261 |
+
|
| 262 |
+
return {
|
| 263 |
+
priority: CarePriority.INCREASED_MONITORING,
|
| 264 |
+
reasons: [
|
| 265 |
+
"Unable to complete assessment - please contact your healthcare provider",
|
| 266 |
+
],
|
| 267 |
+
timestamp: new Date(),
|
| 268 |
+
};
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
/**
|
| 273 |
+
* Get safe next step message based on priority
|
| 274 |
+
*
|
| 275 |
+
* CONSTRAINTS:
|
| 276 |
+
* - Predefined templates only
|
| 277 |
+
* - No dynamic medical text
|
| 278 |
+
* - No diagnostic language
|
| 279 |
+
* - No fear-based messaging
|
| 280 |
+
*/
|
| 281 |
+
getSafeNextStepMessage(priority: CarePriority): string {
|
| 282 |
+
const messages = {
|
| 283 |
+
[CarePriority.EMERGENCY]:
|
| 284 |
+
"Seek immediate medical attention. Call emergency services or go to the nearest emergency room.",
|
| 285 |
+
|
| 286 |
+
[CarePriority.URGENT_REVIEW]:
|
| 287 |
+
"Contact your healthcare provider within the next 24 hours to discuss your readings.",
|
| 288 |
+
|
| 289 |
+
[CarePriority.INCREASED_MONITORING]:
|
| 290 |
+
"Continue monitoring your blood pressure regularly and discuss with your healthcare provider at your next appointment.",
|
| 291 |
+
|
| 292 |
+
[CarePriority.ROUTINE]:
|
| 293 |
+
"Continue routine prenatal care and monitoring as recommended by your healthcare provider.",
|
| 294 |
+
};
|
| 295 |
+
|
| 296 |
+
return messages[priority];
|
| 297 |
+
}
|
| 298 |
+
}
|
src/care-priority/health-assessment.service.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from "@nestjs/common";
|
| 2 |
+
import { CarePriorityService } from "./care-priority.service";
|
| 3 |
+
import { NotificationsService } from "../notifications/notifications.service";
|
| 4 |
+
import { BloodPressureService } from "../blood-pressure/blood-pressure.service";
|
| 5 |
+
import { SymptomsService } from "../symptoms/symptoms.service";
|
| 6 |
+
import { CarePriority } from "./types/care-priority.types";
|
| 7 |
+
import { SymptomType } from "@prisma/client";
|
| 8 |
+
|
| 9 |
+
@Injectable()
|
| 10 |
+
export class HealthAssessmentService {
|
| 11 |
+
private readonly logger = new Logger(HealthAssessmentService.name);
|
| 12 |
+
|
| 13 |
+
constructor(
|
| 14 |
+
private readonly carePriorityService: CarePriorityService,
|
| 15 |
+
private readonly notificationsService: NotificationsService,
|
| 16 |
+
private readonly bloodPressureService: BloodPressureService,
|
| 17 |
+
private readonly symptomsService: SymptomsService
|
| 18 |
+
) {}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Assess user health after a new BP reading or symptom is recorded
|
| 22 |
+
* and trigger appropriate notifications.
|
| 23 |
+
*/
|
| 24 |
+
async assessAndNotify(userId: string): Promise<void> {
|
| 25 |
+
try {
|
| 26 |
+
this.logger.log(`Starting health assessment for user: ${userId}`);
|
| 27 |
+
|
| 28 |
+
// 1. Calculate current care priority
|
| 29 |
+
const assessment = await this.carePriorityService.calculateCarePriority(
|
| 30 |
+
userId
|
| 31 |
+
);
|
| 32 |
+
const { priority } = assessment;
|
| 33 |
+
|
| 34 |
+
// 2. Get latest data for specific alerts
|
| 35 |
+
const latestBP = await this.bloodPressureService.getLatestReading(userId);
|
| 36 |
+
const recentSymptoms = await this.symptomsService.getRecentSymptoms(
|
| 37 |
+
userId,
|
| 38 |
+
24
|
| 39 |
+
);
|
| 40 |
+
const symptomTypes = new Set(
|
| 41 |
+
recentSymptoms.map((s: any) => s.symptomType)
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
// 3. Trigger specific BP alerts if needed
|
| 45 |
+
if (latestBP) {
|
| 46 |
+
if (latestBP.systolic >= 160 || latestBP.diastolic >= 110) {
|
| 47 |
+
await this.notificationsService.sendSevereBPAlert(
|
| 48 |
+
userId,
|
| 49 |
+
latestBP.systolic,
|
| 50 |
+
latestBP.diastolic
|
| 51 |
+
);
|
| 52 |
+
} else if (latestBP.systolic >= 140 || latestBP.diastolic >= 90) {
|
| 53 |
+
await this.notificationsService.sendElevatedBPNotification(
|
| 54 |
+
userId,
|
| 55 |
+
latestBP.systolic,
|
| 56 |
+
latestBP.diastolic
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// 4. Trigger symptom alerts if dangerous symptoms are present
|
| 62 |
+
const dangerousSymptoms = [
|
| 63 |
+
SymptomType.HEADACHE,
|
| 64 |
+
SymptomType.BLURRED_VISION,
|
| 65 |
+
SymptomType.UPPER_ABDOMINAL_PAIN,
|
| 66 |
+
SymptomType.SHORTNESS_OF_BREATH,
|
| 67 |
+
];
|
| 68 |
+
|
| 69 |
+
const hasDangerousSymptom = Array.from(symptomTypes).some((type) =>
|
| 70 |
+
dangerousSymptoms.includes(type)
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
if (hasDangerousSymptom) {
|
| 74 |
+
const activeDangerous = Array.from(symptomTypes).filter((type) =>
|
| 75 |
+
dangerousSymptoms.includes(type)
|
| 76 |
+
);
|
| 77 |
+
await this.notificationsService.sendDangerousSymptomAlert(
|
| 78 |
+
userId,
|
| 79 |
+
activeDangerous.map((s) => s.toString())
|
| 80 |
+
);
|
| 81 |
+
} else if (symptomTypes.size > 0) {
|
| 82 |
+
await this.notificationsService.sendWarningSymptomNotification(
|
| 83 |
+
userId,
|
| 84 |
+
Array.from(symptomTypes)[0].toString()
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// 5. Trigger care priority notification if priority is not ROUTINE
|
| 89 |
+
if (priority !== CarePriority.ROUTINE) {
|
| 90 |
+
await this.notificationsService.sendCarePriorityNotification(
|
| 91 |
+
userId,
|
| 92 |
+
priority
|
| 93 |
+
);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
this.logger.log(`Health assessment completed for user: ${userId}`);
|
| 97 |
+
} catch (error) {
|
| 98 |
+
this.logger.error(
|
| 99 |
+
`Error during health assessment for user ${userId}:`,
|
| 100 |
+
error
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
}
|
src/care-priority/types/care-priority.types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Care Priority Levels
|
| 3 |
+
*
|
| 4 |
+
* CLINICAL SAFETY PRINCIPLES:
|
| 5 |
+
* - These are NOT diagnoses
|
| 6 |
+
* - These are NOT risk scores
|
| 7 |
+
* - These are care escalation recommendations
|
| 8 |
+
* - When uncertain, escalate to higher priority
|
| 9 |
+
*
|
| 10 |
+
* LEVELS:
|
| 11 |
+
* - ROUTINE: Normal monitoring, next scheduled appointment
|
| 12 |
+
* - INCREASED_MONITORING: More frequent self-monitoring, contact provider soon
|
| 13 |
+
* - URGENT_REVIEW: Contact healthcare provider within 24 hours
|
| 14 |
+
* - EMERGENCY: Seek immediate medical attention
|
| 15 |
+
*/
|
| 16 |
+
export enum CarePriority {
|
| 17 |
+
ROUTINE = "ROUTINE",
|
| 18 |
+
INCREASED_MONITORING = "INCREASED_MONITORING",
|
| 19 |
+
URGENT_REVIEW = "URGENT_REVIEW",
|
| 20 |
+
EMERGENCY = "EMERGENCY",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Care Priority Result
|
| 25 |
+
*
|
| 26 |
+
* Returned by the care priority engine
|
| 27 |
+
*/
|
| 28 |
+
export interface CarePriorityResult {
|
| 29 |
+
priority: CarePriority;
|
| 30 |
+
reasons: string[]; // List of factors that contributed to this priority
|
| 31 |
+
timestamp: Date;
|
| 32 |
+
}
|
src/clinic-finder/clinic-finder.controller.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Controller, Get, HttpCode, HttpStatus, Query, UseGuards } from "@nestjs/common";
|
| 2 |
+
import { ClinicFinderService } from "./clinic-finder.service";
|
| 3 |
+
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
| 4 |
+
|
| 5 |
+
@Controller("clinic-finder")
|
| 6 |
+
@UseGuards(JwtAuthGuard)
|
| 7 |
+
export class ClinicFinderController {
|
| 8 |
+
constructor(private readonly clinicFinderService: ClinicFinderService) {}
|
| 9 |
+
|
| 10 |
+
@Get()
|
| 11 |
+
@HttpCode(HttpStatus.OK)
|
| 12 |
+
findAll(
|
| 13 |
+
@Query("city") city?: string,
|
| 14 |
+
@Query("lat") lat?: string,
|
| 15 |
+
@Query("lng") lng?: string,
|
| 16 |
+
@Query("radius") radius?: string
|
| 17 |
+
) {
|
| 18 |
+
if (lat && lng) {
|
| 19 |
+
return this.clinicFinderService.findNearby(
|
| 20 |
+
parseFloat(lat),
|
| 21 |
+
parseFloat(lng),
|
| 22 |
+
radius ? parseInt(radius) : 5000
|
| 23 |
+
);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
if (city) {
|
| 27 |
+
return this.clinicFinderService.findByCity(city);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return this.clinicFinderService.findAll();
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
src/clinic-finder/clinic-finder.module.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from "@nestjs/common";
|
| 2 |
+
import { ClinicFinderController } from "./clinic-finder.controller";
|
| 3 |
+
import { ClinicFinderService } from "./clinic-finder.service";
|
| 4 |
+
|
| 5 |
+
@Module({
|
| 6 |
+
controllers: [ClinicFinderController],
|
| 7 |
+
providers: [ClinicFinderService],
|
| 8 |
+
exports: [ClinicFinderService],
|
| 9 |
+
})
|
| 10 |
+
export class ClinicFinderModule {}
|
| 11 |
+
|
src/clinic-finder/clinic-finder.service.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from "@nestjs/common";
|
| 2 |
+
import axios from "axios";
|
| 3 |
+
import { CLINIC_LOCATIONS, ClinicLocation } from "./data/clinics.data";
|
| 4 |
+
|
| 5 |
+
@Injectable()
|
| 6 |
+
export class ClinicFinderService {
|
| 7 |
+
private readonly logger = new Logger(ClinicFinderService.name);
|
| 8 |
+
private readonly overpassUrl = "https://overpass-api.de/api/interpreter";
|
| 9 |
+
|
| 10 |
+
async findNearby(
|
| 11 |
+
lat: number,
|
| 12 |
+
lng: number,
|
| 13 |
+
radius: number = 5000
|
| 14 |
+
): Promise<ClinicLocation[]> {
|
| 15 |
+
try {
|
| 16 |
+
this.logger.log(`Searching for hospitals near ${lat}, ${lng} within ${radius}m using Overpass API`);
|
| 17 |
+
|
| 18 |
+
const query = `
|
| 19 |
+
[out:json][timeout:25];
|
| 20 |
+
(
|
| 21 |
+
node["amenity"="hospital"](around:${radius},${lat},${lng});
|
| 22 |
+
way["amenity"="hospital"](around:${radius},${lat},${lng});
|
| 23 |
+
node["amenity"="clinic"](around:${radius},${lat},${lng});
|
| 24 |
+
way["amenity"="clinic"](around:${radius},${lat},${lng});
|
| 25 |
+
);
|
| 26 |
+
out center;
|
| 27 |
+
`;
|
| 28 |
+
|
| 29 |
+
const response = await axios.post(this.overpassUrl, `data=${encodeURIComponent(query)}`);
|
| 30 |
+
|
| 31 |
+
if (response.data && response.data.elements) {
|
| 32 |
+
return response.data.elements.map((element: any) => ({
|
| 33 |
+
id: element.id.toString(),
|
| 34 |
+
name: element.tags.name || "Unnamed Hospital",
|
| 35 |
+
latitude: element.lat || element.center?.lat,
|
| 36 |
+
longitude: element.lon || element.center?.lon,
|
| 37 |
+
address: element.tags["addr:street"]
|
| 38 |
+
? `${element.tags["addr:housenumber"] || ""} ${element.tags["addr:street"]}`.trim()
|
| 39 |
+
: "Address not available",
|
| 40 |
+
city: element.tags["addr:city"] || "Detected",
|
| 41 |
+
country: "Nigeria",
|
| 42 |
+
phone: element.tags.phone || element.tags["contact:phone"] || "Contact via Maps",
|
| 43 |
+
isEmergency: element.tags.amenity === "hospital",
|
| 44 |
+
}));
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return [];
|
| 48 |
+
} catch (error: any) {
|
| 49 |
+
this.logger.error(`Failed to fetch nearby hospitals from Overpass: ${error.message}`);
|
| 50 |
+
return this.findAll(); // Fallback to seed data on error
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
findAll(): ClinicLocation[] {
|
| 55 |
+
return CLINIC_LOCATIONS;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
findByCity(city: string): ClinicLocation[] {
|
| 59 |
+
const normalized = city.trim().toLowerCase();
|
| 60 |
+
|
| 61 |
+
// If city is "Ikeja", include "Lagos" clinics as well, as Ikeja is part of Lagos
|
| 62 |
+
const isIkeja = normalized === "ikeja";
|
| 63 |
+
|
| 64 |
+
return CLINIC_LOCATIONS.filter((clinic) => {
|
| 65 |
+
const clinicCity = clinic.city.toLowerCase();
|
| 66 |
+
if (isIkeja) {
|
| 67 |
+
return clinicCity === "ikeja" || clinicCity === "lagos";
|
| 68 |
+
}
|
| 69 |
+
return clinicCity === normalized;
|
| 70 |
+
});
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
src/clinic-finder/data/clinics.data.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface ClinicLocation {
|
| 2 |
+
id: string;
|
| 3 |
+
name: string;
|
| 4 |
+
latitude: number;
|
| 5 |
+
longitude: number;
|
| 6 |
+
address: string;
|
| 7 |
+
city: string;
|
| 8 |
+
country: string;
|
| 9 |
+
phone?: string;
|
| 10 |
+
isEmergency?: boolean;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const CLINIC_LOCATIONS: ClinicLocation[] = [
|
| 14 |
+
{
|
| 15 |
+
id: "lagos-luth",
|
| 16 |
+
name: "Lagos University Teaching Hospital",
|
| 17 |
+
latitude: 6.5069,
|
| 18 |
+
longitude: 3.3673,
|
| 19 |
+
address: "Ishaga Road, Idi-Araba, Surulere",
|
| 20 |
+
city: "Lagos",
|
| 21 |
+
country: "Nigeria",
|
| 22 |
+
phone: "+234-1-877-7000",
|
| 23 |
+
isEmergency: true,
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
id: "lagos-island-maternity",
|
| 27 |
+
name: "Island Maternity Hospital",
|
| 28 |
+
latitude: 6.4513,
|
| 29 |
+
longitude: 3.3940,
|
| 30 |
+
address: "Broad Street, Lagos Island",
|
| 31 |
+
city: "Lagos",
|
| 32 |
+
country: "Nigeria",
|
| 33 |
+
phone: "+234-1-264-3018",
|
| 34 |
+
isEmergency: true,
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
id: "abuja-national-hospital",
|
| 38 |
+
name: "National Hospital Abuja",
|
| 39 |
+
latitude: 9.0360,
|
| 40 |
+
longitude: 7.4899,
|
| 41 |
+
address: "Plot 132 Central District (Phase II), Garki",
|
| 42 |
+
city: "Abuja",
|
| 43 |
+
country: "Nigeria",
|
| 44 |
+
phone: "+234-9-234-0940",
|
| 45 |
+
isEmergency: true,
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
id: "abuja-nyanya-general",
|
| 49 |
+
name: "Nyanya General Hospital",
|
| 50 |
+
latitude: 8.9953,
|
| 51 |
+
longitude: 7.6207,
|
| 52 |
+
address: "Nyanya",
|
| 53 |
+
city: "Abuja",
|
| 54 |
+
country: "Nigeria",
|
| 55 |
+
phone: "+234-9-123-4567",
|
| 56 |
+
isEmergency: false,
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
id: "ikeja-lasuth",
|
| 60 |
+
name: "Lagos State University Teaching Hospital (LASUTH)",
|
| 61 |
+
latitude: 6.5962,
|
| 62 |
+
longitude: 3.3514,
|
| 63 |
+
address: "1-5 Oba Akinjobi Way, Ikeja",
|
| 64 |
+
city: "Ikeja",
|
| 65 |
+
country: "Nigeria",
|
| 66 |
+
phone: "+234-1-493-4500",
|
| 67 |
+
isEmergency: true,
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
id: "ikeja-medical-centre",
|
| 71 |
+
name: "Ikeja Medical Centre",
|
| 72 |
+
latitude: 6.6015,
|
| 73 |
+
longitude: 3.3421,
|
| 74 |
+
address: "11-15 Olufemi Road, Ikeja",
|
| 75 |
+
city: "Ikeja",
|
| 76 |
+
country: "Nigeria",
|
| 77 |
+
phone: "+234-1-497-2345",
|
| 78 |
+
isEmergency: true,
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
id: "reddington-hospital-ikeja",
|
| 82 |
+
name: "Reddington Hospital Ikeja",
|
| 83 |
+
latitude: 6.5921,
|
| 84 |
+
longitude: 3.3587,
|
| 85 |
+
address: "12 Isaac John St, GRA, Ikeja",
|
| 86 |
+
city: "Ikeja",
|
| 87 |
+
country: "Nigeria",
|
| 88 |
+
phone: "+234-1-271-5341",
|
| 89 |
+
isEmergency: true,
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
id: "eunice-clinic-ikeja",
|
| 93 |
+
name: "Eunice Clinic & Hospital",
|
| 94 |
+
latitude: 6.6054,
|
| 95 |
+
longitude: 3.3489,
|
| 96 |
+
address: "24 Allen Ave, Ikeja",
|
| 97 |
+
city: "Ikeja",
|
| 98 |
+
country: "Nigeria",
|
| 99 |
+
phone: "+234-803-123-4567",
|
| 100 |
+
isEmergency: false,
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
id: "nicholas-hospital-ikeja",
|
| 104 |
+
name: "St. Nicholas Hospital Ikeja",
|
| 105 |
+
latitude: 6.5901,
|
| 106 |
+
longitude: 3.3602,
|
| 107 |
+
address: "7b Maryland Est, Ikeja",
|
| 108 |
+
city: "Ikeja",
|
| 109 |
+
country: "Nigeria",
|
| 110 |
+
phone: "+234-1-280-5000",
|
| 111 |
+
isEmergency: true,
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
id: "blue-cross-ikeja",
|
| 115 |
+
name: "Blue Cross Hospital",
|
| 116 |
+
latitude: 6.6123,
|
| 117 |
+
longitude: 3.3345,
|
| 118 |
+
address: "14 Ogba Road, Ikeja",
|
| 119 |
+
city: "Ikeja",
|
| 120 |
+
country: "Nigeria",
|
| 121 |
+
phone: "+234-1-492-3456",
|
| 122 |
+
isEmergency: false,
|
| 123 |
+
},
|
| 124 |
+
];
|
| 125 |
+
|
src/database/database.module.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module, Global } from "@nestjs/common";
|
| 2 |
+
import { PrismaService } from "./prisma.service";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Database Module (Prisma)
|
| 6 |
+
*
|
| 7 |
+
* ORM CHOICE: Prisma
|
| 8 |
+
*
|
| 9 |
+
* JUSTIFICATION:
|
| 10 |
+
* - Type-safe database client with excellent TypeScript support
|
| 11 |
+
* - Intuitive schema definition language
|
| 12 |
+
* - Automatic migration generation
|
| 13 |
+
* - Built-in connection pooling
|
| 14 |
+
* - Great developer experience with Prisma Studio
|
| 15 |
+
* - Strong PostgreSQL support
|
| 16 |
+
*
|
| 17 |
+
* CLINICAL SAFETY CONSIDERATIONS:
|
| 18 |
+
* - All entities will enforce strict validation at schema level
|
| 19 |
+
* - Type safety prevents invalid data operations
|
| 20 |
+
* - Audit logging capabilities via middleware
|
| 21 |
+
* - Transaction support for data integrity
|
| 22 |
+
* - No raw SQL for sensitive operations (use Prisma queries)
|
| 23 |
+
*
|
| 24 |
+
* This module is marked as @Global so PrismaService is available
|
| 25 |
+
* throughout the application without repeated imports
|
| 26 |
+
*/
|
| 27 |
+
|
| 28 |
+
@Global()
|
| 29 |
+
@Module({
|
| 30 |
+
providers: [PrismaService],
|
| 31 |
+
exports: [PrismaService],
|
| 32 |
+
})
|
| 33 |
+
export class DatabaseModule {}
|
src/database/prisma.service.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Injectable,
|
| 3 |
+
OnModuleInit,
|
| 4 |
+
OnModuleDestroy,
|
| 5 |
+
Logger,
|
| 6 |
+
} from "@nestjs/common";
|
| 7 |
+
import { PrismaClient } from "@prisma/client";
|
| 8 |
+
import { PrismaPg } from "@prisma/adapter-pg";
|
| 9 |
+
import { Pool } from "pg";
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Prisma Service
|
| 13 |
+
*
|
| 14 |
+
* Provides database access throughout the application
|
| 15 |
+
* Handles connection lifecycle and health checks
|
| 16 |
+
*
|
| 17 |
+
* CLINICAL SAFETY CONSIDERATIONS:
|
| 18 |
+
* - All queries are type-safe
|
| 19 |
+
* - Automatic connection pooling
|
| 20 |
+
* - Transaction support for data integrity
|
| 21 |
+
* - Query logging in development
|
| 22 |
+
*/
|
| 23 |
+
|
| 24 |
+
@Injectable()
|
| 25 |
+
export class PrismaService
|
| 26 |
+
extends PrismaClient
|
| 27 |
+
implements OnModuleInit, OnModuleDestroy
|
| 28 |
+
{
|
| 29 |
+
private readonly logger = new Logger(PrismaService.name);
|
| 30 |
+
|
| 31 |
+
constructor() {
|
| 32 |
+
const pool = new Pool({
|
| 33 |
+
connectionString: process.env.DATABASE_URL,
|
| 34 |
+
max: 10, // Maintain up to 10 connections
|
| 35 |
+
idleTimeoutMillis: 30000,
|
| 36 |
+
connectionTimeoutMillis: 10000,
|
| 37 |
+
ssl: {
|
| 38 |
+
rejectUnauthorized: false, // Required for Neon in some environments
|
| 39 |
+
},
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
// Log pool errors
|
| 43 |
+
pool.on('error', (err) => {
|
| 44 |
+
this.logger.error('Unexpected error on idle client', err);
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
super({
|
| 48 |
+
adapter: new PrismaPg(pool),
|
| 49 |
+
log:
|
| 50 |
+
process.env.NODE_ENV === "development"
|
| 51 |
+
? ["query", "info", "warn", "error"]
|
| 52 |
+
: ["error"],
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Connect to database on module initialization
|
| 58 |
+
*/
|
| 59 |
+
async onModuleInit() {
|
| 60 |
+
try {
|
| 61 |
+
await this.$connect();
|
| 62 |
+
this.logger.log("✅ Database connection successful");
|
| 63 |
+
|
| 64 |
+
// Test the connection
|
| 65 |
+
await this.$queryRaw`SELECT 1`;
|
| 66 |
+
this.logger.log("📊 Database health check passed");
|
| 67 |
+
} catch (error) {
|
| 68 |
+
this.logger.error("❌ Database connection failed", error);
|
| 69 |
+
throw error;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/**
|
| 74 |
+
* Disconnect from database on module destruction
|
| 75 |
+
*/
|
| 76 |
+
async onModuleDestroy() {
|
| 77 |
+
await this.$disconnect();
|
| 78 |
+
this.logger.log("🔌 Database disconnected");
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Get database health status
|
| 83 |
+
*/
|
| 84 |
+
async isHealthy(): Promise<boolean> {
|
| 85 |
+
try {
|
| 86 |
+
await this.$queryRaw`SELECT 1`;
|
| 87 |
+
return true;
|
| 88 |
+
} catch {
|
| 89 |
+
return false;
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* Get database info (safe, no credentials)
|
| 95 |
+
*/
|
| 96 |
+
getDatabaseInfo() {
|
| 97 |
+
return {
|
| 98 |
+
provider: "postgresql",
|
| 99 |
+
isConnected: true, // If we can call this, we're connected
|
| 100 |
+
};
|
| 101 |
+
}
|
| 102 |
+
}
|
src/education/data/data.txt
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
THIS IS ACCORDING TO WHO
|
| 2 |
+
|
| 3 |
+
Pre-eclampsia is a high blood pressure disorder that typically develops after 20 weeks into pregnancy. It can present serious risks to both mother and baby. Early detection and management are crucial to prevent progression to eclampsia, which involves seizures. Both conditions can be life-threatening.
|
| 4 |
+
|
| 5 |
+
Diagnosis
|
| 6 |
+
|
| 7 |
+
Pre-eclampsia is diagnosed based on the onset of hypertension (blood pressure ≥140/90 mm Hg) proteinuria (protein in the urine) (≥0.3 g/24 hours) after 20 weeks of gestation. Severe pre-eclampsia may include symptoms such as severe headaches, visual disturbances and upper abdominal pain.
|
| 8 |
+
|
| 9 |
+
Risk factors
|
| 10 |
+
|
| 11 |
+
Several factors can increase the risk of developing pre-eclampsia during pregnancy. Understanding these risk factors is essential for proactive monitoring and management. Having a risk factor doesn't always mean pre-eclampsia will occur, but closer medical supervision beyond routine screening is recommended.
|
| 12 |
+
|
| 13 |
+
Several factors can increase the risk of developing pre-eclampsia, including:
|
| 14 |
+
|
| 15 |
+
first-time pregnancies
|
| 16 |
+
multiple pregnancies (twins, triplets, etc.)
|
| 17 |
+
obesity
|
| 18 |
+
pre-existing conditions such as hypertension, diabetes, or kidney disease
|
| 19 |
+
family history of pre-eclampsia.
|
| 20 |
+
Symptoms
|
| 21 |
+
|
| 22 |
+
Symptoms of pre-eclampsia can vary significantly among individuals. While some may experience a range of noticeable symptoms, others may remain asymptomatic. It is important to be aware of potential indicators and seek medical attention if any concerns arise during pregnancy or after childbirth.
|
| 23 |
+
|
| 24 |
+
Common symptoms of pre-eclampsia include:
|
| 25 |
+
|
| 26 |
+
persistent high blood pressure
|
| 27 |
+
proteinuria
|
| 28 |
+
severe headaches
|
| 29 |
+
visual disturbances (e.g., blurred vision, seeing spots)
|
| 30 |
+
upper abdominal pain
|
| 31 |
+
nausea and vomiting (after the first trimester)
|
| 32 |
+
swelling in the hands and face.
|
| 33 |
+
Complications
|
| 34 |
+
|
| 35 |
+
Pre-eclampsia, if left untreated, can lead to serious complications for both mother and baby. These complications can range from short-term issues to long-term health problems. Prompt medical intervention is crucial to minimize these risks.
|
| 36 |
+
|
| 37 |
+
Complications can be severe and include:
|
| 38 |
+
|
| 39 |
+
eclampsia (seizures)
|
| 40 |
+
HELLP syndrome (hemolysis, elevated liver enzymes, low platelet count)
|
| 41 |
+
organ damage (kidneys, liver, brain)
|
| 42 |
+
placental abruption
|
| 43 |
+
preterm birth
|
| 44 |
+
fetal growth restriction
|
| 45 |
+
maternal and fetal death.
|
| 46 |
+
Treatment and management
|
| 47 |
+
|
| 48 |
+
The primary treatment for pre-eclampsia is the administration of magnesium sulfate to prevent seizures.
|
| 49 |
+
|
| 50 |
+
The treatment and management of pre-eclampsia depend on the severity of the condition and the gestational age of the pregnancy. The goal is to prevent complications.
|
| 51 |
+
|
| 52 |
+
Other management strategies include:
|
| 53 |
+
|
| 54 |
+
antihypertensive medications to control blood pressure
|
| 55 |
+
corticosteroids to accelerate fetal lung maturity if preterm delivery is anticipated
|
| 56 |
+
close monitoring of maternal and fetal health.
|
| 57 |
+
Prevention
|
| 58 |
+
|
| 59 |
+
While there's no guaranteed way to prevent pre-eclampsia, certain strategies can help lower the risk. Early and consistent prenatal care is essential for monitoring and managing potential risk factors.
|
| 60 |
+
|
| 61 |
+
Preventive measures focus on regular prenatal care to monitor for early signs of pre-eclampsia. Recommendations include:
|
| 62 |
+
|
| 63 |
+
regular blood pressure checks
|
| 64 |
+
urine tests for protein
|
| 65 |
+
monitoring for symptoms such as headaches and visual disturbances
|
| 66 |
+
lifestyle considerations, such as maintaining a healthy weight and activity (when permitted)
|
| 67 |
+
managing pre-existing conditions, especially pre-existing high blood pressure.
|
| 68 |
+
Additional prevention measures include:
|
| 69 |
+
|
| 70 |
+
low dose of aspirin by 20 weeks or when antenatal care begins
|
| 71 |
+
calcium supplementation during pregnancy in settings with low dietary intake
|
| 72 |
+
treatment of pre-existing high blood pressure with antihypertensive medications.
|
| 73 |
+
WHO response
|
| 74 |
+
|
| 75 |
+
The World Health Organization (WHO) has developed guidelines to improve health during pregnancy. This includes prevention and treatment of pre-eclampsia and eclampsia and continuously reviewing evidence to see if revisions in the recommendations are needed so that improvements in care can be effected. These guidelines aim to reduce maternal and perinatal morbidity and mortality by promoting evidence-based clinical practices. Key WHO recommendations include:
|
| 76 |
+
|
| 77 |
+
calcium supplementation during pregnancy in areas with low dietary calcium intake
|
| 78 |
+
low-dose aspirin during pregnancy for women at high risk of pre-eclampsia
|
| 79 |
+
use of magnesium sulfate for the prevention of eclampsia
|
| 80 |
+
training healthcare providers in the early detection and management of pre-eclampsia
|
| 81 |
+
strengthening health systems to ensure timely and effective care for pregnant women.
|
| 82 |
+
By implementing these guidelines, WHO aims to address the profound inequities in maternal and perinatal health globally and achieve the health targets of the Sustainable Development Goals (SDGs).
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
END OF WHO
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
The causes of preeclampsia and eclampsia are not known. These disorders previously were believed to be caused by a toxin, called “toxemia,” in the blood, but health care providers now know that is not true. Nevertheless, preeclampsia is sometimes still referred to as “toxemia.”
|
| 91 |
+
|
| 92 |
+
To learn more about preeclampsia and eclampsia, scientists are investigating many factors that could contribute to the development and progression of these diseases, including:
|
| 93 |
+
|
| 94 |
+
Placental abnormalities, such as insufficient blood flow
|
| 95 |
+
Genetic factors
|
| 96 |
+
Environmental exposures
|
| 97 |
+
Nutritional factors
|
| 98 |
+
Maternal immunology and autoimmune disorders
|
| 99 |
+
Cardiovascular and inflammatory changes
|
| 100 |
+
Hormonal imbalances
|
| 101 |
+
|
| 102 |
+
Risks During Pregnancy
|
| 103 |
+
Preeclampsia during pregnancy is mild in the majority of cases.1 However, a woman can progress from mild to severe preeclampsia or to full eclampsia very quickly―even in a matter of days. Both preeclampsia and eclampsia can cause serious health problems for the mother and infant.
|
| 104 |
+
|
| 105 |
+
Women with preeclampsia are at increased risk for damage to the kidneys, liver, brain, and other organ and blood systems. Preeclampsia may also affect the placenta. The condition could lead to a separation of the placenta from the uterus (referred to as placental abruption), preterm birth, and pregnancy loss or stillbirth. In some cases, preeclampsia can lead to organ failure or stroke.
|
| 106 |
+
|
| 107 |
+
In severe cases, preeclampsia can develop into eclampsia, which includes seizures. Seizures in eclampsia may cause a woman to lose consciousness and twitch uncontrollably.2 If the fetus is not delivered, these conditions can cause the death of the mother and/or the fetus.
|
| 108 |
+
|
| 109 |
+
Although most pregnant women in developed countries survive preeclampsia, it is still a major cause of illness and death globally.3 According to the World Health Organization, preeclampsia and eclampsia cause 14% of maternal deaths each year, or about 50,000 to 75,000 women worldwide.4
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
Risks After Pregnancy
|
| 113 |
+
In "uncomplicated preeclampsia," the mother's high blood pressure and other symptoms usually go back to normal within 6 weeks of the infant's birth. However, studies have shown that women who had preeclampsia are four times more likely to later develop hypertension (high blood pressure) and are twice as likely to later develop ischemic heart disease (reduced blood supply to the heart muscle, which can cause heart attacks), a blood clot in a vein, and stroke as are women who did not have preeclampsia.5
|
| 114 |
+
|
| 115 |
+
Less commonly, mothers who had preeclampsia can experience permanent damage to their organs, such as their kidneys and liver. They can also experience fluid in the lungs. In the days following birth, women with preeclampsia remain at increased risk for developing eclampsia and seizures.3,6
|
| 116 |
+
|
| 117 |
+
In some women, preeclampsia develops between 48 hours and 6 weeks after they deliver their baby—a condition called postpartum preeclampsia.7,8 Postpartum preeclampsia can occur in women who had preeclampsia during pregnancy and among those who did not. One study found that slightly more than one-half of women who had postpartum preeclampsia did not have preeclampsia during pregnancy.9 If a woman has seizures within 72 hours of delivery, she may have postpartum eclampsia. It is important to recognize and treat postpartum preeclampsia and eclampsia because the risk of complications may be higher than if the conditions had occurred during pregnancy.10 Postpartum preeclampsia and eclampsia can progress very quickly if not treated and may lead to stroke or death. Visit the Preeclampsia Foundation website for mor
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
https://youtu.be/dqMXyDLiUqg
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
Video: Overview of Preeclampsia and Eclampsia-MSD Manual Professional Edition https://www.msdmanuals.com/professional/multimedia/video/overview-of-preeclampsia-and-eclampsia?utm_source=chatgpt.com
|
src/education/data/education.content.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface EducationArticle {
|
| 2 |
+
id: string;
|
| 3 |
+
title: string;
|
| 4 |
+
category: "HYPERTENSION" | "NUTRITION" | "WARNING_SIGNS" | "POSTPARTUM";
|
| 5 |
+
summary: string;
|
| 6 |
+
content: string; // Plain text or safe markdown
|
| 7 |
+
readTimeMinutes: number;
|
| 8 |
+
source: string;
|
| 9 |
+
sourceUrl?: string;
|
| 10 |
+
videoUrl?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Static Education Content
|
| 15 |
+
*
|
| 16 |
+
* CLINICAL SAFETY:
|
| 17 |
+
* - Hardcoded content to ensure clinical accuracy
|
| 18 |
+
* - No dynamic generation
|
| 19 |
+
* - Curated from reputable sources (ACOG, WHO, Preeclampsia Foundation)
|
| 20 |
+
* - Non-alarmist language
|
| 21 |
+
*/
|
| 22 |
+
export const EDUCATION_CONTENT: EducationArticle[] = [
|
| 23 |
+
{
|
| 24 |
+
id: "understanding-bp",
|
| 25 |
+
title: "Understanding Blood Pressure in Pregnancy",
|
| 26 |
+
category: "HYPERTENSION",
|
| 27 |
+
summary:
|
| 28 |
+
"Learn what blood pressure numbers mean and why they matter during pregnancy.",
|
| 29 |
+
content: `Blood pressure is the force of your blood pushing against the walls of your arteries. It is measured with two numbers:
|
| 30 |
+
|
| 31 |
+
1. Systolic (top number): The pressure when your heart beats.
|
| 32 |
+
2. Diastolic (bottom number): The pressure when your heart rests between beats.
|
| 33 |
+
|
| 34 |
+
In pregnancy, it's important to keep track of these numbers. A normal reading is typically below 120/80 mmHg. If your numbers are higher, your healthcare provider will want to monitor you more closely to ensure both you and your baby stay healthy.`,
|
| 35 |
+
readTimeMinutes: 3,
|
| 36 |
+
source: "World Health Organization (WHO) patient information on hypertension in pregnancy",
|
| 37 |
+
sourceUrl:
|
| 38 |
+
"https://www.who.int/health-topics/hypertension#tab=tab_1",
|
| 39 |
+
videoUrl:
|
| 40 |
+
"https://www.preeclampsia.org/video-library",
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
id: "warning-signs",
|
| 44 |
+
title: "Warning Signs to Watch For",
|
| 45 |
+
category: "WARNING_SIGNS",
|
| 46 |
+
summary:
|
| 47 |
+
"Key symptoms that should prompt you to call your healthcare provider.",
|
| 48 |
+
content: `Most pregnancies go smoothly, but knowing which symptoms to watch for can help you get care quickly if needed. Contact your provider if you experience:
|
| 49 |
+
|
| 50 |
+
- Severe headaches that won't go away with medication
|
| 51 |
+
- Changes in vision (blurring, flashing lights, or spots)
|
| 52 |
+
- Sudden swelling in your face or hands
|
| 53 |
+
- Pain in the upper right side of your belly
|
| 54 |
+
- Trouble breathing
|
| 55 |
+
- Nausea or vomiting (especially after mid-pregnancy)
|
| 56 |
+
|
| 57 |
+
If you are unsure, it is always safer to call your provider and ask.`,
|
| 58 |
+
readTimeMinutes: 4,
|
| 59 |
+
source:
|
| 60 |
+
"Preeclampsia Foundation - 7 Signs and Symptoms Every Pregnant Woman Should Know",
|
| 61 |
+
sourceUrl:
|
| 62 |
+
"https://www.preeclampsia.org/health-information/signs-and-symptoms",
|
| 63 |
+
videoUrl:
|
| 64 |
+
"https://www.preeclampsia.org/video-library",
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
id: "nutrition-hypertension",
|
| 68 |
+
title: "Nutrition for Healthy Blood Pressure",
|
| 69 |
+
category: "NUTRITION",
|
| 70 |
+
summary: "Dietary tips to help manage blood pressure naturally.",
|
| 71 |
+
content: `Eating a balanced diet is good for you and your baby. To help support healthy blood pressure:
|
| 72 |
+
|
| 73 |
+
- Stay hydrated: Drink plenty of water throughout the day.
|
| 74 |
+
- Watch sodium intake: Try to limit processed foods which are often high in salt.
|
| 75 |
+
- Eat plenty of fruits and vegetables: These provide essential nutrients like potassium.
|
| 76 |
+
- Choose whole grains: Brown rice, oats, and whole wheat bread are great options.
|
| 77 |
+
|
| 78 |
+
Always talk to your doctor or a nutritionist before making major changes to your diet.`,
|
| 79 |
+
readTimeMinutes: 3,
|
| 80 |
+
source:
|
| 81 |
+
"American College of Obstetricians and Gynecologists (ACOG) – Nutrition During Pregnancy",
|
| 82 |
+
sourceUrl:
|
| 83 |
+
"https://www.acog.org/womens-health/faqs/nutrition-during-pregnancy",
|
| 84 |
+
videoUrl: undefined,
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
id: "postpartum-care",
|
| 88 |
+
title: "Taking Care After Birth",
|
| 89 |
+
category: "POSTPARTUM",
|
| 90 |
+
summary:
|
| 91 |
+
"Why monitoring your health remains important after your baby arrives.",
|
| 92 |
+
content: `The postpartum period (after birth) is a time of recovery. It is important to continue listening to your body.
|
| 93 |
+
|
| 94 |
+
Some conditions, like postpartum preeclampsia, can occur even after the baby is born. Continue to watch for severe headaches, vision changes, or shortness of breath.
|
| 95 |
+
|
| 96 |
+
Make sure to attend your postpartum check-ups so your provider can ensure you are healing well.`,
|
| 97 |
+
readTimeMinutes: 2,
|
| 98 |
+
source:
|
| 99 |
+
"American College of Obstetricians and Gynecologists (ACOG) – Postpartum Preeclampsia information",
|
| 100 |
+
sourceUrl:
|
| 101 |
+
"https://www.acog.org/womens-health/faqs/preeclampsia-and-high-blood-pressure-during-pregnancy",
|
| 102 |
+
videoUrl:
|
| 103 |
+
"https://www.preeclampsia.org/video-library",
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
id: "preeclampsia-eclampsia-overview",
|
| 107 |
+
title: "Preeclampsia and Eclampsia: What You Should Know",
|
| 108 |
+
category: "HYPERTENSION",
|
| 109 |
+
summary:
|
| 110 |
+
"Overview of causes, warning signs, and care for preeclampsia and eclampsia.",
|
| 111 |
+
content: `Preeclampsia is a blood pressure condition that usually develops after 20 weeks of pregnancy. It involves high blood pressure and signs that organs such as the kidneys or liver are under strain. If it is not treated, it can progress to eclampsia, which includes seizures. Early recognition and close medical care greatly reduce the chance of serious problems.
|
| 112 |
+
|
| 113 |
+
Diagnosis
|
| 114 |
+
|
| 115 |
+
- Blood pressure at or above 140/90 mmHg on more than one occasion
|
| 116 |
+
- Protein in the urine (proteinuria)
|
| 117 |
+
- Sometimes other changes in blood tests or organ function
|
| 118 |
+
|
| 119 |
+
Risk factors
|
| 120 |
+
|
| 121 |
+
- First pregnancy
|
| 122 |
+
- Multiple pregnancy (twins, triplets, etc.)
|
| 123 |
+
- History of high blood pressure, diabetes, kidney disease, or autoimmune disease
|
| 124 |
+
- Obesity
|
| 125 |
+
- Family history of preeclampsia
|
| 126 |
+
|
| 127 |
+
Warning symptoms
|
| 128 |
+
|
| 129 |
+
- Severe or persistent headache
|
| 130 |
+
- Changes in vision (blurred vision, flashing lights, or spots)
|
| 131 |
+
- Pain in the upper right side of the abdomen
|
| 132 |
+
- Nausea or vomiting after the first trimester
|
| 133 |
+
- Sudden swelling of the face, hands, or around the eyes
|
| 134 |
+
- Shortness of breath
|
| 135 |
+
|
| 136 |
+
Possible complications
|
| 137 |
+
|
| 138 |
+
- Seizures (eclampsia)
|
| 139 |
+
- HELLP syndrome (problems with the liver and blood clotting)
|
| 140 |
+
- Problems with the kidneys, liver, brain, or lungs
|
| 141 |
+
- Placental abruption (the placenta pulling away from the uterus)
|
| 142 |
+
- Restricted growth of the baby or early birth
|
| 143 |
+
|
| 144 |
+
Treatment and monitoring
|
| 145 |
+
|
| 146 |
+
- Close monitoring of blood pressure, urine, and blood tests
|
| 147 |
+
- Medications to lower blood pressure when needed
|
| 148 |
+
- Magnesium sulfate in some cases to help prevent seizures
|
| 149 |
+
- Corticosteroids to help the baby's lungs if early delivery is likely
|
| 150 |
+
- Timing of birth based on the health of you and your baby
|
| 151 |
+
|
| 152 |
+
After pregnancy
|
| 153 |
+
|
| 154 |
+
Preeclampsia can sometimes develop after birth (postpartum preeclampsia), usually within the first six weeks. Headache, vision changes, shortness of breath, or severe pain after delivery should be checked urgently.
|
| 155 |
+
|
| 156 |
+
Long-term health
|
| 157 |
+
|
| 158 |
+
Women who have had preeclampsia have a higher chance of high blood pressure, heart disease, and stroke later in life than women who did not. Regular follow-up with a primary care provider is important.
|
| 159 |
+
|
| 160 |
+
Prevention and self-care
|
| 161 |
+
|
| 162 |
+
- Attend all antenatal and postpartum visits
|
| 163 |
+
- Have blood pressure and urine checked as recommended
|
| 164 |
+
- Report warning symptoms promptly
|
| 165 |
+
- Follow advice on healthy eating, activity, and weight management when appropriate
|
| 166 |
+
- Take low-dose aspirin and calcium supplements if recommended by your care team
|
| 167 |
+
|
| 168 |
+
This summary is based on guidance from the World Health Organization and other international expert groups.`,
|
| 169 |
+
readTimeMinutes: 8,
|
| 170 |
+
source:
|
| 171 |
+
"World Health Organization (WHO) and international guidelines on preeclampsia and eclampsia",
|
| 172 |
+
sourceUrl:
|
| 173 |
+
"https://www.who.int/health-topics/hypertension#tab=tab_1",
|
| 174 |
+
videoUrl:
|
| 175 |
+
"https://www.msdmanuals.com/professional/multimedia/video/overview-of-preeclampsia-and-eclampsia?utm_source=chatgpt.com",
|
| 176 |
+
},
|
| 177 |
+
];
|
src/education/education.controller.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Controller,
|
| 3 |
+
Get,
|
| 4 |
+
Param,
|
| 5 |
+
Query,
|
| 6 |
+
UseGuards,
|
| 7 |
+
HttpCode,
|
| 8 |
+
HttpStatus,
|
| 9 |
+
} from "@nestjs/common";
|
| 10 |
+
import { EducationService } from "./education.service";
|
| 11 |
+
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Education Controller
|
| 15 |
+
*
|
| 16 |
+
* ENDPOINTS:
|
| 17 |
+
* - GET /education - List all articles (optional category filter)
|
| 18 |
+
* - GET /education/:id - Get specific article
|
| 19 |
+
*
|
| 20 |
+
* RESTRICTIONS:
|
| 21 |
+
* - Authenticated users only
|
| 22 |
+
* - Read-only access
|
| 23 |
+
*/
|
| 24 |
+
@Controller("education")
|
| 25 |
+
@UseGuards(JwtAuthGuard)
|
| 26 |
+
export class EducationController {
|
| 27 |
+
constructor(private readonly educationService: EducationService) {}
|
| 28 |
+
|
| 29 |
+
@Get()
|
| 30 |
+
@HttpCode(HttpStatus.OK)
|
| 31 |
+
findAll(@Query("category") category?: string) {
|
| 32 |
+
if (category) {
|
| 33 |
+
return this.educationService.findByCategory(category);
|
| 34 |
+
}
|
| 35 |
+
return this.educationService.findAll();
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
@Get(":id")
|
| 39 |
+
@HttpCode(HttpStatus.OK)
|
| 40 |
+
findOne(@Param("id") id: string) {
|
| 41 |
+
return this.educationService.findOne(id);
|
| 42 |
+
}
|
| 43 |
+
}
|
src/education/education.module.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from "@nestjs/common";
|
| 2 |
+
import { EducationController } from "./education.controller";
|
| 3 |
+
import { EducationService } from "./education.service";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Education Module
|
| 7 |
+
*
|
| 8 |
+
* RESPONSIBILITIES:
|
| 9 |
+
* CONSTRAINTS:
|
| 10 |
+
* - No personalization
|
| 11 |
+
* - No interpretation
|
| 12 |
+
* - Content delivery only
|
| 13 |
+
*
|
| 14 |
+
* To be implemented in STEP 10
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
@Module({
|
| 18 |
+
controllers: [EducationController],
|
| 19 |
+
providers: [EducationService],
|
| 20 |
+
exports: [EducationService],
|
| 21 |
+
})
|
| 22 |
+
export class EducationModule {}
|
src/education/education.service.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, NotFoundException } from "@nestjs/common";
|
| 2 |
+
import { EDUCATION_CONTENT, EducationArticle } from "./data/education.content";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Education Service
|
| 6 |
+
*
|
| 7 |
+
* RESPONSIBILITIES:
|
| 8 |
+
* - Serve static, curated education content
|
| 9 |
+
* - Filter by category
|
| 10 |
+
* - Retrieve single articles
|
| 11 |
+
*
|
| 12 |
+
* CLINICAL SAFETY:
|
| 13 |
+
* - Read-only access to curated content
|
| 14 |
+
* - No dynamic user-generated content allowed
|
| 15 |
+
*/
|
| 16 |
+
@Injectable()
|
| 17 |
+
export class EducationService {
|
| 18 |
+
/**
|
| 19 |
+
* Get all education articles
|
| 20 |
+
*/
|
| 21 |
+
findAll(): EducationArticle[] {
|
| 22 |
+
return EDUCATION_CONTENT;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Get articles by category
|
| 27 |
+
*/
|
| 28 |
+
findByCategory(category: string): EducationArticle[] {
|
| 29 |
+
return EDUCATION_CONTENT.filter((article) => article.category === category);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Get single article by ID
|
| 34 |
+
*/
|
| 35 |
+
findOne(id: string): EducationArticle {
|
| 36 |
+
const article = EDUCATION_CONTENT.find((a) => a.id === id);
|
| 37 |
+
|
| 38 |
+
if (!article) {
|
| 39 |
+
throw new NotFoundException("Article not found");
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return article;
|
| 43 |
+
}
|
| 44 |
+
}
|
src/health.controller.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Controller, Get } from '@nestjs/common';
|
| 2 |
+
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
| 3 |
+
|
| 4 |
+
@ApiTags('Health')
|
| 5 |
+
@Controller('health')
|
| 6 |
+
export class HealthController {
|
| 7 |
+
@Get()
|
| 8 |
+
@ApiOperation({ summary: 'Check API health and uptime' })
|
| 9 |
+
check() {
|
| 10 |
+
return {
|
| 11 |
+
status: 'ok',
|
| 12 |
+
timestamp: new Date().toISOString(),
|
| 13 |
+
uptime: process.uptime(),
|
| 14 |
+
service: 'MaternAlert Backend',
|
| 15 |
+
version: '1.0.0',
|
| 16 |
+
};
|
| 17 |
+
}
|
| 18 |
+
}
|
src/main.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NestFactory } from "@nestjs/core";
|
| 2 |
+
import { AppModule } from "./app.module";
|
| 3 |
+
import { setupGlobalMiddleware } from "./setup";
|
| 4 |
+
|
| 5 |
+
async function bootstrap() {
|
| 6 |
+
const app = await NestFactory.create(AppModule);
|
| 7 |
+
|
| 8 |
+
setupGlobalMiddleware(app);
|
| 9 |
+
|
| 10 |
+
const port = process.env.PORT || 3000;
|
| 11 |
+
await app.listen(port, '0.0.0.0');
|
| 12 |
+
|
| 13 |
+
console.log(`\n🏥 Maternal Health Support Backend`);
|
| 14 |
+
console.log(`⚠️ CARE-SUPPORT TOOL - NOT A DIAGNOSTIC SYSTEM`);
|
| 15 |
+
console.log(`🚀 Server running on: http://localhost:${port}/api/v1`);
|
| 16 |
+
console.log(`📡 Network access: http://10.120.165.24:${port}/api/v1\n`);
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
bootstrap();
|
src/notifications/dto/register-push-token.dto.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { IsNotEmpty, IsString } from "class-validator";
|
| 2 |
+
|
| 3 |
+
export class RegisterPushTokenDto {
|
| 4 |
+
@IsNotEmpty()
|
| 5 |
+
@IsString()
|
| 6 |
+
token!: string;
|
| 7 |
+
}
|
src/notifications/email.service.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Injectable, Logger } from '@nestjs/common';
|
| 2 |
+
import { ConfigService } from '@nestjs/config';
|
| 3 |
+
import * as nodemailer from 'nodemailer';
|
| 4 |
+
|
| 5 |
+
@Injectable()
|
| 6 |
+
export class EmailService {
|
| 7 |
+
private readonly logger = new Logger(EmailService.name);
|
| 8 |
+
private transporter: nodemailer.Transporter;
|
| 9 |
+
|
| 10 |
+
constructor(private readonly configService: ConfigService) {
|
| 11 |
+
this.transporter = nodemailer.createTransport({
|
| 12 |
+
host: this.configService.get('SMTP_HOST'),
|
| 13 |
+
port: this.configService.get('SMTP_PORT'),
|
| 14 |
+
secure: false, // true for 465, false for other ports
|
| 15 |
+
auth: {
|
| 16 |
+
user: this.configService.get('SMTP_USER'),
|
| 17 |
+
pass: this.configService.get('SMTP_PASS'),
|
| 18 |
+
},
|
| 19 |
+
});
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async sendEmail(to: string, subject: string, text: string, html?: string): Promise<boolean> {
|
| 23 |
+
try {
|
| 24 |
+
const from = this.configService.get('SMTP_FROM');
|
| 25 |
+
|
| 26 |
+
const info = await this.transporter.sendMail({
|
| 27 |
+
from,
|
| 28 |
+
to,
|
| 29 |
+
subject,
|
| 30 |
+
text,
|
| 31 |
+
html: html || text,
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
this.logger.log(`Email sent successfully: ${info.messageId} to ${to}`);
|
| 35 |
+
return true;
|
| 36 |
+
} catch (error: any) {
|
| 37 |
+
this.logger.error(`Failed to send email to ${to}: ${error.message}`, error.stack);
|
| 38 |
+
return false;
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
}
|
src/notifications/notifications.controller.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Controller, Get, Patch, Post, Param, Body, UseGuards, Request } from "@nestjs/common";
|
| 2 |
+
import { NotificationsService } from "./notifications.service";
|
| 3 |
+
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
| 4 |
+
import { RegisterPushTokenDto } from "./dto/register-push-token.dto";
|
| 5 |
+
|
| 6 |
+
@Controller("notifications")
|
| 7 |
+
@UseGuards(JwtAuthGuard)
|
| 8 |
+
export class NotificationsController {
|
| 9 |
+
constructor(private readonly notificationsService: NotificationsService) {}
|
| 10 |
+
|
| 11 |
+
@Get()
|
| 12 |
+
async getUserNotifications(@Request() req: any) {
|
| 13 |
+
return this.notificationsService.getNotificationHistory(req.user.id);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
@Post("register")
|
| 17 |
+
async registerPushToken(@Request() req: any, @Body() dto: RegisterPushTokenDto) {
|
| 18 |
+
return this.notificationsService.registerPushToken(req.user.id, dto.token);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@Patch(":id/read")
|
| 22 |
+
async markAsRead(@Param("id") id: string, @Request() req: any) {
|
| 23 |
+
return this.notificationsService.markAsRead(id, req.user.id);
|
| 24 |
+
}
|
| 25 |
+
}
|
src/notifications/notifications.module.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Module } from "@nestjs/common";
|
| 2 |
+
import { NotificationsService } from "./notifications.service";
|
| 3 |
+
import { NotificationsController } from "./notifications.controller";
|
| 4 |
+
import { EmailService } from "./email.service";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Notifications Module
|
| 8 |
+
*
|
| 9 |
+
* RESPONSIBILITIES:
|
| 10 |
+
* - Send care escalation alerts
|
| 11 |
+
* - Template-based messaging only
|
| 12 |
+
* - No dynamic medical text generation
|
| 13 |
+
*
|
| 14 |
+
* CLINICAL SAFETY CONSTRAINTS:
|
| 15 |
+
* - No fear-based language
|
| 16 |
+
* - No predictions
|
| 17 |
+
* - Predefined templates only
|
| 18 |
+
* - Clear, actionable guidance
|
| 19 |
+
*
|
| 20 |
+
* DELIVERY:
|
| 21 |
+
* - Currently stub implementation (console logging)
|
| 22 |
+
* - Ready for integration with email/SMS services
|
| 23 |
+
* - Examples: SendGrid, Twilio, AWS SNS
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
@Module({
|
| 27 |
+
controllers: [NotificationsController],
|
| 28 |
+
providers: [NotificationsService, EmailService],
|
| 29 |
+
exports: [NotificationsService, EmailService],
|
| 30 |
+
})
|
| 31 |
+
export class NotificationsModule {}
|