From 49ee73330c54934999d76fc923d66fae81c03c9c Mon Sep 17 00:00:00 2001 From: Vishal Mahato Date: Sun, 1 Mar 2026 06:42:39 +0530 Subject: [PATCH 01/12] Implement SOS upgrade backend and roadmap docs --- LifeLine-Backend/docs/api-mismatch-report.md | 47 ++++-- .../api/Emergency/Emergency.controller.mjs | 51 ++++++ .../src/api/Emergency/Emergency.model.mjs | 22 +++ .../src/api/Emergency/Emergency.routes.mjs | 3 + .../src/api/Emergency/Emergency.service.mjs | 102 +++++++++++ .../src/api/Helper/Helper.controller.mjs | 18 ++ .../src/api/Helper/Helper.routes.mjs | 1 + .../v1/Notification.controller.mjs | 158 ++++++++++++++++++ .../Notifications/v1/Notification.routes.mjs | 27 +++ LifeLine-Backend/src/server.mjs | 2 + Lifeline-Frontend/src/config/api.ts | 45 +++-- Lifeline-Frontend/tsconfig.json | 2 + docs/future-implementation-summary.md | 80 +++++++++ docs/sos-upgrade-hld-lld.md | 158 ++++++++++++++++++ 14 files changed, 687 insertions(+), 29 deletions(-) create mode 100644 LifeLine-Backend/src/api/Notifications/v1/Notification.controller.mjs create mode 100644 LifeLine-Backend/src/api/Notifications/v1/Notification.routes.mjs create mode 100644 docs/future-implementation-summary.md create mode 100644 docs/sos-upgrade-hld-lld.md 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/api/Emergency/Emergency.controller.mjs b/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs index 72a197f..c4b798c 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs @@ -306,6 +306,57 @@ 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, + }); + } + } + /** * 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..d3ac292 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.model.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.model.mjs @@ -193,6 +193,28 @@ const emergencySchema = new mongoose.Schema( }, }, + payment: { + status: { + type: String, + enum: ['none', 'approved', 'verified'], + default: 'none', + }, + 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: [ diff --git a/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs b/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs index 1b6179a..67ac2eb 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs @@ -91,6 +91,9 @@ router.post( EmergencyController.sendHelperRequest, ); +router.post('/:id/approve', EmergencyController.approveEmergencyPayment); +router.post('/:id/verify-otp', EmergencyController.verifyEmergencyOtp); + // ==================== 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..6f495b9 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.service.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.service.mjs @@ -19,6 +19,7 @@ import { notifyGuardiansOfSOS, } from '../../socket/handlers/notification.handler.mjs'; import MedicalService from '../Medical/Medical.service.mjs'; +import AuthService from '../Auth/v1/Auth.service.mjs'; /** * Emergency Service for LifeLine Emergency Response System @@ -828,6 +829,107 @@ 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'); + } + + 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, + }; + + 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'); + } + + 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, + }; + + 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}`); + } + } + /** * 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.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/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/server.mjs b/LifeLine-Backend/src/server.mjs index db86123..02369ff 100644 --- a/LifeLine-Backend/src/server.mjs +++ b/LifeLine-Backend/src/server.mjs @@ -54,6 +54,7 @@ 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'; // Use API routes with versioning app.use('/api/auth/v1', authRoutes); @@ -64,6 +65,7 @@ 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); // 404 handler app.use((req, res) => { diff --git a/Lifeline-Frontend/src/config/api.ts b/Lifeline-Frontend/src/config/api.ts index 7892c93..1f08543 100644 --- a/Lifeline-Frontend/src/config/api.ts +++ b/Lifeline-Frontend/src/config/api.ts @@ -11,11 +11,13 @@ import { getErrorMessage } from "../shared/utils/error.utils"; // Lazy store getter to avoid circular dependency: // store.ts → emergencySlice → config/api.ts → store.ts -let _store: any = null; -const getStore = () => { - if (!_store) { - _store = require("../core/store").store; +type AppStore = typeof import("../core/store")["store"]; +let _store: AppStore | null = null; +const getStore = (): AppStore | null => { + if (_store) { + return _store; } + _store = require("../core/store").store as AppStore; return _store; }; @@ -86,10 +88,12 @@ export const API_ENDPOINTS = { AUTH: { LOGIN: "/api/auth/v1/login", REGISTER: "/api/auth/v1/create/user/auth", - CHECK_EMAIL: "/api/auth/v1/check-email", - VERIFY_EMAIL: "/api/auth/v1/verify-email", + CHECK_EMAIL: (email: string) => `/api/auth/v1/check-email/${email}`, + VERIFY_EMAIL: (token: string) => `/api/auth/v1/verify-email/${token}`, FORGOT_PASSWORD: "/api/auth/v1/forgot-password", RESET_PASSWORD: "/api/auth/v1/reset-password", + PROFILE: "/api/auth/v1/profile", + UPDATE_PROFILE: "/api/auth/v1/profile", }, // Emergency @@ -103,6 +107,12 @@ export const API_ENDPOINTS = { ARRIVED: (id: string) => `/api/emergency/${id}/arrived`, RESOLVE: (id: string) => `/api/emergency/${id}/resolve`, CANCEL: (id: string) => `/api/emergency/${id}`, + // New Payment Features + PAYMENT: { + APPROVE: (id: string) => `/api/emergency/${id}/approve`, + VERIFY_OTP: (id: string) => `/api/emergency/${id}/verify-otp`, + }, + ASSIGNED: "/api/emergency/assigned/me", }, // AI Triage @@ -124,24 +134,27 @@ export const API_ENDPOINTS = { // Notifications NOTIFICATIONS: { - LIST: "/api/notifications", - MARK_READ: (id: string) => `/api/notifications/${id}/read`, - MARK_ALL_READ: "/api/notifications/read-all", - UNREAD_COUNT: "/api/notifications/unread-count", + BASE: "/api/notifications/v1", + LIST: "/api/notifications/v1", + MARK_READ: (id: string) => `/api/notifications/v1/${id}/read`, + MARK_ALL_READ: "/api/notifications/v1/read-all", + UNREAD_COUNT: "/api/notifications/v1/unread-count", }, // User USER: { - PROFILE: "/api/user/profile", - UPDATE: "/api/user/update", - MEDICAL_INFO: "/api/user/medical-info", + // Deprecated: Use AUTH.PROFILE instead + PROFILE: "/api/auth/v1/profile", + UPDATE: "/api/auth/v1/profile", + MEDICAL_INFO: "/api/medical/v1/profile/me", }, // Helper HELPER: { - PROFILE: "/api/helper/profile", - AVAILABILITY: "/api/helper/availability", - NEARBY: "/api/helper/nearby", + PROFILE: "/api/helpers/v1/profile/me", + AVAILABILITY: (id: string) => `/api/helpers/v1/${id}/availability`, + NEARBY: "/api/locations/v1/nearby/helpers", + STATS: (id: string) => `/api/helpers/v1/stats/${id}`, }, // NGO diff --git a/Lifeline-Frontend/tsconfig.json b/Lifeline-Frontend/tsconfig.json index dff398d..fb11c82 100644 --- a/Lifeline-Frontend/tsconfig.json +++ b/Lifeline-Frontend/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "strict": true, "baseUrl": ".", + "moduleResolution": "bundler", + "lib": ["ES2015", "DOM"], "paths": { "@/*": ["./*"] } diff --git a/docs/future-implementation-summary.md b/docs/future-implementation-summary.md new file mode 100644 index 0000000..92393ac --- /dev/null +++ b/docs/future-implementation-summary.md @@ -0,0 +1,80 @@ +# Future Implementation Summary + +This document consolidates the future work items across AI, Socket, SOS upgrade, and product roadmap. It is meant to guide sequencing and ownership without duplicating the full detailed plans in the backend docs. + +## 1) SOS Upgrade (Payments, OTP, Profiles) +- Add missing schema fields for helper and emergency (payment status, service type, accepted helpers). +- Introduce a dedicated Payment collection for paid/free flow. +- Implement SOS profile API for helpers to view user medical snapshot and history. +- Complete paid/free acceptance, approval, OTP verification, and release flow. +- Expand helper stats API to include earnings, paid vs free, and performance metrics. + +Primary references: +- LifeLine-Backend/docs/future-implementation.md +- LifeLine-Backend/docs/21-SOS-Upgrade-Status-and-Plan.md + +## 2) AI Triage → SOS Integration +- Auto-create SOS when AI decision is create_emergency. +- Standardize fast-track for critical symptoms with priority=critical. +- Enforce JSON schema validation with fallback to need_more_info. +- Move session storage to Redis for production reliability. + +Primary references: +- LifeLine-Backend/src/Ai/triage/triageService.mjs +- LifeLine-Backend/src/Ai/LifeLine_AI_SOS_Implementation_Plan_UPDATED.md + +## 3) Triage Group Alerts + Multi-Channel Dispatch +- AI should output recipient groups (guardians, nearby helpers, NGOs, hospitals). +- Map groups to actual recipients in DB before dispatch. +- Socket rooming for user, emergency, and optional group rooms. +- Use push/SMS/email for offline users with persisted notifications. + +Primary references: +- LifeLine-Backend/docs/11-Socket-Protocol.md +- LifeLine-Backend/docs/future-features-design.md + +## 4) Context-Aware Helper Assignment +- Map emergency context to helper skill categories. +- Extend helper matching to filter by skills and language. +- Prioritize specialized helpers with fallback to general helpers. + +Primary references: +- LifeLine-Backend/docs/future-implementation.md + +## 5) Socket Expansion + Reliability +- Complete missing socket events across user/helper/NGO flows. +- Add delivery acknowledgements, reconnect replay, and dedupe. +- Add rate limits, backpressure, and payload caps. +- Introduce Redis adapter for scaling. + +Primary references: +- LifeLine-Backend/docs/11-Socket-Protocol.md +- technical_implementation_plan.md + +## 6) Frontend Completion & Integration +- Complete missing screens: NGO dashboards, helper history, medical CRUD. +- Wire all backend NGO, payment, and notification flows. +- Add socket middleware handling for real-time updates. +- Add profile completion checklists and error/loading states. + +Primary references: +- technical_implementation_plan.md +- implementation_plan.md + +## 7) Testing, Monitoring, and Operations +- End-to-end flows for SOS, helper acceptance, and triage. +- AI cost monitoring and safety dashboards. +- Delivery metrics for notifications and socket events. +- Runbooks and incident response playbooks. + +Primary references: +- LifeLine-Backend/docs/07-Testing-Strategy.md +- LifeLine-Backend/docs/08-Deployment-and-Ops.md + +## Suggested Sequencing +1. SOS upgrade payment + OTP end-to-end. +2. AI triage auto-create + critical fast-track. +3. Triage group alerts + multi-channel dispatch. +4. Socket reliability + Redis scaling. +5. Frontend integrations for NGO, helper, and medical CRUD. +6. Monitoring and production hardening. diff --git a/docs/sos-upgrade-hld-lld.md b/docs/sos-upgrade-hld-lld.md new file mode 100644 index 0000000..305acd0 --- /dev/null +++ b/docs/sos-upgrade-hld-lld.md @@ -0,0 +1,158 @@ +# SOS Upgrade HLD & LLD + +This document defines the high-level design (HLD) and low-level design (LLD) for the SOS upgrade roadmap, including payment approval, OTP verification, helper stats, and SOS profile access. + +## HLD + +### Goals +- Support paid/free SOS flow with approval and OTP verification. +- Provide helper-facing SOS profile view with medical snapshot. +- Track helper earnings and performance metrics for stats. +- Keep compatibility with existing emergency flow and triage triggers. + +### Scope +- Emergency payment approval and OTP verification endpoints. +- Payment persistence and release logic. +- SOS profile data aggregation. +- Helper stats API enhancement. +- Notification and socket event integration for SOS flow. + +### Non-Goals +- Replacing existing emergency create/resolve APIs. +- Changing triage decision logic beyond triggering SOS. +- Full analytics dashboard implementation. + +### System Components +- **Frontend**: SOS UI, helper acceptance screens, OTP entry UI, stats screens. +- **Backend API**: Emergency, Helper, Payment, Notification, Auth modules. +- **Database**: Emergency, Helper, Payment, User/Medical collections. +- **Realtime**: Socket events for SOS lifecycle and payment updates. +- **Notification**: Push/SMS/email for approval/OTP and status updates. + +### Data Flow Overview +1. User triggers SOS (manual or AI decision). +2. Emergency created; helpers assigned. +3. Helper accepts with service type and amount (paid/free). +4. User approves payment → OTP sent to user phone. +5. User verifies OTP → payment marked verified → helper earnings updated. +6. Helper can view SOS profile for assigned emergencies. +7. Helper stats endpoint returns earnings and performance. + +### Security & Access +- Auth middleware on all endpoints. +- SOS profile visible only to assigned helpers. +- OTP verification tied to emergency owner. +- Payment operations restricted to emergency owner and assigned helper. + +### Dependencies +- Fast2SMS for OTP. +- Socket.IO for realtime. +- MongoDB for persistence. + +### Risks & Mitigations +- **OTP delivery failures**: fallback to resend; expose otpSent flag. +- **Payment mismatches**: enforce server-side validation of amounts. +- **Unauthorized access**: strict checks on emergency ownership and assignment. + +--- + +## LLD + +### Data Models + +#### Emergency (additional fields) +- payment.status: enum [none, approved, verified] +- payment.otp +- payment.otpExpiresAt +- payment.approvedAt +- payment.approvedBy +- payment.verifiedAt +- payment.verifiedBy +- requiredHelpers +- serviceType +- acceptedHelpers[] + +#### Payment (new collection) +- emergencyId +- helperId +- userId +- amount +- status: enum [pending, approved, verified, released, failed] +- method: enum [cash, upi, card] +- createdAt, updatedAt + +#### Helper (extensions) +- totalEarnings +- pendingAmount +- casesSolved +- activeCase + +### APIs + +#### Emergency +- POST /api/emergency/:id/approve + - Body: {} + - Behavior: generate OTP, store on emergency.payment, send SMS + - Response: { otpSent, emergencyId } + +- POST /api/emergency/:id/verify-otp + - Body: { otp } + - Behavior: validate OTP and expiry; set payment.status=verified + - Response: { paymentStatus } + +- GET /api/emergency/:id/profile + - Behavior: assigned helper only; returns user + medical summary + - Response: { userProfile, medicalSnapshot, emergencySummary } + +#### Helper +- GET /api/helpers/stats/:helperId + - Behavior: return earnings + performance + - Response: { earnings, performance, recentActivity } + +#### Payment +- POST /api/payments + - Body: { emergencyId, helperId, userId, amount, method } + - Behavior: create payment record on paid accept + +- PATCH /api/payments/:id/release + - Behavior: mark released after OTP verification + +### Service Logic + +#### EmergencyService.approveEmergencyPayment +- Validate emergency exists and user owns it. +- Generate OTP and expiry. +- Persist on emergency.payment. +- Send OTP to user phone. + +#### EmergencyService.verifyEmergencyOtp +- Validate OTP and expiry. +- Update emergency.payment.status to verified. +- Trigger payment release workflow. + +#### HelperService.getDashboardMetrics +- Extend to compute totalEarnings, pendingAmount. +- Derive from Payment collection (paid) + resolved emergencies (free). + +#### SOS Profile +- Fetch user, medical, and emergency records. +- Ensure helper is assigned in emergency.assignedHelpers. + +### Socket Events +- emergency:payment_approved +- emergency:payment_verified +- emergency:profile_ready +- helper:stats_updated + +### Error Handling +- 400 for validation errors. +- 401 for missing auth. +- 403 for unauthorized access. +- 404 for missing resources. + +### Testing Checklist +- SOS paid accept → approve → OTP verify → release. +- SOS free accept flow (no OTP). +- Helper stats returns accurate totals. +- SOS profile accessible only by assigned helpers. +- Socket events emitted and received. From 358bb4900563b9bcb0b7e1dab2b6faf68cd6b91d Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Sun, 1 Mar 2026 07:06:26 +0530 Subject: [PATCH 02/12] feat(SOS): Implement centralized SOS activation flow and power button integration - Updated Dashboard component to use orchestrator for SOS activation and deactivation. - Introduced SOSRuntimeBootstrapper for handling app state recovery and deep-link triggers. - Added new orchestrator functions for managing SOS pending intents and cooldowns. - Integrated power button detection for triggering SOS via native module on Android. - Enhanced background location tracking with updated distance interval settings. - Created new files for SOS orchestrator and power button handling. - Added comprehensive SOS emergency test checklist for validation. --- .../src/api/Location/Location.controller.mjs | 96 ++- .../src/api/NGO/NGO.controller.mjs | 105 ++- .../SOS_EMERGENCY_TEST_CHECKLIST.md | 164 +++++ Lifeline-Frontend/app.json | 3 +- Lifeline-Frontend/app/(global)/Map.tsx | 603 +++++++++++++----- Lifeline-Frontend/app/Helper/Dashboard.tsx | 74 ++- Lifeline-Frontend/app/User/Dashboard.tsx | 32 +- Lifeline-Frontend/app/_layout.tsx | 2 + .../plugins/withPowerButtonSOS.js | 516 +++++++++++++++ .../Dashbard/v1/Components/StatusToggle.tsx | 10 +- .../features/SOS/SOSRuntimeBootstrapper.tsx | 213 +++++++ .../src/features/SOS/sos.orchestrator.ts | 140 ++++ .../src/shared/hooks/useBackgroundLocation.ts | 2 +- .../src/shared/services/socket.service.ts | 6 +- .../src/shared/tasks/location.task.ts | 28 +- 15 files changed, 1770 insertions(+), 224 deletions(-) create mode 100644 Lifeline-Frontend/SOS_EMERGENCY_TEST_CHECKLIST.md create mode 100644 Lifeline-Frontend/plugins/withPowerButtonSOS.js create mode 100644 Lifeline-Frontend/src/features/SOS/SOSRuntimeBootstrapper.tsx create mode 100644 Lifeline-Frontend/src/features/SOS/sos.orchestrator.ts 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-Frontend/SOS_EMERGENCY_TEST_CHECKLIST.md b/Lifeline-Frontend/SOS_EMERGENCY_TEST_CHECKLIST.md new file mode 100644 index 0000000..8f522c2 --- /dev/null +++ b/Lifeline-Frontend/SOS_EMERGENCY_TEST_CHECKLIST.md @@ -0,0 +1,164 @@ +# SOS Emergency Flow: Change Log and Test Checklist + +This document covers the latest SOS reliability implementation and how to validate it safely on Android and iOS. + +## 1) Files Changed + +1. `app/User/Dashboard.tsx` + - Integrated centralized SOS activation flow. + - Replaced direct `activeSos()` dispatch on hold-complete with orchestrator trigger. + - On deactivation, clears pending SOS intent. + - Kept current SOS hold timing at `3` seconds. + +2. `app/_layout.tsx` + - Mounted global SOS runtime bootstrapper so SOS recovery/trigger listeners run app-wide. + +3. `src/features/SOS/sos.orchestrator.ts` (new) + - Added centralized SOS flow: + - `triggerSOSActivation(...)` + - `persistSOSPendingIntent(...)` + - `getSOSPendingIntent(...)` + - `clearSOSPendingIntent(...)` + - dedupe cooldown and SOS screen navigation + - Standardized trigger source values. + +4. `src/features/SOS/SOSRuntimeBootstrapper.tsx` (new) + - App-state recovery for pending SOS intent on resume/launch. + - Deep-link trigger support for `.../sos/trigger`. + - Android native event listener hook for future power-button native module. + - Added `power button pressed 5 times` trigger path support. + +5. `plugins/withPowerButtonSOS.js` (new) + - Expo config plugin that generates Android native module/service code during prebuild. + - Registers foreground service + boot receiver. + - Wires native module into `MainApplication`. + +## 2) What Was Verified (Code-Level) + +1. Type safety: + - `pnpm exec tsc --noEmit` passed. + +2. Lint on new SOS modules: + - `pnpm exec eslint src/features/SOS/sos.orchestrator.ts src/features/SOS/SOSRuntimeBootstrapper.tsx app/_layout.tsx` passed. + +3. Repo-wide lint: + - No errors in this change set (existing warnings remain in unrelated files). + +## 3) Pre-Test Setup + +1. Build and run app on device/emulator: + - `pnpm start` (or your normal run command) + - For real power-button detection on Android you must use native build: + - `npx expo prebuild --platform android` + - `npx expo run:android` (or EAS Android build) + +2. Ensure user is authenticated and in User Dashboard. + +3. Allow location permissions when prompted. + +4. For deep-link testing, confirm scheme in `app.json` is `lifeline`. + +## 4) Functional Test Cases + +### A. Dashboard Hold Trigger (Primary) + +1. Open Dashboard. +2. Press and hold SOS button for full 3 seconds. +3. Expected: + - SOS state activates. + - Navigates to `/(global)/SOSActiveAIScreen`. + - No crash or red screen. + +### B. Cancel Before Full Hold + +1. Press SOS and release before 3 seconds. +2. Expected: + - SOS does not activate. + - Ring resets. + - Stays on Dashboard. + +### C. Deactivate Flow + +1. Activate SOS from Dashboard. +2. Hold again for 3 seconds to deactivate. +3. Expected: + - SOS state deactivates. + - Pending SOS intent is cleared. + +### D. Background Recovery + +1. Trigger SOS and immediately send app to background. +2. Bring app back to foreground. +3. Expected: + - If pending intent exists and is fresh, app opens SOS screen. + - No duplicate/crash behavior. + +### E. Killed App Recovery (Launch Path) + +1. Trigger SOS path that persists pending intent (dashboard/deeplink/native event). +2. Kill app process. +3. Re-open app. +4. Expected: + - Runtime bootstrapper checks pending intent. + - Opens SOS screen when authenticated and intent is fresh. + +### F. Deep Link Trigger (Cross-Platform Fallback) + +Use one of these example links: + +- `lifeline://sos/trigger` +- `lifeline://sos/trigger?source=notification_action` + +Expected: +1. SOS activation flow runs. +2. SOS screen opens (if not already on it). +3. Pending intent is handled and cleared safely. + +### G. Android Native Module Event Hook (If Native Module Exists) + +The JS listener supports these events: + +1. `PowerButtonHoldSOS` +2. `powerButtonHoldSOS` +3. `power_button_hold_sos` +4. `PowerButtonFivePressSOS` +5. `powerButtonFivePressSOS` +6. `power_button_5_press_sos` +7. `PowerButtonPressed` (per-press signal) +8. `powerButtonPressed` (per-press signal) +9. `power_button_pressed` (per-press signal) + +Expected: +1. Any of the above events trigger centralized SOS flow. +2. SOS screen opens without breaking navigation. +3. For per-press events, 5 presses inside ~7 seconds triggers SOS screen open. + +## 5) Regression Checklist + +1. User Dashboard loads normally. +2. GPS/socket status still updates. +3. Existing SOS button UI/animation remains functional. +4. No auth redirect regressions. +5. App launches without runtime errors. + +## 6) Important Platform Notes + +1. iOS: + - Third-party apps cannot directly intercept physical power-button holds like Android native/system apps. + - Use deep-link/notification/shortcut fallback path for reliable iOS behavior. + +2. Android: + - Native power-button detection requires native implementation and may vary by OEM/OS policy. + - JS side is ready for both hold event and 5-press event contracts listed above. + - Expo Go cannot test physical power-button interception; use custom dev build/apk. + +## 7) Suggested Test Report Format + +For each test case, record: + +1. Device + OS version +2. App build/version +3. Test case ID (A/B/C/...) +4. Result: Pass/Fail +5. Logs/screenshots +6. Repro steps for failures diff --git a/Lifeline-Frontend/app.json b/Lifeline-Frontend/app.json index 56be878..7524092 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", diff --git a/Lifeline-Frontend/app/(global)/Map.tsx b/Lifeline-Frontend/app/(global)/Map.tsx index 22009bb..8ff5bd0 100644 --- a/Lifeline-Frontend/app/(global)/Map.tsx +++ b/Lifeline-Frontend/app/(global)/Map.tsx @@ -17,9 +17,12 @@ import { Ionicons } from "@expo/vector-icons"; import { socketService } from "@/src/shared/services"; import api, { 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,524 @@ interface Coords { longitude: 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 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 }; +}; + export default function MapScreen() { const router = useRouter(); const webViewRef = useRef(null); + const mapReadyRef = useRef(false); + const myLocationRef = useRef(INITIAL_USER_LOCATION); + 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 [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 fetchAuthById = useCallback(async (id: string) => { + try { + const res = await api.get(`/api/auth/v1/getUserById/${id}`); + return res?.data?.user || null; + } catch { + return null; + } + }, []); + + const fetchUserProfileById = useCallback(async (id: string) => { + try { + const res = await api.get(`/api/users/v1/${id}`); + return res?.data?.data || null; + } catch { + return null; + } + }, []); + + const fetchHelperProfileById = useCallback(async (id: string) => { + try { + const res = await api.get(`/api/helpers/v1/${id}`); + return res?.data?.data || null; + } catch { + return null; + } + }, []); + + const fetchEmergencyById = useCallback(async (id: string) => { + try { + const res = await api.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 api.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, + }) + ); + }, []); + useEffect(() => { let isMounted = true; - const init = async () => { + + const initializeMyLocation = async (role: UserRole) => { try { const { status } = await Location.requestForegroundPermissionsAsync(); - if (status === "granted") { - const loc = await Location.getCurrentPositionAsync({ - accuracy: Location.Accuracy.BestForNavigation, - }); - if (isMounted) { - setUserLocation({ - latitude: loc.coords.latitude, - longitude: loc.coords.longitude, - }); - userLocationRef.current = { - latitude: loc.coords.latitude, - longitude: loc.coords.longitude, - }; - } - } - // 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 (status !== "granted") return; + + const loc = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.BestForNavigation, + }); + const coords = { + latitude: loc.coords.latitude, + longitude: loc.coords.longitude, + }; + + myLocationRef.current = coords; + locationMetaRef.current = { + accuracy: loc.coords.accuracy || undefined, + altitude: loc.coords.altitude || undefined, + speed: loc.coords.speed || undefined, + heading: loc.coords.heading || undefined, + }; + + if (!isMounted) return; + if (role === "helper") { + setHelperLocation(coords); + setUserLocation(coords); + } else { + setUserLocation(coords); + setHelperLocation(coords); } } catch (error) { - console.error("Init error:", error); - } finally { - if (isMounted) setLoading(false); + console.error("Failed to initialize current location:", error); } }; - 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 = normalizeRole(roleParamValue) || authRole || "user"; + const selfAuthId = authIdFromStore || null; + let nextTargetAuthId: string | null = null; + let nextHelperData: HelperData | null = null; + let nextUserData: { name: string; avatar: string } | null = null; + + setMyRole(effectiveRole); + setResolvedSelfAuthId(selfAuthId); + setSocketReady(false); + setTargetAuthId(null); + setHelperData(null); + setUserData(null); + setFocusTarget(effectiveRole === "helper" ? "user" : "helper"); + 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]); + await initializeMyLocation(effectiveRole); - 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) { + 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) { + setUserLocation(userCoords); + } + } else { + const emergencyCoords = coordsFromArray(emergencyData?.location?.coordinates); + if (emergencyCoords && isMounted) { + setUserLocation(emergencyCoords); + } + } + } + } + } catch (error: unknown) { + console.error( + "Map bootstrap error:", + getErrorMessage(error, "Failed to initialize map screen") ); + } finally { + if (!isMounted) return; + setTargetAuthId(nextTargetAuthId); + if (nextHelperData) setHelperData(nextHelperData); + if (nextUserData) setUserData(nextUserData); + setLoading(false); } }; - startWatching(); + + bootstrap(); + return () => { - if (subscription) subscription.remove(); + isMounted = false; }; - }, [mapReady]); + }, [ + authIdFromStore, + authRole, + emergencyIdParam, + fetchAuthById, + fetchEmergencyById, + fetchHelperProfileById, + fetchLocationById, + fetchUserProfileById, + helperIdParam, + roleParamValue, + 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; + } 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 (!resolvedSelfAuthId) 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; + 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", - }); - } + 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 coords = toCoords(payload?.location || payload); + if (!coords) return; + + if (myRole === "helper") { + setUserLocation((prev) => + prev.latitude === coords.latitude && prev.longitude === coords.longitude ? prev : coords + ); + } else { + setHelperLocation((prev) => + prev.latitude === coords.latitude && prev.longitude === coords.longitude ? prev : coords + ); } + + postTargetLocationToMap(coords, myRole); }; - fetchUserData(); - if (myRole === "helper" && userId) { - // helperId - } - }, [userId, getUserById, myRole]); + const unsubscribeDirect = socketService.onUserLocationUpdate((data) => { + applyIncomingLocation(data); + }); + + const unsubscribeEmergency = socketService.onEmergencyLocationUpdate((data) => { + applyIncomingLocation(data); + }); + + return () => { + socketService.unsubscribeFromUser(targetAuthId); + unsubscribeDirect(); + unsubscribeEmergency(); + }; + }, [myRole, postTargetLocationToMap, socketReady, targetAuthId]); + + useEffect(() => { + let subscription: Location.LocationSubscription | null = null; + let isMounted = true; + + const startWatching = async () => { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== "granted" || !isMounted) return; + + subscription = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.BestForNavigation, + timeInterval: 5000, + distanceInterval: 5, + mayShowUserSettingsDialog: true, + }, + (loc) => { + const nextCoords = { + latitude: loc.coords.latitude, + longitude: loc.coords.longitude, + }; + const prev = myLocationRef.current; + if (prev.latitude === nextCoords.latitude && prev.longitude === nextCoords.longitude) { + return; + } + + myLocationRef.current = nextCoords; + locationMetaRef.current = { + accuracy: loc.coords.accuracy || undefined, + altitude: loc.coords.altitude || undefined, + speed: loc.coords.speed || undefined, + heading: loc.coords.heading || undefined, + }; + + if (myRole === "helper") { + setHelperLocation(nextCoords); + } else { + setUserLocation(nextCoords); + } + + postOwnLocationToMap(nextCoords, myRole); + } + ); + }; + + startWatching().catch((error) => { + console.error("Failed to start location watcher:", error); + }); + + return () => { + isMounted = false; + if (subscription) subscription.remove(); + }; + }, [myRole, postOwnLocationToMap]); + + 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]); // 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 +570,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 +578,11 @@ export default function MapScreen() { setIsPrimaryLoading(true); try { if (myRole === "helper") { - await api.put(API_ENDPOINTS.EMERGENCY.ARRIVED(emergencyId)); - socketService.sendHelperArrived(emergencyId); + await api.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 api.put(API_ENDPOINTS.EMERGENCY.CANCEL(emergencyIdParam), { status: "cancelled", cancellationReason: "Cancelled from map view", }); @@ -282,7 +597,7 @@ export default function MapScreen() { } finally { setIsPrimaryLoading(false); } - }, [emergencyId, myRole, router]); + }, [emergencyIdParam, myRole, router]); const generateMapHtml = useCallback(() => { const centerLat = (helperLocation.latitude + userLocation.latitude) / 2; diff --git a/Lifeline-Frontend/app/Helper/Dashboard.tsx b/Lifeline-Frontend/app/Helper/Dashboard.tsx index 5bda428..e07e7e9 100644 --- a/Lifeline-Frontend/app/Helper/Dashboard.tsx +++ b/Lifeline-Frontend/app/Helper/Dashboard.tsx @@ -21,6 +21,7 @@ import { socketService } from "@/src/shared/services"; import { Ionicons } from "@expo/vector-icons"; import { playEmergencySound } from "@/src/shared/utils/notification.utils"; import { useRouter } from "expo-router"; +import * as ExpoLocation from "expo-location"; interface IncomingSOS { emergencyId: string; @@ -38,10 +39,11 @@ export default function HelperDashboard() { const dispatch = useDispatch(); const metrics = useSelector((state: RootState) => state.helper.metrics); const loading = useSelector((state: RootState) => state.helper.loading); + const isAvailable = useSelector((state: RootState) => state.helper.isAvailable); const [incomingSOS, setIncomingSOS] = useState([]); const router = useRouter(); - const userId = user.userId || user.authId; + const userId = user.authId || user.userId; useEffect(() => { if (user.userData?.role !== "helper") { @@ -52,13 +54,8 @@ export default function HelperDashboard() { }, [userId]); useEffect(() => { - if (!socketService.isConnected()) { - socketService.connect(); - } - - if (userId) { - socketService.registerUser(userId); - } + if (!socketService.isConnected()) socketService.connect(); + if (userId) socketService.registerUser(userId, "helper"); // Listen for helper requests (notifications) const unsubscribeNotifications = socketService.onNotification((notif) => { @@ -116,6 +113,67 @@ export default function HelperDashboard() { }; }, [userId, dispatch]); + useEffect(() => { + if (!userId) return; + AsyncStorage.setItem("user_id", userId); + AsyncStorage.setItem("user_role", "helper"); + }, [userId]); + + useEffect(() => { + if (!userId || !isAvailable) return; + + let isActive = true; + let timerId: ReturnType | null = null; + + const reportLocation = async () => { + try { + if (!isActive) return; + if (!socketService.isConnected()) { + socketService.connect(); + return; + } + + socketService.registerUser(userId, "helper"); + + const loc = await ExpoLocation.getCurrentPositionAsync({ + accuracy: ExpoLocation.Accuracy.Balanced, + }); + + socketService.updateLocation({ + latitude: loc.coords.latitude, + longitude: loc.coords.longitude, + accuracy: loc.coords.accuracy || undefined, + altitude: loc.coords.altitude || undefined, + speed: loc.coords.speed || undefined, + heading: loc.coords.heading || undefined, + userId, + role: "helper", + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error("Helper foreground location report failed:", error); + } + }; + + const bootstrap = async () => { + const { status } = await ExpoLocation.requestForegroundPermissionsAsync(); + if (status !== "granted") { + console.warn("Helper foreground location permission denied."); + return; + } + + await reportLocation(); + timerId = setInterval(reportLocation, 5000); + }; + + bootstrap(); + + return () => { + isActive = false; + if (timerId) clearInterval(timerId); + }; + }, [isAvailable, userId]); + return ( { if (elapsedSeconds <= 0) return 0; @@ -32,7 +24,7 @@ const getHoldProgressPercent = (elapsedSeconds: number) => { }; const Dashboard = () => { - const dispatch = useDispatch(); + const dispatch = useDispatch(); const isSOSActive = useSelector((state: RootState) => state.sos.isSOSActive); const userData = useSelector((state: RootState) => state.auth.userData); const socketAuthId = useSelector((state: RootState) => state.auth.authId || state.auth.userId); @@ -336,8 +328,14 @@ const Dashboard = () => { animateRingProgress(100, 260); if (isSOSActive) { dispatch(deactiveSos()); + void clearSOSPendingIntent(); } else { - dispatch(activeSos()); + void triggerSOSActivation({ + dispatch, + isSOSActive, + source: "dashboard_hold", + openScreen: true, + }); } Vibration.vibrate(1000); } @@ -423,7 +421,9 @@ const Dashboard = () => { className="absolute h-72 w-72 rounded-full border-2 border-red-500/30" style={{ transform: [ - { scale: rippleAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 2] }) }, + { + scale: rippleAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 2] }), + }, ], opacity: rippleAnim.interpolate({ inputRange: [0, 1], outputRange: [0.8, 0] }), }} diff --git a/Lifeline-Frontend/app/_layout.tsx b/Lifeline-Frontend/app/_layout.tsx index 5c9ff1d..3bf17cc 100644 --- a/Lifeline-Frontend/app/_layout.tsx +++ b/Lifeline-Frontend/app/_layout.tsx @@ -4,11 +4,13 @@ import { ThemeProvider } from "@/src/theme/ThemeContext"; import { Slot } from "expo-router"; import Providers from "@/src/core/Providers"; import AppErrorBoundary from "@/src/core/AppErrorBoundary"; +import SOSRuntimeBootstrapper from "@/src/features/SOS/SOSRuntimeBootstrapper"; export default function RootLayout() { return ( + diff --git a/Lifeline-Frontend/plugins/withPowerButtonSOS.js b/Lifeline-Frontend/plugins/withPowerButtonSOS.js new file mode 100644 index 0000000..7afada8 --- /dev/null +++ b/Lifeline-Frontend/plugins/withPowerButtonSOS.js @@ -0,0 +1,516 @@ +const fs = require("fs"); +const path = require("path"); +const { + createRunOncePlugin, + withAndroidManifest, + withDangerousMod, +} = require("@expo/config-plugins"); + +const PLUGIN_NAME = "with-power-button-sos"; +const PLUGIN_VERSION = "1.0.0"; + +const SERVICE_CLASS_NAME = ".sos.PowerButtonMonitorService"; +const RECEIVER_CLASS_NAME = ".sos.PowerButtonBootReceiver"; + +const REQUIRED_PERMISSIONS = [ + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.WAKE_LOCK", + "android.permission.FOREGROUND_SERVICE", +]; + +const ensurePermission = (androidManifest, permissionName) => { + const permissions = androidManifest.manifest["uses-permission"] || []; + const hasPermission = permissions.some( + (item) => item?.$?.["android:name"] === permissionName + ); + + if (!hasPermission) { + permissions.push({ + $: { + "android:name": permissionName, + }, + }); + } + + androidManifest.manifest["uses-permission"] = permissions; +}; + +const ensureService = (application) => { + application.service = application.service || []; + const hasService = application.service.some( + (service) => service?.$?.["android:name"] === SERVICE_CLASS_NAME + ); + + if (hasService) return; + + application.service.push({ + $: { + "android:name": SERVICE_CLASS_NAME, + "android:enabled": "true", + "android:exported": "false", + "android:stopWithTask": "false", + "android:foregroundServiceType": "dataSync", + }, + }); +}; + +const ensureBootReceiver = (application) => { + application.receiver = application.receiver || []; + const hasReceiver = application.receiver.some( + (receiver) => receiver?.$?.["android:name"] === RECEIVER_CLASS_NAME + ); + + if (hasReceiver) return; + + application.receiver.push({ + $: { + "android:name": RECEIVER_CLASS_NAME, + "android:enabled": "true", + "android:exported": "true", + }, + "intent-filter": [ + { + action: [ + { $: { "android:name": "android.intent.action.BOOT_COMPLETED" } }, + { $: { "android:name": "android.intent.action.LOCKED_BOOT_COMPLETED" } }, + { $: { "android:name": "android.intent.action.MY_PACKAGE_REPLACED" } }, + ], + }, + ], + }); +}; + +const withPowerButtonSOSManifest = (config) => + withAndroidManifest(config, (modConfig) => { + const androidManifest = modConfig.modResults; + const application = androidManifest?.manifest?.application?.[0]; + + if (!application) { + return modConfig; + } + + REQUIRED_PERMISSIONS.forEach((permission) => { + ensurePermission(androidManifest, permission); + }); + + ensureService(application); + ensureBootReceiver(application); + + return modConfig; + }); + +const findMainApplicationFile = (androidProjectRoot) => { + const javaRoot = path.join(androidProjectRoot, "app", "src", "main", "java"); + if (!fs.existsSync(javaRoot)) return null; + + const stack = [javaRoot]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(absolute); + } else if (entry.isFile() && (entry.name === "MainApplication.kt" || entry.name === "MainApplication.java")) { + return absolute; + } + } + } + + return null; +}; + +const readPackageNameFromMainApplication = (mainApplicationPath) => { + if (!mainApplicationPath || !fs.existsSync(mainApplicationPath)) return null; + const contents = fs.readFileSync(mainApplicationPath, "utf8"); + const packageMatch = contents.match(/^\s*package\s+([a-zA-Z0-9_.]+)\s*$/m); + return packageMatch?.[1] || null; +}; + +const writeFile = (targetPath, contents) => { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, contents, "utf8"); +}; + +const getBridgeTemplate = (packageName) => `package ${packageName}.sos + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import java.lang.ref.WeakReference + +object PowerButtonSOSBridge { + private var reactContextRef: WeakReference? = null + + fun setReactContext(reactContext: ReactApplicationContext) { + reactContextRef = WeakReference(reactContext) + } + + fun emitEvent(eventName: String, payload: WritableMap? = null) { + val reactContext = reactContextRef?.get() ?: return + if (!reactContext.hasActiveCatalystInstance()) return + + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(eventName, payload) + } +} +`; + +const getModuleTemplate = (packageName) => `package ${packageName}.sos + +import android.content.Context +import android.content.Intent +import android.os.Build +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +class PowerButtonSOSModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + companion object { + const val NAME = "PowerButtonSOSModule" + const val PREFS_NAME = "lifeline_power_sos_prefs" + const val PREF_KEY_ENABLED = "enabled" + const val PREF_KEY_TRIGGER_COUNT = "trigger_count" + } + + init { + PowerButtonSOSBridge.setReactContext(reactContext) + } + + override fun getName(): String = NAME + + @ReactMethod + fun setPressTriggerThreshold(pressCount: Int) { + val safeCount = pressCount.coerceAtLeast(2) + prefs().edit().putInt(PREF_KEY_TRIGGER_COUNT, safeCount).apply() + } + + @ReactMethod + fun startListening(pressCount: Int?) { + pressCount?.let { setPressTriggerThreshold(it) } + prefs().edit().putBoolean(PREF_KEY_ENABLED, true).apply() + startMonitorService() + } + + @ReactMethod + fun stopListening() { + prefs().edit().putBoolean(PREF_KEY_ENABLED, false).apply() + reactContext.stopService(Intent(reactContext, PowerButtonMonitorService::class.java)) + } + + private fun startMonitorService() { + val serviceIntent = Intent(reactContext, PowerButtonMonitorService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + reactContext.startForegroundService(serviceIntent) + } else { + reactContext.startService(serviceIntent) + } + } + + private fun prefs() = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) +} +`; + +const getPackageTemplate = (packageName) => `package ${packageName}.sos + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class PowerButtonSOSPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(PowerButtonSOSModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} +`; + +const getServiceTemplate = (packageName) => `package ${packageName}.sos + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.IBinder +import com.facebook.react.bridge.WritableNativeMap +import java.util.ArrayDeque + +class PowerButtonMonitorService : Service() { + companion object { + private const val CHANNEL_ID = "lifeline_power_button_channel" + private const val CHANNEL_NAME = "LifeLine SOS Power Monitor" + private const val NOTIFICATION_ID = 20260301 + private const val PRESS_WINDOW_MS = 7000L + } + + private val powerButtonPresses = ArrayDeque() + + private val screenReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val action = intent?.action ?: return + if (action == Intent.ACTION_SCREEN_OFF || action == Intent.ACTION_SCREEN_ON) { + registerPowerButtonPress() + } + } + } + + override fun onCreate() { + super.onCreate() + startInForeground() + registerScreenReceiver() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!isEnabled()) { + stopSelf() + return START_NOT_STICKY + } + return START_STICKY + } + + override fun onDestroy() { + runCatching { unregisterReceiver(screenReceiver) } + powerButtonPresses.clear() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun registerScreenReceiver() { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + registerReceiver(screenReceiver, filter) + } + + private fun registerPowerButtonPress() { + val now = System.currentTimeMillis() + + while (powerButtonPresses.isNotEmpty() && now - powerButtonPresses.first() > PRESS_WINDOW_MS) { + powerButtonPresses.removeFirst() + } + + powerButtonPresses.addLast(now) + val count = powerButtonPresses.size + + emitPerPressEvents(count) + + val triggerCount = triggerCount() + if (count >= triggerCount) { + powerButtonPresses.clear() + emitTriggerEvents() + launchSOSScreen() + } + } + + private fun emitPerPressEvents(count: Int) { + val payload = WritableNativeMap().apply { putInt("count", count) } + PowerButtonSOSBridge.emitEvent("PowerButtonPressed", payload) + PowerButtonSOSBridge.emitEvent("powerButtonPressed", payload) + PowerButtonSOSBridge.emitEvent("power_button_pressed", payload) + } + + private fun emitTriggerEvents() { + PowerButtonSOSBridge.emitEvent("PowerButtonFivePressSOS", null) + PowerButtonSOSBridge.emitEvent("powerButtonFivePressSOS", null) + PowerButtonSOSBridge.emitEvent("power_button_5_press_sos", null) + } + + private fun launchSOSScreen() { + val deepLinkIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("lifeline://sos/trigger?source=power_button_5_press") + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + runCatching { startActivity(deepLinkIntent) } + } + + private fun triggerCount(): Int { + return prefs().getInt(PowerButtonSOSModule.PREF_KEY_TRIGGER_COUNT, 5).coerceAtLeast(2) + } + + private fun isEnabled(): Boolean { + return prefs().getBoolean(PowerButtonSOSModule.PREF_KEY_ENABLED, false) + } + + private fun prefs() = getSharedPreferences(PowerButtonSOSModule.PREFS_NAME, Context.MODE_PRIVATE) + + private fun startInForeground() { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(channel) + } + + val notificationBuilder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + Notification.Builder(this) + } + + val notification = notificationBuilder + .setContentTitle("LifeLine SOS Monitor Active") + .setContentText("Press power button 5 times quickly to trigger SOS.") + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setOngoing(true) + .build() + + startForeground(NOTIFICATION_ID, notification) + } +} +`; + +const getBootReceiverTemplate = (packageName) => `package ${packageName}.sos + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build + +class PowerButtonBootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val action = intent?.action ?: return + val validAction = + action == Intent.ACTION_BOOT_COMPLETED || + action == Intent.ACTION_LOCKED_BOOT_COMPLETED || + action == Intent.ACTION_MY_PACKAGE_REPLACED + + if (!validAction) return + + val prefs = context.getSharedPreferences(PowerButtonSOSModule.PREFS_NAME, Context.MODE_PRIVATE) + val enabled = prefs.getBoolean(PowerButtonSOSModule.PREF_KEY_ENABLED, false) + if (!enabled) return + + val serviceIntent = Intent(context, PowerButtonMonitorService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } +} +`; + +const patchMainApplicationFile = (mainApplicationPath, packageName) => { + if (!mainApplicationPath || !fs.existsSync(mainApplicationPath)) return; + let contents = fs.readFileSync(mainApplicationPath, "utf8"); + + const isKotlin = mainApplicationPath.endsWith(".kt"); + if (isKotlin) { + const importLine = `import ${packageName}.sos.PowerButtonSOSPackage`; + if (!contents.includes(importLine)) { + const packageDeclarationMatch = contents.match(/^\s*package\s+[^\n]+\n/m); + if (packageDeclarationMatch && packageDeclarationMatch.index !== undefined) { + const insertAt = packageDeclarationMatch.index + packageDeclarationMatch[0].length; + contents = `${contents.slice(0, insertAt)}${importLine}\n${contents.slice(insertAt)}`; + } else { + contents = `${importLine}\n${contents}`; + } + } + + if (!contents.includes("packages.add(PowerButtonSOSPackage())")) { + if (contents.includes("val packages = PackageList(this).packages")) { + contents = contents.replace( + "val packages = PackageList(this).packages", + "val packages = PackageList(this).packages\n packages.add(PowerButtonSOSPackage())" + ); + } else { + const shortGetPackagesPattern = + /override\s+fun\s+getPackages\(\)\s*:\s*List\s*=\s*PackageList\(this\)\.packages/; + if (shortGetPackagesPattern.test(contents)) { + contents = contents.replace( + shortGetPackagesPattern, + "override fun getPackages(): List {\n val packages = PackageList(this).packages\n packages.add(PowerButtonSOSPackage())\n return packages\n }" + ); + } + } + } + } else { + const importLine = `import ${packageName}.sos.PowerButtonSOSPackage;`; + if (!contents.includes(importLine)) { + contents = contents.replace( + /import com\.facebook\.react\.ReactPackage;\n/, + `import com.facebook.react.ReactPackage;\n${importLine}\n` + ); + } + + if (!contents.includes("packages.add(new PowerButtonSOSPackage());")) { + contents = contents.replace( + "List packages = new PackageList(this).getPackages();", + "List packages = new PackageList(this).getPackages();\n packages.add(new PowerButtonSOSPackage());" + ); + } + } + + fs.writeFileSync(mainApplicationPath, contents, "utf8"); +}; + +const withPowerButtonSOSNativeCode = (config) => + withDangerousMod(config, [ + "android", + async (modConfig) => { + const androidProjectRoot = modConfig.modRequest.platformProjectRoot; + const mainApplicationPath = findMainApplicationFile(androidProjectRoot); + const packageName = readPackageNameFromMainApplication(mainApplicationPath); + + if (!mainApplicationPath || !packageName) { + console.warn( + `[${PLUGIN_NAME}] Could not resolve MainApplication or package name. Native SOS module files were not generated.` + ); + return modConfig; + } + + const packagePath = packageName.split(".").join(path.sep); + const sosDir = path.join( + androidProjectRoot, + "app", + "src", + "main", + "java", + packagePath, + "sos" + ); + + writeFile(path.join(sosDir, "PowerButtonSOSBridge.kt"), getBridgeTemplate(packageName)); + writeFile(path.join(sosDir, "PowerButtonSOSModule.kt"), getModuleTemplate(packageName)); + writeFile(path.join(sosDir, "PowerButtonSOSPackage.kt"), getPackageTemplate(packageName)); + writeFile(path.join(sosDir, "PowerButtonMonitorService.kt"), getServiceTemplate(packageName)); + writeFile(path.join(sosDir, "PowerButtonBootReceiver.kt"), getBootReceiverTemplate(packageName)); + + patchMainApplicationFile(mainApplicationPath, packageName); + + return modConfig; + }, + ]); + +const withPowerButtonSOS = (config) => { + config = withPowerButtonSOSManifest(config); + config = withPowerButtonSOSNativeCode(config); + return config; +}; + +module.exports = createRunOncePlugin(withPowerButtonSOS, PLUGIN_NAME, PLUGIN_VERSION); diff --git a/Lifeline-Frontend/src/features/Helper/Dashbard/v1/Components/StatusToggle.tsx b/Lifeline-Frontend/src/features/Helper/Dashbard/v1/Components/StatusToggle.tsx index f0f381d..878028e 100644 --- a/Lifeline-Frontend/src/features/Helper/Dashbard/v1/Components/StatusToggle.tsx +++ b/Lifeline-Frontend/src/features/Helper/Dashbard/v1/Components/StatusToggle.tsx @@ -11,13 +11,13 @@ export function StatusToggle({}) { let [status, setStatus] = useState(isAvailable); const dispatch = useDispatch(); const { startBackgroundTracking, stopBackgroundTracking } = useBackgroundLocation(); - const userId = useSelector((state: RootState) => state.auth.userId || state.auth.authId); + const userId = useSelector((state: RootState) => state.auth.authId || state.auth.userId); useEffect(() => { if (userId) { dispatch(checkAvailability(userId)); } - }, [userId]); + }, [dispatch, userId]); useEffect(() => { setStatus(isAvailable); @@ -25,6 +25,10 @@ export function StatusToggle({}) { useEffect(() => { const syncStatus = async () => { + if (userId) { + await AsyncStorage.setItem("user_id", userId); + } + await AsyncStorage.setItem("user_role", "helper"); await AsyncStorage.setItem("is_available", status ? "true" : "false"); if (status) { startBackgroundTracking(); @@ -33,7 +37,7 @@ export function StatusToggle({}) { } }; syncStatus(); - }, [status, startBackgroundTracking, stopBackgroundTracking]); + }, [status, startBackgroundTracking, stopBackgroundTracking, userId]); const onChange = async (newStatus: boolean) => { setStatus(newStatus); diff --git a/Lifeline-Frontend/src/features/SOS/SOSRuntimeBootstrapper.tsx b/Lifeline-Frontend/src/features/SOS/SOSRuntimeBootstrapper.tsx new file mode 100644 index 0000000..5bbcea0 --- /dev/null +++ b/Lifeline-Frontend/src/features/SOS/SOSRuntimeBootstrapper.tsx @@ -0,0 +1,213 @@ +import React from "react"; +import { AppState, DeviceEventEmitter, Linking, NativeModules, Platform } from "react-native"; +import * as ExpoLinking from "expo-linking"; +import { usePathname } from "expo-router"; +import { useDispatch, useSelector } from "react-redux"; +import { AppDispatch, RootState } from "@/src/core/store"; +import { + clearSOSPendingIntent, + getSOSPendingIntent, + isSOSPendingIntentFresh, + isSOSScreenPath, + normalizeSOSTriggerSource, + persistSOSPendingIntent, + SOSTriggerSource, + triggerSOSActivation, +} from "./sos.orchestrator"; + +const POWER_BUTTON_PRESS_TRIGGER_COUNT = 5; +const POWER_BUTTON_PRESS_WINDOW_MS = 7000; + +const resolveSOSTriggerSourceFromUrl = (url: string): SOSTriggerSource | null => { + const normalizedUrl = url.toLowerCase(); + if (!normalizedUrl.includes("sos/trigger")) { + return null; + } + + const parsedUrl = ExpoLinking.parse(url); + const sourceQuery = parsedUrl.queryParams?.source; + + if (typeof sourceQuery === "string") { + return normalizeSOSTriggerSource(sourceQuery); + } + + return "deeplink"; +}; + +export default function SOSRuntimeBootstrapper() { + const dispatch = useDispatch(); + const pathname = usePathname(); + + const { isAuthenticated, isHydrated } = useSelector((state: RootState) => state.auth); + const isSOSActive = useSelector((state: RootState) => state.sos.isSOSActive); + + const isRecoveringRef = React.useRef(false); + const powerPressTimestampsRef = React.useRef([]); + + const canHandleSOS = isHydrated && isAuthenticated; + const shouldOpenSOSScreen = !isSOSScreenPath(pathname); + + const handleTrigger = React.useCallback( + async (source: SOSTriggerSource) => { + if (!canHandleSOS) { + await persistSOSPendingIntent(source); + return; + } + + await triggerSOSActivation({ + dispatch, + isSOSActive, + source, + openScreen: shouldOpenSOSScreen, + }); + + await clearSOSPendingIntent(); + }, + [canHandleSOS, dispatch, isSOSActive, shouldOpenSOSScreen] + ); + + const recoverPendingSOSIntent = React.useCallback(async () => { + if (!canHandleSOS || isRecoveringRef.current) { + return; + } + + isRecoveringRef.current = true; + try { + const pendingIntent = await getSOSPendingIntent(); + if (!pendingIntent) return; + + if (!isSOSPendingIntentFresh(pendingIntent)) { + await clearSOSPendingIntent(); + return; + } + + await triggerSOSActivation({ + dispatch, + isSOSActive, + source: pendingIntent.source || "app_resume_recovery", + openScreen: shouldOpenSOSScreen, + persistIntent: false, + }); + + await clearSOSPendingIntent(); + } finally { + isRecoveringRef.current = false; + } + }, [canHandleSOS, dispatch, isSOSActive, shouldOpenSOSScreen]); + + const registerPowerButtonPress = React.useCallback(() => { + const now = Date.now(); + const recentPresses = [...powerPressTimestampsRef.current, now].filter( + (ts) => now - ts <= POWER_BUTTON_PRESS_WINDOW_MS + ); + + powerPressTimestampsRef.current = recentPresses; + + if (recentPresses.length >= POWER_BUTTON_PRESS_TRIGGER_COUNT) { + powerPressTimestampsRef.current = []; + void handleTrigger("power_button_5_press"); + } + }, [handleTrigger]); + + React.useEffect(() => { + void recoverPendingSOSIntent(); + }, [recoverPendingSOSIntent]); + + React.useEffect(() => { + const appStateSubscription = AppState.addEventListener("change", (nextState) => { + if (nextState === "active") { + void recoverPendingSOSIntent(); + } + }); + + return () => { + appStateSubscription.remove(); + }; + }, [recoverPendingSOSIntent]); + + React.useEffect(() => { + const consumeUrlIfSOSTrigger = async (url: string | null) => { + if (!url) return; + const source = resolveSOSTriggerSourceFromUrl(url); + if (!source) return; + await handleTrigger(source); + }; + + void Linking.getInitialURL().then((url) => { + void consumeUrlIfSOSTrigger(url); + }); + + const linkSubscription = Linking.addEventListener("url", (event) => { + void consumeUrlIfSOSTrigger(event.url); + }); + + return () => { + linkSubscription.remove(); + }; + }, [handleTrigger]); + + React.useEffect(() => { + if (Platform.OS !== "android") { + return; + } + + const nativePowerModule = (NativeModules.PowerButtonSOSModule || + NativeModules.PowerButtonSOS || + NativeModules.LifeLinePowerButtonSOS) as + | { + setPressTriggerThreshold?: (pressCount: number) => void; + startListening?: (pressCount?: number) => void; + stopListening?: () => void; + } + | undefined; + + if (!nativePowerModule) { + return; + } + + const handleNativePowerTrigger = () => { + void handleTrigger("power_button_native"); + }; + const handleNativePowerFivePress = () => { + void handleTrigger("power_button_5_press"); + }; + const handleNativePowerPressSignal = (event?: { count?: number }) => { + if (typeof event?.count === "number" && event.count >= POWER_BUTTON_PRESS_TRIGGER_COUNT) { + void handleTrigger("power_button_5_press"); + return; + } + registerPowerButtonPress(); + }; + + // Native Android module can either emit a dedicated 5-press event or per-press events. + const listeners = [ + DeviceEventEmitter.addListener("PowerButtonHoldSOS", handleNativePowerTrigger), + DeviceEventEmitter.addListener("powerButtonHoldSOS", handleNativePowerTrigger), + DeviceEventEmitter.addListener("power_button_hold_sos", handleNativePowerTrigger), + DeviceEventEmitter.addListener("PowerButtonFivePressSOS", handleNativePowerFivePress), + DeviceEventEmitter.addListener("powerButtonFivePressSOS", handleNativePowerFivePress), + DeviceEventEmitter.addListener("power_button_5_press_sos", handleNativePowerFivePress), + DeviceEventEmitter.addListener("PowerButtonPressed", handleNativePowerPressSignal), + DeviceEventEmitter.addListener("powerButtonPressed", handleNativePowerPressSignal), + DeviceEventEmitter.addListener("power_button_pressed", handleNativePowerPressSignal), + ]; + + try { + nativePowerModule.setPressTriggerThreshold?.(POWER_BUTTON_PRESS_TRIGGER_COUNT); + nativePowerModule.startListening?.(POWER_BUTTON_PRESS_TRIGGER_COUNT); + } catch (error) { + console.warn("[SOS] Native power listener start failed:", error); + } + + return () => { + listeners.forEach((listener) => listener.remove()); + try { + nativePowerModule.stopListening?.(); + } catch (error) { + console.warn("[SOS] Native power listener stop failed:", error); + } + }; + }, [handleTrigger, registerPowerButtonPress]); + + return null; +} diff --git a/Lifeline-Frontend/src/features/SOS/sos.orchestrator.ts b/Lifeline-Frontend/src/features/SOS/sos.orchestrator.ts new file mode 100644 index 0000000..03cd5aa --- /dev/null +++ b/Lifeline-Frontend/src/features/SOS/sos.orchestrator.ts @@ -0,0 +1,140 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { router } from "expo-router"; +import { AppDispatch } from "@/src/core/store"; +import { activeSos, deactiveSos } from "./sos.slice"; + +const SOS_PENDING_INTENT_STORAGE_KEY = "lifeline_sos_pending_intent_v1"; +const SOS_PENDING_INTENT_MAX_AGE_MS = 10 * 60 * 1000; +const SOS_TRIGGER_COOLDOWN_MS = 1500; + +export const SOS_SCREEN_PATH = "/(global)/SOSActiveAIScreen"; + +let lastTriggerAtMs = 0; + +export type SOSTriggerSource = + | "dashboard_hold" + | "power_button_native" + | "power_button_5_press" + | "notification_action" + | "deeplink" + | "app_resume_recovery" + | "unknown"; + +export type SOSPendingIntent = { + source: SOSTriggerSource; + createdAt: string; +}; + +const SOURCE_LOOKUP: Record = { + dashboard_hold: "dashboard_hold", + power_button_native: "power_button_native", + power_button_5_press: "power_button_5_press", + notification_action: "notification_action", + deeplink: "deeplink", + app_resume_recovery: "app_resume_recovery", + unknown: "unknown", +}; + +export const normalizeSOSTriggerSource = (source: string | null | undefined): SOSTriggerSource => { + if (!source) return "unknown"; + return SOURCE_LOOKUP[source] || "unknown"; +}; + +export const isSOSScreenPath = (pathname: string | null | undefined): boolean => { + return typeof pathname === "string" && pathname.toLowerCase().includes("sosactiveaiscreen"); +}; + +export const openSOSScreen = () => { + try { + router.push(SOS_SCREEN_PATH); + } catch (error) { + console.warn("[SOS] Navigation to SOS screen failed:", error); + } +}; + +export const persistSOSPendingIntent = async ( + source: SOSTriggerSource, + createdAt: Date = new Date() +) => { + try { + const payload: SOSPendingIntent = { + source, + createdAt: createdAt.toISOString(), + }; + await AsyncStorage.setItem(SOS_PENDING_INTENT_STORAGE_KEY, JSON.stringify(payload)); + } catch (error) { + console.warn("[SOS] Failed to persist pending intent:", error); + } +}; + +export const getSOSPendingIntent = async (): Promise => { + try { + const raw = await AsyncStorage.getItem(SOS_PENDING_INTENT_STORAGE_KEY); + if (!raw) return null; + + const parsed = JSON.parse(raw) as Partial | null; + if (!parsed || typeof parsed !== "object") return null; + if (typeof parsed.createdAt !== "string") return null; + + return { + source: normalizeSOSTriggerSource(parsed.source), + createdAt: parsed.createdAt, + }; + } catch (error) { + console.warn("[SOS] Failed to read pending intent:", error); + return null; + } +}; + +export const clearSOSPendingIntent = async () => { + try { + await AsyncStorage.removeItem(SOS_PENDING_INTENT_STORAGE_KEY); + } catch (error) { + console.warn("[SOS] Failed to clear pending intent:", error); + } +}; + +export const isSOSPendingIntentFresh = (pendingIntent: SOSPendingIntent, now = Date.now()) => { + const createdAtMs = Date.parse(pendingIntent.createdAt); + if (Number.isNaN(createdAtMs)) return false; + return now - createdAtMs <= SOS_PENDING_INTENT_MAX_AGE_MS; +}; + +type TriggerSOSActivationInput = { + dispatch: AppDispatch; + isSOSActive: boolean; + source: SOSTriggerSource; + openScreen?: boolean; + persistIntent?: boolean; +}; + +export const triggerSOSActivation = async ({ + dispatch, + isSOSActive, + source, + openScreen = true, + persistIntent = true, +}: TriggerSOSActivationInput) => { + const now = Date.now(); + + if (persistIntent) { + await persistSOSPendingIntent(source, new Date(now)); + } + + const inCooldown = now - lastTriggerAtMs < SOS_TRIGGER_COOLDOWN_MS; + if (!isSOSActive || !inCooldown) { + if (!isSOSActive) { + dispatch(activeSos()); + } + lastTriggerAtMs = now; + } + + if (openScreen) { + openSOSScreen(); + } +}; + +export const deactivateSOSFlow = async (dispatch: AppDispatch) => { + dispatch(deactiveSos()); + await clearSOSPendingIntent(); +}; diff --git a/Lifeline-Frontend/src/shared/hooks/useBackgroundLocation.ts b/Lifeline-Frontend/src/shared/hooks/useBackgroundLocation.ts index 1c13102..2ac9a0a 100644 --- a/Lifeline-Frontend/src/shared/hooks/useBackgroundLocation.ts +++ b/Lifeline-Frontend/src/shared/hooks/useBackgroundLocation.ts @@ -59,7 +59,7 @@ export const useBackgroundLocation = () => { await Location.startLocationUpdatesAsync(LOCATION_TASK_NAME, { accuracy: Location.Accuracy.Balanced, timeInterval: 5000, // 5 seconds - distanceInterval: 5, // 5 meters + distanceInterval: 0, // Always allow 5-second interval updates foregroundService: { notificationTitle: "LifeLine Background Tracking", notificationBody: "Reporting location to help during emergencies.", diff --git a/Lifeline-Frontend/src/shared/services/socket.service.ts b/Lifeline-Frontend/src/shared/services/socket.service.ts index 50fa969..a01981e 100644 --- a/Lifeline-Frontend/src/shared/services/socket.service.ts +++ b/Lifeline-Frontend/src/shared/services/socket.service.ts @@ -393,9 +393,11 @@ class SocketService { speed?: number; heading?: number; userId?: string; + role?: "user" | "helper"; timestamp?: string | number; }): void { - const { latitude, longitude, accuracy, altitude, speed, heading, timestamp } = location; + const { latitude, longitude, accuracy, altitude, speed, heading, timestamp, userId, role } = + location; this.emit(SOCKET_EVENTS.LOCATION.UPDATE, { latitude, longitude, @@ -403,6 +405,8 @@ class SocketService { altitude, speed, heading, + userId, + role, timestamp: timestamp || new Date().toISOString(), }); } diff --git a/Lifeline-Frontend/src/shared/tasks/location.task.ts b/Lifeline-Frontend/src/shared/tasks/location.task.ts index cd127d9..f8f67e7 100644 --- a/Lifeline-Frontend/src/shared/tasks/location.task.ts +++ b/Lifeline-Frontend/src/shared/tasks/location.task.ts @@ -1,10 +1,24 @@ import * as TaskManager from "expo-task-manager"; -import * as Location from "expo-location"; import socketService from "../services/socket.service"; import AsyncStorage from "@react-native-async-storage/async-storage"; export const LOCATION_TASK_NAME = "background-location-task"; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const ensureSocketReady = async (userId: string, role: "user" | "helper") => { + if (!socketService.isConnected()) { + socketService.connect(); + await sleep(800); + } + + if (!socketService.isConnected()) return false; + + socketService.registerUser(userId, role); + await sleep(100); + return socketService.isConnected(); +}; + // Define the background task TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }: any) => { if (error) { @@ -22,6 +36,8 @@ TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }: any) => { try { const userId = await AsyncStorage.getItem("user_id"); const isAvailable = await AsyncStorage.getItem("is_available"); + const storedRole = await AsyncStorage.getItem("user_role"); + const role: "user" | "helper" = storedRole === "helper" ? "helper" : "user"; if (!userId) { console.warn("[Background] No user_id found in storage, skipping update."); @@ -33,6 +49,12 @@ TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }: any) => { return; } + const isSocketReady = await ensureSocketReady(userId, role); + if (!isSocketReady) { + console.warn("[Background] Socket not ready, skipping location emit."); + return; + } + console.log(`[Background] 📍 Reporting location for ${userId}: ${latitude}, ${longitude}`); socketService.updateLocation({ latitude, @@ -41,7 +63,9 @@ TaskManager.defineTask(LOCATION_TASK_NAME, async ({ data, error }: any) => { altitude: altitude || undefined, speed: speed || undefined, heading: heading || undefined, - userId: userId, // Ensure userId is sent + userId, + role, + timestamp: timestamp || new Date().toISOString(), }); } catch (err: any) { console.error("Background Location Update Failed:", err.message); From 44f90b5abebaf1bf8e6437a9166d60715d2916f2 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Sun, 1 Mar 2026 07:10:46 +0530 Subject: [PATCH 03/12] feat(Map): update location publishing to emit every 5 seconds for user/helper roles --- Lifeline-Frontend/app/(global)/Map.tsx | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/Lifeline-Frontend/app/(global)/Map.tsx b/Lifeline-Frontend/app/(global)/Map.tsx index 8ff5bd0..2dedb42 100644 --- a/Lifeline-Frontend/app/(global)/Map.tsx +++ b/Lifeline-Frontend/app/(global)/Map.tsx @@ -482,7 +482,7 @@ export default function MapScreen() { { accuracy: Location.Accuracy.BestForNavigation, timeInterval: 5000, - distanceInterval: 5, + distanceInterval: 0, mayShowUserSettingsDialog: true, }, (loc) => { @@ -550,6 +550,36 @@ export default function MapScreen() { 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); + } + }; + + 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; From 331266940ece849b6ac106e8a58c738c26e1e669 Mon Sep 17 00:00:00 2001 From: Vishal Mahato Date: Sun, 1 Mar 2026 07:50:26 +0530 Subject: [PATCH 04/12] Implement SOS upgrade payments and helper access --- .gitignore | 3 + .../api/Emergency/Emergency.controller.mjs | 29 ++- .../src/api/Emergency/Emergency.model.mjs | 110 ++++++++- .../src/api/Emergency/Emergency.routes.mjs | 1 + .../src/api/Emergency/Emergency.service.mjs | 202 ++++++++++++++++- .../src/api/Helper/Helper.model.mjs | 20 ++ .../src/api/Helper/Helper.service.mjs | 24 +- .../src/api/Payment/Payment.controller.mjs | 34 +++ .../src/api/Payment/Payment.model.mjs | 55 +++++ .../src/api/Payment/Payment.routes.mjs | 12 + .../src/api/Payment/Payment.utils.mjs | 21 ++ LifeLine-Backend/src/server.mjs | 2 + Lifeline-Frontend/src/config/api.ts | 7 + .../src/features/Helper/helperSlice.ts | 31 ++- .../src/features/emergency/emergencySlice.ts | 67 +++++- docs/tests/sos-upgrade-postman-testing.md | 211 ++++++++++++++++++ 16 files changed, 816 insertions(+), 13 deletions(-) create mode 100644 LifeLine-Backend/src/api/Payment/Payment.controller.mjs create mode 100644 LifeLine-Backend/src/api/Payment/Payment.model.mjs create mode 100644 LifeLine-Backend/src/api/Payment/Payment.routes.mjs create mode 100644 LifeLine-Backend/src/api/Payment/Payment.utils.mjs create mode 100644 docs/tests/sos-upgrade-postman-testing.md 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/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs b/LifeLine-Backend/src/api/Emergency/Emergency.controller.mjs index c4b798c..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) { @@ -357,6 +364,24 @@ export class EmergencyController { } } + 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 d3ac292..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,12 +228,68 @@ 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', 'approved', 'verified'], + 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, }, @@ -307,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(), ); @@ -315,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 67ac2eb..4242eb6 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.routes.mjs @@ -93,6 +93,7 @@ router.post( 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 6f495b9..e529973 100644 --- a/LifeLine-Backend/src/api/Emergency/Emergency.service.mjs +++ b/LifeLine-Backend/src/api/Emergency/Emergency.service.mjs @@ -20,6 +20,8 @@ import { } 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 @@ -271,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) { @@ -284,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 @@ -841,6 +898,18 @@ export class EmergencyService { 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); @@ -853,6 +922,43 @@ export class EmergencyService { 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'); @@ -893,6 +999,10 @@ export class EmergencyService { 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'); @@ -915,6 +1025,43 @@ export class EmergencyService { 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 { @@ -930,6 +1077,57 @@ export class EmergencyService { } } + 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.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.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/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/server.mjs b/LifeLine-Backend/src/server.mjs index 02369ff..e3b28fe 100644 --- a/LifeLine-Backend/src/server.mjs +++ b/LifeLine-Backend/src/server.mjs @@ -55,6 +55,7 @@ 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); @@ -66,6 +67,7 @@ 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-Frontend/src/config/api.ts b/Lifeline-Frontend/src/config/api.ts index 1f08543..0bb5aab 100644 --- a/Lifeline-Frontend/src/config/api.ts +++ b/Lifeline-Frontend/src/config/api.ts @@ -101,6 +101,7 @@ export const API_ENDPOINTS = { BASE: "/api/emergency", SOS: "/api/emergency/sos", GET_BY_ID: (id: string) => `/api/emergency/${id}`, + PROFILE: (id: string) => `/api/emergency/${id}/profile`, USER_EMERGENCIES: "/api/emergency/user/me", NEARBY: "/api/emergency/nearby/search", ACCEPT: (id: string) => `/api/emergency/${id}/accept`, @@ -161,6 +162,12 @@ export const API_ENDPOINTS = { NGO: { NEARBY: "/api/ngo/v1/nearby", }, + + // Payments + PAYMENTS: { + CREATE: "/api/payments", + RELEASE: (id: string) => `/api/payments/${id}/release`, + }, } as const; export default api; diff --git a/Lifeline-Frontend/src/features/Helper/helperSlice.ts b/Lifeline-Frontend/src/features/Helper/helperSlice.ts index 0d70960..4e8218f 100644 --- a/Lifeline-Frontend/src/features/Helper/helperSlice.ts +++ b/Lifeline-Frontend/src/features/Helper/helperSlice.ts @@ -1,5 +1,5 @@ import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; -import api from "@/src/config/api"; +import api, { API_ENDPOINTS } from "@/src/config/api"; import { getErrorMessage } from "@/src/shared/utils/error.utils"; export interface DashboardMetrics { @@ -73,6 +73,19 @@ export const getDashboardMetrics = createAsyncThunk( } ); +export const getHelperStats = createAsyncThunk( + "helper/getStats", + async (id: string, { rejectWithValue }) => { + try { + const response = await api.get(API_ENDPOINTS.HELPER.STATS(id)); + return response.data; + } catch (error: any) { + console.error("Error fetching helper stats:", error); + return rejectWithValue(getErrorMessage(error, "Failed to fetch stats")); + } + } +); + const helperSlice = createSlice({ name: "helper", initialState, @@ -113,6 +126,22 @@ const helperSlice = createSlice({ state.loading = false; state.error = action.payload as string; }); + + builder.addCase(getHelperStats.pending, (state) => { + state.loading = true; + state.error = null; + }); + builder.addCase( + getHelperStats.fulfilled, + (state, action: PayloadAction<{ data: DashboardMetrics }>) => { + state.loading = false; + state.metrics = action.payload.data; + } + ); + builder.addCase(getHelperStats.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); }, }); diff --git a/Lifeline-Frontend/src/features/emergency/emergencySlice.ts b/Lifeline-Frontend/src/features/emergency/emergencySlice.ts index 91921e5..12daea1 100644 --- a/Lifeline-Frontend/src/features/emergency/emergencySlice.ts +++ b/Lifeline-Frontend/src/features/emergency/emergencySlice.ts @@ -355,6 +355,56 @@ export const resolveEmergency = createAsyncThunk( } ); +/** + * Approve emergency payment (send OTP) + */ +export const approveEmergencyPayment = createAsyncThunk( + "emergency/approvePayment", + async (emergencyId: string, { rejectWithValue }) => { + try { + const response = await api.post(API_ENDPOINTS.EMERGENCY.PAYMENT.APPROVE(emergencyId)); + return response.data.data; + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; + return rejectWithValue(axiosError.response?.data?.message || "Failed to approve payment"); + } + } +); + +/** + * Verify emergency payment OTP + */ +export const verifyEmergencyOtp = createAsyncThunk( + "emergency/verifyOtp", + async (data: { emergencyId: string; otp: string }, { rejectWithValue }) => { + try { + const response = await api.post(API_ENDPOINTS.EMERGENCY.PAYMENT.VERIFY_OTP(data.emergencyId), { + otp: data.otp, + }); + return response.data.data; + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; + return rejectWithValue(axiosError.response?.data?.message || "Failed to verify OTP"); + } + } +); + +/** + * Fetch SOS profile for helper + */ +export const getSOSProfile = createAsyncThunk( + "emergency/getProfile", + async (emergencyId: string, { rejectWithValue }) => { + try { + const response = await api.get(API_ENDPOINTS.EMERGENCY.PROFILE(emergencyId)); + return response.data.data; + } catch (error: unknown) { + const axiosError = error as { response?: { data?: { message?: string } } }; + return rejectWithValue(axiosError.response?.data?.message || "Failed to get SOS profile"); + } + } +); + /** * Get user's emergency history */ @@ -402,9 +452,22 @@ export const getNearbyEmergencies = createAsyncThunk( */ export const acceptHelperRequest = createAsyncThunk( "emergency/acceptHelper", - async (emergencyId: string, { rejectWithValue }) => { + async ( + params: + | string + | { emergencyId: string; serviceType?: string; amount?: number; method?: "cash" | "upi" | "card" }, + { rejectWithValue } + ) => { try { - const response = await api.put(API_ENDPOINTS.EMERGENCY.ACCEPT(emergencyId)); + const payload = + typeof params === "string" + ? { emergencyId: params } + : { ...params }; + const response = await api.put(API_ENDPOINTS.EMERGENCY.ACCEPT(payload.emergencyId), { + serviceType: payload.serviceType, + amount: payload.amount, + method: payload.method, + }); return response.data.data; } catch (error: unknown) { const axiosError = error as { response?: { data?: { message?: string } } }; diff --git a/docs/tests/sos-upgrade-postman-testing.md b/docs/tests/sos-upgrade-postman-testing.md new file mode 100644 index 0000000..e598028 --- /dev/null +++ b/docs/tests/sos-upgrade-postman-testing.md @@ -0,0 +1,211 @@ +# SOS Upgrade Testing Guide (Postman + API) + +This document provides a step-by-step guide to test the SOS upgrade flows using Postman. It covers the paid/free accept flow, OTP approval/verification, helper stats, and SOS profile access. + +## 1) Prerequisites + +### Environment +- Backend running on: `http://localhost:5000` +- Postman collection with an environment variable: + - `baseUrl = http://localhost:5000` + - `userToken` = JWT for a user + - `helperToken` = JWT for a helper + +### Required Test Data +- A user account with a completed profile +- A helper account marked as available +- Helper and user must be near each other (location set) +- A valid Emergency ID created via SOS + +## 2) Auth Setup (Once) + +### Login User +**POST** `{{baseUrl}}/api/auth/v1/login` +```json +{ + "email": "user@example.com", + "password": "password123" +} +``` +Save the JWT as `userToken`. + +### Login Helper +**POST** `{{baseUrl}}/api/auth/v1/login` +```json +{ + "email": "helper@example.com", + "password": "password123" +} +``` +Save the JWT as `helperToken`. + +## 3) Trigger SOS (Create Emergency) + +### Trigger SOS (User API) +**POST** `{{baseUrl}}/api/emergency/sos` +Headers: `Authorization: Bearer {{userToken}}` +```json +{ + "type": "medical", + "title": "Unconscious patient", + "description": "User fainted and needs immediate help", + "priority": "critical", + "location": { + "coordinates": [77.5946, 12.9716], + "address": "MG Road, Bengaluru" + } +} +``` +Copy `data._id` as `emergencyId`. + +## 4) Helper Accept Flow (Paid/Free) + +### Accept Free Case (Helper API) +**PUT** `{{baseUrl}}/api/emergency/{{emergencyId}}/accept` +Headers: `Authorization: Bearer {{helperToken}}` +```json +{ + "serviceType": "basic_support", + "amount": 0, + "method": "cash" +} +``` + +### Accept Paid Case (Helper API) +**PUT** `{{baseUrl}}/api/emergency/{{emergencyId}}/accept` +Headers: `Authorization: Bearer {{helperToken}}` +```json +{ + "serviceType": "paramedic", + "amount": 1500, + "method": "upi" +} +``` + +## 5) Complete SOS (Resolve) + +### Resolve Emergency (User API) +**PUT** `{{baseUrl}}/api/emergency/{{emergencyId}}/resolve` +Headers: `Authorization: Bearer {{userToken}}` +```json +{ + "resolutionType": "completed", + "notes": "Helper provided first aid", + "rating": 5, + "feedback": "Quick response" +} +``` + +## 6) Payment Approval (After Resolution) + +### Approve Payment (Send OTP) (User API) +**POST** `{{baseUrl}}/api/emergency/{{emergencyId}}/approve` +Headers: `Authorization: Bearer {{userToken}}` + +Expected: +```json +{ + "success": true, + "data": { + "emergencyId": "...", + "otpSent": true + } +} +``` + +## 7) Verify OTP (Release Payment) + +### Verify OTP (User API) +**POST** `{{baseUrl}}/api/emergency/{{emergencyId}}/verify-otp` +Headers: `Authorization: Bearer {{userToken}}` +```json +{ + "otp": "123456" +} +``` + +Expected: +```json +{ + "success": true, + "data": { + "emergencyId": "...", + "paymentStatus": "verified" + } +} +``` + +## 8) SOS Profile (Helper Access Only) + +### Get SOS Profile (Helper API) +**GET** `{{baseUrl}}/api/emergency/{{emergencyId}}/profile` +Headers: `Authorization: Bearer {{helperToken}}` + +Expected: +```json +{ + "success": true, + "data": { + "userProfile": {}, + "medicalSnapshot": {}, + "emergencySummary": {} + } +} +``` + +## 9) Helper Stats + +### Get Helper Stats (Helper API) +**GET** `{{baseUrl}}/api/helpers/v1/stats/{{helperAuthId}}` +Headers: `Authorization: Bearer {{helperToken}}` + +Expected: +```json +{ + "success": true, + "data": { + "today": {}, + "earnings": { + "total": 1500, + "pending": 0 + }, + "stats": { + "casesSolved": 1, + "activeCases": 0 + } + } +} +``` + +## 10) Payment API (Direct Testing) + +### Create Payment (User API) +**POST** `{{baseUrl}}/api/payments` +Headers: `Authorization: Bearer {{userToken}}` +```json +{ + "emergencyId": "{{emergencyId}}", + "helperId": "{{helperAuthId}}", + "userId": "{{userAuthId}}", + "amount": 1500, + "method": "upi", + "serviceType": "paramedic" +} +``` + +### Release Payment (User API) +**PATCH** `{{baseUrl}}/api/payments/{{paymentId}}/release` +Headers: `Authorization: Bearer {{userToken}}` + +## 11) Negative Tests + +- Approve payment before resolve → expect `400` with message: `Payment can only be approved after resolution`. +- Verify OTP before approve → expect `400` with message: `OTP not available for verification`. +- Non-owner tries approve/verify → expect `403` unauthorized. +- Helper tries SOS profile for unrelated emergency → expect `403` unauthorized. + +## 12) Common Errors + +- `Payment not required for this emergency` → amount was 0 or missing on accept. +- `Payment is not pending approval` → already approved or released. +- `OTP expired` → OTP lifetime is 5 minutes. From 9ff1ff8d01631d448bc29d7b6f6442762fa5baef Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Sun, 1 Mar 2026 07:51:47 +0530 Subject: [PATCH 05/12] feat(Map): enhance location handling with improved accuracy checks and smooth transitions --- Lifeline-Frontend/app/(global)/Map.tsx | 629 +++++++++++++++++++++---- Lifeline-Frontend/app/Helper/Case.tsx | 0 2 files changed, 542 insertions(+), 87 deletions(-) create mode 100644 Lifeline-Frontend/app/Helper/Case.tsx diff --git a/Lifeline-Frontend/app/(global)/Map.tsx b/Lifeline-Frontend/app/(global)/Map.tsx index 2dedb42..35e474f 100644 --- a/Lifeline-Frontend/app/(global)/Map.tsx +++ b/Lifeline-Frontend/app/(global)/Map.tsx @@ -37,10 +37,32 @@ 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; @@ -71,11 +93,123 @@ const coordsFromArray = (value: unknown): Coords | 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; +}; + +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 = haversineDistanceMeters(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 && + haversineDistanceMeters(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 locationMetaRef = useRef<{ accuracy?: number; altitude?: number; @@ -105,6 +239,7 @@ export default function MapScreen() { const emergencyIdParam = asSingleParam(emergencyId); const [loading, setLoading] = useState(true); + const [isMapReady, setIsMapReady] = useState(false); const [socketReady, setSocketReady] = useState(false); const [targetAuthId, setTargetAuthId] = useState(null); const [resolvedSelfAuthId, setResolvedSelfAuthId] = useState( @@ -125,6 +260,7 @@ export default function MapScreen() { const [routeLoading, setRouteLoading] = useState(false); const [routeSource, setRouteSource] = useState(""); const [isPrimaryLoading, setIsPrimaryLoading] = useState(false); + const [mapHtml, setMapHtml] = useState(""); const fetchAuthById = useCallback(async (id: string) => { try { @@ -195,31 +331,227 @@ export default function MapScreen() { ); }, []); + 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 = haversineDistanceMeters(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 && + haversineDistanceMeters(previous.coords, rawCoords) > 120 + ) { + return; + } + + if ( + previous && + (typeof accuracy !== "number" || accuracy > 25) && + haversineDistanceMeters(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 && + haversineDistanceMeters(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) && + haversineDistanceMeters(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 && + haversineDistanceMeters(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(() => { let isMounted = true; - const initializeMyLocation = async (role: UserRole) => { + const initializeMyLocation = async (role: UserRole): Promise => { try { + if (Platform.OS === "android") { + await Location.enableNetworkProviderAsync().catch(() => {}); + } + const { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== "granted") return; + 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 (!bestFix || (fix.coords.accuracy ?? Infinity) < (bestFix.coords.accuracy ?? Infinity)) { + bestFix = fix; + } + + if ((fix.coords.accuracy ?? Infinity) <= 20) { + break; + } + + if (attempt < 3) { + await sleep(650); + } + } + + if (!bestFix) return null; + + const accuracy = bestFix.coords.accuracy || undefined; + if (typeof accuracy === "number" && accuracy > HARD_REJECT_ACCURACY_METERS) { + return null; + } - const loc = await Location.getCurrentPositionAsync({ - accuracy: Location.Accuracy.BestForNavigation, - }); const coords = { - latitude: loc.coords.latitude, - longitude: loc.coords.longitude, + 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: loc.coords.accuracy || undefined, - altitude: loc.coords.altitude || undefined, - speed: loc.coords.speed || undefined, - heading: loc.coords.heading || undefined, + accuracy, + altitude: bestFix.coords.altitude || undefined, + speed: bestFix.coords.speed || undefined, + heading: bestFix.coords.heading || undefined, }; - if (!isMounted) return; + if (!isMounted) return coords; if (role === "helper") { setHelperLocation(coords); setUserLocation(coords); @@ -227,8 +559,10 @@ export default function MapScreen() { setUserLocation(coords); setHelperLocation(coords); } + return coords; } catch (error) { console.error("Failed to initialize current location:", error); + return null; } }; @@ -238,17 +572,34 @@ export default function MapScreen() { 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; 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 { - await initializeMyLocation(effectiveRole); + const myInitialCoords = await initializeMyLocation(effectiveRole); + if (effectiveRole === "helper") { + initialHelperCoords = myInitialCoords; + initialUserCoords = myInitialCoords; + } else { + initialUserCoords = myInitialCoords; + initialHelperCoords = myInitialCoords; + } const emergencyData = emergencyIdParam ? await fetchEmergencyById(emergencyIdParam) : null; @@ -296,6 +647,12 @@ export default function MapScreen() { 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); } } @@ -342,11 +699,22 @@ export default function MapScreen() { 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); } } @@ -359,6 +727,13 @@ export default function MapScreen() { ); } 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); @@ -390,6 +765,7 @@ export default function MapScreen() { const data = JSON.parse(event.nativeEvent.data); if (data.type === "mapReady") { mapReadyRef.current = true; + setIsMapReady(true); } else if (data.type === "routeInfo") { setDistance(data.distance); setEta(data.duration); @@ -400,6 +776,15 @@ export default function MapScreen() { } catch {} }, []); + useEffect(() => { + if (!isMapReady) return; + + postOwnLocationToMap(myLocationRef.current, myRole); + + const targetCoords = myRole === "helper" ? userLocation : helperLocation; + postTargetLocationToMap(targetCoords, myRole); + }, [helperLocation, isMapReady, myRole, postOwnLocationToMap, postTargetLocationToMap, userLocation]); + useEffect(() => { if (!resolvedSelfAuthId) return; @@ -420,6 +805,12 @@ export default function MapScreen() { }; }, [myRole, resolvedSelfAuthId]); + useEffect(() => { + lastTargetFixRef.current = null; + recentTargetFixesRef.current = []; + pendingTargetJumpRef.current = null; + }, [targetAuthId]); + useEffect(() => { if (!socketReady || !emergencyIdParam) return; @@ -439,20 +830,14 @@ export default function MapScreen() { const incomingUserId = pickFirstString(payload?.userId); if (!incomingUserId || incomingUserId !== targetAuthId) return; - const coords = toCoords(payload?.location || payload); + const locationPayload = payload?.location || payload; + const coords = toCoords(locationPayload); if (!coords) return; - if (myRole === "helper") { - setUserLocation((prev) => - prev.latitude === coords.latitude && prev.longitude === coords.longitude ? prev : coords - ); - } else { - setHelperLocation((prev) => - prev.latitude === coords.latitude && prev.longitude === coords.longitude ? prev : coords - ); - } - - postTargetLocationToMap(coords, myRole); + 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) => { @@ -468,7 +853,7 @@ export default function MapScreen() { unsubscribeDirect(); unsubscribeEmergency(); }; - }, [myRole, postTargetLocationToMap, socketReady, targetAuthId]); + }, [applyTargetCoords, myRole, socketReady, targetAuthId]); useEffect(() => { let subscription: Location.LocationSubscription | null = null; @@ -481,35 +866,25 @@ export default function MapScreen() { subscription = await Location.watchPositionAsync( { accuracy: Location.Accuracy.BestForNavigation, - timeInterval: 5000, - distanceInterval: 0, + timeInterval: 1500, + distanceInterval: 1, mayShowUserSettingsDialog: true, }, (loc) => { - const nextCoords = { + const rawCoords = { latitude: loc.coords.latitude, longitude: loc.coords.longitude, }; - const prev = myLocationRef.current; - if (prev.latitude === nextCoords.latitude && prev.longitude === nextCoords.longitude) { - return; - } - - myLocationRef.current = nextCoords; + const accuracy = loc.coords.accuracy || undefined; + const timestampMs = toTimestampMs(loc.timestamp); locationMetaRef.current = { - accuracy: loc.coords.accuracy || undefined, + accuracy, altitude: loc.coords.altitude || undefined, speed: loc.coords.speed || undefined, heading: loc.coords.heading || undefined, }; - if (myRole === "helper") { - setHelperLocation(nextCoords); - } else { - setUserLocation(nextCoords); - } - - postOwnLocationToMap(nextCoords, myRole); + applyOwnCoords(rawCoords, accuracy, timestampMs, myRole); } ); }; @@ -522,7 +897,7 @@ export default function MapScreen() { isMounted = false; if (subscription) subscription.remove(); }; - }, [myRole, postOwnLocationToMap]); + }, [applyOwnCoords, myRole]); const myLatitude = myRole === "helper" ? helperLocation.latitude : userLocation.latitude; const myLongitude = myRole === "helper" ? helperLocation.longitude : userLocation.longitude; @@ -629,13 +1004,10 @@ export default function MapScreen() { } }, [emergencyIdParam, myRole, router]); - const generateMapHtml = useCallback(() => { - const centerLat = (helperLocation.latitude + userLocation.latitude) / 2; - const centerLng = (helperLocation.longitude + userLocation.longitude) / 2; - - // 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 ` @@ -695,8 +1067,8 @@ export default function MapScreen() {