diff --git a/.gitignore b/.gitignore index 0ef98c3..e2a5a23 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ out/ bin/ obj/ .vscode/ +.appdata/ +AppData/ + diff --git a/Backend Architecture & Integration_Guide.md b/Backend Architecture & Integration_Guide.md new file mode 100644 index 0000000..8365b5a --- /dev/null +++ b/Backend Architecture & Integration_Guide.md @@ -0,0 +1,1225 @@ +# Backend Architecture and Integration Guide + +## 1. Core Entities + +### 1.1 Helper + +Volunteer responder capable of assisting users. + +`Response` + +```json +[ + + +{ + "id": "helper_101", + "name": "Rahul Kumar", + "phone": "+91XXXXXXXXXX", + "skills": ["cpr", "first_aid"], + "location": { + "lat": 23.796, + "lng": 86.432 + }, + "distance": "5km", + "status": "available", + "rating": 4.7, + "verified": true +} + +{ + "id": "helper_102", + "name": "Priya Singh", + "phone": "+91XXXXXXXXXX", + "skills": ["first_aid"], + "location": { + "lat": 23.798, + "lng": 86.435 + }, + "distance": "3km", + "status": "available", + "rating": 4.9, + "verified": true +} + + +] + +``` + +Future fields may include: + +- vehicle availability +- certifications +- active assignments +- response speed metrics + +--- + +### 1.2 NGO / Organization + +Organizations that provide structured emergency assistance. + +`Response` + +```json +[ + { + "id": "ngo_001", + "name": "Red Cross Dhanbad", + "phone": "+91XXXXXXXXXX", + "services": ["medical", "disaster_relief"], + "location": { + "lat": 23.8, + "lng": 86.45 + }, + "distance": "1km", + "coverageRadius": 50, + "activeResponders": 8 + }, + + { + "id": "ngo_002", + "name": "Dhanbad Fire Department", + "phone": "+91XXXXXXXXXX", + "services": ["medical", "disaster_relief"], + "location": { + "lat": 23.8, + "lng": 86.45 + }, + "distance": "500m", + "coverageRadius": 50, + "activeResponders": 8 + } + // .... +] +``` + +Future fields: + +- response vehicles +- operating hours +- dispatch teams +- emergency capacity + +### 1.3 AI Emergency System + +**Method** + +```text +POST +``` + +**URL** + +```text +{{baseUrl}}/api/chatWithAi +``` + +The AI system checks the user's problem and decides what to do next. + +It reads the user's: + +- message +- voice +- location +- conversation + +Then the AI decides whether the situation is **small** or **dangerous**. + +#### AI Decision Flow + +```text +AI checks the problem + ↓ +Small problem -> show guidance tips + ↓ +Big danger -> notify nearby helpers + ↓ +Alert family + helpers + ↓ +Voice instructions for user + ↓ +Help arrives in 2-5 minutes +``` + +--- + +#### Small Problem (Guidance Mode) + +If the AI thinks the situation is **not very dangerous**, it gives simple guidance. + +Examples: + +```text +Apply pressure to stop bleeding +Wash the wound with clean water +Stay calm and sit down +``` + +No SOS alert is sent in this case. + +--- + +#### Big Danger (Emergency Mode) + +If the AI detects a **serious emergency**, it will automatically trigger an SOS alert. + +Examples of serious emergencies: + +```text +heart attack +person unconscious +heavy bleeding +fire +major accident +``` + +The AI will: + +- send SOS to the server +- notify nearby helpers +- alert family members +- start voice guidance + +--- + +#### AI Triggering SOS + +When the AI detects a dangerous situation, it sends the emergency data to the backend. + +**Method** + +```text +POST +``` + +**URL** + +```text +{{baseUrl}}/api/emergency/sos +``` + +**Authorization** + +```text +Bearer Token ({{accessToken}}) +``` + +Request body example: + +```json +{ + "title": "Medical Emergency", + "message": "Severe chest pain", + "latitude": 40.7128, + "longitude": -74.006, + "address": "123 Main St, New York, NY", + "medicalInfo": { + "bloodType": "O+", + "conditions": ["Asthma"] + // ...additional medical info + } +} +``` + +--- + +#### Voice Guidance for User + +While help is coming, the AI gives instructions. + +Examples: + +```text +Stay calm. Help is on the way. +Check if the person is breathing. +Apply pressure to the wound. +Move away from smoke. +``` + +This helps users handle the situation until responders arrive. + +### 1.4 AI Emergency Creation + +Once the AI finishes checking the situation in **1.4**, it decides whether the problem is serious enough to create an emergency. + +If the AI detects **big danger**, it creates an **Emergency record** using important fields from the emergency schema. + +The AI collects the following information: + +- emergency type (medical, accident, fire, etc.) +- priority level (critical, high, medium) +- user location +- user information +- description of the problem +- medical information if available + +Then the AI sends this data to the emergency API. + +--- + +#### AI Emergency Creation Flow + +```text +AI analyzes user message / voice + ↓ +AI detects serious danger + ↓ +Collect emergency data + ↓ +Create emergency request + ↓ +Send data to Emergency API + ↓ +Emergency stored in database + ↓ +Helper matching system starts +``` + +--- + +#### Emergency API + +**Method** + +``` +POST +``` + +**URL** + +``` +{{baseUrl}}/api/emergency/sos +``` + +**Authorization** + +``` +Bearer Token ({{accessToken}}) +``` + +--- + +#### Example Request Sent by AI + +```json +{ + "type": "medical", + "priority": "critical", + "title": "Medical Emergency", + "description": "Severe chest pain and breathing difficulty", + + "location": { + "type": "Point", + "coordinates": [-74.006, 40.7128], + "address": "123 Main St, New York, NY", + "accuracy": 15 + }, + + "medicalInfo": { + "bloodType": "O+", + "conditions": ["Asthma"] + } +} +``` + +## What Happens After Emergency is Created + +Once the emergency is stored: + +1. The system saves the emergency in the database. +2. Emergency status becomes **active**. +3. The system sends the emergency data to the **dispatch engine**. +4. The dispatch engine uses **AI analysis from section 1.4** to determine what type of helpers are required. + +Example AI result: + +```json +{ + "requiredHelpers": ["doctor", "nurse", "ambulance"] +} +``` + +or + +```json +{ + "requiredHelpers": ["firefighter", "rescue_team", "ngo"] +} +``` + +5. Based on the required helper types, the system searches for **nearby helpers or NGOs whose skills match the emergency**. + +Example matching logic: + +- medical emergency → doctor, nurse, ambulance +- fire emergency → firefighter, rescue team +- accident → first aid helper, ambulance +- disaster → NGO + rescue team + +6. The system finds helpers within the **search radius** using the location data. + +7. All matching helpers are **notified in real time** using WebSocket / Push notifications. + +Example real-time notification event: + +``` +event: emergency_alert +data: +{ + "emergencyId": "65e5b4f2c9e77c001f3e45b2", + "type": "medical", + "priority": "critical", + "distance": "1.2km" +} +``` + +8. Helpers receive the emergency request in their app and can **accept or reject** it. + +9. Once a helper accepts the request, their status changes through the response stages. + +``` +requested → accepted → arriving → arrived +``` + +10. Response metrics start tracking the event. + +Example metrics started: + +``` +sosTriggeredAt +firstHelperAssignedAt +firstHelperAcceptedAt +firstHelperArrivedAt +``` + +These metrics help measure **response time and system performance**. + +--- + +## Communication Log + +Each important event is stored in the communication log. + +Examples: + +``` +SOS sent +Helpers notified +Helper accepted +Helper arrived +Status updated +``` + +This allows the system to track the **complete emergency timeline**. + +--- + +## Final Result + +After the emergency is created: + +- AI determines the **required helper types** +- nearby helpers or NGOs with matching skills are found +- helpers are **notified in real time** +- the first available helper accepts the request +- the AI continues giving voice instructions +- help arrives as quickly as possible + +Goal response time: + +``` +2–5 minutes +``` + +--- + +Your sections are already strong. I improved them **slightly for clarity, consistency, and flow**, while keeping your structure almost the same. Improvements include: + +- clearer flow +- small grammar fixes +- better SMS formatting +- better separation between **Family Alert** and **Status Updates** +- more consistent wording with backend systems +- added **Google Maps link improvement** +- clarified **real-time updates** + +Below is the **improved version**. + +--- + +# 1.5 Family Alert System + +When an emergency is created in **1.4 AI Emergency Creation**, the system also notifies the user's **registered family members or emergency contacts**. + +The system fetches the emergency details created in **Section 1.4** and sends them to all registered family numbers using SMS. + +This ensures that family members are aware of the situation immediately and can monitor updates while help is on the way. + +--- + +## Family Alert Flow + +``` +Emergency created (Section 1.4) + ↓ +Fetch emergency data + ↓ +Fetch user's emergency contacts + ↓ +Generate emergency SMS message + ↓ +Send SMS to all family numbers + ↓ +Family receives emergency alert +``` + +--- + +## Data Used from Section 1.4 + +The SMS message is generated using the emergency data created earlier. + +Important fields used: + +- `title` +- `description` +- `type` +- `location.address` +- `location.coordinates` +- `priority` +- `createdAt` + +Example emergency data: + +```json +{ + "type": "medical", + "priority": "critical", + "title": "Medical Emergency", + "description": "Severe chest pain and breathing difficulty", + "location": { + "coordinates": [-74.006, 40.7128], + "address": "123 Main St, New York, NY" + } +} +``` + +--- + +## Example SMS Message + +``` +🚨 LifeLine Emergency Alert + +An SOS has been triggered. + +Emergency: Medical Emergency +Details: Severe chest pain and breathing difficulty +Priority: CRITICAL + +Location: +123 Main St, New York, NY + +View Location: +https://maps.google.com/?q=40.7128,-74.006 + +Help is on the way. + +Track live location: +https://lifeline.app/track/abc123 +``` + +--- + +## Fast2SMS Integration + +The backend uses **Fast2SMS** to send the alert message. + +Example implementation: + +```javascript +const options = { + method: 'POST', + url: 'https://www.fast2sms.com/dev/bulkV2', + headers: { + accept: 'application/json', + authorization: process.env.FAST2SMS_API_KEY, + 'content-type': 'application/json', + }, + data: { + route: 'q', + numbers: mobileNumbers, + message: `🚨 LifeLine Emergency Alert + +Emergency: ${title} +Details: ${description} +Priority: ${priority} + +Location: +${address} + +View Map: +https://maps.google.com/?q=${latitude},${longitude} + +Help is on the way.`, + }, +}; +``` + +--- + +## Purpose + +The family alert system ensures that: + +- family members are notified immediately +- they receive the **exact emergency details** +- they know the **user's location** +- they can **track the situation in real time** + +This allows family members to stay informed while helpers are responding to the emergency. + +--- + +# 1.6 Helper & NGO Status Updates + +After helpers or NGOs are notified about an emergency, the system continuously sends **status updates** to the user and the user's emergency contacts. + +These updates allow everyone involved to understand what is happening and when help will arrive. + +Status updates can be delivered through: + +- in-app notifications +- push notifications +- SMS alerts (for critical updates) + +--- + +## Status Update Flow + +``` +Emergency created + ↓ +Helpers / NGOs notified + ↓ +Helper accepts request + ↓ +Status updates sent to user + ↓ +Status updates sent to family contacts + ↓ +Helper arrives at location + ↓ +Emergency resolved +``` + +--- + +## Helper Status Stages + +Each helper request moves through different stages. + +``` +requested → accepted → arriving → arrived → completed +``` + +Explanation: + +| Status | Meaning | +| --------- | ----------------------------------- | +| requested | helper received emergency request | +| accepted | helper accepted the request | +| arriving | helper is traveling to the location | +| arrived | helper reached the user | +| completed | emergency resolved | + +--- + +## Example Status Update (Helper Accepted) + +``` +LifeLine Update + +A helper has accepted the emergency request. + +Helper: Rahul Kumar +Skill: CPR / First Aid +Distance: 1.2 km +Estimated arrival: 3 minutes +``` + +--- + +## Example Status Update (Helper Arriving) + +``` +LifeLine Update + +Helper Rahul Kumar is on the way. + +Current distance: 800 meters +Estimated arrival: 2 minutes +``` + +--- + +## Example Status Update (Helper Arrived) + +``` +LifeLine Update + +Your helper has arrived at the location. + +Helper: Rahul Kumar +Status: Arrived +``` + +--- + +## NGO Status Updates + +If the emergency requires an NGO or organization, the system also sends updates about the NGO response. + +Example: + +``` +LifeLine Update + +Red Cross Dhanbad has responded to the emergency. + +Rescue team dispatched. +Estimated arrival: 5 minutes +``` + +--- + +## Notification Recipients + +Status updates are sent to: + +- the **user who triggered the SOS** +- the **registered emergency contacts** +- optionally **admins or dispatch operators** + +--- + +## Real-Time Updates + +To deliver updates quickly, the system uses real-time communication. + +Recommended technologies: + +- **WebSocket (Socket.io)** for live updates +- **Push notifications** for mobile alerts +- **SMS** for emergency contacts + +--- + +## Example Real-Time Event + +```json +{ + "event": "helper_status_update", + "emergencyId": "65e5b4f2c9e77c001f3e45b2", + "helperName": "Rahul Kumar", + "status": "arriving", + "distance": "800m", + "estimatedArrival": "2 minutes" +} +``` + +# 1.7 Smart Helper Ranking System + +When an emergency occurs, there may be **many helpers available nearby**. +Sending alerts to all helpers at once can create confusion and inefficient responses. + +To solve this, the system uses a **Smart Helper Ranking System** to identify the **best possible responders**. + +This ranking system evaluates helpers using multiple factors instead of only distance. + +--- + +## Ranking Factors + +The system calculates a **helper score** based on the following parameters: + +| Factor | Description | +| -------------- | ------------------------------------------------- | +| Distance | How far the helper is from the emergency location | +| Rating | Helper rating based on previous responses | +| Response Speed | Average time taken to accept emergencies | +| Availability | Whether the helper is currently available | +| Experience | Number of emergencies handled before | +| Verification | Whether the helper is identity verified | + +--- + +## Helper Score Calculation + +Each helper receives a **score** calculated by the dispatch engine. + +Example formula: + +``` +helperScore = +(distanceWeight × distanceScore) ++ (ratingWeight × ratingScore) ++ (responseSpeedWeight × responseSpeedScore) ++ (experienceWeight × experienceScore) ++ (availabilityWeight × availabilityScore) +``` + +Higher scores indicate **better responders**. + +--- + +## Example Helper Ranking + +Example evaluated helpers: + +```json +[ + { + "helperId": "helper_101", + "distance": "1.2km", + "rating": 4.8, + "avgResponseTime": "2.3 minutes", + "completedEmergencies": 54, + "score": 92 + }, + { + "helperId": "helper_102", + "distance": "2.1km", + "rating": 4.9, + "avgResponseTime": "3.0 minutes", + "completedEmergencies": 33, + "score": 88 + } +] +``` + +The dispatch engine selects the **top-ranked helpers first**. + +--- + +## Benefits + +This system improves: + +- emergency response reliability +- faster helper arrival +- higher quality assistance +- better system performance + +--- + +# 1.8 Multi-Stage Dispatch System + +Not every emergency requires **all helpers to be notified at once**. + +Instead, the system uses a **wave-based dispatch model**. + +This reduces notification overload and improves response efficiency. + +--- + +## Dispatch Waves + +The dispatch system sends alerts in **multiple stages**. + +``` +Wave 1 → Top helpers within 1 km +Wave 2 → Additional helpers within 3 km +Wave 3 → NGOs and emergency organizations +``` + +--- + +## Dispatch Flow + +``` +Emergency created + ↓ +Helper ranking system selects best responders + ↓ +Wave 1 notifications sent + ↓ +Wait for response (10 seconds) + ↓ +If no helper accepts + ↓ +Wave 2 notifications sent + ↓ +If still no response + ↓ +Wave 3 escalation to NGOs and organizations +``` + +--- + +## Example Dispatch Notification + +```json +{ + "event": "emergency_alert", + "wave": 1, + "emergencyId": "65e5b4f2c9e77c001f3e45b2", + "priority": "critical", + "distance": "1.2km" +} +``` + +--- + +## Advantages + +Wave-based dispatch: + +- prevents responder crowding +- prioritizes best helpers +- ensures emergency coverage + +--- + +# 1.9 Live Location Tracking + +During emergencies, the user's location may **change frequently**. + +For example: + +- accident victim moving +- fire evacuation +- helper approaching location + +To support this, the system uses **live location tracking**. + +--- + +## Location Update System + +When SOS is active, the mobile app sends **location updates** about every 5 seconds. + +Current mobile implementation: + +- if app is open, foreground GPS tracking runs +- if app is in background, background tracking runs +- only one tracking mode runs at a time + +Before sending data to backend, the app cleans the location data: + +- drops invalid or very old points +- waits at least about 4.5 seconds between sends +- ignores tiny GPS shake when user is not moving +- smooths small random drift +- if no good point is sent for about 15 seconds, sends one keepalive update + +--- + +## Location Update Flow + +``` +Emergency triggered + ↓ +Mobile SOS tracking starts + ↓ +App chooses foreground or background mode + ↓ +App checks point quality + ↓ +`location:update` sent to server + ↓ +Server saves location and broadcasts it + ↓ +Helpers receive live updates +``` + +--- + +## Example Location Update Event + +```json +{ + "event": "location:update", + "userId": "65e5b4f2c9e77c001f3e45b2", + "role": "user", + "latitude": 23.796, + "longitude": 86.432, + "accuracy": 8, + "timestamp": "2026-03-08T12:05:21Z" +} +``` + +--- + +## Benefits + +Live tracking allows helpers to: + +- locate users faster +- adjust routes in real time +- navigate moving victims +- see cleaner map movement with less false GPS drift + +--- + +# 1.10 Emergency Severity Scoring + +Not all emergencies are equally dangerous. + +To improve decision making, the AI system calculates a **severity score**. + +--- + +## Severity Score Range + +``` +0 → No danger +100 → Extreme emergency +``` + +--- + +## Example Severity Scores + +| Situation | Score | +| --------------- | ----- | +| Minor injury | 20 | +| Broken bone | 60 | +| Severe bleeding | 85 | +| Heart attack | 95 | +| Fire incident | 90 | + +--- + +## AI Evaluation Inputs + +The AI evaluates: + +- text message from user +- voice input +- breathing sounds +- panic detection +- previous medical conditions +- environmental clues + +--- + +## Example AI Result + +```json +{ + "emergencyType": "medical", + "severityScore": 91, + "priority": "critical" +} +``` + +--- + +## Severity-Based Actions + +| Score Range | Action | +| ----------- | ------------------------------- | +| 0–30 | Guidance only | +| 31–70 | Notify nearby helpers | +| 71–100 | Trigger full emergency response | + +--- + +# 1.11 Emergency Escalation System + +Sometimes nearby helpers **do not accept the emergency request**. + +To ensure assistance is always available, the system uses an **automatic escalation mechanism**. + +--- + +## Escalation Flow + +``` +Emergency created + ↓ +Notify nearby helpers + ↓ +Wait for acceptance (20 seconds) + ↓ +If no response + ↓ +Expand search radius + ↓ +Notify additional helpers + ↓ +Alert NGOs and emergency services +``` + +--- + +## Escalation Levels + +| Level | Action | +| ------- | --------------------------------------- | +| Level 1 | Notify helpers within 1 km | +| Level 2 | Notify helpers within 5 km | +| Level 3 | Notify NGOs and emergency organizations | + +--- + +## Escalation Event Example + +```json +{ + "event": "dispatch_escalation", + "level": 2, + "searchRadius": "5km", + "emergencyId": "65e5b4f2c9e77c001f3e45b2" +} +``` + +--- + +# 1.12 Hospital Integration + +Hospitals play a crucial role in emergency response. + +The system integrates nearby hospitals to coordinate medical support. + +--- + +## Hospital Entity + +Example hospital data: + +```json +{ + "id": "hospital_001", + "name": "City Medical Center", + "location": { + "lat": 23.80, + "lng": 86.45 + }, + "emergencyBeds": 12, + "ambulances": 3, + "traumaCenter": true +} +``` + +--- + +## Hospital Integration Flow + +``` +Emergency detected + ↓ +AI identifies medical emergency + ↓ +Find nearest hospital + ↓ +Notify emergency department + ↓ +Prepare emergency team +``` + +--- + +## Benefits + +Hospital integration enables: + +- faster treatment preparation +- ambulance coordination +- emergency bed availability + +--- + +# 1.13 Offline Emergency Trigger (Optional) + +In many regions, internet connectivity may be unstable. + +The system supports **offline emergency triggers**. + +--- + +## Offline SOS Methods + +Users can trigger SOS using: + +- power button pressed multiple times +- special SMS message +- offline emergency mode + +--- + +## Example SMS SOS + +``` +SOS LAT:23.796 LNG:86.432 +``` + +The backend server detects this message and creates an emergency event. + +--- + +# 1.14 Emergency Timeline System + +Every emergency generates a **complete timeline of events**. + +This allows tracking of the entire emergency lifecycle. + +--- + +## Example Timeline + +``` +12:01 SOS triggered +12:01 AI analysis completed +12:02 helpers notified +12:03 helper accepted request +12:05 helper arrived +12:15 emergency resolved +``` + +--- + +## Timeline Data Example + +```json +{ + "emergencyId": "65e5b4f2c9e77c001f3e45b2", + "events": [ + { "type": "sos_triggered", "time": "12:01" }, + { "type": "helper_notified", "time": "12:02" }, + { "type": "helper_arrived", "time": "12:05" } + ] +} +``` + +--- + +# 1.15 System Analytics + +The platform tracks important metrics to improve system performance. + +--- + +## Key Metrics + +| Metric | Purpose | +| ------------------------- | ----------------------------- | +| Average response time | Measures system efficiency | +| Helper acceptance rate | Measures responder engagement | +| Emergency resolution rate | Measures success rate | +| AI detection accuracy | Measures AI reliability | + +--- + +## Example Analytics Data + +```json +{ + "averageResponseTime": "2.7 minutes", + "helperAcceptanceRate": "74%", + "emergenciesResolved": 1250 +} +``` diff --git a/LifeLine-Backend/.env.example b/LifeLine-Backend/.env.example index 586aa6a..090bbce 100644 --- a/LifeLine-Backend/.env.example +++ b/LifeLine-Backend/.env.example @@ -6,6 +6,12 @@ JWT_SECRET=your_super_secret_key_here NODE_ENV=development FAST2SMS_API_KEY=your_fast2sms_api_key_here +# Cloudinary (Profile Image Uploads) +CLOUDINARY_CLOUD_NAME=your_cloud_name +CLOUDINARY_API_KEY=your_api_key +CLOUDINARY_API_SECRET=your_api_secret +CLOUDINARY_PROFILE_FOLDER=lifeline/profile-pictures + # AI Configuration (OpenAI / ChatGPT) OPENAI_API_KEY=your_openai_api_key_here OPENAI_MODEL=gpt-4o-mini @@ -18,4 +24,4 @@ SESSION_TTL=300 # Triage Settings MAX_TRIAGE_QUESTIONS=8 -TRIAGE_TIMEOUT_SECONDS=300 \ No newline at end of file +TRIAGE_TIMEOUT_SECONDS=300 diff --git a/LifeLine-Backend/docker-compose.yml b/LifeLine-Backend/docker-compose.yml index f886206..c238a3b 100644 --- a/LifeLine-Backend/docker-compose.yml +++ b/LifeLine-Backend/docker-compose.yml @@ -11,7 +11,26 @@ services: - /app/node_modules environment: - NODE_ENV=development + - MONGODB_URI=mongodb://mongo:27017/lifeline env_file: - .env - command: pnpm run dev:docker + depends_on: + - mongo + command: sh -c "pnpm run dev:docker & pnpm run share" restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "5" + mongo: + image: mongo:6 + container_name: lifeline-mongo + ports: + - "27017:27017" + volumes: + - lifeline_mongo:/data/db + restart: unless-stopped + +volumes: + lifeline_mongo: diff --git a/LifeLine-Backend/docs/04-Business-Logic-and-Rules.md b/LifeLine-Backend/docs/04-Business-Logic-and-Rules.md index d334f14..eeaaa56 100644 --- a/LifeLine-Backend/docs/04-Business-Logic-and-Rules.md +++ b/LifeLine-Backend/docs/04-Business-Logic-and-Rules.md @@ -12,43 +12,43 @@ This document defines the core rules and constraints that govern the LifeLine sy ## 2. Emergency Lifecycle -1. **Creation:** - * **Rule:** User must be authenticated (JWT) to trigger SOS. - * **Rule:** Location coordinates are mandatory. *System attempts to fallback to user's last known location if missing in request.* - * **Status:** Starts as `active`. - -2. **Notification:** - * **Rule:** System searches for Helpers within a defined radius (Default: 5km). - * **Rule:** Helpers must be `ONLINE` and `VERIFIED` to receive alerts. - -3. **Acceptance:** - * **Rule:** Multiple helpers can accept an emergency request. - * **Constraint:** Acceptance is allowed until `maxHelpers` limit (default: 3) is reached. - * **Status:** Remains `active` but updates `assignedHelpers` status to `accepted`. - * **Action:** Chat room is created; user notified of helper ETA. - -4. **Resolution:** - * **Rule:** Only the User or Helper can mark an emergency as `RESOLVED`. - * **Status:** Changes to `resolved`. - * **Action:** Chat is archived; session closed. +1. **Creation:** + * **Rule:** User must be authenticated (JWT) to trigger SOS. + * **Rule:** Location coordinates are mandatory. *System attempts to fallback to user's last known location if missing in request.* + * **Status:** Starts as `active`. + +2. **Notification:** + * **Rule:** System searches for Helpers within a defined radius (Default: 5km). + * **Rule:** Helpers must be `ONLINE` and `VERIFIED` to receive alerts. + +3. **Acceptance:** + * **Rule:** Multiple helpers can accept an emergency request. + * **Constraint:** Acceptance is allowed until `maxHelpers` limit (default: 3) is reached. + * **Status:** Remains `active` but updates `assignedHelpers` status to `accepted`. + * **Action:** Chat room is created; user notified of helper ETA. + +4. **Resolution:** + * **Rule:** Only the User or Helper can mark an emergency as `RESOLVED`. + * **Status:** Changes to `resolved`. + * **Action:** Chat is archived; session closed. ## 3. AI Triage Logic -* **Trigger:** User initiates chat with "Triage Bot". -* **Process:** AI asks clarifying questions (Default max: 8). -* **Output:** - * **Advice:** Immediate first-aid steps. - * **Severity Score:** Low/Medium/High. - * **Escalation:** If High severity, system auto-prompts to create an SOS. +* **Trigger:** User initiates chat with "Triage Bot". +* **Process:** AI asks clarifying questions (Default max: 8). +* **Output:** + * **Advice:** Immediate first-aid steps. + * **Severity Score:** Low/Medium/High. + * **Escalation:** If High severity, system auto-prompts to create an SOS. ## 4. Helper Verification -* **Requirement:** Helpers must upload valid ID/Certification to be "Verified." -* **Constraint:** Unverified helpers cannot accept emergency requests. -* **Validation:** Admin manual review via `PATCH /:id/credentials/:credentialId/verify`. +* **Requirement:** Helpers must upload valid ID/Certification to be "Verified." +* **Constraint:** Unverified helpers cannot accept emergency requests. +* **Validation:** Admin manual review via `PATCH /:id/credentials/:credentialId/verify`. ## 5. Data Privacy & Retention -* **Rule:** Emergency chats are accessible only to participants. -* **Retention:** Emergency logs are kept for legal compliance. -* **Location:** Real-time tracking is active *only* during an active emergency or when app is in foreground. +* **Rule:** Emergency chats are accessible only to participants. +* **Retention:** Emergency logs are kept for legal compliance. +* **Location:** Real-time tracking runs only during active emergencies. If the app is open it tracks in foreground; if the app goes to background it continues with background tracking. diff --git a/LifeLine-Backend/docs/11-Socket-Protocol.md b/LifeLine-Backend/docs/11-Socket-Protocol.md index 29a8376..022c3d6 100644 --- a/LifeLine-Backend/docs/11-Socket-Protocol.md +++ b/LifeLine-Backend/docs/11-Socket-Protocol.md @@ -3,91 +3,109 @@ This document outlines the real-time event-driven architecture using **Socket.io**. ## Connection -* **Endpoint:** `ws://localhost:5000` -* **Auth:** No handshake auth required. Client must emit `user:register` immediately after connection. + +* **Endpoint:** `ws://localhost:5000` +* **Auth:** No handshake auth required. Client must emit `user:register` immediately after connection. ## Registration -* **Event:** `user:register` -* **Payload:** `{ userId: "..." }` -* **Description:** Maps the socket ID to a User ID for targeted notifications. + +* **Event:** `user:register` +* **Payload:** `{ userId: "..." }` +* **Description:** Maps the socket ID to a User ID for targeted notifications. ## Namespaces & Rooms -* `user:{userId}` - Private channel for specific user notifications. -* `emergency:{emergencyId}` - Shared room for all participants in an active emergency. + +* `user:{userId}` - Private channel for specific user notifications. +* `emergency:{emergencyId}` - Shared room for all participants in an active emergency. ## Events ### 1. Emergency Lifecycle #### Client -> Server -* `emergency:triggerSOS` - * **Payload:** `{ type, title, description, location: { coordinates, address... } }` - * **Description:** User initiates a new emergency. -* `emergency:join` - * **Payload:** `{ emergencyId, role }` - * **Description:** User/Helper joins the active emergency room. -* `emergency:leave` - * **Payload:** `{ emergencyId }` - * **Description:** Participant leaves the room. -* `emergency:helperRespond` - * **Payload:** `{ emergencyId, response: 'accepted'|'declined', eta, location }` - * **Description:** Helper accepts or rejects the request. -* `emergency:helperArrived` - * **Payload:** `{ emergencyId }` - * **Description:** Helper confirms arrival at the scene. + +* `emergency:triggerSOS` + * **Payload:** `{ type, title, description, location: { coordinates, address... } }` + * **Description:** User initiates a new emergency. +* `emergency:join` + * **Payload:** `{ emergencyId, role }` + * **Description:** User/Helper joins the active emergency room. +* `emergency:leave` + * **Payload:** `{ emergencyId }` + * **Description:** Participant leaves the room. +* `emergency:helperRespond` + * **Payload:** `{ emergencyId, response: 'accepted'|'declined', eta, location }` + * **Description:** Helper accepts or rejects the request. +* `emergency:helperArrived` + * **Payload:** `{ emergencyId }` + * **Description:** Helper confirms arrival at the scene. #### Server -> Client -* `emergency:newAlert` - * **Target:** Nearby Helpers - * **Payload:** `{ emergencyId, location, distance, type, priority, timestamp }` -* `emergency:sosTriggered` - * **Target:** User (Ack) - * **Payload:** `{ success, emergencyId, assignedHelpers }` -* `emergency:participantJoined` - * **Target:** Emergency Room - * **Payload:** `{ emergencyId, userId, role, timestamp }` -* `emergency:participantLeft` - * **Target:** Emergency Room - * **Payload:** `{ emergencyId, userId, timestamp }` -* `emergency:helperResponse` - * **Target:** Emergency Room - * **Payload:** `{ emergencyId, helperId, response, eta, location }` -* `emergency:helperArrival` - * **Target:** Emergency Room - * **Payload:** `{ emergencyId, helperId, arrivedAt }` -* `emergency:status` - * **Target:** Emergency Room - * **Payload:** `{ status: 'resolved'|'cancelled', ...details }` -* `emergency:error` - * **Target:** Sender - * **Payload:** `{ success: false, message: "Error description" }` + +* `emergency:newAlert` + * **Target:** Nearby Helpers + * **Payload:** `{ emergencyId, location, distance, type, priority, timestamp }` +* `emergency:sosTriggered` + * **Target:** User (Ack) + * **Payload:** `{ success, emergencyId, assignedHelpers }` +* `emergency:participantJoined` + * **Target:** Emergency Room + * **Payload:** `{ emergencyId, userId, role, timestamp }` +* `emergency:participantLeft` + * **Target:** Emergency Room + * **Payload:** `{ emergencyId, userId, timestamp }` +* `emergency:helperResponse` + * **Target:** Emergency Room + * **Payload:** `{ emergencyId, helperId, response, eta, location }` +* `emergency:helperArrival` + * **Target:** Emergency Room + * **Payload:** `{ emergencyId, helperId, arrivedAt }` +* `emergency:status` + * **Target:** Emergency Room + * **Payload:** `{ status: 'resolved'|'cancelled', ...details }` +* `emergency:error` + * **Target:** Sender + * **Payload:** `{ success: false, message: "Error description" }` ### 2. Chat & Communication #### Client -> Server -* `emergency:sendMessage` - * **Payload:** `{ emergencyId, message, messageType: 'text' }` + +* `emergency:sendMessage` + * **Payload:** `{ emergencyId, message, messageType: 'text' }` #### Server -> Client -* `emergency:message` - * **Payload:** `{ emergencyId, senderId, message, messageType, timestamp }` + +* `emergency:message` + * **Payload:** `{ emergencyId, senderId, message, messageType, timestamp }` ### 3. Location Tracking #### Client -> Server -* `location:update` - * **Payload:** `{ latitude, longitude, accuracy, speed, heading, altitude }` - * **Description:** Periodic update from User/Helper device. + +* `location:update` + * **Payload:** `{ latitude, longitude, accuracy?, speed?, heading?, altitude?, timestamp?, userId?, role? }` + * **Description:** Sent regularly during SOS (about every 5 seconds). Client filters noisy points before sending. Identity is mainly taken from `user:register`; `userId/role` here are optional. #### Server -> Client -* `emergency:locationUpdate` - * **Target:** Emergency Room - * **Payload:** `{ emergencyId, userId, location, timestamp }` - * **Description:** Real-time map update for participants. + +* `location:updated` + * **Target:** Sender + * **Payload:** `{ success, message, data, source, timestamp, serverTimestamp }` + * **Description:** Confirms that the latest location was saved. +* `emergency:locationUpdate` + * **Target:** `emergency:{emergencyId}` + * **Payload:** `{ emergencyId, userId, role, source, location, timestamp, serverTimestamp }` + * **Description:** Sends live map updates to everyone in that emergency room. +* `location:userLocationUpdate` + * **Target:** `tracking:{userId}` subscribers + * **Payload:** `{ userId, role, source, location, timestamp, serverTimestamp }` + * **Description:** Sends live updates to users tracking that person. ### 4. Notifications #### Server -> Client -* `notification` - * **Target:** `user:{userId}` - * **Payload:** `{ type, title, message, data, timestamp }` + +* `notification` + * **Target:** `user:{userId}` + * **Payload:** `{ type, title, message, data, timestamp }` diff --git a/LifeLine-Backend/docs/16-Backend-Features-Guide.md b/LifeLine-Backend/docs/16-Backend-Features-Guide.md index e620cb4..24269c4 100644 --- a/LifeLine-Backend/docs/16-Backend-Features-Guide.md +++ b/LifeLine-Backend/docs/16-Backend-Features-Guide.md @@ -5,144 +5,171 @@ This document provides a comprehensive overview of the features implemented in t --- ## 1. Authentication & Identity + **Goal**: Secure, stateless user management for Users, Helpers, and Organizations. ### Features -* **Multi-Role System**: Distinct profiles for `User` (Victim/General), `Helper` (Responder), and `NGO`. -* **JWT Authentication**: Stateless session management using JSON Web Tokens (Access + Refresh tokens). -* **Secure Password Handling**: Uses **Argon2** for industry-standard password hashing. -* **OTP Verification**: Phone/Email verification using 6-digit OTPs (via Fast2SMS/Email). -* **Rate Limiting**: Prevents brute-force attacks on login/signup endpoints. + +* **Multi-Role System**: Distinct profiles for `User` (Victim/General), `Helper` (Responder), and `NGO`. +* **JWT Authentication**: Stateless session management using JSON Web Tokens (Access + Refresh tokens). +* **Secure Password Handling**: Uses **Argon2** for industry-standard password hashing. +* **OTP Verification**: Phone/Email verification using 6-digit OTPs (via Fast2SMS/Email). +* **Rate Limiting**: Prevents brute-force attacks on login/signup endpoints. ### Implementation -* **Routes**: `/api/auth/v1/*` -* **Codebase**: - * [Auth.controller.mjs](file:///src/api/Auth/v1/Auth.controller.mjs): Handles HTTP requests. - * [Auth.service.mjs](file:///src/api/Auth/v1/Auth.service.mjs): Core business logic (hashing, token generation). - * [Auth.middleware.mjs](file:///src/api/Auth/v1/Auth.middleware.mjs): Intercepts requests to verify `Authorization: Bearer `. + +* **Routes**: `/api/auth/v1/*` +* **Codebase**: + * [Auth.controller.mjs](file:///src/api/Auth/v1/Auth.controller.mjs): Handles HTTP requests. + * [Auth.service.mjs](file:///src/api/Auth/v1/Auth.service.mjs): Core business logic (hashing, token generation). + * [Auth.middleware.mjs](file:///src/api/Auth/v1/Auth.middleware.mjs): Intercepts requests to verify `Authorization: Bearer `. --- ## 2. Emergency Response System (Core) + **Goal**: Instant SOS triggering and lifecycle management of emergencies. ### Features -* **One-Tap SOS**: Creates an emergency record with location, type (Medical, Fire, etc.), and priority. -* **Real-time Tracking**: Live updates of Victim and Helper locations via WebSockets. -* **Emergency Lifecycle**: Tracks status transitions: `SEARCHING` -> `ASSIGNED` -> `ACTIVE` -> `RESOLVED`. -* **Nearby Helper Search**: Uses MongoDB Geospatial queries (`$near`, `$maxDistance`) to find responders within range. + +* **One-Tap SOS**: Creates an emergency record with location, type (Medical, Fire, etc.), and priority. +* **Real-time Tracking**: Live updates of Victim and Helper locations via WebSockets. +* **Emergency Lifecycle**: Tracks status transitions: `SEARCHING` -> `ASSIGNED` -> `ACTIVE` -> `RESOLVED`. +* **Nearby Helper Search**: Uses MongoDB Geospatial queries (`$near`, `$maxDistance`) to find responders within range. ### Implementation -* **Routes**: `/api/emergency/*` -* **Codebase**: - * [Emergency.service.mjs](file:///src/api/Emergency/Emergency.service.mjs): Manages emergency creation and status updates. - * [socket/handlers/emergency.handler.mjs](file:///src/socket/handlers/emergency.handler.mjs): Handles real-time events like `SOS_TRIGGERED`, `HELPER_ACCEPTED`. - * **Database**: Uses `2dsphere` indexes on `Location` schema for fast geospatial lookups. + +* **Routes**: `/api/emergency/*` +* **Codebase**: + * [Emergency.service.mjs](file:///src/api/Emergency/Emergency.service.mjs): Manages emergency creation and status updates. + * [socket/handlers/emergency.handler.mjs](file:///src/socket/handlers/emergency.handler.mjs): Handles real-time events like `SOS_TRIGGERED`, `HELPER_ACCEPTED`. + * **Database**: Uses `2dsphere` indexes on `Location` schema for fast geospatial lookups. --- ## 3. AI Triage System + **Goal**: AI-driven assessment to determine if a situation is a critical emergency. ### Features -* **Conversational Assessment**: Uses **OpenAI (GPT-4)** to ask clarifying questions about symptoms/situation. -* **Critical Fast-Track**: Regex-based detection of keywords (e.g., "unconscious", "fire", "bleeding") to trigger SOS immediately without waiting for AI. -* **Structured Output**: AI returns JSON decisions (`severity`, `action`, `category`) for programmatic handling. -* **Context Awareness**: Remembers previous messages in the session to provide coherent responses. + +* **Conversational Assessment**: Uses **OpenAI (GPT-4)** to ask clarifying questions about symptoms/situation. +* **Critical Fast-Track**: Regex-based detection of keywords (e.g., "unconscious", "fire", "bleeding") to trigger SOS immediately without waiting for AI. +* **Structured Output**: AI returns JSON decisions (`severity`, `action`, `category`) for programmatic handling. +* **Context Awareness**: Remembers previous messages in the session to provide coherent responses. ### Implementation -* **Routes**: `/api/triage/*` -* **Codebase**: - * [triageService.mjs](file:///src/Ai/triage/triageService.mjs): Orchestrates the LangChain flow. - * [triagePrompts.mjs](file:///src/Ai/triage/triagePrompts.mjs): Contains system prompts and Few-Shot examples to guide the AI. - * **State Management**: Uses **Redis** (or in-memory fallback) to store conversation history by `sessionId`. + +* **Routes**: `/api/triage/*` +* **Codebase**: + * [triageService.mjs](file:///src/Ai/triage/triageService.mjs): Orchestrates the LangChain flow. + * [triagePrompts.mjs](file:///src/Ai/triage/triagePrompts.mjs): Contains system prompts and Few-Shot examples to guide the AI. + * **State Management**: Uses **Redis** (or in-memory fallback) to store conversation history by `sessionId`. --- ## 4. Medical Profile & Health Records + **Goal**: Provide responders with critical health data during emergencies. ### Features -* **Comprehensive Health Data**: Stores Blood Type, Allergies, Chronic Conditions, Medications. -* **Emergency Access Control**: Data is encrypted but accessible to *verified* helpers only during an *active* SOS. -* **File Attachments**: Upload and retrieval of medical documents/reports. + +* **Comprehensive Health Data**: Stores Blood Type, Allergies, Chronic Conditions, Medications. +* **Emergency Access Control**: Data is encrypted but accessible to *verified* helpers only during an *active* SOS. +* **File Attachments**: Upload and retrieval of medical documents/reports. ### Implementation -* **Routes**: `/api/medical/v1/*` -* **Codebase**: - * [Medical.Schema.mjs](file:///src/api/Medical/v1/Medical.Schema.mjs): Defines complex nested schemas for health data. - * [Medical.controller.mjs](file:///src/api/Medical/Medical.controller.mjs): Handles CRUD operations and permission checks. + +* **Routes**: `/api/medical/v1/*` +* **Codebase**: + * [Medical.Schema.mjs](file:///src/api/Medical/v1/Medical.Schema.mjs): Defines complex nested schemas for health data. + * [Medical.controller.mjs](file:///src/api/Medical/Medical.controller.mjs): Handles CRUD operations and permission checks. --- ## 5. Helper & Volunteer Ecosystem + **Goal**: Manage the network of responders and their verification. ### Features -* **Skill Verification**: Helpers can list skills (CPR, First Aid) which are verified by admin/system. -* **Availability Toggle**: Helpers can go `Online`/`Offline` to control receiving SOS alerts. -* **Service Areas**: Defines the geographical radius where a helper operates. -* **Seamless Role Upgrade**: Standard users can upgrade to Helper status instantly by completing their profile. + +* **Skill Verification**: Helpers can list skills (CPR, First Aid) which are verified by admin/system. +* **Availability Toggle**: Helpers can go `Online`/`Offline` to control receiving SOS alerts. +* **Service Areas**: Defines the geographical radius where a helper operates. +* **Seamless Role Upgrade**: Standard users can upgrade to Helper status instantly by completing their profile. ### Implementation -* **Routes**: `/api/helpers/v1/*` -* **Codebase**: - * [Helper.model.mjs](file:///src/api/Helper/Helper.model.mjs): Schema for helper attributes, ratings, and verification status. - * [Helper.service.mjs](file:///src/api/Helper/Helper.service.mjs): Logic for finding best-matched helpers based on skills and location. + +* **Routes**: `/api/helpers/v1/*` +* **Codebase**: + * [Helper.model.mjs](file:///src/api/Helper/Helper.model.mjs): Schema for helper attributes, ratings, and verification status. + * [Helper.service.mjs](file:///src/api/Helper/Helper.service.mjs): Logic for finding best-matched helpers based on skills and location. --- ## 6. Real-Time Location Services + **Goal**: continuous location tracking and geofencing. ### Features -* **Live Location Stream**: High-frequency location updates via WebSocket during active emergencies. -* **Static Locations**: Users can save "Home", "Work", or "School" addresses for quick selection. -* **Geocoding**: Converts coordinates to readable addresses (and vice-versa). + +* **Live Location Stream**: During SOS, app sends location via WebSocket about every 5 seconds. +* **Cleaner GPS Data**: App filters bad/noisy points before sending and sends a keepalive point if updates are silent for too long. +* **Foreground/Background Handling**: If app is open it tracks in foreground; if app is in background it switches to background tracking. +* **Static Locations**: Users can save "Home", "Work", or "School" addresses for quick selection. +* **Geocoding**: Converts coordinates to readable addresses (and vice-versa). ### Implementation -* **Routes**: `/api/locations/v1/*` -* **Codebase**: - * [Location.service.mjs](file:///src/api/Location/Location.service.mjs): Handles storage and retrieval of location data. - * [socket/handlers/location.handler.mjs](file:///src/socket/handlers/location.handler.mjs): Processes `LOCATION_UPDATE` events and broadcasts to relevant rooms. + +* **Routes**: `/api/locations/v1/*` +* **Codebase**: + * [Location.service.mjs](file:///src/api/Location/Location.service.mjs): Handles storage and retrieval of location data. + * [socket/handlers/location.handler.mjs](file:///src/socket/handlers/location.handler.mjs): Receives `location:update`, confirms save with `location:updated`, and pushes live updates to tracking and emergency rooms. --- ## 7. Notification System + **Goal**: Multi-channel alerting for critical events. ### Features -* **Push Notifications**: Firebase Cloud Messaging (FCM) for app alerts. -* **SMS Alerts**: Fast2SMS integration for OTPs and critical SOS alerts to emergency contacts. -* **In-App Notifications**: Persistent notification history within the app. + +* **Push Notifications**: Firebase Cloud Messaging (FCM) for app alerts. +* **SMS Alerts**: Fast2SMS integration for OTPs and critical SOS alerts to emergency contacts. +* **In-App Notifications**: Persistent notification history within the app. ### Implementation -* **Codebase**: - * [Notification.model.mjs](file:///src/api/Notifications/v1/Notification.model.mjs): Schema for storing notification history. - * [socket/handlers/notification.handler.mjs](file:///src/socket/handlers/notification.handler.mjs): Real-time delivery to connected sockets. + +* **Codebase**: + * [Notification.model.mjs](file:///src/api/Notifications/v1/Notification.model.mjs): Schema for storing notification history. + * [socket/handlers/notification.handler.mjs](file:///src/socket/handlers/notification.handler.mjs): Real-time delivery to connected sockets. --- ## 8. NGO Management + **Goal**: Integrate larger organizations for disaster response. ### Features -* **Resource Management**: NGOs can list available ambulances, supplies, and personnel. -* **Disaster Mode**: Special protocols for handling mass-casualty events or natural disasters. + +* **Resource Management**: NGOs can list available ambulances, supplies, and personnel. +* **Disaster Mode**: Special protocols for handling mass-casualty events or natural disasters. ### Implementation -* **Routes**: `/api/ngo/v1/*` -* **Codebase**: - * [NGO.model.mjs](file:///src/api/NGO/NGO.model.mjs): Schema for organization details and resources. - * [NGO.controller.mjs](file:///src/api/NGO/NGO.controller.mjs): Logic for NGO onboarding and dispatch. + +* **Routes**: `/api/ngo/v1/*` +* **Codebase**: + * [NGO.model.mjs](file:///src/api/NGO/NGO.model.mjs): Schema for organization details and resources. + * [NGO.controller.mjs](file:///src/api/NGO/NGO.controller.mjs): Logic for NGO onboarding and dispatch. --- ## Technology Stack Summary -* **Runtime**: Node.js (v20+) -* **Framework**: Express.js -* **Language**: JavaScript (ES Modules) -* **Database**: MongoDB (Mongoose ODM) -* **Real-time**: Socket.IO -* **AI**: LangChain + OpenAI -* **Containerization**: Docker + +* **Runtime**: Node.js (v20+) +* **Framework**: Express.js +* **Language**: JavaScript (ES Modules) +* **Database**: MongoDB (Mongoose ODM) +* **Real-time**: Socket.IO +* **AI**: LangChain + OpenAI +* **Containerization**: Docker diff --git a/LifeLine-Backend/docs/20-End-to-End-Workflows.md b/LifeLine-Backend/docs/20-End-to-End-Workflows.md index 9710787..2a49fbc 100644 --- a/LifeLine-Backend/docs/20-End-to-End-Workflows.md +++ b/LifeLine-Backend/docs/20-End-to-End-Workflows.md @@ -9,24 +9,29 @@ This document outlines the primary user journeys and system workflows within the Users can join the platform as standard users (seeking help) or helpers (providing assistance). ### A. Standard User Registration -1. **Sign Up**: User submits basic details (Name, Email, Phone, Password) via `POST /api/auth/v1/create/user/auth`. -2. **Verification**: System sends an OTP/Link to verify email/phone (if enabled). -3. **Profile Creation**: A basic `User` profile is created linked to their `Auth` identity. -4. **Login**: User logs in via `POST /api/auth/v1/login` to receive an `accessToken`. + +1. **Sign Up**: User submits basic details (Name, Email, Phone, Password) via `POST /api/auth/v1/create/user/auth`. +2. **Verification**: System sends an OTP/Link to verify email/phone (if enabled). +3. **Profile Creation**: A basic `User` profile is created linked to their `Auth` identity. +4. **Login**: User logs in via `POST /api/auth/v1/login` to receive an `accessToken`. ### B. Helper Registration (Two Paths) + **Path 1: Direct Registration** -* User selects "Register as Helper" during sign-up. -* Submits standard data **PLUS** professional details (Skills, Credentials, Pricing) in a single request. -* System creates both `Auth` and `Helper` profiles immediately. + +* User selects "Register as Helper" during sign-up. +* Submits standard data **PLUS** professional details (Skills, Credentials, Pricing) in a single request. +* System creates both `Auth` and `Helper` profiles immediately. **Path 2: Upgrade from User** -* Existing user logs in. -* Navigates to "Become a Helper". -* Submits helper details via `POST /api/helpers/v1`. -* System upgrades `Auth.role` to `helper` and creates the `Helper` profile. + +* Existing user logs in. +* Navigates to "Become a Helper". +* Submits helper details via `POST /api/helpers/v1`. +* System upgrades `Auth.role` to `helper` and creates the `Helper` profile. ### Registration Flow Diagram + ```mermaid graph TD A[Start: User Registration] --> B{Role Selection} @@ -57,15 +62,16 @@ graph TD Before an emergency occurs, users are encouraged to set up their medical ID. -1. **Input Data**: User provides critical health info via `POST /api/medical/v1/create`: - * **Blood Type** (e.g., O+) - * **Allergies** (e.g., Peanuts - Anaphylaxis) - * **Conditions** (e.g., Asthma, Diabetes) - * **Emergency Contacts** (Family/Guardians) -2. **Storage**: Data is securely stored and linked to the `userId`. -3. **Usage**: This data is **automatically retrieved** and attached to any SOS alert triggered by this user, allowing helpers to see vital info immediately. +1. **Input Data**: User provides critical health info via `POST /api/medical/v1/create`: + * **Blood Type** (e.g., O+) + * **Allergies** (e.g., Peanuts - Anaphylaxis) + * **Conditions** (e.g., Asthma, Diabetes) + * **Emergency Contacts** (Family/Guardians) +2. **Storage**: Data is securely stored and linked to the `userId`. +3. **Usage**: This data is **automatically retrieved** and attached to any SOS alert triggered by this user, allowing helpers to see vital info immediately. ### Medical Data Flow + ```mermaid sequenceDiagram participant User @@ -94,24 +100,26 @@ sequenceDiagram When a user is unsure if they need emergency help, they use the AI Triage system. ### Flow Steps -1. **Initiation**: - * User opens Triage Chat (`POST /api/triage/start`). - * AI greets the user (e.g., "Describe your symptoms"). - -2. **Assessment Loop**: - * **User Input**: "I have chest pain and shortness of breath." - * **AI Analysis**: - * **Critical Check**: System scans for keywords (e.g., "heart attack", "fire"). If found -> **Immediate SOS**. - * **Questioning**: If unclear, AI asks follow-up questions (max 8) to gauge severity. - * **Decision**: AI determines one of three outcomes: - * `need_more_info`: Ask another question. - * `no_emergency`: Provide self-care advice (e.g., "Take rest"). - * `create_emergency`: Situation is critical. - -3. **Outcome**: - * If **Emergency**: System automatically triggers the SOS workflow (see below) with pre-filled data from the chat. + +1. **Initiation**: + * User opens Triage Chat (`POST /api/triage/start`). + * AI greets the user (e.g., "Describe your symptoms"). + +2. **Assessment Loop**: + * **User Input**: "I have chest pain and shortness of breath." + * **AI Analysis**: + * **Critical Check**: System scans for keywords (e.g., "heart attack", "fire"). If found -> **Immediate SOS**. + * **Questioning**: If unclear, AI asks follow-up questions (max 8) to gauge severity. + * **Decision**: AI determines one of three outcomes: + * `need_more_info`: Ask another question. + * `no_emergency`: Provide self-care advice (e.g., "Take rest"). + * `create_emergency`: Situation is critical. + +3. **Outcome**: + * If **Emergency**: System automatically triggers the SOS workflow (see below) with pre-filled data from the chat. ### AI Triage Logic + ```mermaid stateDiagram-v2 [*] --> Init: User Starts Chat @@ -144,35 +152,43 @@ stateDiagram-v2 The core lifecycle of an emergency event. ### Phase 1: Trigger -* **Manual Trigger**: User presses the SOS button in the app (`POST /api/emergency/sos`). -* **AI Trigger**: Triage system auto-creates the alert. -* **System Action**: - 1. Creates an `Emergency` record with status `ACTIVE`. - 2. Fetches user's **Medical Profile** and attaches it to the alert. - 3. Captures current **Location** (GPS coordinates). + +* **Manual Trigger**: User presses the SOS button in the app (`POST /api/emergency/sos`). +* **AI Trigger**: Triage system auto-creates the alert. +* **System Action**: + 1. Creates an `Emergency` record with status `ACTIVE`. + 2. Fetches user's **Medical Profile** and attaches it to the alert. + 3. Captures current **Location** (GPS coordinates). ### Phase 2: Notification & Dispatch -* **Guardian Alert**: System notifies linked Emergency Contacts (SMS/Push). -* **Helper Search**: - 1. System queries `LocationService` for helpers within a specific radius (e.g., 5km). - 2. Filters helpers based on **Skills** (e.g., "CPR") required for the specific emergency type. -* **Broadcast**: Emits `EMERGENCY_SOS` socket event to identified helpers. + +* **Guardian Alert**: System notifies linked Emergency Contacts (SMS/Push). +* **Helper Search**: + 1. System queries `LocationService` for helpers within a specific radius (e.g., 5km). + 2. Filters helpers based on **Skills** (e.g., "CPR") required for the specific emergency type. +* **Broadcast**: Emits `EMERGENCY_SOS` socket event to identified helpers. ### Phase 3: Response -* **Helper Acceptance**: A helper accepts the request (`POST /api/emergency/{id}/accept`). -* **Assignment**: - * System adds helper to `assignedHelpers` list. - * User is notified: "Helper [Name] is on the way." -* **Live Tracking**: User and Helper share real-time location updates via sockets. + +* **Helper Acceptance**: A helper accepts the request (`POST /api/emergency/{id}/accept`). +* **Assignment**: + * System adds helper to `assignedHelpers` list. + * User is notified: "Helper [Name] is on the way." +* **Live Tracking**: + * App sends cleaned location points about every 5 seconds. + * If app is open, foreground tracking runs; if app is minimized, background tracking runs. + * Server shares these updates to all emergency participants using `emergency:locationUpdate`. ### Phase 4: Resolution -* **Arrival**: Helper marks status as `arrived`. -* **Completion**: - * User or Helper marks emergency as `resolved`. - * System calculates response metrics (Time to Accept, Time to Arrive). - * User is prompted to review/rate the helper. + +* **Arrival**: Helper marks status as `arrived`. +* **Completion**: + * User or Helper marks emergency as `resolved`. + * System calculates response metrics (Time to Accept, Time to Arrive). + * User is prompted to review/rate the helper. ### SOS Lifecycle Diagram + ```mermaid sequenceDiagram actor Victim diff --git a/LifeLine-Backend/docs/api-mismatch-report.md b/LifeLine-Backend/docs/api-mismatch-report.md index 3340d58..7256269 100644 --- a/LifeLine-Backend/docs/api-mismatch-report.md +++ b/LifeLine-Backend/docs/api-mismatch-report.md @@ -6,18 +6,26 @@ This document details the discrepancies found between the current Frontend API c | Category | Status | Notes | | :--- | :--- | :--- | -| **Auth** | ⚠️ Partial | Routes exist but some path structures differ. | -| **User** | ❌ Mismatch | Frontend uses `/api/user/*`, Backend uses `/api/users/v1/*`. | -| **Helper** | ❌ Mismatch | Frontend uses `/api/helper/*`, Backend uses `/api/helpers/v1/*`. | -| **Emergency** | ✅ Mostly Match | Core flows align; new features missing. | -| **Medical** | ❌ Mismatch | Frontend expects `/api/user/medical-info`, Backend exposes `/api/medical/v1/*`. | +| **Auth** | ⚠️ Partial | `api.ts` has missing path params for check/verify email. | +| **User** | ❌ Mismatch | Frontend config uses `/api/user/*`, Backend uses `/api/users/v1/*`. | +| **Helper** | ⚠️ Partial | Frontend config is wrong, but helperSlice uses correct `/api/helpers/v1/*`. | +| **Emergency** | ✅ Mostly Match | Core flows align; new features missing in frontend config. | +| **Medical** | ✅ Match | Frontend `medicalSlice` uses `/api/medical/v1/*` correctly. | +| **Notifications** | ❌ Missing | Frontend calls `/api/notifications/v1/*` but backend has no route registered. | | **New Features** | ❌ Missing | Payment, OTP, and Context Dispatch endpoints are absent in Frontend. | --- ## 2. Detailed Mismatches & Required Updates -### A. User & Profile Routes +### A. Auth Routes + +| Action | Frontend Config (`api.ts`) | Backend Actual Route | Required Frontend Change | +| :--- | :--- | :--- | :--- | +| **Check Email** | `GET /api/auth/v1/check-email` | `GET /api/auth/v1/check-email/:email` | Add `:email` path param | +| **Verify Email** | `GET /api/auth/v1/verify-email` | `GET /api/auth/v1/verify-email/:token` | Add `:token` path param | + +### B. User & Profile Routes | Action | Frontend Config (`api.ts`) | Backend Actual Route | Required Frontend Change | | :--- | :--- | :--- | :--- | @@ -25,7 +33,7 @@ This document details the discrepancies found between the current Frontend API c | **Update Profile** | `POST /api/user/update` | `PATCH /api/auth/v1/profile` | Change method to `PATCH` & URL to match backend | | **Medical Info** | `GET /api/user/medical-info` | `GET /api/medical/v1/profile/me` | Update URL to `/api/medical/v1/profile/me` | -### B. Helper Routes +### C. Helper Routes | Action | Frontend Config (`api.ts`) | Backend Actual Route | Required Frontend Change | | :--- | :--- | :--- | :--- | @@ -33,7 +41,7 @@ This document details the discrepancies found between the current Frontend API c | **Availability** | `PATCH /api/helper/availability` | `PATCH /api/helpers/v1/:id/availability` | Update URL to include `:id` param | | **Search** | `GET /api/helper/nearby` | `GET /api/locations/v1/nearby/helpers` | Update URL to Location service endpoint | -### C. Emergency Routes (New Features) +### D. Emergency Routes (New Features) The following endpoints need to be added to `api.ts` to support the new features: @@ -47,7 +55,7 @@ ASSIGNED: "/api/emergency/assigned/me", NEARBY_WITH_ASSIGNED: "/api/emergency/nearby/search?includeAssigned=true", ``` -### D. Helper Stats & Context (New Features) +### E. Helper Stats & Context (New Features) Add these to the `HELPER` object in `api.ts`: @@ -59,11 +67,24 @@ CONTEXT_DISPATCH: "/api/emergency/context-dispatch", // If applicable --- -## 3. Action Plan +## 3. Frontend Direct API Calls (Hardcoded Paths) + +These are direct API calls in frontend code that bypass `API_ENDPOINTS`: + +- `/api/helpers/v1/:id/skills` in [VerifySkillsScreen.tsx](file:///D:/projects/dr/LifeLine/Lifeline-Frontend/src/features/auth/screens/VerifySkillsScreen.tsx#L320-L330) ✅ matches backend `PATCH /api/helpers/v1/:id/skills` +- `/api/auth/v1/getUserById/:id` in [Map.tsx](file:///D:/projects/dr/LifeLine/Lifeline-Frontend/app/(global)/Map.tsx#L202-L210) ✅ matches backend `GET /api/auth/v1/getUserById/:id` +- `/api/notifications/v1/user/:userId` in [Notifications.tsx](file:///D:/projects/dr/LifeLine/Lifeline-Frontend/app/Helper/Notifications.tsx#L26-L38) ❌ backend does not register `/api/notifications/v1/*` + +## 4. Backend Route Registration Gaps + +Backend route registration in [server.mjs](file:///D:/projects/dr/LifeLine/LifeLine-Backend/src/server.mjs#L49-L66) does not include a Notifications router, so any `/api/notifications/*` calls will fail with 404. The Notifications module exists in docs, but no routes are wired into the server. + +## 5. Action Plan -1. **Refactor `api.ts`**: Update the `API_ENDPOINTS` constant in the frontend to reflect the correct Backend paths and versioning (`v1`). -2. **Update Services**: Modify frontend service files (e.g., `auth.service.ts`, `helper.service.ts`) to use the corrected endpoint keys. -3. **Implement New Logic**: Create `payment.service.ts` to handle the new Payment/OTP endpoints. +1. **Refactor `api.ts`**: Update the `API_ENDPOINTS` constant in the frontend to reflect the correct Backend paths and versioning (`v1`), including Auth `check-email/:email` and `verify-email/:token`. +2. **Align Notifications**: Either register `/api/notifications` routes in backend or update frontend to use an existing notifications endpoint if one exists. +3. **Update Services**: Ensure slices and screens use the updated endpoint keys (avoid hardcoded paths where possible). +4. **Implement New Logic**: Create `payment.service.ts` to handle the new Payment/OTP endpoints. --- diff --git a/LifeLine-Backend/src/Ai/triage/triage.controller.mjs b/LifeLine-Backend/src/Ai/triage/triage.controller.mjs index 17add28..3a5b629 100644 --- a/LifeLine-Backend/src/Ai/triage/triage.controller.mjs +++ b/LifeLine-Backend/src/Ai/triage/triage.controller.mjs @@ -29,13 +29,18 @@ export const TriageController = { }); } - const { location, language, emergencyType } = req.body; + const { location, language, emergencyType, emergencyId } = req.body; + + if (emergencyId) { + await EmergencyService.getEmergency(emergencyId, userId); + } const result = await TriageService.startSession( userId, location, language || 'en', emergencyType, + emergencyId, ); return res.status(200).json(result); @@ -97,8 +102,29 @@ export const TriageController = { try { const userId = result.session?.userId; const location = result.session?.extractedInfo?.location; + const existingEmergencyId = result.session?.emergencyId; + + if (existingEmergencyId && userId) { + const emergencyUpdate = { + type: result.emergency?.type, + title: result.emergency?.title, + description: result.emergency?.description, + priority: result.emergency?.priority, + medicalInfo: result.emergency?.medicalInfo || {}, + }; + + const emergencyResult = await EmergencyService.updateEmergencyDetails( + existingEmergencyId, + userId, + emergencyUpdate, + ); - if (userId) { + if (emergencyResult.success) { + result.emergencyCreated = true; + result.emergencyId = existingEmergencyId; + result.emergencyData = emergencyResult.data; + } + } else if (userId) { const emergencyResult = await EmergencyService.triggerSOS( { title: result.emergency.title, diff --git a/LifeLine-Backend/src/Ai/triage/triageService.mjs b/LifeLine-Backend/src/Ai/triage/triageService.mjs index 4b69a90..91433d7 100644 --- a/LifeLine-Backend/src/Ai/triage/triageService.mjs +++ b/LifeLine-Backend/src/Ai/triage/triageService.mjs @@ -101,11 +101,19 @@ export const TriageService = { location = null, language = 'en', emergencyType = null, + emergencyId = null, ) { // Check for existing active session const existingSession = await TriageSessionManager.getSessionByUserId(userId); if (existingSession) { + if (emergencyId && existingSession.emergencyId !== emergencyId) { + existingSession.setBoundEmergencyId(emergencyId); + await TriageSessionManager.saveSession( + existingSession.sessionId, + existingSession, + ); + } return { success: true, sessionId: existingSession.sessionId, @@ -117,7 +125,7 @@ export const TriageService = { } // Create new session - const state = new TriageState(userId, location, language); + const state = new TriageState(userId, location, language, emergencyId); state.setPhase(TRIAGE_PHASES.IN_PROGRESS); // Get appropriate greeting @@ -132,6 +140,10 @@ export const TriageService = { state.updateExtractedInfo({ selectedType: emergencyType }); } + if (emergencyId) { + state.setBoundEmergencyId(emergencyId); + } + // Add greeting to conversation state.addMessage('assistant', greeting); @@ -167,7 +179,8 @@ export const TriageService = { } // Validate ownership - if (state.userId !== userId) { + const effectiveUserId = userId || state.userId; + if (state.userId !== effectiveUserId) { return { success: false, error: 'Unauthorized access to session', diff --git a/LifeLine-Backend/src/Ai/triage/triageState.mjs b/LifeLine-Backend/src/Ai/triage/triageState.mjs index f71a149..b43aaa8 100644 --- a/LifeLine-Backend/src/Ai/triage/triageState.mjs +++ b/LifeLine-Backend/src/Ai/triage/triageState.mjs @@ -21,7 +21,12 @@ function generateSessionId() { * Manages the state of a triage conversation session */ export class TriageState { - constructor(userId, location = null, language = LANGUAGES.ENGLISH) { + constructor( + userId, + location = null, + language = LANGUAGES.ENGLISH, + emergencyId = null, + ) { this.sessionId = generateSessionId(); this.userId = userId; this.language = language; @@ -60,7 +65,7 @@ export class TriageState { this.lastUpdated = new Date().toISOString(); // Emergency ID (if created) - this.emergencyId = null; + this.emergencyId = emergencyId; } /** @@ -135,6 +140,12 @@ export class TriageState { return this; } + setBoundEmergencyId(emergencyId) { + this.emergencyId = emergencyId; + this.lastUpdated = new Date().toISOString(); + return this; + } + /** * Get conversation history formatted for AI */ @@ -216,6 +227,7 @@ export class TriageState { data.userId, data.extractedInfo?.location, data.language, + data.emergencyId, ); Object.assign(state, data); return state; diff --git a/LifeLine-Backend/src/api/Auth/v1/Auth.model.mjs b/LifeLine-Backend/src/api/Auth/v1/Auth.model.mjs index 9fc3f38..266d889 100644 --- a/LifeLine-Backend/src/api/Auth/v1/Auth.model.mjs +++ b/LifeLine-Backend/src/api/Auth/v1/Auth.model.mjs @@ -46,6 +46,11 @@ const authSchema = new mongoose.Schema( default: null, }, + profileImagePublicId: { + type: String, + default: null, + }, + // Authentication password: { type: String, diff --git a/LifeLine-Backend/src/api/Auth/v1/Auth.service.mjs b/LifeLine-Backend/src/api/Auth/v1/Auth.service.mjs index 21299f1..6bbff16 100644 --- a/LifeLine-Backend/src/api/Auth/v1/Auth.service.mjs +++ b/LifeLine-Backend/src/api/Auth/v1/Auth.service.mjs @@ -31,6 +31,7 @@ class AuthService { password, role, profileImage, + profileImagePublicId, skills, credentials, pricing, @@ -66,6 +67,7 @@ class AuthService { password: hashedPassword, role, profileImage, + profileImagePublicId, isVerified: false, }); @@ -78,6 +80,7 @@ class AuthService { fullName, email, phoneNumber: mobileNumber, + profileImage, }, { session }, ); diff --git a/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs b/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs index 72a197f..175e2e1 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs @@ -233,8 +233,15 @@ export class EmergencyController { try { const { id } = req.params; const helperId = req.user.userId; - - const result = await EmergencyService.acceptHelperRequest(id, helperId); + const { serviceType, amount, method } = req.body || {}; + const parsedAmount = + amount === undefined || amount === null ? undefined : Number(amount); + + const result = await EmergencyService.acceptHelperRequest(id, helperId, { + serviceType, + amount: Number.isFinite(parsedAmount) ? parsedAmount : undefined, + method, + }); res.json(result); } catch (error) { @@ -306,6 +313,75 @@ export class EmergencyController { } } + static async approveEmergencyPayment(req, res) { + try { + const { id } = req.params; + const userId = req.user.userId; + + const result = await EmergencyService.approveEmergencyPayment( + id, + userId, + ); + + res.json(result); + } catch (error) { + const statusCode = error.message.includes('not found') ? 404 : 400; + res.status(statusCode).json({ + success: false, + message: 'Failed to approve emergency payment', + error: error.message, + }); + } + } + + static async verifyEmergencyOtp(req, res) { + try { + const { id } = req.params; + const userId = req.user.userId; + const { otp } = req.body; + + if (!otp) { + return res.status(400).json({ + success: false, + message: 'OTP is required', + }); + } + + const result = await EmergencyService.verifyEmergencyOtp( + id, + userId, + otp, + ); + + res.json(result); + } catch (error) { + const statusCode = error.message.includes('not found') ? 404 : 400; + res.status(statusCode).json({ + success: false, + message: 'Failed to verify OTP', + error: error.message, + }); + } + } + + static async getSOSProfile(req, res) { + try { + const { id } = req.params; + const helperId = req.user.userId; + + const result = await EmergencyService.getSOSProfile(id, helperId); + + res.json(result); + } catch (error) { + const statusCode = error.message.includes('not found') ? 404 : 403; + res.status(statusCode).json({ + success: false, + message: 'Failed to get SOS profile', + error: error.message, + }); + } + } + /** * Update emergency status * @param {Object} req - Express request object diff --git a/LifeLine-Backend/src/api/Emergency/Emergency.model.mjs b/LifeLine-Backend/src/api/Emergency/Emergency.model.mjs index c2a3e78..888193d 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.model.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.model.mjs @@ -91,6 +91,21 @@ const emergencySchema = new mongoose.Schema( maxlength: 500, }, + medicalInfo: { + bloodType: String, + allergies: [String], + conditions: [String], + medications: [String], + organDonor: Boolean, + emergencyContacts: [ + { + name: String, + phoneNumber: String, + relationship: String, + }, + ], + }, + // Helper Assignments @@ -100,6 +115,26 @@ const emergencySchema = new mongoose.Schema( type: mongoose.Schema.Types.ObjectId, ref: 'Auth', }, + serviceType: { + type: String, + }, + amount: { + type: Number, + min: 0, + }, + method: { + type: String, + enum: ['cash', 'upi', 'card'], + }, + paymentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Payment', + }, + paymentStatus: { + type: String, + enum: ['pending', 'approved', 'verified', 'released', 'failed', 'free'], + default: 'free', + }, status: { type: String, enum: ['requested', 'accepted', 'arriving', 'arrived', 'completed'], @@ -193,6 +228,84 @@ const emergencySchema = new mongoose.Schema( }, }, + requiredHelpers: { + type: Number, + default: 1, + min: 1, + }, + + serviceType: { + type: String, + }, + + acceptedHelpers: [ + { + helperId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Auth', + }, + serviceType: { + type: String, + }, + amount: { + type: Number, + min: 0, + }, + method: { + type: String, + enum: ['cash', 'upi', 'card'], + }, + paymentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Payment', + }, + paymentStatus: { + type: String, + enum: ['pending', 'approved', 'verified', 'released', 'failed', 'free'], + default: 'free', + }, + acceptedAt: Date, + }, + ], + + payment: { + status: { + type: String, + enum: ['none', 'pending', 'approved', 'verified'], + default: 'none', + }, + paymentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Payment', + }, + helperId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Auth', + }, + amount: { + type: Number, + min: 0, + }, + method: { + type: String, + enum: ['cash', 'upi', 'card'], + }, + otp: { + type: String, + }, + otpExpiresAt: Date, + approvedAt: Date, + approvedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Auth', + }, + verifiedAt: Date, + verifiedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Auth', + }, + }, + // Resolution Details resolution: { resolvedBy: [ @@ -285,7 +398,7 @@ emergencySchema.methods.assignHelper = function (helperId) { }; // Method to accept helper assignment -emergencySchema.methods.acceptHelper = function (helperId) { +emergencySchema.methods.acceptHelper = function (helperId, data = {}) { const assignment = this.assignedHelpers.find( (helper) => helper.helperId.toString() === helperId.toString(), ); @@ -293,6 +406,21 @@ emergencySchema.methods.acceptHelper = function (helperId) { if (assignment && assignment.status === 'requested') { assignment.status = 'accepted'; assignment.acceptedAt = new Date(); + if (data.serviceType) { + assignment.serviceType = data.serviceType; + } + if (typeof data.amount === 'number') { + assignment.amount = data.amount; + } + if (data.method) { + assignment.method = data.method; + } + if (data.paymentId) { + assignment.paymentId = data.paymentId; + } + if (data.paymentStatus) { + assignment.paymentStatus = data.paymentStatus; + } if (!this.responseMetrics.firstHelperAcceptedAt) { this.responseMetrics.firstHelperAcceptedAt = new Date(); diff --git a/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs b/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs index 1b6179a..4242eb6 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs @@ -91,6 +91,10 @@ router.post( EmergencyController.sendHelperRequest, ); +router.post('/:id/approve', EmergencyController.approveEmergencyPayment); +router.post('/:id/verify-otp', EmergencyController.verifyEmergencyOtp); +router.get('/:id/profile', EmergencyController.getSOSProfile); + // ==================== PARAMETERIZED ROUTES (MUST BE AFTER SPECIFIC ROUTES) ==================== /** diff --git a/LifeLine-Backend/src/api/Emergency/Emergency.service.mjs b/LifeLine-Backend/src/api/Emergency/Emergency.service.mjs index e16dcf7..76dae06 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.service.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.service.mjs @@ -19,6 +19,9 @@ import { notifyGuardiansOfSOS, } from '../../socket/handlers/notification.handler.mjs'; import MedicalService from '../Medical/Medical.service.mjs'; +import AuthService from '../Auth/v1/Auth.service.mjs'; +import * as PaymentUtils from '../Payment/Payment.utils.mjs'; +import UserService from '../User/User.service.mjs'; /** * Emergency Service for LifeLine Emergency Response System @@ -270,7 +273,7 @@ export class EmergencyService { * @param {string} helperId - Helper ID * @returns {Promise} Acceptance result */ - static async acceptHelperRequest(emergencyId, helperId) { + static async acceptHelperRequest(emergencyId, helperId, data = {}) { try { const emergency = await Emergency.findById(emergencyId); if (!emergency) { @@ -283,13 +286,68 @@ export class EmergencyService { ); } - const assignment = emergency.acceptHelper(helperId); + const { serviceType, amount, method } = data || {}; + const paymentRequired = + typeof amount === 'number' && Number.isFinite(amount) && amount > 0; + + let payment = null; + if (paymentRequired) { + payment = await PaymentUtils.createPayment({ + emergencyId: emergency._id, + helperId, + userId: emergency.userId, + amount, + method: method || 'cash', + serviceType, + }); + } + + const assignment = emergency.acceptHelper(helperId, { + serviceType, + amount: paymentRequired ? amount : 0, + method: method || (paymentRequired ? 'cash' : undefined), + paymentId: payment?._id, + paymentStatus: paymentRequired ? 'pending' : 'free', + }); if (!assignment) { throw new Error( 'Helper not assigned to this emergency', ); } + if (paymentRequired) { + emergency.payment = { + ...emergency.payment, + status: 'pending', + paymentId: payment?._id, + helperId, + amount, + method: method || 'cash', + }; + } + + if (serviceType) { + emergency.serviceType = serviceType; + } + + if (emergency.acceptedHelpers) { + const existingAccepted = emergency.acceptedHelpers.find( + (accepted) => + accepted.helperId?.toString() === helperId.toString(), + ); + if (!existingAccepted) { + emergency.acceptedHelpers.push({ + helperId, + serviceType, + amount: paymentRequired ? amount : 0, + method: method || (paymentRequired ? 'cash' : undefined), + paymentId: payment?._id, + paymentStatus: paymentRequired ? 'pending' : 'free', + acceptedAt: new Date(), + }); + } + } + await emergency.save(); // Send notifications @@ -515,6 +573,85 @@ export class EmergencyService { } } + static async updateEmergencyDetails(emergencyId, userId, updateData = {}) { + try { + const emergency = await Emergency.findById(emergencyId); + if (!emergency) { + throw new Error(EmergencyConstants.MESSAGES.ERROR.EMERGENCY_NOT_FOUND); + } + + const ownerId = emergency.userId?._id || emergency.userId; + if (!ownerId || ownerId.toString() !== userId.toString()) { + throw new Error(EmergencyConstants.MESSAGES.ERROR.UNAUTHORIZED); + } + + const allowedUpdates = {}; + + if (updateData.type) { + if ( + !Object.values(EmergencyConstants.EMERGENCY_TYPES).includes( + updateData.type, + ) + ) { + throw new Error(EmergencyConstants.MESSAGES.VALIDATION.INVALID_TYPE); + } + allowedUpdates.type = updateData.type; + } + + if (updateData.title) { + if (updateData.title.length > 100) { + throw new Error( + EmergencyConstants.MESSAGES.VALIDATION.TITLE_TOO_LONG, + ); + } + allowedUpdates.title = updateData.title; + } + + if (updateData.description) { + if (updateData.description.length > 500) { + throw new Error( + EmergencyConstants.MESSAGES.VALIDATION.DESCRIPTION_TOO_LONG, + ); + } + allowedUpdates.description = updateData.description; + } + + if (updateData.priority) { + if ( + !Object.values(EmergencyConstants.EMERGENCY_PRIORITIES).includes( + updateData.priority, + ) + ) { + throw new Error( + EmergencyConstants.MESSAGES.VALIDATION.INVALID_PRIORITY, + ); + } + allowedUpdates.priority = updateData.priority; + } + + if (updateData.medicalInfo) { + allowedUpdates.medicalInfo = updateData.medicalInfo; + } + + if (Object.keys(allowedUpdates).length === 0) { + return { + success: true, + data: emergency, + }; + } + + emergency.set(allowedUpdates); + await emergency.save(); + + return { + success: true, + data: emergency, + }; + } catch (error) { + throw new Error(`Failed to update emergency: ${error.message}`); + } + } + /** * Get user's emergencies * @param {string} userId - User ID @@ -828,6 +965,248 @@ export class EmergencyService { return []; } + static async approveEmergencyPayment(emergencyId, userId) { + try { + const emergency = await Emergency.findById(emergencyId); + if (!emergency) { + throw new Error('Emergency not found'); + } + + const ownerId = emergency.userId?._id || emergency.userId; + if (!ownerId || ownerId.toString() !== userId) { + throw new Error('Unauthorized to approve payment'); + } + + if (emergency.status !== EmergencyConstants.EMERGENCY_STATUSES.RESOLVED) { + throw new Error('Payment can only be approved after resolution'); + } + + if (!emergency.payment?.paymentId) { + throw new Error('Payment not required for this emergency'); + } + + if (emergency.payment?.status !== 'pending') { + throw new Error('Payment is not pending approval'); + } + + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const otpExpiresAt = new Date(Date.now() + 5 * 60 * 1000); + + emergency.payment = { + ...emergency.payment, + status: 'approved', + otp, + otpExpiresAt, + approvedAt: new Date(), + approvedBy: userId, + }; + + if (emergency.payment?.paymentId) { + await PaymentUtils.updatePaymentStatus( + emergency.payment.paymentId, + 'approved', + ); + } + + if (emergency.acceptedHelpers?.length) { + emergency.acceptedHelpers = emergency.acceptedHelpers.map((item) => { + if ( + emergency.payment?.helperId && + item.helperId?.toString() === emergency.payment.helperId.toString() + ) { + return { + ...item, + paymentStatus: 'approved', + }; + } + return item; + }); + } + + if (emergency.assignedHelpers?.length) { + emergency.assignedHelpers = emergency.assignedHelpers.map((item) => { + if ( + emergency.payment?.helperId && + item.helperId?.toString() === emergency.payment.helperId.toString() + ) { + return { + ...item, + paymentStatus: 'approved', + }; + } + return item; + }); + } + + await emergency.save(); + + const Auth = mongoose.model('Auth'); + const auth = await Auth.findById(userId).select('phoneNumber').lean(); + let otpSent = false; + + if (auth?.phoneNumber) { + try { + await AuthService.sendOTP(auth.phoneNumber, otp); + otpSent = true; + } catch (error) { + otpSent = false; + } + } + + return { + success: true, + message: otpSent ? 'OTP sent successfully' : 'OTP generated', + data: { + emergencyId: emergency._id, + otpSent, + }, + }; + } catch (error) { + throw new Error(`Failed to approve emergency payment: ${error.message}`); + } + } + + static async verifyEmergencyOtp(emergencyId, userId, otp) { + try { + const emergency = await Emergency.findById(emergencyId); + if (!emergency) { + throw new Error('Emergency not found'); + } + + const ownerId = emergency.userId?._id || emergency.userId; + if (!ownerId || ownerId.toString() !== userId) { + throw new Error('Unauthorized to verify OTP'); + } + + if (emergency.status !== EmergencyConstants.EMERGENCY_STATUSES.RESOLVED) { + throw new Error('Payment can only be verified after resolution'); + } + + const payment = emergency.payment || {}; + if (!payment.otp || payment.status !== 'approved') { + throw new Error('OTP not available for verification'); + } + + if (payment.otpExpiresAt && payment.otpExpiresAt < new Date()) { + throw new Error('OTP expired'); + } + + if (payment.otp !== otp) { + throw new Error('Invalid OTP'); + } + + emergency.payment = { + ...payment, + status: 'verified', + otp: undefined, + otpExpiresAt: undefined, + verifiedAt: new Date(), + verifiedBy: userId, + }; + + if (emergency.payment?.paymentId) { + await PaymentUtils.updatePaymentStatus( + emergency.payment.paymentId, + 'released', + ); + } + + if (emergency.acceptedHelpers?.length) { + emergency.acceptedHelpers = emergency.acceptedHelpers.map((item) => { + if ( + emergency.payment?.helperId && + item.helperId?.toString() === emergency.payment.helperId.toString() + ) { + return { + ...item, + paymentStatus: 'released', + }; + } + return item; + }); + } + + if (emergency.assignedHelpers?.length) { + emergency.assignedHelpers = emergency.assignedHelpers.map((item) => { + if ( + emergency.payment?.helperId && + item.helperId?.toString() === emergency.payment.helperId.toString() + ) { + return { + ...item, + paymentStatus: 'released', + }; + } + return item; + }); + } + + await emergency.save(); + + return { + success: true, + message: 'OTP verified successfully', + data: { + emergencyId: emergency._id, + paymentStatus: emergency.payment?.status, + }, + }; + } catch (error) { + throw new Error(`Failed to verify OTP: ${error.message}`); + } + } + + static async getSOSProfile(emergencyId, helperId) { + try { + const emergency = await Emergency.findById(emergencyId); + if (!emergency) { + throw new Error('Emergency not found'); + } + + const isAssignedHelper = emergency.assignedHelpers.some((assignment) => { + const assignedId = assignment.helperId?._id || assignment.helperId; + return assignedId && assignedId.toString() === helperId.toString(); + }); + + if (!isAssignedHelper) { + throw new Error('Unauthorized to access SOS profile'); + } + + const userProfile = await UserService.getUserByAuthId(emergency.userId); + + let medicalSnapshot = null; + try { + medicalSnapshot = await MedicalService.getMedicalInfoByUserId( + emergency.userId, + ); + } catch (error) { + medicalSnapshot = null; + } + + const historyCount = await Emergency.countDocuments({ + userId: emergency.userId, + }); + + return { + success: true, + data: { + userProfile, + medicalSnapshot, + emergencySummary: { + emergencyId: emergency._id, + type: emergency.type, + status: emergency.status, + priority: emergency.priority, + location: emergency.location, + historyCount, + }, + }, + }; + } catch (error) { + throw new Error(`Failed to get SOS profile: ${error.message}`); + } + } + /** * Check if user has access to emergency * @param {Object} emergency - Emergency object diff --git a/LifeLine-Backend/src/api/Helper/Helper.controller.mjs b/LifeLine-Backend/src/api/Helper/Helper.controller.mjs index 43a4bcb..8355450 100644 --- a/LifeLine-Backend/src/api/Helper/Helper.controller.mjs +++ b/LifeLine-Backend/src/api/Helper/Helper.controller.mjs @@ -160,6 +160,24 @@ export default class HelperController { } } + static async getHelperStats(req, res) { + try { + const { id } = req.params; + console.log('HelperController.getHelperStats:', id); + const metrics = await HelperService.getDashboardMetrics(id); + + res.status(200).json({ + success: true, + data: metrics, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: error.message, + }); + } + } + /** * Update helper profile * @param {Object} req - Express request object diff --git a/LifeLine-Backend/src/api/Helper/Helper.model.mjs b/LifeLine-Backend/src/api/Helper/Helper.model.mjs index de766f4..02b2dbd 100644 --- a/LifeLine-Backend/src/api/Helper/Helper.model.mjs +++ b/LifeLine-Backend/src/api/Helper/Helper.model.mjs @@ -105,6 +105,26 @@ const helperSchema = new mongoose.Schema( type: String, default: '95%', }, + + totalEarnings: { + type: Number, + default: 0, + min: 0, + }, + pendingAmount: { + type: Number, + default: 0, + min: 0, + }, + casesSolved: { + type: Number, + default: 0, + min: 0, + }, + activeCase: { + type: Boolean, + default: false, + }, }, { timestamps: true, diff --git a/LifeLine-Backend/src/api/Helper/Helper.routes.mjs b/LifeLine-Backend/src/api/Helper/Helper.routes.mjs index 326eff7..79fd42e 100644 --- a/LifeLine-Backend/src/api/Helper/Helper.routes.mjs +++ b/LifeLine-Backend/src/api/Helper/Helper.routes.mjs @@ -22,6 +22,7 @@ router.patch( ); // /api/helpers/v1/checkCurrentAvailability/699ac6a6e6b3b33cff46e09b router.post('/', AuthMiddleware.authenticate, HelperController.createHelper); +router.get('/stats/:id', HelperController.getHelperStats); router.get('/:id', HelperController.getHelper); router.get('/:id/dashboard', HelperController.getDashboardMetrics); router.get( diff --git a/LifeLine-Backend/src/api/Helper/Helper.service.mjs b/LifeLine-Backend/src/api/Helper/Helper.service.mjs index 78b7af1..b0d78b0 100644 --- a/LifeLine-Backend/src/api/Helper/Helper.service.mjs +++ b/LifeLine-Backend/src/api/Helper/Helper.service.mjs @@ -2,6 +2,7 @@ import Helper from './Helper.model.mjs'; import HelperUtils from './Helper.utils.mjs'; import AuthUtils from '../Auth/v1/Auth.utils.mjs'; import HelperConstants from './Helper.constants.mjs'; +import Payment from '../Payment/Payment.model.mjs'; import AuthConstants from '../Auth/v1/Auth.constants.mjs'; import mongoose from 'mongoose'; import Emergency from '../Emergency/Emergency.model.mjs'; @@ -428,15 +429,25 @@ export default class HelperService { return false; }).length; - // Calculate total earnings (mock logic since Payment isn't integrated yet) - const totalEarnings = casesSolved * 500; // Mock 500 per solved case - const pendingEarnings = activeCases * 500; + const payments = await Payment.find({ helperId }).lean(); + const totalEarnings = payments + .filter((p) => p.status === 'released') + .reduce((sum, p) => sum + (p.amount || 0), 0); + const pendingEarnings = payments + .filter((p) => + ['pending', 'approved', 'verified'].includes(p.status), + ) + .reduce((sum, p) => sum + (p.amount || 0), 0); // Format recent activity + const paymentByEmergency = new Map( + payments.map((p) => [p.emergencyId?.toString(), p]), + ); const recentActivity = emergencies .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) .slice(0, 5) .map((e) => { + const payment = paymentByEmergency.get(e._id?.toString()); return { time: new Date(e.createdAt).toLocaleTimeString([], { hour: '2-digit', @@ -444,7 +455,12 @@ export default class HelperService { }), title: `${e.type.charAt(0).toUpperCase() + e.type.slice(1)} Emergency`, status: e.status.charAt(0).toUpperCase() + e.status.slice(1), - payout: e.status === 'resolved' ? 500 : 0, + payout: + payment?.status === 'released' + ? payment.amount || 0 + : e.status === 'resolved' + ? 0 + : 0, }; }); diff --git a/LifeLine-Backend/src/api/Location/Location.controller.mjs b/LifeLine-Backend/src/api/Location/Location.controller.mjs index c6ff9e9..a58a2eb 100644 --- a/LifeLine-Backend/src/api/Location/Location.controller.mjs +++ b/LifeLine-Backend/src/api/Location/Location.controller.mjs @@ -3,6 +3,58 @@ import LocationService from './Location.service.mjs'; /** * LocationController - Simple handlers for Location operations */ +const parseNearbyQueryParams = (query = {}) => { + const latitudeRaw = query.latitude ?? query.lat; + const longitudeRaw = query.longitude ?? query.lng; + + if (latitudeRaw === undefined || longitudeRaw === undefined) { + throw new Error('Latitude and Longitude are required'); + } + + const latitude = Number(latitudeRaw); + const longitude = Number(longitudeRaw); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + throw new Error('Invalid latitude/longitude'); + } + + const radiusKmRaw = + query.radiusKm !== undefined + ? Number(query.radiusKm) + : query.radiusMeters !== undefined + ? Number(query.radiusMeters) / 1000 + : query.maxDistance !== undefined + ? Number(query.maxDistance) / 1000 + : query.radius !== undefined + ? Number(query.radius) + : 10; + + const radiusUnit = String(query.radiusUnit || '').toLowerCase(); + let radiusKm = radiusKmRaw; + + if (radiusUnit === 'm' || radiusUnit === 'meter' || radiusUnit === 'meters') { + radiusKm = radiusKmRaw / 1000; + } else if ( + radiusUnit === 'km' || + radiusUnit === 'kilometer' || + radiusUnit === 'kilometers' + ) { + radiusKm = radiusKmRaw; + } else if (query.radius !== undefined && radiusKmRaw > 1000) { + // Backward compatibility: some clients pass radius in meters. + radiusKm = radiusKmRaw / 1000; + } + + if (!Number.isFinite(radiusKm) || radiusKm <= 0) { + radiusKm = 10; + } + + return { + center: { lat: latitude, lng: longitude }, + radiusKm, + }; +}; + export default class LocationController { static async createLocation(req, res) { try { @@ -90,18 +142,20 @@ export default class LocationController { static async searchNearbyLocations(req, res) { try { - const { latitude, longitude, radius } = req.query; - if (!latitude || !longitude) - throw new Error('Latitude and Longitude are required'); - const center = { - lat: parseFloat(latitude), - lng: parseFloat(longitude), - }; + const { center, radiusKm } = parseNearbyQueryParams(req.query); const locations = await LocationService.searchLocationsWithinRadius( center, - parseFloat(radius) || 10, + radiusKm, ); - res.status(200).json({ success: true, data: locations }); + res.status(200).json({ + success: true, + data: locations, + meta: { + radiusKm, + center, + total: locations.length, + }, + }); } catch (error) { res.status(400).json({ success: false, message: error.message }); } @@ -109,23 +163,25 @@ export default class LocationController { static async searchNearbyHelpers(req, res) { try { - const { latitude, longitude, radius } = req.query; - + const { center, radiusKm } = parseNearbyQueryParams(req.query); console.log('Searching nearby helpers with params:-->', { - latitude, - longitude, - radius, + center, + radiusKm, }); - - if (!latitude || !longitude) - throw new Error('Latitude and Longitude are required'); - const center = { lat: parseFloat(latitude), lng: parseFloat(longitude) }; const helpers = await LocationService.searchNearbyHelpers( center, - parseFloat(radius) || 10, + radiusKm, ); console.log('Helpers found:-->', helpers); - res.status(200).json({ success: true, data: helpers }); + res.status(200).json({ + success: true, + data: helpers, + meta: { + radiusKm, + center, + total: helpers.length, + }, + }); } catch (error) { res.status(400).json({ success: false, message: error.message }); } diff --git a/LifeLine-Backend/src/api/NGO/NGO.controller.mjs b/LifeLine-Backend/src/api/NGO/NGO.controller.mjs index 6492f33..a03be2d 100644 --- a/LifeLine-Backend/src/api/NGO/NGO.controller.mjs +++ b/LifeLine-Backend/src/api/NGO/NGO.controller.mjs @@ -2,6 +2,58 @@ import NGOPayment from './NGOPayment.model.mjs'; import * as NGOPaymentUtils from './NGOPayment.utils.mjs'; import NGO from './NGO.model.mjs'; +const parseNearbyQueryParams = (query = {}) => { + const latitudeRaw = query.latitude ?? query.lat; + const longitudeRaw = query.longitude ?? query.lng; + + if (latitudeRaw === undefined || longitudeRaw === undefined) { + throw new Error('Latitude and longitude are required.'); + } + + const latitude = Number(latitudeRaw); + const longitude = Number(longitudeRaw); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + throw new Error('Invalid coordinates provided.'); + } + + const radiusKmRaw = + query.radiusKm !== undefined + ? Number(query.radiusKm) + : query.radiusMeters !== undefined + ? Number(query.radiusMeters) / 1000 + : query.maxDistance !== undefined + ? Number(query.maxDistance) / 1000 + : query.radius !== undefined + ? Number(query.radius) + : 10; + + const radiusUnit = String(query.radiusUnit || '').toLowerCase(); + let radiusKm = radiusKmRaw; + + if (radiusUnit === 'm' || radiusUnit === 'meter' || radiusUnit === 'meters') { + radiusKm = radiusKmRaw / 1000; + } else if ( + radiusUnit === 'km' || + radiusUnit === 'kilometer' || + radiusUnit === 'kilometers' + ) { + radiusKm = radiusKmRaw; + } else if (query.radius !== undefined && radiusKmRaw > 1000) { + // Backward compatibility: some clients pass radius in meters. + radiusKm = radiusKmRaw / 1000; + } + + if (!Number.isFinite(radiusKm) || radiusKm <= 0) { + radiusKm = 10; + } + + return { + center: { lat: latitude, lng: longitude }, + radiusKm, + }; +}; + /** * NGOController - API handlers for NGO operations * @author Senior Software Engineer @@ -105,44 +157,41 @@ export default class NGOController { */ static async getNearby(req, res) { try { - const { latitude, longitude, radius = 10000 } = req.query; // Default radius 10km - - if (!latitude || !longitude) { - return res.status(400).json({ - success: false, - message: 'Latitude and longitude are required.', - }); - } - - const parsedLongitude = parseFloat(longitude); - const parsedLatitude = parseFloat(latitude); + const { center, radiusKm } = parseNearbyQueryParams(req.query); - if (isNaN(parsedLongitude) || isNaN(parsedLatitude)) { - return res.status(400).json({ - success: false, - message: 'Invalid coordinates provided.', - }); - } - - const NGOs = await NGO.find({ - 'location.coordinates': { - $near: { - $geometry: { + const NGOs = await NGO.aggregate([ + { + $geoNear: { + near: { type: 'Point', - coordinates: [parsedLongitude, parsedLatitude], + coordinates: [center.lng, center.lat], }, - $maxDistance: parseInt(radius), + distanceField: 'distanceMeters', + maxDistance: Math.round(radiusKm * 1000), + spherical: true, + query: { registrationStatus: 'approved' }, // Only approved NGOs }, }, - registrationStatus: 'approved', // Only fetch approved NGOs - }); + ]); + + const formattedNGOs = NGOs.map((ngo) => ({ + ...ngo, + id: ngo._id?.toString(), + distanceKm: Number((ngo.distanceMeters / 1000).toFixed(2)), + distance: `${(ngo.distanceMeters / 1000).toFixed(1)} km`, + })); res.status(200).json({ success: true, - data: NGOs, + data: formattedNGOs, + meta: { + radiusKm, + center, + total: formattedNGOs.length, + }, }); } catch (error) { - res.status(500).json({ + res.status(400).json({ success: false, message: `Failed to get nearby NGOs: ${error.message}`, }); diff --git a/LifeLine-Backend/src/api/Notifications/v1/Notification.controller.mjs b/LifeLine-Backend/src/api/Notifications/v1/Notification.controller.mjs new file mode 100644 index 0000000..a711d0b --- /dev/null +++ b/LifeLine-Backend/src/api/Notifications/v1/Notification.controller.mjs @@ -0,0 +1,158 @@ +import Notification from './Notification.model.mjs'; + +/** + * Notification Controller + * Handles fetching and updating user notifications + */ +export const NotificationController = { + /** + * Get all notifications for the authenticated user + */ + async getNotifications(req, res) { + try { + const userId = req.user.userId; + const { page = 1, limit = 20, type } = req.query; + + const query = { + 'recipient.userId': userId, + }; + + if (type) { + query.type = type; + } + + const notifications = await Notification.find(query) + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(parseInt(limit)); + + const total = await Notification.countDocuments(query); + const unreadCount = await Notification.countDocuments({ + ...query, + 'channels.push.status': { $ne: 'read' }, + }); + + res.status(200).json({ + success: true, + data: notifications, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + pages: Math.ceil(total / limit), + }, + unreadCount, + }); + } catch (error) { + console.error('Get notifications error:', error); + res.status(500).json({ + success: false, + message: 'Failed to fetch notifications', + error: error.message, + }); + } + }, + + /** + * Get unread notification count + */ + async getUnreadCount(req, res) { + try { + const userId = req.user.userId; + + const count = await Notification.countDocuments({ + 'recipient.userId': userId, + 'channels.push.status': { $ne: 'read' }, + }); + + res.status(200).json({ + success: true, + count, + }); + } catch (error) { + console.error('Get unread count error:', error); + res.status(500).json({ + success: false, + message: 'Failed to get unread count', + error: error.message, + }); + } + }, + + /** + * Mark a specific notification as read + */ + async markAsRead(req, res) { + try { + const userId = req.user.userId; + const { id } = req.params; + + const notification = await Notification.findOne({ + _id: id, + 'recipient.userId': userId, + }); + + if (!notification) { + return res.status(404).json({ + success: false, + message: 'Notification not found', + }); + } + + // Update status to read + notification.channels.push.status = 'read'; + notification.channels.push.readAt = new Date(); + await notification.save(); + + res.status(200).json({ + success: true, + message: 'Notification marked as read', + data: notification, + }); + } catch (error) { + console.error('Mark as read error:', error); + res.status(500).json({ + success: false, + message: 'Failed to mark notification as read', + error: error.message, + }); + } + }, + + /** + * Mark all notifications as read + */ + async markAllAsRead(req, res) { + try { + const userId = req.user.userId; + + const result = await Notification.updateMany( + { + 'recipient.userId': userId, + 'channels.push.status': { $ne: 'read' }, + }, + { + $set: { + 'channels.push.status': 'read', + 'channels.push.readAt': new Date(), + }, + } + ); + + res.status(200).json({ + success: true, + message: 'All notifications marked as read', + count: result.modifiedCount, + }); + } catch (error) { + console.error('Mark all read error:', error); + res.status(500).json({ + success: false, + message: 'Failed to mark all notifications as read', + error: error.message, + }); + } + }, +}; + +export default NotificationController; diff --git a/LifeLine-Backend/src/api/Notifications/v1/Notification.routes.mjs b/LifeLine-Backend/src/api/Notifications/v1/Notification.routes.mjs new file mode 100644 index 0000000..6ed9718 --- /dev/null +++ b/LifeLine-Backend/src/api/Notifications/v1/Notification.routes.mjs @@ -0,0 +1,27 @@ +import express from 'express'; +import NotificationController from './Notification.controller.mjs'; +import AuthMiddleware from '../../Auth/v1/Auth.middleware.mjs'; + +const router = express.Router(); + +// Apply authentication middleware to all routes +router.use(AuthMiddleware.authenticate); + +/** + * Notification Routes + * Base Path: /api/notifications/v1 + */ + +// Get all notifications with pagination +router.get('/', NotificationController.getNotifications); + +// Get unread count +router.get('/unread-count', NotificationController.getUnreadCount); + +// Mark all as read +router.patch('/read-all', NotificationController.markAllAsRead); + +// Mark specific notification as read +router.patch('/:id/read', NotificationController.markAsRead); + +export default router; diff --git a/LifeLine-Backend/src/api/Payment/Payment.controller.mjs b/LifeLine-Backend/src/api/Payment/Payment.controller.mjs new file mode 100644 index 0000000..12d097e --- /dev/null +++ b/LifeLine-Backend/src/api/Payment/Payment.controller.mjs @@ -0,0 +1,34 @@ +import * as PaymentUtils from './Payment.utils.mjs'; + +export default class PaymentController { + static async createPayment(req, res) { + try { + const { emergencyId, helperId, userId, amount, method, serviceType } = + req.body; + const payment = await PaymentUtils.createPayment({ + emergencyId, + helperId, + userId, + amount, + method, + serviceType, + }); + res.status(201).json({ success: true, data: payment }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } + } + + static async releasePayment(req, res) { + try { + const { paymentId } = req.params; + const payment = await PaymentUtils.updatePaymentStatus( + paymentId, + 'released', + ); + res.status(200).json({ success: true, data: payment }); + } catch (error) { + res.status(400).json({ success: false, message: error.message }); + } + } +} diff --git a/LifeLine-Backend/src/api/Payment/Payment.model.mjs b/LifeLine-Backend/src/api/Payment/Payment.model.mjs new file mode 100644 index 0000000..77a1801 --- /dev/null +++ b/LifeLine-Backend/src/api/Payment/Payment.model.mjs @@ -0,0 +1,55 @@ +import mongoose from 'mongoose'; + +const PaymentSchema = new mongoose.Schema({ + emergencyId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Emergency', + required: true, + index: true, + }, + helperId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Auth', + required: true, + index: true, + }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Auth', + required: true, + index: true, + }, + amount: { + type: Number, + required: true, + min: 0, + }, + status: { + type: String, + enum: ['pending', 'approved', 'verified', 'released', 'failed'], + default: 'pending', + }, + method: { + type: String, + enum: ['cash', 'upi', 'card'], + required: true, + }, + serviceType: { + type: String, + }, + createdAt: { + type: Date, + default: Date.now, + }, + updatedAt: { + type: Date, + default: Date.now, + }, +}); + +PaymentSchema.pre('save', function (next) { + this.updatedAt = Date.now(); + next(); +}); + +export default mongoose.model('Payment', PaymentSchema); diff --git a/LifeLine-Backend/src/api/Payment/Payment.routes.mjs b/LifeLine-Backend/src/api/Payment/Payment.routes.mjs new file mode 100644 index 0000000..310723b --- /dev/null +++ b/LifeLine-Backend/src/api/Payment/Payment.routes.mjs @@ -0,0 +1,12 @@ +import express from 'express'; +import PaymentController from './Payment.controller.mjs'; +import AuthMiddleware from '../Auth/v1/Auth.middleware.mjs'; + +const router = express.Router(); + +router.use(AuthMiddleware.authenticate); + +router.post('/', PaymentController.createPayment); +router.patch('/:paymentId/release', PaymentController.releasePayment); + +export default router; diff --git a/LifeLine-Backend/src/api/Payment/Payment.utils.mjs b/LifeLine-Backend/src/api/Payment/Payment.utils.mjs new file mode 100644 index 0000000..e13a04e --- /dev/null +++ b/LifeLine-Backend/src/api/Payment/Payment.utils.mjs @@ -0,0 +1,21 @@ +import Payment from './Payment.model.mjs'; + +export async function createPayment(data) { + return Payment.create(data); +} + +export async function getPaymentById(paymentId) { + return Payment.findById(paymentId); +} + +export async function getPaymentsByEmergency(emergencyId) { + return Payment.find({ emergencyId }).sort({ createdAt: -1 }); +} + +export async function getPaymentsByHelper(helperId) { + return Payment.find({ helperId }).sort({ createdAt: -1 }); +} + +export async function updatePaymentStatus(paymentId, status) { + return Payment.findByIdAndUpdate(paymentId, { status }, { new: true }); +} diff --git a/LifeLine-Backend/src/api/User/User.service.mjs b/LifeLine-Backend/src/api/User/User.service.mjs index 061ec6c..4112afd 100644 --- a/LifeLine-Backend/src/api/User/User.service.mjs +++ b/LifeLine-Backend/src/api/User/User.service.mjs @@ -23,6 +23,7 @@ export default class UserService { fullName, email, phoneNumber, + profileImage, dateOfBirth, gender, address, @@ -67,6 +68,7 @@ export default class UserService { fullName: sanitizedData.fullName, email: sanitizedData.email, phoneNumber: sanitizedData.phoneNumber, + profileImage, dateOfBirth, gender, address, @@ -165,6 +167,7 @@ export default class UserService { // Update fields const allowedUpdates = [ 'fullName', + 'profileImage', 'phoneNumber', 'dateOfBirth', 'gender', diff --git a/LifeLine-Backend/src/api/User/User.utils.mjs b/LifeLine-Backend/src/api/User/User.utils.mjs index 1f53cdc..4afbe22 100644 --- a/LifeLine-Backend/src/api/User/User.utils.mjs +++ b/LifeLine-Backend/src/api/User/User.utils.mjs @@ -90,6 +90,7 @@ export default class UserUtils { fullName: user.fullName, email: user.email, phoneNumber: user.phoneNumber, + profileImage: user.profileImage, dateOfBirth: user.dateOfBirth, gender: user.gender, address: user.address, diff --git a/LifeLine-Backend/src/config/mongo.config.mjs b/LifeLine-Backend/src/config/mongo.config.mjs index c73c1a9..c8fe611 100644 --- a/LifeLine-Backend/src/config/mongo.config.mjs +++ b/LifeLine-Backend/src/config/mongo.config.mjs @@ -12,9 +12,12 @@ const connectDB = async () => { (process.env.NODE_ENV === 'test' ? process.env.TEST_MONGODB_URI || process.env.MONGODB_URI : process.env.MONGODB_URI) || 'mongodb://localhost:27017/lifeline'; + const normalizedMongoURI = + typeof mongoURI === 'string' ? mongoURI.trim() : mongoURI; // Connection options for better performance and reliability const options = { + family: 4, // Automatically create indexes autoIndex: true, @@ -29,7 +32,7 @@ const connectDB = async () => { }; // Connect to MongoDB - const conn = await mongoose.connect(mongoURI, options); + const conn = await mongoose.connect(normalizedMongoURI, options); console.log(`✅ MongoDB Connected: ${conn.connection.host}`); console.log(`📂 Database: ${conn.connection.name}`); diff --git a/LifeLine-Backend/src/server.mjs b/LifeLine-Backend/src/server.mjs index db86123..31c60d8 100644 --- a/LifeLine-Backend/src/server.mjs +++ b/LifeLine-Backend/src/server.mjs @@ -4,6 +4,8 @@ import http from 'http'; import cors from 'cors'; import dotenv from 'dotenv'; import cookieParser from 'cookie-parser'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { connectDB, disconnectDB } from './config/mongo.config.mjs'; import { initializeSocket } from './config/socket.config.mjs'; import { setupSocketHandlers } from './socket/index.mjs'; @@ -11,6 +13,9 @@ import { setupSocketHandlers } from './socket/index.mjs'; // Load environment variables dotenv.config(); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + // Initialize Express app const app = express(); @@ -32,6 +37,7 @@ app.use( app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); +app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); // Health check endpoint app.get('/health', (req, res) => { @@ -54,6 +60,8 @@ import locationRoutes from './api/Location/Location.routes.mjs'; import emergencyRoutes from './api/Emergency/Emergency.routes.mjs'; import triageRoutes from './Ai/triage/triage.routes.mjs'; import ngoRoutes from './api/NGO/NGO.routes.mjs'; +import notificationRoutes from './api/Notifications/v1/Notification.routes.mjs'; +import paymentRoutes from './api/Payment/Payment.routes.mjs'; // Use API routes with versioning app.use('/api/auth/v1', authRoutes); @@ -64,6 +72,8 @@ app.use('/api/locations/v1', locationRoutes); app.use('/api/emergency', emergencyRoutes); app.use('/api/triage', triageRoutes); app.use('/api/ngo/v1', ngoRoutes); +app.use('/api/notifications/v1', notificationRoutes); +app.use('/api/payments', paymentRoutes); // 404 handler app.use((req, res) => { diff --git a/LifeLine-Backend/src/socket/SOCKET_API.md b/LifeLine-Backend/src/socket/SOCKET_API.md index be7010a..13c3991 100644 --- a/LifeLine-Backend/src/socket/SOCKET_API.md +++ b/LifeLine-Backend/src/socket/SOCKET_API.md @@ -37,7 +37,8 @@ Server now knows this user is online. ## Step 3 — User sends location -Server always knows user’s latest location. +While SOS is active, the app sends location about every 5 seconds. +Before sending, the app removes noisy GPS points. ## Step 4 — User triggers SOS @@ -92,20 +93,28 @@ User or helper sends location regularly. ```js socket.emit('location:update', { + // required latitude, longitude, + + // optional but recommended accuracy, timestamp, altitude, speed, - heading + heading, + + // optional metadata (identity already comes from user:register) + userId, + role }); ``` Server confirms: ```js -socket.on('location:updated', (data) => {}); +socket.on('location:updated', (data) => {}); // your update was saved +socket.on('emergency:locationUpdate', (data) => {}); // room gets live map update ``` --- diff --git a/LifeLine-Backend/src/socket/handlers/triage.handler.mjs b/LifeLine-Backend/src/socket/handlers/triage.handler.mjs index 8b1992b..a9fa186 100644 --- a/LifeLine-Backend/src/socket/handlers/triage.handler.mjs +++ b/LifeLine-Backend/src/socket/handlers/triage.handler.mjs @@ -51,13 +51,18 @@ export const registerTriageHandlers = (io, socket) => { return; } - const { location, language, emergencyType } = data; + const { location, language, emergencyType, emergencyId } = data; + + if (emergencyId) { + await EmergencyService.getEmergency(emergencyId, userId); + } const result = await TriageService.startSession( userId, location, language || 'en', emergencyType, + emergencyId, ); socket.emit(TRIAGE_EVENTS.SESSION_STARTED, result); @@ -65,6 +70,7 @@ export const registerTriageHandlers = (io, socket) => { // Store session ID on socket for easy access if (result.success) { socket.triageSessionId = result.sessionId; + socket.triageEmergencyId = emergencyId || null; } console.log( @@ -113,6 +119,7 @@ export const registerTriageHandlers = (io, socket) => { const result = await TriageService.processMessage( effectiveSessionId, message.trim(), + socket.userId, ); if (!result.success) { @@ -142,12 +149,35 @@ export const registerTriageHandlers = (io, socket) => { message: result.message, }); - // Create emergency + // Create or update emergency try { const userId = result.session?.userId || socket.userId; const location = result.session?.extractedInfo?.location; + const existingEmergencyId = + result.session?.emergencyId || socket.triageEmergencyId; + + if (existingEmergencyId && userId) { + const emergencyResult = + await EmergencyService.updateEmergencyDetails( + existingEmergencyId, + userId, + { + type: result.emergency?.type, + title: result.emergency?.title, + description: result.emergency?.description, + priority: result.emergency?.priority, + medicalInfo: result.emergency?.medicalInfo || {}, + }, + ); - if (userId) { + if (emergencyResult.success) { + socket.emit(TRIAGE_EVENTS.EMERGENCY_CREATED, { + success: true, + emergencyId: existingEmergencyId, + emergency: emergencyResult.data, + }); + } + } else if (userId) { const emergencyResult = await EmergencyService.triggerSOS( { title: result.emergency.title, diff --git a/LifeLine-Backend/src/uploads/profile-pictures/feebfa2d-e8e7-42e8-a624-b3fa5915f48c-1772334762120-49172070.jpeg b/LifeLine-Backend/src/uploads/profile-pictures/feebfa2d-e8e7-42e8-a624-b3fa5915f48c-1772334762120-49172070.jpeg new file mode 100644 index 0000000..3c58b89 Binary files /dev/null and b/LifeLine-Backend/src/uploads/profile-pictures/feebfa2d-e8e7-42e8-a624-b3fa5915f48c-1772334762120-49172070.jpeg differ diff --git a/LifeLine-Backend/src/utils/cloudinary.utils.mjs b/LifeLine-Backend/src/utils/cloudinary.utils.mjs new file mode 100644 index 0000000..ddfdff5 --- /dev/null +++ b/LifeLine-Backend/src/utils/cloudinary.utils.mjs @@ -0,0 +1,135 @@ +import axios from 'axios'; +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; + +const DEFAULT_FOLDER = 'lifeline/profile-pictures'; + +const sanitizePublicId = (value) => { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9/_-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 120); +}; + +const getMimeTypeFromPath = (filePath) => { + const extension = path.extname(filePath).toLowerCase(); + if (extension === '.png') return 'image/png'; + if (extension === '.gif') return 'image/gif'; + if (extension === '.webp') return 'image/webp'; + if (extension === '.heic') return 'image/heic'; + if (extension === '.heif') return 'image/heif'; + return 'image/jpeg'; +}; + +class CloudinaryUtils { + static isConfigured() { + return Boolean( + process.env.CLOUDINARY_CLOUD_NAME && + process.env.CLOUDINARY_API_KEY && + process.env.CLOUDINARY_API_SECRET, + ); + } + + static getConfig() { + if (!this.isConfigured()) { + throw new Error( + 'Cloudinary is not configured. Set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET.', + ); + } + + return { + cloudName: process.env.CLOUDINARY_CLOUD_NAME, + apiKey: process.env.CLOUDINARY_API_KEY, + apiSecret: process.env.CLOUDINARY_API_SECRET, + folder: process.env.CLOUDINARY_PROFILE_FOLDER || DEFAULT_FOLDER, + }; + } + + static generateSignature(params, apiSecret) { + const serialized = Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, value]) => `${key}=${value}`) + .join('&'); + + return crypto + .createHash('sha1') + .update(`${serialized}${apiSecret}`) + .digest('hex'); + } + + static async uploadImageDataUri(dataUri, options = {}) { + const { cloudName, apiKey, apiSecret, folder } = this.getConfig(); + const timestamp = Math.floor(Date.now() / 1000); + const publicId = sanitizePublicId( + options.publicIdSeed || `profile_${timestamp}_${Math.round(Math.random() * 1e6)}`, + ); + const targetFolder = options.folder || folder; + + const signature = this.generateSignature( + { + folder: targetFolder, + public_id: publicId, + timestamp, + }, + apiSecret, + ); + + const body = new URLSearchParams(); + body.append('file', dataUri); + body.append('api_key', apiKey); + body.append('timestamp', String(timestamp)); + body.append('signature', signature); + body.append('public_id', publicId); + if (targetFolder) { + body.append('folder', targetFolder); + } + + const url = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`; + const response = await axios.post(url, body.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + timeout: 30000, + }); + + return response.data; + } + + static async uploadImageFromFile(filePath, options = {}) { + const buffer = await fs.readFile(filePath); + const mimeType = options.mimeType || getMimeTypeFromPath(filePath); + const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`; + return this.uploadImageDataUri(dataUri, options); + } + + static async deleteImage(publicId) { + if (!this.isConfigured() || !publicId) return null; + + const { cloudName, apiKey, apiSecret } = this.getConfig(); + const timestamp = Math.floor(Date.now() / 1000); + const signature = this.generateSignature({ public_id: publicId, timestamp }, apiSecret); + + const body = new URLSearchParams(); + body.append('public_id', publicId); + body.append('api_key', apiKey); + body.append('timestamp', String(timestamp)); + body.append('signature', signature); + + const url = `https://api.cloudinary.com/v1_1/${cloudName}/image/destroy`; + const response = await axios.post(url, body.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + timeout: 15000, + }); + + return response.data; + } +} + +export default CloudinaryUtils; diff --git a/LifeLine-Backend/src/utils/multer.utils.mjs b/LifeLine-Backend/src/utils/multer.utils.mjs index c4cba35..2242724 100644 --- a/LifeLine-Backend/src/utils/multer.utils.mjs +++ b/LifeLine-Backend/src/utils/multer.utils.mjs @@ -23,7 +23,14 @@ export const FILE_SIZE_LIMITS = { // Allowed file types export const ALLOWED_FILE_TYPES = { - IMAGES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + IMAGES: [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/heic', + 'image/heif', + ], DOCUMENTS: [ 'application/pdf', 'application/msword', diff --git a/Lifeline-Frontend/Dockerfile b/Lifeline-Frontend/Dockerfile new file mode 100644 index 0000000..dc5a7e0 --- /dev/null +++ b/Lifeline-Frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine + +WORKDIR /app + +RUN npm install -g pnpm + +COPY package.json pnpm-lock.yaml ./ + +RUN pnpm install --frozen-lockfile + +COPY . . + +EXPOSE 8081 +EXPOSE 19000 +EXPOSE 19001 +EXPOSE 19002 + +CMD ["pnpm", "start", "--", "--tunnel", "--clear"] diff --git a/Lifeline-Frontend/app.json b/Lifeline-Frontend/app.json index 56be878..8ea339d 100644 --- a/Lifeline-Frontend/app.json +++ b/Lifeline-Frontend/app.json @@ -13,7 +13,8 @@ "root": "./app" } ], - "@react-native-community/datetimepicker" + "@react-native-community/datetimepicker", + "./plugins/withPowerButtonSOS" ], "splash": { "image": "./assets/icons/icon.png", @@ -37,10 +38,7 @@ "NSLocationWhenInUseUsageDescription": "LifeLine needs your location to send help during emergencies.", "NSLocationAlwaysAndWhenInUseUsageDescription": "LifeLine needs your location to send help during emergencies even when the app is in background.", "NSLocationAlwaysUsageDescription": "LifeLine needs your location to send help during emergencies even when the app closed.", - "UIBackgroundModes": [ - "location", - "fetch" - ], + "UIBackgroundModes": ["location", "fetch"], "NSCameraUsageDescription": "Camera is used to record emergency evidence.", "NSMicrophoneUsageDescription": "Microphone is used for audio recording during emergencies.", "NSContactsUsageDescription": "Contacts is used to send help during emergencies.", diff --git a/Lifeline-Frontend/app/(global)/Map.tsx b/Lifeline-Frontend/app/(global)/Map.tsx index 22009bb..80d5041 100644 --- a/Lifeline-Frontend/app/(global)/Map.tsx +++ b/Lifeline-Frontend/app/(global)/Map.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useCallback } from "react"; +import React, { useEffect, useState, useRef, useCallback, useMemo } from "react"; import { View, Text, @@ -15,11 +15,14 @@ import { WebView } from "react-native-webview"; import * as Location from "expo-location"; import { Ionicons } from "@expo/vector-icons"; import { socketService } from "@/src/shared/services"; -import api, { API_ENDPOINTS } from "@/src/config/api"; +import apiClient, { API_ENDPOINTS } from "@/src/config/api"; import { getErrorMessage } from "@/src/shared/utils/error.utils"; +import { useSelector } from "react-redux"; +import { RootState } from "@/src/core/store"; interface HelperData { id: string; + authId?: string | null; name: string; avatar: string; rating: number; @@ -34,212 +37,1054 @@ interface Coords { longitude: number; } +interface LocationFixSample { + coords: Coords; + timestamp: number; + accuracy?: number; +} + +interface PendingJumpState { + coords: Coords; + firstSeenAt: number; + confirmations: number; +} + +const DEFAULT_HELPER_AVATAR = "https://randomuser.me/api/portraits/women/44.jpg"; +const DEFAULT_USER_AVATAR = "https://randomuser.me/api/portraits/men/32.jpg"; +const INITIAL_HELPER_LOCATION: Coords = { latitude: 0, longitude: 0 }; +const INITIAL_USER_LOCATION: Coords = { latitude: 0, longitude: 0 }; +const MAX_ACCEPTABLE_ACCURACY_METERS = 50; +const HARD_REJECT_ACCURACY_METERS = 150; +const MAX_JUMP_METERS = 800; +const MAX_REASONABLE_SPEED_MPS = 70; +const RECENT_FIXES_WINDOW_SIZE = 5; +const STALE_FIX_TOLERANCE_MS = 5000; +const LARGE_JUMP_CONFIRM_DISTANCE_METERS = 220; +const LARGE_JUMP_MATCH_RADIUS_METERS = 90; +const LARGE_JUMP_CONFIRMATIONS_REQUIRED = 2; +const LARGE_JUMP_CONFIRM_TIMEOUT_MS = 12000; + +const asSingleParam = (value: string | string[] | undefined): string | undefined => + Array.isArray(value) ? value[0] : value; + +const normalizeRole = (value?: string): UserRole | null => + value === "helper" || value === "user" ? value : null; + +const pickFirstString = (...values: unknown[]): string | null => { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +}; + +const toCoords = (raw: any): Coords | null => { + const latitude = Number(raw?.latitude); + const longitude = Number(raw?.longitude); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + return { latitude, longitude }; +}; + +const coordsFromArray = (value: unknown): Coords | null => { + if (!Array.isArray(value) || value.length < 2) return null; + const longitude = Number(value[0]); + const latitude = Number(value[1]); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + return { latitude, longitude }; +}; + +const toTimestampMs = (value: unknown): number => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return Date.now(); +}; + +const haversineDistanceMeters = (a: Coords, b: Coords): number => { + const toRad = (deg: number) => (deg * Math.PI) / 180; + const R = 6371000; + const dLat = toRad(b.latitude - a.latitude); + const dLon = toRad(b.longitude - a.longitude); + const lat1 = toRad(a.latitude); + const lat2 = toRad(b.latitude); + + const x = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); + const y = 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x)); + return R * y; +}; + +// Vincenty inverse formula on WGS-84 ellipsoid for high-accuracy geodesic distance. +const geodesicDistanceMeters = (a: Coords, b: Coords): number => { + if (a.latitude === b.latitude && a.longitude === b.longitude) return 0; + + const toRad = (deg: number) => (deg * Math.PI) / 180; + const A = 6378137; + const B = 6356752.314245; + const F = 1 / 298.257223563; + + const phi1 = toRad(a.latitude); + const phi2 = toRad(b.latitude); + const L = toRad(b.longitude - a.longitude); + const U1 = Math.atan((1 - F) * Math.tan(phi1)); + const U2 = Math.atan((1 - F) * Math.tan(phi2)); + + const sinU1 = Math.sin(U1); + const cosU1 = Math.cos(U1); + const sinU2 = Math.sin(U2); + const cosU2 = Math.cos(U2); + + let lambda = L; + let lambdaPrev = 0; + let iterations = 200; + + let sinSigma = 0; + let cosSigma = 0; + let sigma = 0; + let sinAlpha = 0; + let cosSqAlpha = 0; + let cos2SigmaM = 0; + + while (iterations > 0) { + const sinLambda = Math.sin(lambda); + const cosLambda = Math.cos(lambda); + const sinSqSigma = + cosU2 * sinLambda * (cosU2 * sinLambda) + + (cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) * (cosU1 * sinU2 - sinU1 * cosU2 * cosLambda); + sinSigma = Math.sqrt(sinSqSigma); + + if (sinSigma === 0) return 0; + + cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda; + sigma = Math.atan2(sinSigma, cosSigma); + sinAlpha = (cosU1 * cosU2 * sinLambda) / sinSigma; + cosSqAlpha = 1 - sinAlpha * sinAlpha; + cos2SigmaM = cosSqAlpha !== 0 ? cosSigma - (2 * sinU1 * sinU2) / cosSqAlpha : 0; + + const C = (F / 16) * cosSqAlpha * (4 + F * (4 - 3 * cosSqAlpha)); + lambdaPrev = lambda; + lambda = + L + + (1 - C) * + F * + sinAlpha * + (sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM))); + + if (Math.abs(lambda - lambdaPrev) <= 1e-12) break; + iterations -= 1; + } + + if (iterations === 0) { + return haversineDistanceMeters(a, b); + } + + const uSq = (cosSqAlpha * (A * A - B * B)) / (B * B); + const bigA = 1 + (uSq / 16384) * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq))); + const bigB = (uSq / 1024) * (256 + uSq * (-128 + uSq * (74 - 47 * uSq))); + const deltaSigma = + bigB * + sinSigma * + (cos2SigmaM + + (bigB / 4) * + (cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) - + (bigB / 6) * + cos2SigmaM * + (-3 + 4 * sinSigma * sinSigma) * + (-3 + 4 * cos2SigmaM * cos2SigmaM))); + + return B * bigA * (sigma - deltaSigma); +}; + +const smoothCoords = (from: Coords, to: Coords, alpha: number): Coords => ({ + latitude: from.latitude + (to.latitude - from.latitude) * alpha, + longitude: from.longitude + (to.longitude - from.longitude) * alpha, +}); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const median = (values: number[]): number => { + if (!values.length) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +}; + +const getMedianCoords = (samples: LocationFixSample[]): Coords | null => { + if (!samples.length) return null; + return { + latitude: median(samples.map((sample) => sample.coords.latitude)), + longitude: median(samples.map((sample) => sample.coords.longitude)), + }; +}; + +const pushRecentFix = ( + samples: LocationFixSample[], + nextSample: LocationFixSample +): LocationFixSample[] => { + const next = [...samples, nextSample]; + if (next.length > RECENT_FIXES_WINDOW_SIZE) { + next.splice(0, next.length - RECENT_FIXES_WINDOW_SIZE); + } + return next; +}; + +const getMovementThresholdMeters = (accuracy?: number): number => { + if (typeof accuracy !== "number" || !Number.isFinite(accuracy)) return 2; + return Math.min(12, Math.max(1.5, accuracy * 0.18)); +}; + +const evaluateJumpConfirmation = ( + pending: PendingJumpState | null, + previousCoords: Coords, + incomingCoords: Coords, + nowTs: number +): { accept: boolean; pending: PendingJumpState | null } => { + const jumpDistance = geodesicDistanceMeters(previousCoords, incomingCoords); + if (jumpDistance < LARGE_JUMP_CONFIRM_DISTANCE_METERS) { + return { accept: true, pending: null }; + } + + const isPendingExpired = pending && nowTs - pending.firstSeenAt > LARGE_JUMP_CONFIRM_TIMEOUT_MS; + const canConfirmWithPending = + pending && + !isPendingExpired && + geodesicDistanceMeters(pending.coords, incomingCoords) <= LARGE_JUMP_MATCH_RADIUS_METERS; + + if (!canConfirmWithPending) { + return { + accept: false, + pending: { coords: incomingCoords, firstSeenAt: nowTs, confirmations: 1 }, + }; + } + + const nextConfirmations = pending.confirmations + 1; + if (nextConfirmations >= LARGE_JUMP_CONFIRMATIONS_REQUIRED) { + return { accept: true, pending: null }; + } + + return { + accept: false, + pending: { + coords: incomingCoords, + firstSeenAt: pending.firstSeenAt, + confirmations: nextConfirmations, + }, + }; +}; + export default function MapScreen() { const router = useRouter(); const webViewRef = useRef(null); + const mapReadyRef = useRef(false); + const myLocationRef = useRef(INITIAL_USER_LOCATION); + const lastOwnFixRef = useRef<{ coords: Coords; timestamp: number; accuracy?: number } | null>( + null + ); + const lastTargetFixRef = useRef<{ coords: Coords; timestamp: number; accuracy?: number } | null>( + null + ); + const recentOwnFixesRef = useRef([]); + const recentTargetFixesRef = useRef([]); + const pendingOwnJumpRef = useRef(null); + const pendingTargetJumpRef = useRef(null); + const bootstrapSignatureRef = useRef(null); + const locationMetaRef = useRef<{ + accuracy?: number; + altitude?: number; + speed?: number; + heading?: number; + }>({}); + + const auth = useSelector((state: RootState) => state.auth); + const authIdFromStore = auth.authId || auth.userId || null; + const authRole = auth.userData?.role; + const { helperId, userId, role: roleParam, emergencyId, } = useLocalSearchParams<{ - helperId: string; - userId: string; - role: string; + helperId?: string | string[]; + userId?: string | string[]; + role?: string | string[]; emergencyId?: string; }>(); + const helperIdParam = asSingleParam(helperId); + const userIdParam = asSingleParam(userId); + const roleParamValue = asSingleParam(roleParam); + const emergencyIdParam = asSingleParam(emergencyId); + const [loading, setLoading] = useState(true); - const [mapReady, setMapReady] = useState(false); + const [isMapReady, setIsMapReady] = useState(false); + const [socketReady, setSocketReady] = useState(false); + const [targetAuthId, setTargetAuthId] = useState(null); + const [resolvedSelfAuthId, setResolvedSelfAuthId] = useState(authIdFromStore); + const [helperData, setHelperData] = useState(null); const [userData, setUserData] = useState<{ name: string; avatar: string } | null>(null); - const [helperLocation, setHelperLocation] = useState({ - latitude: 28.6139, - longitude: 77.209, - }); - const [userLocation, setUserLocation] = useState({ - latitude: 28.6239, - longitude: 77.219, - }); - // Use ref to avoid unnecessary re-renders on location update - const userLocationRef = useRef(userLocation); + const [helperLocation, setHelperLocation] = useState(INITIAL_HELPER_LOCATION); + const [userLocation, setUserLocation] = useState(INITIAL_USER_LOCATION); const [eta, setEta] = useState("--"); const [distance, setDistance] = useState("--"); const [focusTarget, setFocusTarget] = useState<"user" | "helper">("helper"); const [rideType, setRideType] = useState<"car" | "bike" | "foot">("car"); - const [myRole] = useState((roleParam as UserRole) || "user"); + const [myRole, setMyRole] = useState( + normalizeRole(roleParamValue) || authRole || "user" + ); const [routeLoading, setRouteLoading] = useState(false); const [routeSource, setRouteSource] = useState(""); const [isPrimaryLoading, setIsPrimaryLoading] = useState(false); + const [mapHtml, setMapHtml] = useState(""); + + const bootstrapRole = normalizeRole(roleParamValue) || normalizeRole(authRole) || "user"; + const bootstrapSignature = useMemo( + () => [bootstrapRole, emergencyIdParam || "", helperIdParam || "", userIdParam || ""].join("|"), + [bootstrapRole, emergencyIdParam, helperIdParam, userIdParam] + ); + const webViewSource = useMemo(() => ({ html: mapHtml }), [mapHtml]); + + const fetchAuthById = useCallback(async (id: string) => { + try { + const res = await apiClient.get(`/api/auth/v1/getUserById/${id}`); + return res?.data?.user || null; + } catch { + return null; + } + }, []); + + const fetchUserProfileById = useCallback(async (id: string) => { + try { + const res = await apiClient.get(`/api/users/v1/${id}`); + return res?.data?.data || null; + } catch { + return null; + } + }, []); + + const fetchHelperProfileById = useCallback(async (id: string) => { + try { + const res = await apiClient.get(`/api/helpers/v1/${id}`); + return res?.data?.data || null; + } catch { + return null; + } + }, []); + + const fetchEmergencyById = useCallback(async (id: string) => { + try { + const res = await apiClient.get(API_ENDPOINTS.EMERGENCY.GET_BY_ID(id)); + return res?.data?.data || null; + } catch { + return null; + } + }, []); + + const fetchLocationById = useCallback(async (id: string) => { + try { + const res = await apiClient.get(`/api/locations/v1/${id}`); + return res?.data?.data || null; + } catch { + return null; + } + }, []); + + const postOwnLocationToMap = useCallback((coords: Coords, role: UserRole) => { + if (!webViewRef.current || !mapReadyRef.current) return; + + webViewRef.current.postMessage( + JSON.stringify({ + type: role === "helper" ? "updateHelperLocation" : "updateUserLocation", + latitude: coords.latitude, + longitude: coords.longitude, + }) + ); + }, []); + + const postTargetLocationToMap = useCallback((coords: Coords, role: UserRole) => { + if (!webViewRef.current || !mapReadyRef.current) return; + + webViewRef.current.postMessage( + JSON.stringify({ + type: role === "helper" ? "updateUserLocation" : "updateHelperLocation", + latitude: coords.latitude, + longitude: coords.longitude, + }) + ); + }, []); + + const getSmoothingAlpha = useCallback((accuracy?: number) => { + if (typeof accuracy !== "number" || !Number.isFinite(accuracy)) return 0.4; + if (accuracy <= 10) return 0.75; + if (accuracy <= 20) return 0.6; + if (accuracy <= 35) return 0.5; + return 0.35; + }, []); + + const isUnrealisticJump = useCallback( + (previous: Coords, next: Coords, previousTs: number, nextTs: number) => { + const distanceMeters = geodesicDistanceMeters(previous, next); + const seconds = Math.max((nextTs - previousTs) / 1000, 1); + const speedMps = distanceMeters / seconds; + return distanceMeters > MAX_JUMP_METERS && speedMps > MAX_REASONABLE_SPEED_MPS; + }, + [] + ); + + const applyOwnCoords = useCallback( + (rawCoords: Coords, accuracy: number | undefined, timestampMs: number, role: UserRole) => { + if (typeof accuracy === "number" && accuracy > HARD_REJECT_ACCURACY_METERS) { + return; + } + + const previous = lastOwnFixRef.current; + if (previous && timestampMs + STALE_FIX_TOLERANCE_MS < previous.timestamp) { + return; + } + + if ( + previous && + isUnrealisticJump(previous.coords, rawCoords, previous.timestamp, timestampMs) + ) { + return; + } + + if ( + previous && + typeof accuracy === "number" && + accuracy > MAX_ACCEPTABLE_ACCURACY_METERS && + geodesicDistanceMeters(previous.coords, rawCoords) > 120 + ) { + return; + } + + if ( + previous && + (typeof accuracy !== "number" || accuracy > 25) && + geodesicDistanceMeters(previous.coords, rawCoords) >= LARGE_JUMP_CONFIRM_DISTANCE_METERS + ) { + const jumpDecision = evaluateJumpConfirmation( + pendingOwnJumpRef.current, + previous.coords, + rawCoords, + timestampMs + ); + pendingOwnJumpRef.current = jumpDecision.pending; + if (!jumpDecision.accept) { + return; + } + } else { + pendingOwnJumpRef.current = null; + } + + recentOwnFixesRef.current = pushRecentFix(recentOwnFixesRef.current, { + coords: rawCoords, + timestamp: timestampMs, + accuracy, + }); + const stabilizedCoords = getMedianCoords(recentOwnFixesRef.current) || rawCoords; + + const nextCoords = previous + ? smoothCoords(previous.coords, stabilizedCoords, getSmoothingAlpha(accuracy)) + : stabilizedCoords; + + if ( + previous && + geodesicDistanceMeters(previous.coords, nextCoords) < getMovementThresholdMeters(accuracy) + ) { + return; + } + + lastOwnFixRef.current = { coords: nextCoords, timestamp: timestampMs, accuracy }; + myLocationRef.current = nextCoords; + + if (role === "helper") { + setHelperLocation(nextCoords); + } else { + setUserLocation(nextCoords); + } + + postOwnLocationToMap(nextCoords, role); + }, + [getSmoothingAlpha, isUnrealisticJump, postOwnLocationToMap] + ); + + const applyTargetCoords = useCallback( + (rawCoords: Coords, accuracy: number | undefined, timestampMs: number, role: UserRole) => { + if (typeof accuracy === "number" && accuracy > HARD_REJECT_ACCURACY_METERS * 1.5) { + return; + } + + const previous = lastTargetFixRef.current; + if (previous && timestampMs + STALE_FIX_TOLERANCE_MS < previous.timestamp) { + return; + } + + if ( + previous && + isUnrealisticJump(previous.coords, rawCoords, previous.timestamp, timestampMs) + ) { + return; + } + + if ( + previous && + (typeof accuracy !== "number" || accuracy > 30) && + geodesicDistanceMeters(previous.coords, rawCoords) >= + LARGE_JUMP_CONFIRM_DISTANCE_METERS * 1.4 + ) { + const jumpDecision = evaluateJumpConfirmation( + pendingTargetJumpRef.current, + previous.coords, + rawCoords, + timestampMs + ); + pendingTargetJumpRef.current = jumpDecision.pending; + if (!jumpDecision.accept) { + return; + } + } else { + pendingTargetJumpRef.current = null; + } + + recentTargetFixesRef.current = pushRecentFix(recentTargetFixesRef.current, { + coords: rawCoords, + timestamp: timestampMs, + accuracy, + }); + const stabilizedCoords = getMedianCoords(recentTargetFixesRef.current) || rawCoords; + + const nextCoords = previous + ? smoothCoords(previous.coords, stabilizedCoords, getSmoothingAlpha(accuracy)) + : stabilizedCoords; + + if ( + previous && + geodesicDistanceMeters(previous.coords, nextCoords) < getMovementThresholdMeters(accuracy) + ) { + return; + } + + lastTargetFixRef.current = { coords: nextCoords, timestamp: timestampMs, accuracy }; + + if (role === "helper") { + setUserLocation(nextCoords); + } else { + setHelperLocation(nextCoords); + } + + postTargetLocationToMap(nextCoords, role); + }, + [getSmoothingAlpha, isUnrealisticJump, postTargetLocationToMap] + ); useEffect(() => { + setResolvedSelfAuthId(authIdFromStore || null); + }, [authIdFromStore]); + + useEffect(() => { + if (bootstrapSignatureRef.current === bootstrapSignature) { + return; + } + + bootstrapSignatureRef.current = bootstrapSignature; let isMounted = true; - const init = async () => { + + const initializeMyLocation = async (role: UserRole): Promise => { try { + if (Platform.OS === "android") { + await Location.enableNetworkProviderAsync().catch(() => {}); + } + const { status } = await Location.requestForegroundPermissionsAsync(); - if (status === "granted") { - const loc = await Location.getCurrentPositionAsync({ + if (status !== "granted") return null; + + let bestFix: Location.LocationObject | null = null; + const lastKnownFix = await Location.getLastKnownPositionAsync({ + maxAge: 15000, + requiredAccuracy: HARD_REJECT_ACCURACY_METERS, + }).catch(() => null); + if (lastKnownFix) { + bestFix = lastKnownFix; + } + + for (let attempt = 0; attempt < 4; attempt += 1) { + const fix = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.BestForNavigation, + mayShowUserSettingsDialog: true, }); - if (isMounted) { - setUserLocation({ - latitude: loc.coords.latitude, - longitude: loc.coords.longitude, - }); - userLocationRef.current = { - latitude: loc.coords.latitude, - longitude: loc.coords.longitude, - }; + + if ( + !bestFix || + (fix.coords.accuracy ?? Infinity) < (bestFix.coords.accuracy ?? Infinity) + ) { + bestFix = fix; + } + + if ((fix.coords.accuracy ?? Infinity) <= 20) { + break; + } + + if (attempt < 3) { + await sleep(650); } } - // Mock data - replace with API - if (isMounted) { - setHelperData({ - id: helperId || "helper-1", - name: "Dr. Sarah Chen", - avatar: "https://randomuser.me/api/portraits/women/44.jpg", - rating: 4.9, - role: "Emergency Responder", - rescues: 1247, - }); - setUserData({ - name: "John Doe", - avatar: "https://randomuser.me/api/portraits/men/32.jpg", - }); - setHelperLocation({ latitude: 24.0849165, longitude: 85.825372 }); + + if (!bestFix) return null; + + const accuracy = bestFix.coords.accuracy || undefined; + if (typeof accuracy === "number" && accuracy > HARD_REJECT_ACCURACY_METERS) { + return null; } + + const coords = { + latitude: bestFix.coords.latitude, + longitude: bestFix.coords.longitude, + }; + + const timestampMs = toTimestampMs(bestFix.timestamp); + myLocationRef.current = coords; + lastOwnFixRef.current = { coords, timestamp: timestampMs, accuracy }; + locationMetaRef.current = { + accuracy, + altitude: bestFix.coords.altitude || undefined, + speed: bestFix.coords.speed || undefined, + heading: bestFix.coords.heading || undefined, + }; + + if (!isMounted) return coords; + if (role === "helper") { + setHelperLocation(coords); + setUserLocation(coords); + } else { + setUserLocation(coords); + setHelperLocation(coords); + } + return coords; } catch (error) { - console.error("Init error:", error); - } finally { - if (isMounted) setLoading(false); + console.error("Failed to initialize current location:", error); + return null; } }; - init(); - return () => { - isMounted = false; - }; - }, [helperId, userId]); - // Debounce location update to avoid rapid socket emits and UI flicker - useEffect(() => { - if (!socketService.isConnected()) socketService.connect(); - const timeout = setTimeout(() => { + const bootstrap = async () => { + const effectiveRole = bootstrapRole; + const selfAuthId = authIdFromStore || null; + let nextTargetAuthId: string | null = null; + let nextHelperData: HelperData | null = null; + let nextUserData: { name: string; avatar: string } | null = null; + let initialHelperCoords: Coords | null = null; + let initialUserCoords: Coords | null = null; + + setLoading(true); + setMyRole(effectiveRole); + setResolvedSelfAuthId(selfAuthId); + setSocketReady(false); + mapReadyRef.current = false; + setIsMapReady(false); + setTargetAuthId(null); + setHelperData(null); + setUserData(null); + setFocusTarget(effectiveRole === "helper" ? "user" : "helper"); + lastOwnFixRef.current = null; + lastTargetFixRef.current = null; + recentOwnFixesRef.current = []; + recentTargetFixesRef.current = []; + pendingOwnJumpRef.current = null; + pendingTargetJumpRef.current = null; + try { - socketService.updateLocation({ - latitude: userLocation.latitude, - longitude: userLocation.longitude, - accuracy: 10, - altitude: 200, - speed: 5, - heading: 90, - userId: userId || undefined, - }); - } catch (err) { - console.error("Socket location update failed:", err); - } - }, 300); // 300ms debounce - return () => clearTimeout(timeout); - }, [userLocation.latitude, userLocation.longitude]); + const myInitialCoords = await initializeMyLocation(effectiveRole); + if (effectiveRole === "helper") { + initialHelperCoords = myInitialCoords; + initialUserCoords = myInitialCoords; + } else { + initialUserCoords = myInitialCoords; + initialHelperCoords = myInitialCoords; + } - useEffect(() => { - let subscription: Location.LocationSubscription | null = null; - const startWatching = async () => { - const { status } = await Location.requestForegroundPermissionsAsync(); - if (status === "granted") { - subscription = await Location.watchPositionAsync( - { - accuracy: Location.Accuracy.BestForNavigation, - timeInterval: 5000, - distanceInterval: 5, - mayShowUserSettingsDialog: true, - }, - (loc) => { - // Only update state if location actually changed - const prev = userLocationRef.current; - if (prev.latitude !== loc.coords.latitude || prev.longitude !== loc.coords.longitude) { - const newLocation = { - latitude: loc.coords.latitude, - longitude: loc.coords.longitude, - }; - setUserLocation(newLocation); - userLocationRef.current = newLocation; + const emergencyData = emergencyIdParam ? await fetchEmergencyById(emergencyIdParam) : null; + + if (effectiveRole === "user") { + let helperCandidateId = helperIdParam; + if (!helperCandidateId && emergencyData?.assignedHelpers?.length > 0) { + const helperFromEmergency = emergencyData.assignedHelpers[0]?.helperId; + helperCandidateId = + (typeof helperFromEmergency === "string" && helperFromEmergency) || + helperFromEmergency?._id || + null; + } + + if (helperCandidateId) { + const helperProfile = await fetchHelperProfileById(helperCandidateId); + let helperAuthId = pickFirstString(helperProfile?.authId); + let helperAuth = helperAuthId ? await fetchAuthById(helperAuthId) : null; + + if (!helperAuth) { + const fallbackAuth = await fetchAuthById(helperCandidateId); + if (fallbackAuth) { + helperAuth = fallbackAuth; + helperAuthId = pickFirstString(fallbackAuth?._id, helperCandidateId); + } } - // Emit location update via socket (debounced by state effect) - if (webViewRef.current && mapReady) { - webViewRef.current.postMessage( - JSON.stringify({ - type: "updateUserLocation", - latitude: loc.coords.latitude, - longitude: loc.coords.longitude, - }) - ); + + nextTargetAuthId = helperAuthId; + nextHelperData = { + id: pickFirstString(helperProfile?.id, helperProfile?._id, helperCandidateId) || "", + authId: helperAuthId, + name: + pickFirstString(helperAuth?.fullName, helperAuth?.name, helperProfile?.name) || + "Emergency Helper", + avatar: + pickFirstString(helperAuth?.profileImage, helperProfile?.avatar) || + DEFAULT_HELPER_AVATAR, + rating: Number(helperProfile?.rating ?? 4.8), + role: pickFirstString(helperProfile?.role, helperAuth?.role) || "Emergency Responder", + rescues: Number(helperProfile?.rescues ?? helperProfile?.totalRescues ?? 0), + }; + + const helperLocationId = pickFirstString( + helperProfile?.locationId, + helperAuth?.locationId + ); + if (helperLocationId) { + const helperLocationDoc = await fetchLocationById(helperLocationId); + const helperCoords = coordsFromArray(helperLocationDoc?.coordinates); + if (helperCoords && isMounted) { + initialHelperCoords = helperCoords; + lastTargetFixRef.current = { + coords: helperCoords, + timestamp: Date.now(), + accuracy: helperLocationDoc?.accuracy, + }; + setHelperLocation(helperCoords); + } } } + } else { + let userCandidateId = userIdParam; + if (!userCandidateId) { + userCandidateId = + pickFirstString(emergencyData?.userId?._id, emergencyData?.userId) || undefined; + } + + if (userCandidateId) { + let userAuth = await fetchAuthById(userCandidateId); + let userProfile = null; + let resolvedUserAuthId = userAuth + ? pickFirstString(userAuth?._id, userCandidateId) + : null; + + if (!userAuth) { + userProfile = await fetchUserProfileById(userCandidateId); + resolvedUserAuthId = pickFirstString(userProfile?.authId); + if (resolvedUserAuthId) { + userAuth = await fetchAuthById(resolvedUserAuthId); + } + } + + nextTargetAuthId = resolvedUserAuthId; + nextUserData = { + name: + pickFirstString( + userAuth?.fullName, + userAuth?.name, + userProfile?.fullName, + userProfile?.name, + emergencyData?.userId?.name + ) || "User", + avatar: pickFirstString(userAuth?.profileImage) || DEFAULT_USER_AVATAR, + }; + + const userLocationId = pickFirstString( + userProfile?.locationId, + userAuth?.locationId, + emergencyData?.userId?.locationId + ); + if (userLocationId) { + const userLocationDoc = await fetchLocationById(userLocationId); + const userCoords = coordsFromArray(userLocationDoc?.coordinates); + if (userCoords && isMounted) { + initialUserCoords = userCoords; + lastTargetFixRef.current = { + coords: userCoords, + timestamp: Date.now(), + accuracy: userLocationDoc?.accuracy, + }; + setUserLocation(userCoords); + } + } else { + const emergencyCoords = coordsFromArray(emergencyData?.location?.coordinates); + if (emergencyCoords && isMounted) { + initialUserCoords = emergencyCoords; + lastTargetFixRef.current = { + coords: emergencyCoords, + timestamp: Date.now(), + }; + setUserLocation(emergencyCoords); + } + } + } + } + } catch (error: unknown) { + console.error( + "Map bootstrap error:", + getErrorMessage(error, "Failed to initialize map screen") ); + } finally { + if (!isMounted) return; + const fallbackCoords = myLocationRef.current; + const finalHelperCoords = initialHelperCoords || fallbackCoords; + const finalUserCoords = initialUserCoords || fallbackCoords; + + setHelperLocation(finalHelperCoords); + setUserLocation(finalUserCoords); + setMapHtml(generateMapHtml(finalHelperCoords, finalUserCoords)); + setTargetAuthId(nextTargetAuthId); + if (nextHelperData) setHelperData(nextHelperData); + if (nextUserData) setUserData(nextUserData); + setLoading(false); } }; - startWatching(); + + bootstrap(); + return () => { - if (subscription) subscription.remove(); + isMounted = false; }; - }, [mapReady]); + // `generateMapHtml` is memoized with empty deps and intentionally excluded to avoid reloading WebView. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + bootstrapRole, + bootstrapSignature, + emergencyIdParam, + fetchAuthById, + fetchEmergencyById, + fetchHelperProfileById, + fetchLocationById, + fetchUserProfileById, + helperIdParam, + userIdParam, + ]); const onWebViewMessage = useCallback((event: { nativeEvent: { data: string } }) => { try { const data = JSON.parse(event.nativeEvent.data); - console.log("WebView message received:", data); - if (data.type === "mapReady") setMapReady(true); - else if (data.type === "routeInfo") { + if (data.type === "mapReady") { + mapReadyRef.current = true; + setIsMapReady(true); + } else if (data.type === "routeInfo") { setDistance(data.distance); setEta(data.duration); if (data.source) setRouteSource(data.source); } else if (data.type === "routeLoading") { setRouteLoading(data.loading); } - } catch (e) {} + } catch {} }, []); - const getUserById = useCallback(async () => { - if (!userId) return null; + useEffect(() => { + if (!isMapReady) return; - try { - const res = await api.get(`/api/auth/v1/getUserById/${userId}`); - return res; - } catch (err: unknown) { - console.error("Failed to fetch user data:", getErrorMessage(err, "Failed to fetch user")); - return null; + postOwnLocationToMap(myLocationRef.current, myRole); + + const targetCoords = myRole === "helper" ? userLocation : helperLocation; + postTargetLocationToMap(targetCoords, myRole); + }, [ + helperLocation, + isMapReady, + myRole, + postOwnLocationToMap, + postTargetLocationToMap, + userLocation, + ]); + + useEffect(() => { + if (!resolvedSelfAuthId) return; + + if (!socketService.isConnected()) { + socketService.connect(); } - }, [userId]); + + socketService.registerUser(resolvedSelfAuthId, myRole); + const unsubscribeState = socketService.subscribeToState((state) => { + const isConnected = state.connectionState === "connected"; + const ready = isConnected && state.isRegistered && state.userId === resolvedSelfAuthId; + setSocketReady(ready); + }); + + return () => { + unsubscribeState(); + setSocketReady(false); + }; + }, [myRole, resolvedSelfAuthId]); useEffect(() => { - const fetchUserData = async () => { - if (userId) { - const res = await getUserById(); - if (res?.data?.user) { - const user = res.data.user; - console.log("User data fetched:", user); - setUserData({ - name: user.fullName || user.name || "User", - avatar: user.profileImage || "https://randomuser.me/api/portraits/men/32.jpg", - }); + lastTargetFixRef.current = null; + recentTargetFixesRef.current = []; + pendingTargetJumpRef.current = null; + }, [targetAuthId]); + + useEffect(() => { + if (!socketReady || !emergencyIdParam) return; + + socketService.joinEmergencyRoom( + emergencyIdParam, + myRole === "helper" ? "helper" : "participant" + ); + + return () => { + socketService.leaveEmergencyRoom(emergencyIdParam); + }; + }, [socketReady, emergencyIdParam, myRole]); + + useEffect(() => { + if (!socketReady || !targetAuthId) return; + + socketService.subscribeToUser(targetAuthId); + + const applyIncomingLocation = (payload: any) => { + const incomingUserId = pickFirstString(payload?.userId); + if (!incomingUserId || incomingUserId !== targetAuthId) return; + + const locationPayload = payload?.location || payload; + const coords = toCoords(locationPayload); + if (!coords) return; + + const accuracy = + typeof locationPayload?.accuracy === "number" ? locationPayload.accuracy : undefined; + const timestampMs = toTimestampMs(locationPayload?.timestamp || payload?.timestamp); + applyTargetCoords(coords, accuracy, timestampMs, myRole); + }; + + const unsubscribeDirect = socketService.onUserLocationUpdate((data) => { + applyIncomingLocation(data); + }); + + const unsubscribeEmergency = socketService.onEmergencyLocationUpdate((data) => { + applyIncomingLocation(data); + }); + + return () => { + socketService.unsubscribeFromUser(targetAuthId); + unsubscribeDirect(); + unsubscribeEmergency(); + }; + }, [applyTargetCoords, myRole, socketReady, targetAuthId]); + + useEffect(() => { + let subscription: Location.LocationSubscription | null = null; + let isMounted = true; + + const startWatching = async () => { + if (Platform.OS === "android") { + await Location.enableNetworkProviderAsync().catch(() => {}); + } + + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== "granted" || !isMounted) return; + + subscription = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.BestForNavigation, + timeInterval: 1000, + distanceInterval: 0.8, + mayShowUserSettingsDialog: true, + }, + (loc) => { + const rawCoords = { + latitude: loc.coords.latitude, + longitude: loc.coords.longitude, + }; + const accuracy = loc.coords.accuracy || undefined; + const timestampMs = toTimestampMs(loc.timestamp); + locationMetaRef.current = { + accuracy, + altitude: loc.coords.altitude || undefined, + speed: loc.coords.speed || undefined, + heading: loc.coords.heading || undefined, + }; + + applyOwnCoords(rawCoords, accuracy, timestampMs, myRole); } + ); + }; + + startWatching().catch((error) => { + console.error("Failed to start location watcher:", error); + }); + + return () => { + isMounted = false; + if (subscription) subscription.remove(); + }; + }, [applyOwnCoords, myRole]); + + const myLatitude = myRole === "helper" ? helperLocation.latitude : userLocation.latitude; + const myLongitude = myRole === "helper" ? helperLocation.longitude : userLocation.longitude; + + useEffect(() => { + if (!socketReady) return; + + const timeout = setTimeout(() => { + try { + const meta = locationMetaRef.current; + socketService.updateLocation({ + latitude: myLatitude, + longitude: myLongitude, + accuracy: meta.accuracy, + altitude: meta.altitude, + speed: meta.speed, + heading: meta.heading, + timestamp: new Date().toISOString(), + }); + } catch (err) { + console.error("Socket location update failed:", err); + } + }, 300); + + return () => clearTimeout(timeout); + }, [myLatitude, myLongitude, socketReady]); + + // Keep publishing at a fixed 5-second cadence for both user/helper roles. + useEffect(() => { + if (!socketReady) return; + + const emitCurrentLocation = () => { + try { + const current = myLocationRef.current; + const meta = locationMetaRef.current; + + socketService.updateLocation({ + latitude: current.latitude, + longitude: current.longitude, + accuracy: meta.accuracy, + altitude: meta.altitude, + speed: meta.speed, + heading: meta.heading, + role: myRole, + timestamp: new Date().toISOString(), + }); + } catch (err) { + console.error("Periodic socket location update failed:", err); } }; - fetchUserData(); - if (myRole === "helper" && userId) { - // helperId - } - }, [userId, getUserById, myRole]); + emitCurrentLocation(); + const intervalId = setInterval(emitCurrentLocation, 5000); + + return () => clearInterval(intervalId); + }, [myRole, socketReady]); // Open external maps for turn-by-turn navigation const openExternalNavigation = useCallback(() => { const destination = myRole === "helper" ? userLocation : helperLocation; const origin = myRole === "helper" ? helperLocation : userLocation; - - const scheme = Platform.select({ ios: "maps:", android: "geo:" }); const latLng = `${destination.latitude},${destination.longitude}`; - const label = myRole === "helper" ? "Emergency Location" : "Helper Location"; // Try Google Maps first (most common) const googleMapsUrl = `https://www.google.com/maps/dir/?api=1&origin=${origin.latitude},${origin.longitude}&destination=${destination.latitude},${destination.longitude}&travelmode=${rideType === "car" ? "driving" : rideType === "bike" ? "bicycling" : "walking"}`; @@ -255,7 +1100,7 @@ export default function MapScreen() { }, [myRole, userLocation, helperLocation, rideType]); const handlePrimaryAction = useCallback(async () => { - if (!emergencyId) { + if (!emergencyIdParam) { Alert.alert("Missing Emergency", "Emergency ID is required for this action."); return; } @@ -263,11 +1108,11 @@ export default function MapScreen() { setIsPrimaryLoading(true); try { if (myRole === "helper") { - await api.put(API_ENDPOINTS.EMERGENCY.ARRIVED(emergencyId)); - socketService.sendHelperArrived(emergencyId); + await apiClient.put(API_ENDPOINTS.EMERGENCY.ARRIVED(emergencyIdParam)); + socketService.sendHelperArrived(emergencyIdParam); Alert.alert("Success", "Marked as arrived."); } else { - await api.put(API_ENDPOINTS.EMERGENCY.CANCEL(emergencyId), { + await apiClient.put(API_ENDPOINTS.EMERGENCY.CANCEL(emergencyIdParam), { status: "cancelled", cancellationReason: "Cancelled from map view", }); @@ -275,24 +1120,19 @@ export default function MapScreen() { router.back(); } } catch (error: unknown) { - Alert.alert( - "Action Failed", - getErrorMessage(error, "Failed to process emergency action") - ); + Alert.alert("Action Failed", getErrorMessage(error, "Failed to process emergency action")); } finally { setIsPrimaryLoading(false); } - }, [emergencyId, myRole, router]); - - const generateMapHtml = useCallback(() => { - const centerLat = (helperLocation.latitude + userLocation.latitude) / 2; - const centerLng = (helperLocation.longitude + userLocation.longitude) / 2; + }, [emergencyIdParam, myRole, router]); - // OSRM profile mapping - const osrmProfile = - rideType === "car" ? "driving" : rideType === "bike" ? "cycling" : "walking"; + const generateMapHtml = useCallback( + (initialHelperLocation: Coords, initialUserLocation: Coords) => { + const centerLat = (initialHelperLocation.latitude + initialUserLocation.latitude) / 2; + const centerLng = (initialHelperLocation.longitude + initialUserLocation.longitude) / 2; + const osrmProfile = "driving"; - return ` + return ` @@ -350,8 +1190,8 @@ export default function MapScreen() {