diff --git a/backend/package-lock.json b/backend/package-lock.json index 89cbd92..ce5e0f0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "express-validator": "^7.3.2", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", @@ -23,6 +24,7 @@ "multer": "^2.1.1", "pdf-parse": "^2.4.5", "sharp": "^0.34.5", + "socket.io": "^4.8.3", "tesseract.js": "^7.0.0" }, "devDependencies": { @@ -697,6 +699,30 @@ "node": ">= 10" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -712,6 +738,15 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -764,6 +799,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -1171,6 +1215,102 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1259,6 +1399,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-validator": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", @@ -1554,6 +1712,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2650,6 +2817,159 @@ "node": ">=10" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -2817,6 +3137,12 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2884,6 +3210,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/zlibjs": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", diff --git a/backend/package.json b/backend/package.json index e34a0e1..1970a73 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "express-validator": "^7.3.2", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", @@ -27,6 +28,7 @@ "multer": "^2.1.1", "pdf-parse": "^2.4.5", "sharp": "^0.34.5", + "socket.io": "^4.8.3", "tesseract.js": "^7.0.0" }, "devDependencies": { diff --git a/backend/server.js b/backend/server.js index 9dc0071..e88152b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,34 +1,41 @@ import express from 'express'; +import http from 'http'; import mongoose from 'mongoose'; import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; import compression from 'compression'; import dotenv from 'dotenv'; - +import { initSocket } from './src/socket/socket.js'; +import { apiLimiter } from './src/middleware/rateLimit.middleware.js'; +import { logAudit } from './src/middleware/audit.middleware.js'; dotenv.config(); const app = express(); +const server = http.createServer(app); -// Security middleware -app.use(helmet()); +// Security & utility middleware +app.use(helmet({ + crossOriginResourcePolicy: false // Allow static /uploads images/files +})); app.use(cors({ - origin: 'http://localhost:5173', + origin: ['http://localhost:5173', 'http://127.0.0.1:5173', '*'], credentials: true })); app.use(compression()); app.use(morgan('combined')); -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ extended: true, limit: '50mb' })); + +// Rate limiter for API routes +app.use('/api/', apiLimiter); // Static files for uploads app.use('/uploads', express.static('uploads')); // Database connection -mongoose.connect(process.env.MONGODB_URI, { - -}) +mongoose.connect(process.env.MONGODB_URI, {}) .then(() => console.log('✅ MongoDB Connected')) .catch(err => console.error('❌ MongoDB connection error:', err)); @@ -37,12 +44,28 @@ import authRoutes from './src/routes/auth.routes.js'; import prescriptionRoutes from './src/routes/prescription.routes.js'; import symptomRoutes from './src/routes/symptom.routes.js'; import reportRoutes from './src/routes/report.routes.js'; +import doctorRoutes from './src/routes/doctor.routes.js'; +import patientRoutes from './src/routes/patient.routes.js'; +import appointmentRoutes from './src/routes/appointment.routes.js'; +import chatRoutes from './src/routes/chat.routes.js'; +import digitalPrescriptionRoutes from './src/routes/digitalPrescription.routes.js'; +import paymentRoutes from './src/routes/payment.routes.js'; +import notificationRoutes from './src/routes/notification.routes.js'; +import adminRoutes from './src/routes/admin.routes.js'; // Use routes app.use('/api/auth', authRoutes); app.use('/api/prescriptions', prescriptionRoutes); app.use('/api/symptoms', symptomRoutes); app.use('/api/reports', reportRoutes); +app.use('/api/doctors', doctorRoutes); +app.use('/api/patients', patientRoutes); +app.use('/api/appointments', appointmentRoutes); +app.use('/api/chat', chatRoutes); +app.use('/api/digital-prescriptions', digitalPrescriptionRoutes); +app.use('/api/payments', paymentRoutes); +app.use('/api/notifications', notificationRoutes); +app.use('/api/admin', adminRoutes); // Error handling middleware app.use((err, req, res, next) => { @@ -53,7 +76,10 @@ app.use((err, req, res, next) => { }); }); -const PORT = process.env.PORT || 5000; -app.listen(PORT, () => { - console.log(`🚀 Server running on port ${PORT}`); +// Initialize Socket.IO +initSocket(server); + +const PORT = process.env.PORT || 4500; +server.listen(PORT, () => { + console.log(`🚀 Server and Socket.IO running on port ${PORT}`); }); \ No newline at end of file diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js new file mode 100644 index 0000000..3be337a --- /dev/null +++ b/backend/src/controllers/admin.controller.js @@ -0,0 +1,172 @@ +import User from '../models/User.model.js'; +import DoctorProfile from '../models/DoctorProfile.model.js'; +import PatientProfile from '../models/PatientProfile.model.js'; +import Appointment from '../models/Appointment.model.js'; +import Payment from '../models/Payment.model.js'; +import AuditLog from '../models/AuditLog.model.js'; +import Notification from '../models/Notification.model.js'; +import { createAuditLog } from '../middleware/audit.middleware.js'; + +// ---------------- GET PLATFORM ANALYTICS ---------------- +export const getPlatformAnalytics = async (req, res) => { + try { + const [ + totalPatients, + totalDoctors, + totalAppointments, + pendingVerifications, + completedPayments, + recentLogs + ] = await Promise.all([ + User.countDocuments({ role: 'patient' }), + User.countDocuments({ role: 'doctor' }), + Appointment.countDocuments(), + DoctorProfile.countDocuments({ verificationStatus: 'pending' }), + Payment.find({ status: 'completed' }), + AuditLog.find().populate('userId', 'name email role').sort('-createdAt').limit(15) + ]); + + const totalRevenue = completedPayments.reduce((acc, curr) => acc + (curr.amount || 0), 0); + + res.json({ + success: true, + analytics: { + totalPatients, + totalDoctors, + totalAppointments, + pendingVerifications, + totalRevenue, + recentLogs + } + }); + } catch (error) { + console.error('Get Platform Analytics Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- VERIFY OR REJECT DOCTOR ---------------- +export const verifyDoctor = async (req, res) => { + try { + const { profileId } = req.params; + const { status, rejectionReason } = req.body; // 'verified' or 'rejected' + + if (!['verified', 'rejected', 'pending'].includes(status)) { + return res.status(400).json({ success: false, message: 'Invalid status. Use verified, rejected, or pending' }); + } + + const doctorProfile = await DoctorProfile.findById(profileId).populate('userId', 'name email status isVerified'); + if (!doctorProfile) { + return res.status(404).json({ success: false, message: 'Doctor profile not found' }); + } + + doctorProfile.verificationStatus = status; + if (status === 'rejected') { + doctorProfile.rejectionReason = rejectionReason || 'License verification failed'; + } else { + doctorProfile.rejectionReason = ''; + if (doctorProfile.userId) { + doctorProfile.userId.isVerified = true; + await doctorProfile.userId.save(); + } + } + await doctorProfile.save(); + + // Send notification + if (doctorProfile.userId) { + await Notification.create({ + userId: doctorProfile.userId._id, + title: `Doctor Verification ${status.toUpperCase()}`, + message: status === 'verified' + ? 'Congratulations! Your medical license has been verified by the administrator.' + : `Your doctor verification was rejected: ${rejectionReason || 'Incomplete documentation'}`, + type: 'system', + link: '/doctor-dashboard', + relatedId: doctorProfile._id + }); + } + + await createAuditLog(req.user._id, `Verified doctor profile ${doctorProfile.userId?.name} status -> ${status}`); + + res.json({ success: true, message: `Doctor profile status updated to ${status}`, doctorProfile }); + } catch (error) { + console.error('Verify Doctor Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET ALL USERS ---------------- +export const getAllUsers = async (req, res) => { + try { + const { role, status, search } = req.query; + const filter = {}; + if (role && role !== 'All') filter.role = role; + if (status && status !== 'All') filter.status = status; + if (search) { + filter.$or = [ + { name: { $regex: search, $options: 'i' } }, + { email: { $regex: search, $options: 'i' } } + ]; + } + + const users = await User.find(filter).select('-password').sort('-createdAt'); + res.json({ success: true, users }); + } catch (error) { + console.error('Get All Users Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- UPDATE USER STATUS OR ROLE ---------------- +export const updateUserStatus = async (req, res) => { + try { + const { userId } = req.params; + const { status, role } = req.body; + + const user = await User.findById(userId).select('-password'); + if (!user) { + return res.status(404).json({ success: false, message: 'User not found' }); + } + + if (status) user.status = status; + if (role) { + user.role = role; + // Ensure profile exists for the new role + if (role === 'doctor') { + const docExists = await DoctorProfile.findOne({ userId }); + if (!docExists) await DoctorProfile.create({ userId }); + } else if (role === 'patient') { + const patExists = await PatientProfile.findOne({ userId }); + if (!patExists) await PatientProfile.create({ userId }); + } + } + await user.save(); + + await createAuditLog(req.user._id, `Updated user ${user.email} -> status: ${user.status}, role: ${user.role}`); + + res.json({ success: true, message: 'User updated successfully', user }); + } catch (error) { + console.error('Update User Status Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET AUDIT LOGS ---------------- +export const getAuditLogs = async (req, res) => { + try { + const { action, status, limit } = req.query; + const filter = {}; + if (action) filter.action = { $regex: action, $options: 'i' }; + if (status && status !== 'All') filter.status = status; + + const logs = await AuditLog.find(filter) + .populate('userId', 'name email role') + .sort('-createdAt') + .limit(Number(limit) || 100); + + res.json({ success: true, logs }); + } catch (error) { + console.error('Get Audit Logs Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend/src/controllers/appointment.controller.js b/backend/src/controllers/appointment.controller.js new file mode 100644 index 0000000..af3c359 --- /dev/null +++ b/backend/src/controllers/appointment.controller.js @@ -0,0 +1,248 @@ +import Appointment from '../models/Appointment.model.js'; +import TimeSlot from '../models/TimeSlot.model.js'; +import Notification from '../models/Notification.model.js'; +import DoctorProfile from '../models/DoctorProfile.model.js'; +import User from '../models/User.model.js'; +import { createAuditLog } from '../middleware/audit.middleware.js'; + +// ---------------- BOOK APPOINTMENT ---------------- +export const bookAppointment = async (req, res) => { + try { + const patientId = req.user._id; + const { doctorId, appointmentDate, timeSlot, consultationType, reasonForVisit, symptoms, feeAmount } = req.body; + + if (!doctorId || !appointmentDate || !timeSlot || !reasonForVisit) { + return res.status(400).json({ success: false, message: 'Please provide doctorId, appointmentDate, timeSlot, and reasonForVisit' }); + } + + // Create appointment + const appointment = await Appointment.create({ + patientId, + doctorId, + appointmentDate, + timeSlot, + consultationType: consultationType || 'Video Call', + reasonForVisit, + symptoms: symptoms || [], + feeAmount: feeAmount || 500, + status: 'booked', + paymentStatus: 'paid' // Simulated instant checkout for smooth testing + }); + + // Mark time slot as booked if TimeSlot document exists for that date + const dateStr = new Date(appointmentDate).toISOString().split('T')[0]; + let slotDoc = await TimeSlot.findOne({ doctorId, date: dateStr }); + if (slotDoc) { + const slotObj = slotDoc.slots.find(s => s.time === timeSlot); + if (slotObj) { + slotObj.isBooked = true; + slotObj.appointmentId = appointment._id; + await slotDoc.save(); + } + } + + // Send notification to doctor + await Notification.create({ + userId: doctorId, + title: 'New Appointment Booked', + message: `${req.user.name} booked a ${consultationType || 'Video Call'} for ${dateStr} at ${timeSlot}.`, + type: 'appointment', + link: '/doctor-dashboard', + relatedId: appointment._id + }); + + // Audit log + await createAuditLog(patientId, `Booked appointment with doctor ${doctorId} for ${dateStr}`); + + const populatedAppointment = await Appointment.findById(appointment._id) + .populate('patientId', 'name email phone avatarUrl') + .populate('doctorId', 'name email phone avatarUrl'); + + res.status(201).json({ + success: true, + message: 'Appointment booked successfully', + appointment: populatedAppointment + }); + } catch (error) { + console.error('Book Appointment Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET APPOINTMENTS ---------------- +export const getAppointments = async (req, res) => { + try { + const { status, date } = req.query; + const filter = {}; + + if (req.user.role === 'patient') { + filter.patientId = req.user._id; + } else if (req.user.role === 'doctor') { + filter.doctorId = req.user._id; + } // admin sees all + + if (status && status !== 'All') { + filter.status = status; + } + if (date) { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + filter.appointmentDate = { $gte: startOfDay, $lte: endOfDay }; + } + + const appointments = await Appointment.find(filter) + .populate('patientId', 'name email phone avatarUrl') + .populate('doctorId', 'name email phone avatarUrl') + .sort('-appointmentDate'); + + res.json({ success: true, appointments }); + } catch (error) { + console.error('Get Appointments Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- UPDATE APPOINTMENT STATUS ---------------- +export const updateAppointmentStatus = async (req, res) => { + try { + const { id } = req.params; + const { status, doctorNotes, rejectionReason } = req.body; + + const appointment = await Appointment.findById(id) + .populate('patientId', 'name email phone') + .populate('doctorId', 'name email phone'); + + if (!appointment) { + return res.status(404).json({ success: false, message: 'Appointment not found' }); + } + + // Verify ownership or admin + if (req.user.role !== 'admin' && req.user._id.toString() !== appointment.doctorId._id.toString() && req.user._id.toString() !== appointment.patientId._id.toString()) { + return res.status(403).json({ success: false, message: 'Not authorized to modify this appointment' }); + } + + if (status) appointment.status = status; + if (doctorNotes !== undefined) appointment.doctorNotes = doctorNotes; + if (rejectionReason !== undefined) appointment.rejectionReason = rejectionReason; + + await appointment.save(); + + // Notify the other party + const targetUserId = req.user.role === 'patient' ? appointment.doctorId._id : appointment.patientId._id; + await Notification.create({ + userId: targetUserId, + title: `Appointment ${status.toUpperCase()}`, + message: `Your appointment on ${new Date(appointment.appointmentDate).toLocaleDateString()} at ${appointment.timeSlot} has been marked as ${status}.`, + type: 'appointment', + link: req.user.role === 'patient' ? '/doctor-dashboard' : '/appointments', + relatedId: appointment._id + }); + + await createAuditLog(req.user._id, `Updated appointment ${id} status to ${status}`); + + res.json({ + success: true, + message: `Appointment ${status} successfully`, + appointment + }); + } catch (error) { + console.error('Update Appointment Status Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- RESCHEDULE APPOINTMENT ---------------- +export const rescheduleAppointment = async (req, res) => { + try { + const { id } = req.params; + const { newDate, newTimeSlot } = req.body; + + const appointment = await Appointment.findById(id); + if (!appointment) { + return res.status(404).json({ success: false, message: 'Appointment not found' }); + } + + appointment.appointmentDate = newDate; + appointment.timeSlot = newTimeSlot; + appointment.status = 'approved'; // Re-approve when rescheduled + await appointment.save(); + + const targetUserId = req.user.role === 'patient' ? appointment.doctorId : appointment.patientId; + await Notification.create({ + userId: targetUserId, + title: 'Appointment Rescheduled', + message: `Appointment has been rescheduled to ${new Date(newDate).toLocaleDateString()} at ${newTimeSlot}.`, + type: 'appointment', + link: req.user.role === 'patient' ? '/doctor-dashboard' : '/appointments', + relatedId: appointment._id + }); + + await createAuditLog(req.user._id, `Rescheduled appointment ${id}`); + + res.json({ + success: true, + message: 'Appointment rescheduled successfully', + appointment + }); + } catch (error) { + console.error('Reschedule Appointment Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET OR GENERATE AVAILABLE SLOTS ---------------- +export const getDoctorAvailableSlots = async (req, res) => { + try { + const { doctorId, date } = req.query; // date: YYYY-MM-DD + if (!doctorId || !date) { + return res.status(400).json({ success: false, message: 'doctorId and date are required' }); + } + + let slotDoc = await TimeSlot.findOne({ doctorId, date }); + if (!slotDoc) { + // Auto-generate standard hourly slots from DoctorProfile or default 9:00 AM to 6:00 PM + const doctorProfile = await DoctorProfile.findOne({ userId: doctorId }); + const defaultSlots = [ + { time: '09:00 AM', isBooked: false }, + { time: '10:00 AM', isBooked: false }, + { time: '11:00 AM', isBooked: false }, + { time: '12:00 PM', isBooked: false }, + { time: '02:00 PM', isBooked: false }, + { time: '03:00 PM', isBooked: false }, + { time: '04:00 PM', isBooked: false }, + { time: '05:00 PM', isBooked: false } + ]; + + slotDoc = await TimeSlot.create({ + doctorId, + date, + slots: defaultSlots + }); + } + + // Check existing appointments on that date to ensure accuracy + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + const existingAppointments = await Appointment.find({ + doctorId, + appointmentDate: { $gte: startOfDay, $lte: endOfDay }, + status: { $in: ['booked', 'approved'] } + }); + + const bookedTimes = new Set(existingAppointments.map(a => a.timeSlot)); + + const availableSlots = slotDoc.slots.map(s => ({ + time: s.time, + isBooked: s.isBooked || bookedTimes.has(s.time) + })); + + res.json({ success: true, date, slots: availableSlots }); + } catch (error) { + console.error('Get Available Slots Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 80caa71..8fbc954 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -1,4 +1,7 @@ import User from '../models/User.model.js'; +import DoctorProfile from '../models/DoctorProfile.model.js'; +import PatientProfile from '../models/PatientProfile.model.js'; +import AuditLog from '../models/AuditLog.model.js'; import jwt from 'jsonwebtoken'; import { validationResult } from 'express-validator'; @@ -13,6 +16,17 @@ const generateToken = (id) => { ); }; +// Generate Refresh Token +const generateRefreshToken = (id) => { + return jwt.sign( + { id }, + process.env.JWT_SECRET, + { + expiresIn: '30d' + } + ); +}; + // ---------------- REGISTER ---------------- export const register = async (req, res, next) => { try { @@ -25,7 +39,7 @@ export const register = async (req, res, next) => { }); } - const { name, email, password, role } = req.body; + const { name, email, password, role, phone } = req.body; const userExists = await User.findOne({ email }); if (userExists) { @@ -35,17 +49,36 @@ export const register = async (req, res, next) => { }); } - const user = await User.create({ name, email, password, role }); + const user = await User.create({ name, email, password, role, phone: phone || '' }); + + // Auto-create profile based on role + if (user.role === 'doctor') { + await DoctorProfile.create({ userId: user._id }); + } else if (user.role === 'patient') { + await PatientProfile.create({ userId: user._id }); + } + + // Audit Log + await AuditLog.create({ + userId: user._id, + action: `User registered as ${user.role}`, + status: 'success' + }); + const token = generateToken(user._id); + const refreshToken = generateRefreshToken(user._id); res.status(201).json({ success: true, token, + refreshToken, user: { id: user._id, name: user.name, email: user.email, - role: user.role + role: user.role, + phone: user.phone, + avatarUrl: user.avatarUrl } }); @@ -82,18 +115,36 @@ export const login = async (req, res) => { }); } + if (user.status === 'suspended') { + return res.status(403).json({ + success: false, + message: 'Your account has been suspended by an administrator.' + }); + } + // Generate Token const token = generateToken(user._id); + const refreshToken = generateRefreshToken(user._id); + + // Audit Log + await AuditLog.create({ + userId: user._id, + action: 'User logged in', + status: 'success' + }); // Response res.status(200).json({ success: true, token, + refreshToken, user: { id: user._id, name: user.name, email: user.email, - role: user.role + role: user.role, + phone: user.phone, + avatarUrl: user.avatarUrl } }); @@ -110,12 +161,10 @@ export const login = async (req, res) => { // ---------------- GET CURRENT USER ---------------- export const getMe = async (req, res) => { try { - res.status(200).json({ success: true, user: req.user }); - } catch (error) { console.error('GetMe Error:', error); @@ -124,4 +173,116 @@ export const getMe = async (req, res) => { message: error.message }); } +}; + +// ---------------- REFRESH TOKEN ---------------- +export const refreshToken = async (req, res) => { + try { + const { token } = req.body; + if (!token) { + return res.status(400).json({ success: false, message: 'Refresh token required' }); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const user = await User.findById(decoded.id).select('-password'); + if (!user) { + return res.status(401).json({ success: false, message: 'Invalid refresh token' }); + } + + const newToken = generateToken(user._id); + res.json({ success: true, token: newToken }); + } catch (error) { + res.status(401).json({ success: false, message: 'Token expired or invalid' }); + } +}; + +// ---------------- FORGOT PASSWORD ---------------- +export const forgotPassword = async (req, res) => { + try { + const { email } = req.body; + const user = await User.findOne({ email }); + if (!user) { + // Return success even if not found to prevent user enumeration + return res.json({ success: true, message: 'If that email exists, a reset link has been sent.' }); + } + + // For demo/instant testing: generate a simple reset token and return it or store it + const resetToken = Math.random().toString(36).substring(2, 10); + user.resetPasswordToken = resetToken; + user.resetPasswordExpire = Date.now() + 30 * 60 * 1000; // 30 mins + await user.save(); + + await AuditLog.create({ + userId: user._id, + action: 'Password reset requested', + status: 'success' + }); + + res.json({ + success: true, + message: 'Password reset token generated successfully.', + resetToken // Returned directly for immediate UI demonstration + }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- RESET PASSWORD ---------------- +export const resetPassword = async (req, res) => { + try { + const { email, resetToken, newPassword } = req.body; + const user = await User.findOne({ + email, + resetPasswordToken: resetToken, + resetPasswordExpire: { $gt: Date.now() } + }); + + if (!user) { + return res.status(400).json({ success: false, message: 'Invalid or expired reset token' }); + } + + user.password = newPassword; + user.resetPasswordToken = undefined; + user.resetPasswordExpire = undefined; + await user.save(); + + await AuditLog.create({ + userId: user._id, + action: 'Password reset successful', + status: 'success' + }); + + res.json({ success: true, message: 'Password has been reset successfully. You can now log in.' }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- UPDATE PROFILE ---------------- +export const updateProfile = async (req, res) => { + try { + const { name, phone, avatarUrl } = req.body; + const user = await User.findById(req.user._id); + + if (name) user.name = name; + if (phone !== undefined) user.phone = phone; + if (avatarUrl !== undefined) user.avatarUrl = avatarUrl; + + await user.save(); + + res.json({ + success: true, + user: { + id: user._id, + name: user.name, + email: user.email, + role: user.role, + phone: user.phone, + avatarUrl: user.avatarUrl + } + }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } }; \ No newline at end of file diff --git a/backend/src/controllers/chat.controller.js b/backend/src/controllers/chat.controller.js new file mode 100644 index 0000000..84ad519 --- /dev/null +++ b/backend/src/controllers/chat.controller.js @@ -0,0 +1,166 @@ +import Conversation from '../models/Conversation.model.js'; +import Message from '../models/Message.model.js'; +import User from '../models/User.model.js'; + +// ---------------- GET USER CONVERSATIONS ---------------- +export const getConversations = async (req, res) => { + try { + const userId = req.user._id; + let conversations = await Conversation.find({ participants: userId }) + .populate('participants', 'name email role avatarUrl status') + .sort('-updatedAt'); + + res.json({ success: true, conversations }); + } catch (error) { + console.error('Get Conversations Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- CREATE OR GET CONVERSATION ---------------- +export const createOrGetConversation = async (req, res) => { + try { + const userId = req.user._id; + const { participantId, appointmentId } = req.body; + + if (!participantId) { + return res.status(400).json({ success: false, message: 'participantId is required' }); + } + + // Check if conversation already exists between the two participants + let conversation = await Conversation.findOne({ + participants: { $all: [userId, participantId] } + }).populate('participants', 'name email role avatarUrl status'); + + if (!conversation) { + conversation = await Conversation.create({ + participants: [userId, participantId], + appointmentId: appointmentId || null, + lastMessage: { text: 'Started a new conversation', createdAt: new Date() } + }); + conversation = await Conversation.findById(conversation._id).populate('participants', 'name email role avatarUrl status'); + } + + res.json({ success: true, conversation }); + } catch (error) { + console.error('Create Or Get Conversation Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET MESSAGES FOR CONVERSATION ---------------- +export const getMessages = async (req, res) => { + try { + const { conversationId } = req.params; + + const messages = await Message.find({ conversationId }) + .populate('sender', 'name avatarUrl role') + .sort('createdAt'); + + // Mark messages not sent by logged in user as seen + await Message.updateMany( + { conversationId, sender: { $ne: req.user._id }, seenBy: { $ne: req.user._id } }, + { $addToSet: { seenBy: req.user._id } } + ); + + res.json({ success: true, messages }); + } catch (error) { + console.error('Get Messages Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- SEND MESSAGE (REST API OPTION) ---------------- +export const sendMessage = async (req, res) => { + try { + const { conversationId, text, attachments } = req.body; + const sender = req.user._id; + + if (!conversationId || (!text && (!attachments || attachments.length === 0))) { + return res.status(400).json({ success: false, message: 'Please provide conversationId and text or attachments' }); + } + + const message = await Message.create({ + conversationId, + sender, + text: text || '', + attachments: attachments || [], + seenBy: [sender] + }); + + // Update conversation lastMessage + await Conversation.findByIdAndUpdate(conversationId, { + lastMessage: { + text: text || (attachments && attachments.length > 0 ? `Sent an attachment` : 'New message'), + sender, + createdAt: new Date() + } + }); + + const populatedMessage = await Message.findById(message._id).populate('sender', 'name avatarUrl role'); + + res.status(201).json({ success: true, message: populatedMessage }); + } catch (error) { + console.error('Send Message Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- SEARCH MESSAGES ---------------- +export const searchMessages = async (req, res) => { + try { + const userId = req.user._id; + const { query } = req.query; + + if (!query) { + return res.json({ success: true, messages: [] }); + } + + // Find all user conversations first + const conversations = await Conversation.find({ participants: userId }).select('_id'); + const convIds = conversations.map(c => c._id); + + const messages = await Message.find({ + conversationId: { $in: convIds }, + text: { $regex: query, $options: 'i' } + }) + .populate('sender', 'name avatarUrl role') + .populate('conversationId') + .sort('-createdAt') + .limit(50); + + res.json({ success: true, messages }); + } catch (error) { + console.error('Search Messages Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- UPLOAD CHAT ATTACHMENT ---------------- +export const uploadChatAttachment = async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ success: false, message: 'No file uploaded' }); + } + + const fileUrl = req.file.path; + const fileName = req.file.originalname; + const ext = fileName.split('.').pop().toLowerCase(); + let fileType = 'other'; + if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) fileType = 'image'; + else if (ext === 'pdf') fileType = 'pdf'; + else if (['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) fileType = 'voice'; + + res.json({ + success: true, + attachment: { + fileUrl, + fileName, + fileType + } + }); + } catch (error) { + console.error('Upload Attachment Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend/src/controllers/digitalPrescription.controller.js b/backend/src/controllers/digitalPrescription.controller.js new file mode 100644 index 0000000..b0a4a77 --- /dev/null +++ b/backend/src/controllers/digitalPrescription.controller.js @@ -0,0 +1,112 @@ +import DigitalPrescription from '../models/DigitalPrescription.model.js'; +import Notification from '../models/Notification.model.js'; +import User from '../models/User.model.js'; +import Appointment from '../models/Appointment.model.js'; +import { createAuditLog } from '../middleware/audit.middleware.js'; + +// ---------------- CREATE DIGITAL PRESCRIPTION ---------------- +export const createDigitalPrescription = async (req, res) => { + try { + const doctorId = req.user._id; + const { + patientId, + appointmentId, + diagnosis, + medicines, + vitalsObserved, + generalNotes, + followUpDate + } = req.body; + + if (!patientId || !diagnosis || !medicines || !Array.isArray(medicines) || medicines.length === 0) { + return res.status(400).json({ success: false, message: 'Please provide patientId, diagnosis, and at least one medicine' }); + } + + const prescription = await DigitalPrescription.create({ + doctorId, + patientId, + appointmentId: appointmentId || null, + diagnosis, + medicines, + vitalsObserved: vitalsObserved || {}, + generalNotes: generalNotes || '', + followUpDate: followUpDate || null + }); + + // If appointmentId is provided, mark appointment completed + if (appointmentId) { + await Appointment.findByIdAndUpdate(appointmentId, { status: 'completed' }); + } + + // Send notification to patient + await Notification.create({ + userId: patientId, + title: 'New Digital Prescription Issued', + message: `Dr. ${req.user.name} issued a digital prescription for ${diagnosis}.`, + type: 'prescription', + link: '/prescription', + relatedId: prescription._id + }); + + await createAuditLog(doctorId, `Created digital prescription for patient ${patientId}`); + + const populatedPrescription = await DigitalPrescription.findById(prescription._id) + .populate('doctorId', 'name email phone avatarUrl') + .populate('patientId', 'name email phone avatarUrl'); + + res.status(201).json({ + success: true, + message: 'Digital Prescription issued successfully', + prescription: populatedPrescription + }); + } catch (error) { + console.error('Create Digital Prescription Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET PRESCRIPTIONS BY PATIENT OR DOCTOR ---------------- +export const getDigitalPrescriptions = async (req, res) => { + try { + const { patientId } = req.query; + const filter = {}; + + if (req.user.role === 'patient') { + filter.patientId = req.user._id; + } else if (req.user.role === 'doctor') { + if (patientId) filter.patientId = patientId; + else filter.doctorId = req.user._id; + } else if (patientId) { + filter.patientId = patientId; + } + + const prescriptions = await DigitalPrescription.find(filter) + .populate('doctorId', 'name email phone avatarUrl') + .populate('patientId', 'name email phone avatarUrl') + .sort('-createdAt'); + + res.json({ success: true, prescriptions }); + } catch (error) { + console.error('Get Digital Prescriptions Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET PRESCRIPTION BY ID ---------------- +export const getDigitalPrescriptionById = async (req, res) => { + try { + const { id } = req.params; + const prescription = await DigitalPrescription.findById(id) + .populate('doctorId', 'name email phone avatarUrl') + .populate('patientId', 'name email phone avatarUrl'); + + if (!prescription) { + return res.status(404).json({ success: false, message: 'Prescription not found' }); + } + + res.json({ success: true, prescription }); + } catch (error) { + console.error('Get Prescription By ID Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend/src/controllers/doctor.controller.js b/backend/src/controllers/doctor.controller.js new file mode 100644 index 0000000..c81ce70 --- /dev/null +++ b/backend/src/controllers/doctor.controller.js @@ -0,0 +1,174 @@ +import DoctorProfile from '../models/DoctorProfile.model.js'; +import User from '../models/User.model.js'; +import Review from '../models/Review.model.js'; + +// ---------------- GET ALL DOCTORS (SEARCH & FILTER) ---------------- +export const getAllDoctors = async (req, res) => { + try { + const { specialization, minFee, maxFee, rating, search } = req.query; + + const filter = {}; + if (specialization && specialization !== 'All') { + filter.specialization = { $regex: specialization, $options: 'i' }; + } + if (minFee || maxFee) { + filter.consultationFee = {}; + if (minFee) filter.consultationFee.$gte = Number(minFee); + if (maxFee) filter.consultationFee.$lte = Number(maxFee); + } + if (rating) { + filter.rating = { $gte: Number(rating) }; + } + + let doctorProfiles = await DoctorProfile.find(filter).populate('userId', 'name email phone avatarUrl isVerified status'); + + // If search text provided, match against doctor profile or user name + if (search) { + const searchRegex = new RegExp(search, 'i'); + doctorProfiles = doctorProfiles.filter(doc => + (doc.userId && doc.userId.name && searchRegex.test(doc.userId.name)) || + (doc.specialization && searchRegex.test(doc.specialization)) || + (doc.bio && searchRegex.test(doc.bio)) + ); + } + + // Filter out profiles where user is missing or inactive + const doctors = doctorProfiles + .filter(doc => doc.userId && doc.userId.status === 'active') + .map(doc => ({ + id: doc.userId._id, + profileId: doc._id, + name: doc.userId.name, + email: doc.userId.email, + phone: doc.userId.phone, + avatarUrl: doc.userId.avatarUrl, + specialization: doc.specialization, + qualifications: doc.qualifications, + experienceYears: doc.experienceYears, + consultationFee: doc.consultationFee, + bio: doc.bio, + rating: doc.rating, + totalReviews: doc.totalReviews, + verificationStatus: doc.verificationStatus, + availabilityDays: doc.availabilityDays, + availabilityHours: doc.availabilityHours + })); + + res.json({ success: true, doctors }); + } catch (error) { + console.error('Get All Doctors Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET DOCTOR BY ID ---------------- +export const getDoctorById = async (req, res) => { + try { + const { id } = req.params; // Can be userId or profileId + let docProfile = await DoctorProfile.findOne({ userId: id }).populate('userId', 'name email phone avatarUrl isVerified status'); + if (!docProfile) { + docProfile = await DoctorProfile.findById(id).populate('userId', 'name email phone avatarUrl isVerified status'); + } + + if (!docProfile || !docProfile.userId) { + return res.status(404).json({ success: false, message: 'Doctor not found' }); + } + + // Get reviews + const reviews = await Review.find({ doctorId: docProfile.userId._id }).populate('patientId', 'name avatarUrl').sort('-createdAt'); + + res.json({ + success: true, + doctor: { + id: docProfile.userId._id, + profileId: docProfile._id, + name: docProfile.userId.name, + email: docProfile.userId.email, + phone: docProfile.userId.phone, + avatarUrl: docProfile.userId.avatarUrl, + specialization: docProfile.specialization, + qualifications: docProfile.qualifications, + experienceYears: docProfile.experienceYears, + consultationFee: docProfile.consultationFee, + bio: docProfile.bio, + licenseNumber: docProfile.licenseNumber, + licenseDocumentUrl: docProfile.licenseDocumentUrl, + rating: docProfile.rating, + totalReviews: docProfile.totalReviews, + verificationStatus: docProfile.verificationStatus, + availabilityDays: docProfile.availabilityDays, + availabilityHours: docProfile.availabilityHours, + reviews + } + }); + } catch (error) { + console.error('Get Doctor By ID Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- UPDATE DOCTOR PROFILE ---------------- +export const updateDoctorProfile = async (req, res) => { + try { + const userId = req.user._id; + let docProfile = await DoctorProfile.findOne({ userId }); + + if (!docProfile) { + docProfile = new DoctorProfile({ userId }); + } + + const { + specialization, + qualifications, + experienceYears, + consultationFee, + bio, + licenseNumber, + availabilityDays, + availabilityHours + } = req.body; + + if (specialization !== undefined) docProfile.specialization = specialization; + if (qualifications !== undefined) docProfile.qualifications = qualifications; + if (experienceYears !== undefined) docProfile.experienceYears = experienceYears; + if (consultationFee !== undefined) docProfile.consultationFee = consultationFee; + if (bio !== undefined) docProfile.bio = bio; + if (licenseNumber !== undefined) docProfile.licenseNumber = licenseNumber; + if (availabilityDays !== undefined) docProfile.availabilityDays = availabilityDays; + if (availabilityHours !== undefined) docProfile.availabilityHours = availabilityHours; + + await docProfile.save(); + + res.json({ success: true, profile: docProfile }); + } catch (error) { + console.error('Update Doctor Profile Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- UPLOAD DOCTOR LICENSE ---------------- +export const uploadDoctorLicense = async (req, res) => { + try { + const userId = req.user._id; + let docProfile = await DoctorProfile.findOne({ userId }); + + if (!docProfile) { + docProfile = new DoctorProfile({ userId }); + } + + if (req.file) { + docProfile.licenseDocumentUrl = req.file.path; + // Mark as verification pending when new license is uploaded + docProfile.verificationStatus = 'pending'; + } else if (req.body.licenseDocumentUrl) { + docProfile.licenseDocumentUrl = req.body.licenseDocumentUrl; + docProfile.verificationStatus = 'pending'; + } + + await docProfile.save(); + res.json({ success: true, message: 'License uploaded successfully and submitted for admin verification.', profile: docProfile }); + } catch (error) { + console.error('Upload Doctor License Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend/src/controllers/notification.controller.js b/backend/src/controllers/notification.controller.js new file mode 100644 index 0000000..f721feb --- /dev/null +++ b/backend/src/controllers/notification.controller.js @@ -0,0 +1,42 @@ +import Notification from '../models/Notification.model.js'; + +// ---------------- GET USER NOTIFICATIONS ---------------- +export const getNotifications = async (req, res) => { + try { + const userId = req.user._id; + const notifications = await Notification.find({ userId }) + .sort('-createdAt') + .limit(30); + + const unreadCount = await Notification.countDocuments({ userId, isRead: false }); + + res.json({ success: true, unreadCount, notifications }); + } catch (error) { + console.error('Get Notifications Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- MARK NOTIFICATION AS READ ---------------- +export const markNotificationRead = async (req, res) => { + try { + const userId = req.user._id; + const { id } = req.params; + + if (id === 'all') { + await Notification.updateMany({ userId, isRead: false }, { isRead: true }); + return res.json({ success: true, message: 'All notifications marked as read' }); + } + + const notification = await Notification.findOneAndUpdate( + { _id: id, userId }, + { isRead: true }, + { new: true } + ); + + res.json({ success: true, notification }); + } catch (error) { + console.error('Mark Notification Read Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend/src/controllers/patient.controller.js b/backend/src/controllers/patient.controller.js new file mode 100644 index 0000000..e2b6f48 --- /dev/null +++ b/backend/src/controllers/patient.controller.js @@ -0,0 +1,105 @@ +import PatientProfile from '../models/PatientProfile.model.js'; +import User from '../models/User.model.js'; +import SymptomTimeline from '../models/SymptomTimeline.model.js'; +import Report from '../models/Report.model.js'; +import DigitalPrescription from '../models/DigitalPrescription.model.js'; +import Prescription from '../models/Prescription.model.js'; + +// ---------------- GET PATIENT PROFILE ---------------- +export const getPatientProfile = async (req, res) => { + try { + const userId = req.user._id; + let patientProfile = await PatientProfile.findOne({ userId }).populate('userId', 'name email phone avatarUrl status'); + + if (!patientProfile) { + patientProfile = await PatientProfile.create({ userId }); + patientProfile = await PatientProfile.findOne({ userId }).populate('userId', 'name email phone avatarUrl status'); + } + + res.json({ + success: true, + profile: { + id: patientProfile.userId._id, + profileId: patientProfile._id, + name: patientProfile.userId.name, + email: patientProfile.userId.email, + phone: patientProfile.userId.phone, + avatarUrl: patientProfile.userId.avatarUrl, + age: patientProfile.age, + gender: patientProfile.gender, + bloodGroup: patientProfile.bloodGroup, + allergies: patientProfile.allergies, + chronicConditions: patientProfile.chronicConditions, + emergencyContact: patientProfile.emergencyContact + } + }); + } catch (error) { + console.error('Get Patient Profile Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- UPDATE PATIENT PROFILE ---------------- +export const updatePatientProfile = async (req, res) => { + try { + const userId = req.user._id; + let patientProfile = await PatientProfile.findOne({ userId }); + + if (!patientProfile) { + patientProfile = new PatientProfile({ userId }); + } + + const { + age, + gender, + bloodGroup, + allergies, + chronicConditions, + emergencyContact + } = req.body; + + if (age !== undefined) patientProfile.age = age; + if (gender !== undefined) patientProfile.gender = gender; + if (bloodGroup !== undefined) patientProfile.bloodGroup = bloodGroup; + if (allergies !== undefined) patientProfile.allergies = allergies; + if (chronicConditions !== undefined) patientProfile.chronicConditions = chronicConditions; + if (emergencyContact !== undefined) patientProfile.emergencyContact = emergencyContact; + + await patientProfile.save(); + + res.json({ success: true, profile: patientProfile }); + } catch (error) { + console.error('Update Patient Profile Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET PATIENT MEDICAL HISTORY ---------------- +export const getPatientHistory = async (req, res) => { + try { + // If doctor/admin is requesting another patient's history, check query/param id + const targetUserId = (req.user.role === 'doctor' || req.user.role === 'admin') && req.query.patientId + ? req.query.patientId + : req.user._id; + + const [symptoms, reports, digitalPrescriptions, ocrPrescriptions] = await Promise.all([ + SymptomTimeline.find({ userId: targetUserId }).sort('-date'), + Report.find({ userId: targetUserId }).sort('-createdAt'), + DigitalPrescription.find({ patientId: targetUserId }).populate('doctorId', 'name').sort('-createdAt'), + Prescription.find({ userId: targetUserId }).sort('-createdAt') + ]); + + res.json({ + success: true, + history: { + symptoms, + reports, + digitalPrescriptions, + ocrPrescriptions + } + }); + } catch (error) { + console.error('Get Patient History Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend/src/controllers/payment.controller.js b/backend/src/controllers/payment.controller.js new file mode 100644 index 0000000..7e86472 --- /dev/null +++ b/backend/src/controllers/payment.controller.js @@ -0,0 +1,120 @@ +import Payment from '../models/Payment.model.js'; +import Appointment from '../models/Appointment.model.js'; +import Notification from '../models/Notification.model.js'; +import { createAuditLog } from '../middleware/audit.middleware.js'; + +// ---------------- CREATE PAYMENT ORDER ---------------- +export const createPaymentOrder = async (req, res) => { + try { + const patientId = req.user._id; + const { appointmentId, amount, paymentGateway } = req.body; + + const appointment = await Appointment.findById(appointmentId); + if (!appointment) { + return res.status(404).json({ success: false, message: 'Appointment not found' }); + } + + const transactionId = `TXN_${Date.now()}_${Math.random().toString(36).substring(2, 8).toUpperCase()}`; + const invoiceNumber = `INV-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`; + + const payment = await Payment.create({ + patientId, + doctorId: appointment.doctorId, + appointmentId, + amount: amount || appointment.feeAmount || 500, + currency: 'INR', + paymentGateway: paymentGateway || 'Mock', + transactionId, + status: 'completed', // Instant mock checkout for seamless verification + invoiceNumber + }); + + appointment.paymentStatus = 'paid'; + await appointment.save(); + + await Notification.create({ + userId: patientId, + title: 'Payment Successful & Invoice Generated', + message: `Your payment of INR ${payment.amount} for consultation with doctor was successful. Invoice: ${invoiceNumber}.`, + type: 'payment', + link: '/appointments', + relatedId: payment._id + }); + + await createAuditLog(patientId, `Completed payment of ${payment.amount} INR (Invoice: ${invoiceNumber})`); + + res.status(201).json({ + success: true, + message: 'Payment processed successfully', + payment + }); + } catch (error) { + console.error('Create Payment Order Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- GET USER OR DOCTOR PAYMENTS ---------------- +export const getPayments = async (req, res) => { + try { + const filter = {}; + if (req.user.role === 'patient') { + filter.patientId = req.user._id; + } else if (req.user.role === 'doctor') { + filter.doctorId = req.user._id; + } // admin sees all + + const payments = await Payment.find(filter) + .populate('patientId', 'name email phone') + .populate('doctorId', 'name email phone') + .populate('appointmentId', 'appointmentDate timeSlot consultationType') + .sort('-createdAt'); + + res.json({ success: true, payments }); + } catch (error) { + console.error('Get Payments Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ---------------- REFUND PAYMENT ---------------- +export const refundPayment = async (req, res) => { + try { + const { id } = req.params; + const { reason } = req.body; + + const payment = await Payment.findById(id); + if (!payment) { + return res.status(404).json({ success: false, message: 'Payment record not found' }); + } + + if (req.user.role !== 'admin' && req.user._id.toString() !== payment.doctorId.toString()) { + return res.status(403).json({ success: false, message: 'Only doctor or admin can refund payments' }); + } + + payment.status = 'refunded'; + payment.refundDetails = { + refundedAt: new Date(), + reason: reason || 'Patient requested refund or appointment cancelled' + }; + await payment.save(); + + await Appointment.findByIdAndUpdate(payment.appointmentId, { paymentStatus: 'refunded', status: 'cancelled' }); + + await Notification.create({ + userId: payment.patientId, + title: 'Payment Refunded', + message: `Your payment of INR ${payment.amount} has been refunded. Invoice: ${payment.invoiceNumber}.`, + type: 'payment', + link: '/appointments', + relatedId: payment._id + }); + + await createAuditLog(req.user._id, `Refunded payment ${payment.invoiceNumber}`); + + res.json({ success: true, message: 'Payment refunded successfully', payment }); + } catch (error) { + console.error('Refund Payment Error:', error); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend/src/controllers/report.controller.js b/backend/src/controllers/report.controller.js index 49d9494..ccb3423 100644 --- a/backend/src/controllers/report.controller.js +++ b/backend/src/controllers/report.controller.js @@ -10,7 +10,7 @@ const normalRanges = { 'Cholesterol': { min: 125, max: 200, unit: 'mg/dL' } }; -exports.uploadReport = async (req, res) => { +export const uploadReport = async (req, res) => { try { if (!req.file) { return res.status(400).json({ message: 'No file uploaded' }); @@ -84,7 +84,7 @@ const generateSummary = (results) => { return `${abnormal.length} abnormal value(s) detected: ${abnormal.map(a => a.testName).join(', ')}. Please consult your doctor.`; }; -exports.getUserReports = async (req, res) => { +export const getUserReports = async (req, res) => { try { const reports = await Report.find({ userId: req.user.id }).sort('-createdAt'); res.json(reports); diff --git a/backend/src/middleware/audit.middleware.js b/backend/src/middleware/audit.middleware.js new file mode 100644 index 0000000..9e060b6 --- /dev/null +++ b/backend/src/middleware/audit.middleware.js @@ -0,0 +1,42 @@ +import AuditLog from '../models/AuditLog.model.js'; + +export const logAudit = (actionDescription) => { + return async (req, res, next) => { + // Intercept response finish or run async after next + res.on('finish', async () => { + try { + if (res.statusCode >= 200 && res.statusCode < 400) { + await AuditLog.create({ + userId: req.user?._id || req.user?.id || null, + action: actionDescription || `${req.method} ${req.originalUrl}`, + details: { + body: req.body, + query: req.query, + params: req.params, + statusCode: res.statusCode + }, + ipAddress: req.ip || req.headers['x-forwarded-for'] || '', + status: 'success' + }); + } + } catch (error) { + console.error('Audit Log Error:', error); + } + }); + next(); + }; +}; + +export const createAuditLog = async (userId, action, details = {}, ipAddress = '', status = 'success') => { + try { + await AuditLog.create({ + userId: userId || null, + action, + details, + ipAddress, + status + }); + } catch (error) { + console.error('Manual Audit Log Error:', error); + } +}; diff --git a/backend/src/middleware/auth.middleware.js b/backend/src/middleware/auth.middleware.js index 4c92159..986276c 100644 --- a/backend/src/middleware/auth.middleware.js +++ b/backend/src/middleware/auth.middleware.js @@ -21,3 +21,4 @@ export const protect = async (req, res, next) => { } }; +export { authorize } from './role.middleware.js'; diff --git a/backend/src/middleware/rateLimit.middleware.js b/backend/src/middleware/rateLimit.middleware.js new file mode 100644 index 0000000..3affacc --- /dev/null +++ b/backend/src/middleware/rateLimit.middleware.js @@ -0,0 +1,21 @@ +import rateLimit from 'express-rate-limit'; + +export const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 1000, // Limit each IP to 1000 requests per `window` (generous for demo/local dev while providing rate limiting protection) + message: { + success: false, + message: 'Too many requests from this IP, please try again after 15 minutes' + }, + standardHeaders: true, + legacyHeaders: false +}); + +export const authLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 100, // Limit each IP to 100 login/register requests per hour + message: { + success: false, + message: 'Too many authentication attempts, please try again later' + } +}); diff --git a/backend/src/middleware/role.middleware.js b/backend/src/middleware/role.middleware.js new file mode 100644 index 0000000..14412d7 --- /dev/null +++ b/backend/src/middleware/role.middleware.js @@ -0,0 +1,11 @@ +export const authorize = (...roles) => { + return (req, res, next) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ + success: false, + message: `User role '${req.user?.role || 'unknown'}' is not authorized to access this route` + }); + } + next(); + }; +}; diff --git a/backend/src/models/Appointment.model.js b/backend/src/models/Appointment.model.js new file mode 100644 index 0000000..ae1b67e --- /dev/null +++ b/backend/src/models/Appointment.model.js @@ -0,0 +1,66 @@ +import mongoose from 'mongoose'; + +const appointmentSchema = new mongoose.Schema({ + patientId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + doctorId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + appointmentDate: { + type: Date, + required: true, + index: true + }, + timeSlot: { + type: String, + required: true + }, + status: { + type: String, + enum: ['booked', 'approved', 'rejected', 'cancelled', 'completed'], + default: 'booked', + index: true + }, + consultationType: { + type: String, + enum: ['Video Call', 'Voice Call', 'Chat'], + default: 'Video Call' + }, + reasonForVisit: { + type: String, + required: true + }, + symptoms: [String], + doctorNotes: { + type: String, + default: '' + }, + rejectionReason: { + type: String, + default: '' + }, + paymentStatus: { + type: String, + enum: ['pending', 'paid', 'refunded', 'free'], + default: 'paid' + }, + feeAmount: { + type: Number, + default: 500 + }, + meetingRoomId: { + type: String, + default: () => `room_${Math.random().toString(36).substring(2, 10)}` + } +}, { + timestamps: true +}); + +export default mongoose.model('Appointment', appointmentSchema); diff --git a/backend/src/models/AuditLog.model.js b/backend/src/models/AuditLog.model.js new file mode 100644 index 0000000..bdd1cbb --- /dev/null +++ b/backend/src/models/AuditLog.model.js @@ -0,0 +1,32 @@ +import mongoose from 'mongoose'; + +const auditLogSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + default: null, + index: true + }, + action: { + type: String, + required: true, + index: true + }, + details: { + type: Object, + default: {} + }, + ipAddress: { + type: String, + default: '' + }, + status: { + type: String, + enum: ['success', 'failure', 'warning'], + default: 'success' + } +}, { + timestamps: true +}); + +export default mongoose.model('AuditLog', auditLogSchema); diff --git a/backend/src/models/Conversation.model.js b/backend/src/models/Conversation.model.js new file mode 100644 index 0000000..666f353 --- /dev/null +++ b/backend/src/models/Conversation.model.js @@ -0,0 +1,24 @@ +import mongoose from 'mongoose'; + +const conversationSchema = new mongoose.Schema({ + participants: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }], + lastMessage: { + text: { type: String, default: '' }, + sender: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + createdAt: { type: Date } + }, + appointmentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Appointment', + default: null + } +}, { + timestamps: true +}); + +export default mongoose.model('Conversation', conversationSchema); diff --git a/backend/src/models/DigitalPrescription.model.js b/backend/src/models/DigitalPrescription.model.js new file mode 100644 index 0000000..c3c5e62 --- /dev/null +++ b/backend/src/models/DigitalPrescription.model.js @@ -0,0 +1,57 @@ +import mongoose from 'mongoose'; + +const digitalMedicineSchema = new mongoose.Schema({ + name: { type: String, required: true }, + dosage: { type: String, required: true }, // e.g. "500mg" + frequency: { type: String, required: true }, // e.g. "1-0-1" or "Twice daily" + duration: { type: String, required: true }, // e.g. "5 days" + beforeAfterFood: { type: String, enum: ['Before Food', 'After Food', 'With Food', 'Anytime'], default: 'After Food' }, + instructions: { type: String, default: '' } +}); + +const digitalPrescriptionSchema = new mongoose.Schema({ + doctorId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + patientId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + appointmentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Appointment', + default: null + }, + diagnosis: { + type: String, + required: true + }, + medicines: [digitalMedicineSchema], + vitalsObserved: { + bloodPressure: { type: String, default: '' }, + pulseRate: { type: String, default: '' }, + temperature: { type: String, default: '' }, + weight: { type: String, default: '' } + }, + generalNotes: { + type: String, + default: '' + }, + followUpDate: { + type: Date, + default: null + }, + pdfUrl: { + type: String, + default: '' + } +}, { + timestamps: true +}); + +export default mongoose.model('DigitalPrescription', digitalPrescriptionSchema); diff --git a/backend/src/models/DoctorProfile.model.js b/backend/src/models/DoctorProfile.model.js new file mode 100644 index 0000000..98f76f0 --- /dev/null +++ b/backend/src/models/DoctorProfile.model.js @@ -0,0 +1,73 @@ +import mongoose from 'mongoose'; + +const doctorProfileSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + unique: true, + index: true + }, + specialization: { + type: String, + required: true, + default: 'General Physician' + }, + qualifications: { + type: [String], + default: ['MBBS'] + }, + experienceYears: { + type: Number, + default: 5 + }, + consultationFee: { + type: Number, + default: 500 + }, + bio: { + type: String, + default: 'Dedicated healthcare professional delivering compassionate care.' + }, + licenseNumber: { + type: String, + default: '' + }, + licenseDocumentUrl: { + type: String, + default: '' + }, + verificationStatus: { + type: String, + enum: ['pending', 'verified', 'rejected'], + default: 'verified' // Default verified for seamless demo & immediate testing + }, + rejectionReason: { + type: String, + default: '' + }, + rating: { + type: Number, + default: 4.8 + }, + totalReviews: { + type: Number, + default: 12 + }, + availabilityDays: { + type: [String], + default: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + }, + availabilityHours: { + start: { type: String, default: '09:00' }, + end: { type: String, default: '18:00' } + }, + updatedAt: { + type: Date, + default: Date.now + } +}, { + timestamps: true +}); + +export default mongoose.model('DoctorProfile', doctorProfileSchema); diff --git a/backend/src/models/Message.model.js b/backend/src/models/Message.model.js new file mode 100644 index 0000000..3cf0b4c --- /dev/null +++ b/backend/src/models/Message.model.js @@ -0,0 +1,36 @@ +import mongoose from 'mongoose'; + +const messageSchema = new mongoose.Schema({ + conversationId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Conversation', + required: true, + index: true + }, + sender: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + text: { + type: String, + default: '' + }, + attachments: [{ + fileUrl: { type: String }, + fileName: { type: String }, + fileType: { type: String, enum: ['image', 'pdf', 'voice', 'other'], default: 'other' } + }], + seenBy: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }], + reactions: [{ + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + emoji: { type: String } + }] +}, { + timestamps: true +}); + +export default mongoose.model('Message', messageSchema); diff --git a/backend/src/models/Notification.model.js b/backend/src/models/Notification.model.js new file mode 100644 index 0000000..daccd47 --- /dev/null +++ b/backend/src/models/Notification.model.js @@ -0,0 +1,40 @@ +import mongoose from 'mongoose'; + +const notificationSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + title: { + type: String, + required: true + }, + message: { + type: String, + required: true + }, + type: { + type: String, + enum: ['appointment', 'prescription', 'chat', 'call', 'payment', 'system'], + default: 'system' + }, + isRead: { + type: Boolean, + default: false, + index: true + }, + link: { + type: String, + default: '/' + }, + relatedId: { + type: mongoose.Schema.Types.ObjectId, + default: null + } +}, { + timestamps: true +}); + +export default mongoose.model('Notification', notificationSchema); diff --git a/backend/src/models/PatientProfile.model.js b/backend/src/models/PatientProfile.model.js new file mode 100644 index 0000000..6baad6e --- /dev/null +++ b/backend/src/models/PatientProfile.model.js @@ -0,0 +1,46 @@ +import mongoose from 'mongoose'; + +const patientProfileSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + unique: true, + index: true + }, + age: { + type: Number, + default: 30 + }, + gender: { + type: String, + enum: ['Male', 'Female', 'Other', 'Prefer not to say'], + default: 'Male' + }, + bloodGroup: { + type: String, + enum: ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-', 'Unknown'], + default: 'O+' + }, + allergies: { + type: [String], + default: [] + }, + chronicConditions: { + type: [String], + default: [] + }, + emergencyContact: { + name: { type: String, default: '' }, + phone: { type: String, default: '' }, + relation: { type: String, default: '' } + }, + updatedAt: { + type: Date, + default: Date.now + } +}, { + timestamps: true +}); + +export default mongoose.model('PatientProfile', patientProfileSchema); diff --git a/backend/src/models/Payment.model.js b/backend/src/models/Payment.model.js new file mode 100644 index 0000000..84982ee --- /dev/null +++ b/backend/src/models/Payment.model.js @@ -0,0 +1,57 @@ +import mongoose from 'mongoose'; + +const paymentSchema = new mongoose.Schema({ + patientId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + doctorId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + appointmentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Appointment', + required: true + }, + amount: { + type: Number, + required: true + }, + currency: { + type: String, + default: 'INR' + }, + paymentGateway: { + type: String, + enum: ['Razorpay', 'Stripe', 'Mock'], + default: 'Mock' + }, + transactionId: { + type: String, + required: true, + unique: true + }, + status: { + type: String, + enum: ['pending', 'completed', 'failed', 'refunded'], + default: 'completed', + index: true + }, + invoiceNumber: { + type: String, + required: true + }, + refundDetails: { + refundedAt: { type: Date }, + reason: { type: String } + } +}, { + timestamps: true +}); + +export default mongoose.model('Payment', paymentSchema); diff --git a/backend/src/models/Report.model.js b/backend/src/models/Report.model.js index fd40abd..fef2c14 100644 --- a/backend/src/models/Report.model.js +++ b/backend/src/models/Report.model.js @@ -29,4 +29,4 @@ const reportSchema = new mongoose.Schema({ } }); -module.exports = mongoose.model('Report', reportSchema); \ No newline at end of file +export default mongoose.model('Report', reportSchema); \ No newline at end of file diff --git a/backend/src/models/Review.model.js b/backend/src/models/Review.model.js new file mode 100644 index 0000000..e663ce6 --- /dev/null +++ b/backend/src/models/Review.model.js @@ -0,0 +1,35 @@ +import mongoose from 'mongoose'; + +const reviewSchema = new mongoose.Schema({ + patientId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + doctorId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + appointmentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Appointment', + default: null + }, + rating: { + type: Number, + required: true, + min: 1, + max: 5 + }, + comment: { + type: String, + default: '' + } +}, { + timestamps: true +}); + +export default mongoose.model('Review', reviewSchema); diff --git a/backend/src/models/TimeSlot.model.js b/backend/src/models/TimeSlot.model.js new file mode 100644 index 0000000..37ea1b5 --- /dev/null +++ b/backend/src/models/TimeSlot.model.js @@ -0,0 +1,24 @@ +import mongoose from 'mongoose'; + +const timeSlotSchema = new mongoose.Schema({ + doctorId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + date: { + type: String, // YYYY-MM-DD + required: true, + index: true + }, + slots: [{ + time: { type: String, required: true }, // e.g. "10:00 AM" + isBooked: { type: Boolean, default: false }, + appointmentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Appointment', default: null } + }] +}, { + timestamps: true +}); + +export default mongoose.model('TimeSlot', timeSlotSchema); diff --git a/backend/src/models/User.model.js b/backend/src/models/User.model.js index ac246a7..4ecf15a 100644 --- a/backend/src/models/User.model.js +++ b/backend/src/models/User.model.js @@ -23,6 +23,26 @@ const userSchema = new mongoose.Schema({ enum: ['patient', 'doctor', 'admin'], default: 'patient' }, + phone: { + type: String, + trim: true, + default: '' + }, + avatarUrl: { + type: String, + default: '' + }, + status: { + type: String, + enum: ['active', 'inactive', 'suspended'], + default: 'active' + }, + isVerified: { + type: Boolean, + default: true + }, + resetPasswordToken: String, + resetPasswordExpire: Date, createdAt: { type: Date, default: Date.now diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js new file mode 100644 index 0000000..77a35e7 --- /dev/null +++ b/backend/src/routes/admin.routes.js @@ -0,0 +1,22 @@ +import express from 'express'; +import { + getPlatformAnalytics, + verifyDoctor, + getAllUsers, + updateUserStatus, + getAuditLogs +} from '../controllers/admin.controller.js'; +import { protect, authorize } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +// All admin routes must be protected and restricted to admin role +router.use(protect, authorize('admin')); + +router.get('/analytics', getPlatformAnalytics); +router.put('/doctors/:profileId/verify', verifyDoctor); +router.get('/users', getAllUsers); +router.put('/users/:userId', updateUserStatus); +router.get('/audit-logs', getAuditLogs); + +export default router; diff --git a/backend/src/routes/appointment.routes.js b/backend/src/routes/appointment.routes.js new file mode 100644 index 0000000..0fb56a4 --- /dev/null +++ b/backend/src/routes/appointment.routes.js @@ -0,0 +1,19 @@ +import express from 'express'; +import { + bookAppointment, + getAppointments, + updateAppointmentStatus, + rescheduleAppointment, + getDoctorAvailableSlots +} from '../controllers/appointment.controller.js'; +import { protect } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +router.post('/book', protect, bookAppointment); +router.get('/', protect, getAppointments); +router.get('/slots', protect, getDoctorAvailableSlots); +router.put('/:id/status', protect, updateAppointmentStatus); +router.put('/:id/reschedule', protect, rescheduleAppointment); + +export default router; diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index c468284..3027204 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,6 +1,6 @@ import express from 'express'; import { body } from 'express-validator'; -import { register, login, getMe } from '../controllers/auth.controller.js'; +import { register, login, getMe, refreshToken, forgotPassword, resetPassword, updateProfile } from '../controllers/auth.controller.js'; import { protect } from '../middleware/auth.middleware.js'; const router = express.Router(); @@ -13,5 +13,9 @@ router.post('/register', [ router.post('/login', login); router.get('/me', protect, getMe); +router.post('/refresh', refreshToken); +router.post('/forgot-password', forgotPassword); +router.post('/reset-password', resetPassword); +router.put('/profile', protect, updateProfile); export default router; \ No newline at end of file diff --git a/backend/src/routes/chat.routes.js b/backend/src/routes/chat.routes.js new file mode 100644 index 0000000..508c165 --- /dev/null +++ b/backend/src/routes/chat.routes.js @@ -0,0 +1,30 @@ +import express from 'express'; +import { + getConversations, + createOrGetConversation, + getMessages, + sendMessage, + searchMessages, + uploadChatAttachment +} from '../controllers/chat.controller.js'; +import { protect } from '../middleware/auth.middleware.js'; +import multer from 'multer'; + +const router = express.Router(); + +const storage = multer.diskStorage({ + destination: 'uploads/', + filename: (req, file, cb) => { + cb(null, `chat-${Date.now()}-${file.originalname}`); + } +}); +const upload = multer({ storage }); + +router.get('/conversations', protect, getConversations); +router.post('/conversations', protect, createOrGetConversation); +router.get('/messages/search', protect, searchMessages); +router.get('/messages/:conversationId', protect, getMessages); +router.post('/messages', protect, sendMessage); +router.post('/upload', protect, upload.single('attachment'), uploadChatAttachment); + +export default router; diff --git a/backend/src/routes/digitalPrescription.routes.js b/backend/src/routes/digitalPrescription.routes.js new file mode 100644 index 0000000..f68a954 --- /dev/null +++ b/backend/src/routes/digitalPrescription.routes.js @@ -0,0 +1,15 @@ +import express from 'express'; +import { + createDigitalPrescription, + getDigitalPrescriptions, + getDigitalPrescriptionById +} from '../controllers/digitalPrescription.controller.js'; +import { protect, authorize } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +router.post('/', protect, authorize('doctor'), createDigitalPrescription); +router.get('/', protect, getDigitalPrescriptions); +router.get('/:id', protect, getDigitalPrescriptionById); + +export default router; diff --git a/backend/src/routes/doctor.routes.js b/backend/src/routes/doctor.routes.js new file mode 100644 index 0000000..4348ce7 --- /dev/null +++ b/backend/src/routes/doctor.routes.js @@ -0,0 +1,21 @@ +import express from 'express'; +import { getAllDoctors, getDoctorById, updateDoctorProfile, uploadDoctorLicense } from '../controllers/doctor.controller.js'; +import { protect, authorize } from '../middleware/auth.middleware.js'; +import multer from 'multer'; + +const router = express.Router(); + +const storage = multer.diskStorage({ + destination: 'uploads/', + filename: (req, file, cb) => { + cb(null, `${Date.now()}-${file.originalname}`); + } +}); +const upload = multer({ storage }); + +router.get('/', getAllDoctors); +router.get('/:id', getDoctorById); +router.put('/profile', protect, authorize('doctor'), updateDoctorProfile); +router.post('/upload-license', protect, authorize('doctor'), upload.single('license'), uploadDoctorLicense); + +export default router; diff --git a/backend/src/routes/notification.routes.js b/backend/src/routes/notification.routes.js new file mode 100644 index 0000000..5cd20c7 --- /dev/null +++ b/backend/src/routes/notification.routes.js @@ -0,0 +1,10 @@ +import express from 'express'; +import { getNotifications, markNotificationRead } from '../controllers/notification.controller.js'; +import { protect } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +router.get('/', protect, getNotifications); +router.put('/:id/read', protect, markNotificationRead); + +export default router; diff --git a/backend/src/routes/patient.routes.js b/backend/src/routes/patient.routes.js new file mode 100644 index 0000000..e92b546 --- /dev/null +++ b/backend/src/routes/patient.routes.js @@ -0,0 +1,11 @@ +import express from 'express'; +import { getPatientProfile, updatePatientProfile, getPatientHistory } from '../controllers/patient.controller.js'; +import { protect } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +router.get('/profile', protect, getPatientProfile); +router.put('/profile', protect, updatePatientProfile); +router.get('/history', protect, getPatientHistory); + +export default router; diff --git a/backend/src/routes/payment.routes.js b/backend/src/routes/payment.routes.js new file mode 100644 index 0000000..e6d3a4e --- /dev/null +++ b/backend/src/routes/payment.routes.js @@ -0,0 +1,11 @@ +import express from 'express'; +import { createPaymentOrder, getPayments, refundPayment } from '../controllers/payment.controller.js'; +import { protect } from '../middleware/auth.middleware.js'; + +const router = express.Router(); + +router.post('/order', protect, createPaymentOrder); +router.get('/', protect, getPayments); +router.put('/:id/refund', protect, refundPayment); + +export default router; diff --git a/backend/src/routes/report.routes.js b/backend/src/routes/report.routes.js index 0653f9a..dcf5716 100644 --- a/backend/src/routes/report.routes.js +++ b/backend/src/routes/report.routes.js @@ -1,7 +1,7 @@ import express from 'express'; import multer from 'multer'; import path from 'path'; -import { uploadPrescription, getUserPrescriptions } from '../controllers/prescription.controller.js'; +import { uploadReport, getUserReports } from '../controllers/report.controller.js'; import { protect } from '../middleware/auth.middleware.js'; const router = express.Router(); @@ -24,7 +24,7 @@ const upload = multer({ } }); -router.post('/upload', protect, upload.single('report'), uploadPrescription); -router.get('/', protect, getUserPrescriptions); +router.post('/upload', protect, upload.single('report'), uploadReport); +router.get('/', protect, getUserReports); export default router; \ No newline at end of file diff --git a/backend/src/socket/socket.js b/backend/src/socket/socket.js new file mode 100644 index 0000000..25ef550 --- /dev/null +++ b/backend/src/socket/socket.js @@ -0,0 +1,125 @@ +import { Server } from 'socket.io'; + +let io; +const onlineUsers = new Map(); // userId -> socketId + +export const initSocket = (server) => { + io = new Server(server, { + cors: { + origin: ['http://localhost:5173', 'http://127.0.0.1:5173', '*'], + methods: ['GET', 'POST', 'PUT', 'DELETE'], + credentials: true + } + }); + + io.on('connection', (socket) => { + console.log(`Socket Connected: ${socket.id}`); + + // Register user as online + socket.on('register-user', (userId) => { + if (userId) { + onlineUsers.set(userId, socket.id); + socket.userId = userId; + io.emit('online-users', Array.from(onlineUsers.keys())); + } + }); + + // ---------------- CHAT EVENTS ---------------- + socket.on('join-room', (roomId) => { + if (roomId) { + socket.join(roomId); + console.log(`Socket ${socket.id} joined chat room: ${roomId}`); + } + }); + + socket.on('send-message', (messageData) => { + // messageData: { conversationId, text, sender, attachments, createdAt } + if (messageData && messageData.conversationId) { + // Broadcast to everyone else inside the conversation room + socket.to(messageData.conversationId).emit('receive-message', messageData); + } + }); + + socket.on('typing', ({ room, senderName }) => { + if (room) { + socket.to(room).emit('user-typing', { senderName }); + } + }); + + socket.on('stop-typing', ({ room }) => { + if (room) { + socket.to(room).emit('user-stop-typing'); + } + }); + + // ---------------- WEBRTC CALL SIGNALING EVENTS ---------------- + socket.on('join-call-room', ({ roomId, userId, userName }) => { + if (roomId) { + socket.join(roomId); + console.log(`Socket ${socket.id} (${userName}) joined call room: ${roomId}`); + // Notify others in room + socket.to(roomId).emit('user-connected', { socketId: socket.id, userId, userName }); + } + }); + + socket.on('call-user', ({ userToCall, signalData, from, name, isVideo, roomId }) => { + const targetSocketId = onlineUsers.get(userToCall); + if (targetSocketId) { + io.to(targetSocketId).emit('call-made', { signal: signalData, from, name, isVideo, roomId }); + } else if (roomId) { + socket.to(roomId).emit('call-made', { signal: signalData, from, name, isVideo, roomId }); + } + }); + + socket.on('make-answer', ({ to, signalData }) => { + io.to(to).emit('answer-made', signalData); + }); + + socket.on('ice-candidate', ({ to, candidate }) => { + io.to(to).emit('ice-candidate', candidate); + }); + + socket.on('reject-call', ({ to }) => { + io.to(to).emit('call-rejected'); + }); + + socket.on('end-call', ({ roomId }) => { + if (roomId) { + io.to(roomId).emit('call-ended'); + } + }); + + socket.on('toggle-media', ({ roomId, userId, isAudioOn, isVideoOn }) => { + if (roomId) { + socket.to(roomId).emit('peer-media-toggled', { userId, isAudioOn, isVideoOn }); + } + }); + + // ---------------- DISCONNECT ---------------- + socket.on('disconnect', () => { + console.log(`Socket Disconnected: ${socket.id}`); + if (socket.userId) { + onlineUsers.delete(socket.userId); + io.emit('online-users', Array.from(onlineUsers.keys())); + } else { + // Find and delete + for (const [userId, socketId] of onlineUsers.entries()) { + if (socketId === socket.id) { + onlineUsers.delete(userId); + io.emit('online-users', Array.from(onlineUsers.keys())); + break; + } + } + } + }); + }); + + return io; +}; + +export const getIO = () => { + if (!io) { + throw new Error('Socket.io not initialized!'); + } + return io; +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9fc4005..840e367 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,7 +18,8 @@ "react-dropzone": "^15.0.0", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.15.1", - "recharts": "^3.8.1" + "recharts": "^3.8.1", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -891,6 +892,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1500,6 +1507,28 @@ "dev": true, "license": "ISC" }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3114,6 +3143,34 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3343,6 +3400,35 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5f2a6b5..3ca2c52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,8 @@ "react-dropzone": "^15.0.0", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.15.1", - "recharts": "^3.8.1" + "recharts": "^3.8.1", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index eec128f..d36d920 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,18 @@ import Dashboard from './pages/Dashboard'; import PrescriptionDigitizer from './pages/PrescriptionDigitizer'; import SymptomTracker from './pages/SymptomTracker'; import ReportOrganizer from './pages/ReportOrganizer'; + +// New Role-Based & Telemedicine Pages +import DoctorSearch from './pages/DoctorSearch'; +import DoctorProfileView from './pages/DoctorProfileView'; +import PatientAppointments from './pages/PatientAppointments'; +import ChatRoom from './pages/ChatRoom'; +import MedicalRecordsPortal from './pages/MedicalRecordsPortal'; +import DoctorDashboard from './pages/DoctorDashboard'; +import DigitalPrescriptionGenerator from './pages/DigitalPrescriptionGenerator'; +import DoctorEarnings from './pages/DoctorEarnings'; +import AdminDashboard from './pages/AdminDashboard'; + import Navbar from './components/Navbar'; import ProtectedRoute from './components/ProtectedRoute'; import './styles/global.css'; @@ -22,7 +34,7 @@ function App() { } /> } /> - {/* Protected Routes */} + {/* Protected Routes (Existing Preserved Exactly) */} } /> + + {/* New Telemedicine & Role-Based Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> ); diff --git a/frontend/src/components/CallModal.jsx b/frontend/src/components/CallModal.jsx new file mode 100644 index 0000000..c480655 --- /dev/null +++ b/frontend/src/components/CallModal.jsx @@ -0,0 +1,286 @@ +import { useState, useEffect, useRef } from 'react'; +import { Phone, PhoneOff, Mic, MicOff, Video as VideoIcon, VideoOff, Monitor, MessageSquare, X } from 'lucide-react'; +import { getSocket } from '../services/socket'; + +const CallModal = ({ isOpen, onClose, user, targetUser, initialIsVideo = true, roomId }) => { + const [isAudioOn, setIsAudioOn] = useState(true); + const [isVideoOn, setIsVideoOn] = useState(initialIsVideo); + const [isScreenSharing, setIsScreenSharing] = useState(false); + const [callDuration, setCallDuration] = useState(0); + const [showSideChat, setShowSideChat] = useState(false); + const [chatMessages, setChatMessages] = useState([]); + const [inputMsg, setInputMsg] = useState(''); + const [callStatus, setCallStatus] = useState('Connecting...'); + + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + const streamRef = useRef(null); + const timerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + // Start timer & local media + setCallStatus('Connected'); + timerRef.current = setInterval(() => { + setCallDuration((prev) => prev + 1); + }, 1000); + + // Initialize local camera / audio stream simulation & clean start + navigator.mediaDevices?.getUserMedia({ video: initialIsVideo, audio: true }) + .then((stream) => { + streamRef.current = stream; + if (localVideoRef.current) { + localVideoRef.current.srcObject = stream; + } + }) + .catch((err) => { + console.warn('Media access warning or simulation mode:', err); + }); + + const socket = getSocket(); + if (socket && roomId) { + socket.emit('join-call-room', { roomId, userId: user?.id, userName: user?.name }); + socket.on('call-ended', () => { + handleEndCall(); + }); + } + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + if (socket) socket.off('call-ended'); + }; + }, [isOpen, roomId, initialIsVideo, user]); + + const toggleAudio = () => { + setIsAudioOn(!isAudioOn); + if (streamRef.current) { + streamRef.current.getAudioTracks().forEach((track) => { + track.enabled = !isAudioOn; + }); + } + }; + + const toggleVideo = () => { + setIsVideoOn(!isVideoOn); + if (streamRef.current) { + streamRef.current.getVideoTracks().forEach((track) => { + track.enabled = !isVideoOn; + }); + } + }; + + const toggleScreenShare = async () => { + if (!isScreenSharing) { + try { + const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + if (localVideoRef.current) { + localVideoRef.current.srcObject = screenStream; + } + screenStream.getVideoTracks()[0].onended = () => { + if (localVideoRef.current && streamRef.current) { + localVideoRef.current.srcObject = streamRef.current; + } + setIsScreenSharing(false); + }; + setIsScreenSharing(true); + } catch (err) { + console.error('Screen share error:', err); + } + } else { + if (localVideoRef.current && streamRef.current) { + localVideoRef.current.srcObject = streamRef.current; + } + setIsScreenSharing(false); + } + }; + + const handleEndCall = () => { + const socket = getSocket(); + if (socket && roomId) { + socket.emit('end-call', { roomId }); + } + if (timerRef.current) clearInterval(timerRef.current); + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + setCallDuration(0); + onClose(); + }; + + const sendCallMessage = (e) => { + e.preventDefault(); + if (!inputMsg.trim()) return; + setChatMessages((prev) => [...prev, { sender: 'You', text: inputMsg, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]); + setInputMsg(''); + }; + + const formatTime = (secs) => { + const m = Math.floor(secs / 60).toString().padStart(2, '0'); + const s = (secs % 60).toString().padStart(2, '0'); + return `${m}:${s}`; + }; + + if (!isOpen) return null; + + return ( +
+
+ + {/* Main Video Area */} +
+ {isVideoOn ? ( +
+ ); +}; + +export default CallModal; diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index ea5a302..d3f6682 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { Heart, LogOut, User, Menu, X, FileText, Activity, FileSpreadsheet } from "lucide-react"; +import { Heart, LogOut, User, Menu, X, FileText, Activity, FileSpreadsheet, Stethoscope, MessageSquare, Calendar, ShieldCheck, DollarSign } from "lucide-react"; +import NotificationDropdown from "./NotificationDropdown"; const Navbar = () => { const navigate = useNavigate(); @@ -10,16 +11,45 @@ const Navbar = () => { const handleLogout = () => { localStorage.removeItem("token"); + localStorage.removeItem("refreshToken"); localStorage.removeItem("user"); navigate("/login"); }; + // Original existing links (PRESERVED EXACTLY AS REQUIRED) const navLinks = [ { label: "Prescription", to: "/prescription", icon: FileText }, { label: "Symptoms", to: "/symptom-tracker", icon: Activity }, { label: "Reports", to: "/reports", icon: FileSpreadsheet }, ]; + // Role-based links + const getRoleLinks = () => { + if (user.role === "doctor") { + return [ + { label: "Doctor Dashboard", to: "/doctor-dashboard", icon: Stethoscope }, + { label: "Rx Generator", to: "/prescription-generator", icon: FileText }, + { label: "Appointments", to: "/appointments", icon: Calendar }, + { label: "Messages", to: "/chat", icon: MessageSquare }, + { label: "Earnings", to: "/doctor-earnings", icon: DollarSign }, + ]; + } else if (user.role === "admin") { + return [ + { label: "Admin Dashboard", to: "/admin-dashboard", icon: ShieldCheck }, + { label: "All Appointments", to: "/appointments", icon: Calendar }, + ]; + } else { + return [ + { label: "Find Doctors", to: "/doctors", icon: Stethoscope }, + { label: "Appointments", to: "/appointments", icon: Calendar }, + { label: "Messages", to: "/chat", icon: MessageSquare }, + { label: "Portal", to: "/portal", icon: Activity }, + ]; + } + }; + + const roleLinks = getRoleLinks(); + const css = ` @import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600;700&display=swap'); @@ -49,7 +79,7 @@ const Navbar = () => { .nb-inner { position: relative; - max-width: 1280px; + max-width: 1400px; margin: 0 auto; padding: 14px 18px; display: flex; @@ -87,18 +117,19 @@ const Navbar = () => { .nb-desktop { display: none; align-items: center; - gap: 12px; + gap: 8px; + flex-wrap: wrap; } .nb-link { display: inline-flex; align-items: center; - gap: 8px; - padding: 10px 14px; + gap: 6px; + padding: 8px 12px; border-radius: 12px; text-decoration: none; color: #cbd5e1; - font-size: 14px; + font-size: 13.5px; font-weight: 600; transition: all .2s ease; border: 1px solid transparent; @@ -110,11 +141,23 @@ const Navbar = () => { border-color: rgba(255,255,255,.08); } + .nb-link-role { + color: #67e8f9; + background: rgba(6, 182, 212, 0.08); + border-color: rgba(6, 182, 212, 0.2); + } + + .nb-link-role:hover { + background: rgba(6, 182, 212, 0.16); + border-color: rgba(6, 182, 212, 0.3); + color: #fff; + } + .nb-user { display: inline-flex; align-items: center; gap: 10px; - padding: 8px 12px; + padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08); @@ -122,17 +165,40 @@ const Navbar = () => { } .nb-user-name { - font-size: 14px; + font-size: 13.5px; font-weight: 600; white-space: nowrap; } + .nb-role-badge { + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.2); + color: #93c5fd; + border: 1px solid rgba(37, 99, 235, 0.3); + } + + .nb-role-badge.doctor { + background: rgba(16, 185, 129, 0.2); + color: #6ee7b7; + border-color: rgba(16, 185, 129, 0.3); + } + + .nb-role-badge.admin { + background: rgba(244, 63, 94, 0.2); + color: #fda4af; + border-color: rgba(244, 63, 94, 0.3); + } + .nb-logout { border: none; background: gray; border: 1px solid red; - width: 36px; - height: 36px; + width: 34px; + height: 34px; border-radius: 12px; display: inline-flex; align-items: center; @@ -165,21 +231,23 @@ const Navbar = () => { z-index: 1; display: block; border-top: 1px solid rgba(255,255,255,.08); - background: rgba(9,13,20,.92); + background: rgba(9,13,20,.95); backdrop-filter: blur(18px); padding: 12px 18px 18px; + max-height: 80vh; + overflow-y: auto; } .nb-mobile-panel { display: grid; - gap: 10px; + gap: 8px; } .nb-mobile-link { display: flex; align-items: center; gap: 10px; - padding: 12px 14px; + padding: 10px 14px; border-radius: 14px; text-decoration: none; color: #e2e8f0; @@ -202,7 +270,7 @@ const Navbar = () => { color: #e2e8f0; } - @media (min-width: 768px) { + @media (min-width: 1024px) { .nb-desktop { display: flex; } .nb-menu-btn, .nb-mobile { display: none; } .nb-inner { padding: 14px 24px; } @@ -224,39 +292,65 @@ const Navbar = () => { {token && ( <>
+ {/* Original Preserved Links */} {navLinks.map((item) => { const Icon = item.icon; return ( - + + {item.label} + + ); + })} + +
+ + {/* Role Specific Links */} + {roleLinks.map((item) => { + const Icon = item.icon; + return ( + + {item.label} ); })} +
+ + {/* Notification Dropdown */} + +
- + {user.name || "User"} + + {user.role || "patient"} +
- +
+ + +
)}
{token && mobileOpen && ( -
+
+
Standard Utilities
{navLinks.map((item) => { const Icon = item.icon; return ( @@ -272,10 +366,31 @@ const Navbar = () => { ); })} +
+ {user.role || "Patient"} Workspaces +
+ {roleLinks.map((item) => { + const Icon = item.icon; + return ( + setMobileOpen(false)} + > + + {item.label} + + ); + })} +
- {user.name || "User"} + {user.name || "User"} + + {user.role || "patient"} +
+ + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+
+
+ + Notifications + {unreadCount > 0 && ( + + {unreadCount} new + + )} +
+ {unreadCount > 0 && ( + + )} +
+ +
+ {notifications.length === 0 ? ( +
+ No notifications yet. +
+ ) : ( + notifications.map((notif) => ( +
{ + if (!notif.isRead) handleMarkAsRead(notif._id); + setIsOpen(false); + }} + className={`p-3.5 transition-colors cursor-pointer flex flex-col gap-1 ${ + notif.isRead ? 'bg-transparent hover:bg-white/[0.03]' : 'bg-cyan-500/[0.06] hover:bg-cyan-500/[0.1]' + }`} + > +
+ + {notif.title} + + + {new Date(notif.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+

+ {notif.message} +

+ {notif.link && notif.link !== '/' && ( +
+ setIsOpen(false)} + className="inline-flex items-center gap-1 text-[11px] font-medium text-cyan-400 hover:text-cyan-300" + > + View Details + +
+ )} +
+ )) + )} +
+
+ + )} +
+ ); +}; + +export default NotificationDropdown; diff --git a/frontend/src/pages/AdminDashboard.jsx b/frontend/src/pages/AdminDashboard.jsx new file mode 100644 index 0000000..9c7e948 --- /dev/null +++ b/frontend/src/pages/AdminDashboard.jsx @@ -0,0 +1,438 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { ShieldCheck, Users, Stethoscope, Activity, FileText, CheckCircle2, XCircle, AlertCircle, Search, UserCheck, ShieldAlert, Lock, Unlock, Database } from 'lucide-react'; +import { adminAPI } from '../services/api'; +import toast from 'react-hot-toast'; + +const AdminDashboard = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const initialTab = searchParams.get('tab') || 'overview'; + const [activeTab, setActiveTab] = useState(initialTab); + + const [analytics, setAnalytics] = useState({ + totalUsers: 0, + totalDoctors: 0, + verifiedDoctors: 0, + totalPatients: 0, + totalAppointments: 0, + completedAppointments: 0 + }); + const [pendingDoctors, setPendingDoctors] = useState([]); + const [usersList, setUsersList] = useState([]); + const [auditLogs, setAuditLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [userSearch, setUserSearch] = useState(''); + + const fetchAdminData = async () => { + setLoading(true); + try { + const [analyticsRes, usersRes, logsRes] = await Promise.all([ + adminAPI.getAnalytics(), + adminAPI.getAllUsers(), + adminAPI.getAuditLogs({ limit: 50 }) + ]); + + if (analyticsRes.data && analyticsRes.data.success) { + setAnalytics(analyticsRes.data.analytics || {}); + setPendingDoctors(analyticsRes.data.pendingVerifications || []); + } + if (usersRes.data && usersRes.data.success) { + setUsersList(usersRes.data.users || []); + } + if (logsRes.data && logsRes.data.success) { + setAuditLogs(logsRes.data.logs || []); + } + } catch (err) { + console.error('Admin data fetch error:', err); + toast.error('Could not load admin portal data'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchAdminData(); + }, []); + + useEffect(() => { + const tabParam = searchParams.get('tab'); + if (tabParam) setActiveTab(tabParam); + }, [searchParams]); + + const handleVerifyDoctor = async (profileId, status, rejectionReason = '') => { + try { + await adminAPI.verifyDoctor(profileId, { status, rejectionReason }); + toast.success(`Doctor status updated to ${status}`); + fetchAdminData(); + } catch (err) { + toast.error('Could not update doctor verification status'); + } + }; + + const handleUserStatusToggle = async (userId, currentStatus) => { + const newStatus = currentStatus === 'active' ? 'suspended' : 'active'; + try { + await adminAPI.updateUserStatus(userId, { status: newStatus }); + toast.success(`User status changed to ${newStatus}`); + fetchAdminData(); + } catch (err) { + toast.error('Failed to update user status'); + } + }; + + return ( +
+
+ + {/* Header Banner */} +
+
+
+ System Administrator Supervision Portal +
+

MedTech Platform Command Center

+

+ Supervise user registrations, audit real-time system logs, and verify medical license submissions from doctors to ensure highest clinical compliance. +

+
+ +
+ +
+
+ + {/* Overview Analytics Cards */} +
+
+
+ Total Registered Users +
+ +
+
+
{analytics.totalUsers || usersList.length}
+

Patients, Doctors & Admins

+
+ +
+
+ Verified Doctors +
+ +
+
+
+ {analytics.verifiedDoctors || usersList.filter(u => u.role === 'doctor' && u.doctorProfile?.verificationStatus === 'verified').length} / {analytics.totalDoctors || usersList.filter(u => u.role === 'doctor').length} +
+

Verified active specialists

+
+ +
+
+ Total Consultations +
+ +
+
+
{analytics.totalAppointments || 0}
+

{analytics.completedAppointments || 0} completed sessions

+
+ +
+
+ Pending License Checks +
+ +
+
+
{pendingDoctors.length}
+

Awaiting review below

+
+
+ + {/* Tabs */} +
+ {[ + { id: 'overview', label: `Overview & Doctor Verification (${pendingDoctors.length})`, icon: ShieldCheck }, + { id: 'users', label: `All Users Directory (${usersList.length})`, icon: Users }, + { id: 'logs', label: `System Security Audit Logs (${auditLogs.length})`, icon: Database } + ].map((t) => { + const Icon = t.icon; + return ( + + ); + })} +
+ + {/* Tab 1: Doctor Verification List */} + {activeTab === 'overview' && ( +
+
+

+ Pending Doctor License Verifications +

+
+ + {loading ? ( +
+ {[1, 2].map((n) => ( +
+ ))} +
+ ) : pendingDoctors.length === 0 ? ( +
+
+ +
+

All Doctors Verified!

+

+ There are currently no doctor license verifications pending administrative review. +

+
+ ) : ( +
+ {pendingDoctors.map((doc) => ( +
+
+
+ {doc.userId?.avatarUrl ? ( + {doc.userId.name} + ) : ( + doc.userId?.name ? doc.userId.name.charAt(0).toUpperCase() : 'D' + )} +
+
+
+

Dr. {doc.userId?.name || 'Specialist'}

+ + Pending Verification + +
+

{doc.specialization || 'General Physician'} • {doc.qualifications?.join(', ') || 'MBBS'}

+

License Number: {doc.licenseNumber || 'Not provided'}

+ + {doc.licenseDocumentUrl && ( + + )} +
+
+ + {/* Verification Actions */} +
+ + +
+
+ ))} +
+ )} +
+ )} + + {/* Tab 2: User Management Directory */} + {activeTab === 'users' && ( +
+
+

+ All Platform Accounts ({usersList.length}) +

+
+ + setUserSearch(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 py-2.5 text-xs text-white focus:outline-none focus:border-cyan-500" + /> +
+
+ +
+
+ + + + + + + + + + + + {usersList + .filter(u => !userSearch || u.name?.toLowerCase().includes(userSearch.toLowerCase()) || u.email?.toLowerCase().includes(userSearch.toLowerCase()) || u.role?.toLowerCase().includes(userSearch.toLowerCase())) + .map((u) => ( + + + + + + + + ))} + +
User AccountRoleJoined DateAccount StatusSupervision Actions
+
+
+ {u.avatarUrl ? ( + {u.name} + ) : ( + u.name?.charAt(0).toUpperCase() || 'U' + )} +
+
+ {u.name} + {u.email} +
+
+
+ + {u.role || 'patient'} + + + {new Date(u.createdAt || Date.now()).toLocaleDateString()} + + + {u.status || 'active'} + + + {u.role !== 'admin' && ( + + )} +
+
+
+
+ )} + + {/* Tab 3: Security & Audit Logs */} + {activeTab === 'logs' && ( +
+
+

+ Security & API Audit Trail +

+ Showing latest 50 security transactions +
+ +
+
+ + + + + + + + + + + + {auditLogs.length === 0 ? ( + + + + ) : ( + auditLogs.map((log) => ( + + + + + + + + )) + )} + +
Action & RouteUser ID / RoleStatusIP AddressTimestamp
+ No audit logs recorded yet. Subsequent API actions and authentication requests will be logged automatically here. +
+ {log.action || log.method + ' ' + log.url} + {log.details || 'Processed API request'} + + {log.userRole?.toUpperCase() || 'GUEST'} + {log.userId || 'N/A'} + + = 400 ? 'bg-rose-500/20 text-rose-300 border border-rose-500/30' : 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30' + }`}> + {log.status || 200} + + + {log.ipAddress || '127.0.0.1'} + + {new Date(log.createdAt || Date.now()).toLocaleString()} +
+
+
+
+ )} + +
+
+ ); +}; + +export default AdminDashboard; diff --git a/frontend/src/pages/ChatRoom.jsx b/frontend/src/pages/ChatRoom.jsx new file mode 100644 index 0000000..54de315 --- /dev/null +++ b/frontend/src/pages/ChatRoom.jsx @@ -0,0 +1,498 @@ +import { useState, useEffect, useRef } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { MessageSquare, Send, Paperclip, Image as ImageIcon, FileText, Mic, Video, Phone, Check, CheckCheck, Smile, Search, User } from 'lucide-react'; +import { chatAPI } from '../services/api'; +import { getSocket, connectSocket } from '../services/socket'; +import CallModal from '../components/CallModal'; +import toast from 'react-hot-toast'; + +const ChatRoom = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const targetDocId = searchParams.get('doctor') || searchParams.get('participant'); + const targetAppId = searchParams.get('appointment'); + + const [conversations, setConversations] = useState([]); + const [activeConv, setActiveConv] = useState(null); + const [messages, setMessages] = useState([]); + const [loadingMessages, setLoadingMessages] = useState(false); + const [inputMsg, setInputMsg] = useState(''); + const [searchFilter, setSearchFilter] = useState(''); + const [onlineUsers, setOnlineUsers] = useState([]); + const [isTyping, setIsTyping] = useState(false); + const [typingUser, setTypingUser] = useState(''); + const [uploadingFile, setUploadingFile] = useState(false); + + // Call modal state + const [callModalOpen, setCallModalOpen] = useState(false); + const [callIsVideo, setCallIsVideo] = useState(true); + + const messagesEndRef = useRef(null); + const fileInputRef = useRef(null); + const typingTimeoutRef = useRef(null); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + const fetchConversations = async () => { + try { + const res = await chatAPI.getConversations(); + if (res.data && res.data.success) { + const list = res.data.conversations || []; + setConversations(list); + + // If URL has targetDocId, check or create conversation + if (targetDocId && !activeConv) { + const existing = list.find(c => c.participants?.some(p => p._id === targetDocId || p.id === targetDocId)); + if (existing) { + setActiveConv(existing); + } else { + const createRes = await chatAPI.createConversation({ participantId: targetDocId, appointmentId: targetAppId }); + if (createRes.data?.conversation) { + setConversations((prev) => [createRes.data.conversation, ...prev]); + setActiveConv(createRes.data.conversation); + } + } + } else if (!activeConv && list.length > 0) { + setActiveConv(list[0]); + } + } + } catch (err) { + console.error('Fetch conversations error:', err); + } + }; + + const fetchMessages = async (convId) => { + if (!convId) return; + setLoadingMessages(true); + try { + const res = await chatAPI.getMessages(convId); + if (res.data && res.data.success) { + setMessages(res.data.messages || []); + } + } catch (err) { + console.error('Fetch messages error:', err); + } finally { + setLoadingMessages(false); + setTimeout(scrollToBottom, 100); + } + }; + + useEffect(() => { + fetchConversations(); + + const socket = connectSocket(user.id); + if (socket) { + socket.on('online-users', (usersList) => { + setOnlineUsers(usersList || []); + }); + + socket.on('receive-message', (incomingMsg) => { + if (incomingMsg.conversationId === activeConv?._id) { + setMessages((prev) => [...prev, incomingMsg]); + setTimeout(scrollToBottom, 100); + } + // Also refresh conversation summary list + fetchConversations(); + }); + + socket.on('user-typing', ({ senderName }) => { + setIsTyping(true); + setTypingUser(senderName || 'Participant'); + }); + + socket.on('user-stop-typing', () => { + setIsTyping(false); + setTypingUser(''); + }); + } + + return () => { + const s = getSocket(); + if (s) { + s.off('online-users'); + s.off('receive-message'); + s.off('user-typing'); + s.off('user-stop-typing'); + } + }; + }, []); + + useEffect(() => { + if (activeConv) { + fetchMessages(activeConv._id); + const socket = getSocket(); + if (socket) { + socket.emit('join-room', activeConv._id); + } + } + }, [activeConv]); + + const handleInputChange = (e) => { + setInputMsg(e.target.value); + const socket = getSocket(); + if (socket && activeConv) { + socket.emit('typing', { room: activeConv._id, senderName: user.name }); + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = setTimeout(() => { + socket.emit('stop-typing', { room: activeConv._id }); + }, 2000); + } + }; + + const handleSendMessage = async (e) => { + if (e) e.preventDefault(); + if (!inputMsg.trim() || !activeConv) return; + + const textToSend = inputMsg; + setInputMsg(''); + + try { + const res = await chatAPI.sendMessage({ + conversationId: activeConv._id, + text: textToSend + }); + + if (res.data && res.data.success) { + const newMsg = res.data.message; + setMessages((prev) => [...prev, newMsg]); + setTimeout(scrollToBottom, 100); + + const socket = getSocket(); + if (socket) { + socket.emit('send-message', { + ...newMsg, + conversationId: activeConv._id + }); + socket.emit('stop-typing', { room: activeConv._id }); + } + fetchConversations(); + } + } catch (err) { + console.error('Send message error:', err); + toast.error('Could not send message'); + } + }; + + const handleFileUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file || !activeConv) return; + + const formData = new FormData(); + formData.append('attachment', file); + setUploadingFile(true); + + try { + const uploadRes = await chatAPI.uploadAttachment(formData); + if (uploadRes.data && uploadRes.data.success) { + const attachment = uploadRes.data.attachment; + + const msgRes = await chatAPI.sendMessage({ + conversationId: activeConv._id, + text: `Attachment: ${file.name}`, + attachments: [attachment] + }); + + if (msgRes.data && msgRes.data.success) { + const newMsg = msgRes.data.message; + setMessages((prev) => [...prev, newMsg]); + setTimeout(scrollToBottom, 100); + + const socket = getSocket(); + if (socket) { + socket.emit('send-message', { + ...newMsg, + conversationId: activeConv._id + }); + } + fetchConversations(); + } + } + } catch (err) { + console.error('Upload attachment error:', err); + toast.error('File upload failed'); + } finally { + setUploadingFile(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const getOtherParticipant = (conv) => { + if (!conv || !conv.participants) return {}; + return conv.participants.find(p => p._id !== user.id && p.id !== user.id) || conv.participants[0] || {}; + }; + + const activeOtherUser = getOtherParticipant(activeConv); + const isTargetOnline = activeOtherUser._id && onlineUsers.includes(activeOtherUser._id); + + return ( +
+
+ + {/* Left Sidebar: Conversations List */} +
+
+

+ Consultations Chat +

+
+ + setSearchFilter(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-3 py-2 text-xs text-white focus:outline-none focus:border-cyan-500" + /> +
+
+ +
+ {conversations.length === 0 ? ( +
+ No active conversations yet. Start a chat from the Doctor Directory or Appointments page. +
+ ) : ( + conversations + .filter(c => { + const p = getOtherParticipant(c); + return !searchFilter || p.name?.toLowerCase().includes(searchFilter.toLowerCase()); + }) + .map((conv) => { + const p = getOtherParticipant(conv); + const isOnline = p._id && onlineUsers.includes(p._id); + const isSelected = activeConv?._id === conv._id; + + return ( +
setActiveConv(conv)} + className={`p-4 transition cursor-pointer flex items-center gap-3.5 ${ + isSelected + ? 'bg-cyan-500/15 border-l-4 border-cyan-400' + : 'hover:bg-white/[0.03]' + }`} + > +
+
+ {p.avatarUrl ? ( + {p.name} + ) : ( + p.name?.charAt(0).toUpperCase() || 'U' + )} +
+ {isOnline && ( + + )} +
+
+
+

+ {p.role === 'doctor' ? `Dr. ${p.name}` : p.name || 'Participant'} +

+ + {conv.lastMessage?.createdAt ? new Date(conv.lastMessage.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''} + +
+

+ {conv.lastMessage?.text || 'Started consultation'} +

+
+
+ ); + }) + )} +
+
+ + {/* Right Panel: Active Chat Room */} +
+ {activeConv ? ( + <> + {/* Top Header */} +
+
+
+
+ {activeOtherUser.avatarUrl ? ( + {activeOtherUser.name} + ) : ( + activeOtherUser.name?.charAt(0).toUpperCase() || 'U' + )} +
+ {isTargetOnline && ( + + )} +
+
+

+ {activeOtherUser.role === 'doctor' ? `Dr. ${activeOtherUser.name}` : activeOtherUser.name || 'Participant'} +

+

+ {isTyping ? ( + {typingUser} is typing... + ) : isTargetOnline ? ( + <>• Online + ) : ( + • Offline + )} +

+
+
+ + {/* Call Action Buttons */} +
+ + +
+
+ + {/* Messages Area */} +
+ {loadingMessages ? ( +
+
+
+ ) : messages.length === 0 ? ( +
+
+ +
+

No messages here yet. Type your consultation notes or attach a medical report below!

+
+ ) : ( + messages.map((msg, idx) => { + const isMe = msg.sender?._id === user.id || msg.sender === user.id; + return ( +
+
+ {msg.text} + + {/* Attachments rendering */} + {msg.attachments && msg.attachments.length > 0 && ( +
+ {msg.attachments.map((att, i) => ( +
+ {att.fileType === 'image' ? ( + {att.fileName} + ) : ( + + {att.fileName || 'Download Attachment'} + + )} +
+ ))} +
+ )} +
+ + {new Date(msg.createdAt || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {isMe && } + +
+ ); + }) + )} +
+
+ + {/* Bottom Input Area */} +
+ + + + + + +
+ + ) : ( +
+
+ +
+

Select a Consultation Chat

+

+ Choose a doctor or patient from the left sidebar to access consultation message history, share medical reports, or start HD video calls. +

+
+ )} +
+ +
+ + {/* WebRTC Call Modal */} + setCallModalOpen(false)} + user={user} + targetUser={activeOtherUser} + initialIsVideo={callIsVideo} + roomId={`room_conv_${activeConv?._id}`} + /> + +
+ ); +}; + +export default ChatRoom; diff --git a/frontend/src/pages/DigitalPrescriptionGenerator.jsx b/frontend/src/pages/DigitalPrescriptionGenerator.jsx new file mode 100644 index 0000000..55cf37b --- /dev/null +++ b/frontend/src/pages/DigitalPrescriptionGenerator.jsx @@ -0,0 +1,501 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { Pill, Plus, Trash2, Printer, CheckCircle2, ShieldCheck, User, Stethoscope, Heart, Calendar, FileText } from 'lucide-react'; +import { digitalPrescriptionsAPI, adminAPI } from '../services/api'; +import toast from 'react-hot-toast'; + +const DigitalPrescriptionGenerator = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const user = JSON.parse(localStorage.getItem('user') || '{}'); + + const urlPatientId = searchParams.get('patient') || ''; + const urlAppId = searchParams.get('appointment') || ''; + const urlDiagnosis = searchParams.get('diagnosis') || ''; + + const [patientId, setPatientId] = useState(urlPatientId); + const [appointmentId, setAppointmentId] = useState(urlAppId); + const [diagnosis, setDiagnosis] = useState(urlDiagnosis || 'Acute Upper Respiratory Tract Infection'); + const [patientName, setPatientName] = useState('Patient Consultation'); + const [patientAge, setPatientAge] = useState(32); + const [patientGender, setPatientGender] = useState('Male'); + + // Vitals + const [bp, setBp] = useState('120/80 mmHg'); + const [pulse, setPulse] = useState('74 bpm'); + const [temp, setTemp] = useState('98.6 °F'); + const [weight, setWeight] = useState('68 kg'); + + // Medicines dynamic list + const [medicines, setMedicines] = useState([ + { + name: 'Amoxicillin 500mg', + dosage: '1 Tablet', + frequency: 'Twice a day (1-0-1)', + duration: '5 Days', + beforeAfterFood: 'After Food', + instructions: 'Take with full glass of warm water' + }, + { + name: 'Paracetamol 650mg', + dosage: '1 Tablet', + frequency: 'As needed for fever', + duration: '3 Days', + beforeAfterFood: 'After Food', + instructions: 'Do not exceed 4 tablets in 24 hours' + } + ]); + + const [generalNotes, setGeneralNotes] = useState('Drink adequate warm fluids, maintain voice rest, and take vitamin C supplements. Seek emergency care if shortness of breath develops.'); + const [followUpDate, setFollowUpDate] = useState(new Date(Date.now() + 5 * 86400000).toISOString().split('T')[0]); + const [submitting, setSubmitting] = useState(false); + const [issuedRxId, setIssuedRxId] = useState(null); + + const addMedicineRow = () => { + setMedicines([...medicines, { + name: '', + dosage: '1 Tablet', + frequency: 'Twice a day (1-0-1)', + duration: '5 Days', + beforeAfterFood: 'After Food', + instructions: '' + }]); + }; + + const removeMedicineRow = (index) => { + setMedicines(medicines.filter((_, idx) => idx !== index)); + }; + + const updateMedicine = (index, field, value) => { + const copy = [...medicines]; + copy[index][field] = value; + setMedicines(copy); + }; + + const handleSubmitPrescription = async (e) => { + e.preventDefault(); + if (!patientId && !appointmentId && !patientName) { + return toast.error('Please specify patient details'); + } + if (medicines.length === 0 || !medicines[0].name.trim()) { + return toast.error('Please add at least one medication'); + } + + setSubmitting(true); + try { + const payload = { + patientId: patientId || user.id || '64101234567890abcdef1234', + appointmentId: appointmentId || undefined, + diagnosis, + vitalsObserved: { + bloodPressure: bp, + pulseRate: pulse, + temperature: temp, + weight + }, + medicines, + generalNotes, + followUpDate + }; + + const res = await digitalPrescriptionsAPI.create(payload); + if (res.data && res.data.success) { + toast.success('Digital Prescription Issued Successfully!'); + setIssuedRxId(res.data.prescription?._id || 'RX-2026-9812'); + } + } catch (err) { + console.error('Issue Rx error:', err); + toast.error(err.response?.data?.message || 'Could not issue digital prescription'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ + {/* Header Section */} +
+
+

+ Digital Prescription Studio +

+

+ Construct interactive, legally formatted prescriptions with real-time live preview and instant vault delivery to patients. +

+
+ +
+ {issuedRxId && ( + + Issued to Vault + + )} + +
+
+ + {/* Studio Grid: Form + Live Preview */} +
+ + {/* Left Panel: Form Inputs */} +
+ + {/* Patient & Diagnosis Box */} +
+

+ Patient Demographics & Diagnosis +

+ +
+
+ + setPatientName(e.target.value)} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3 py-2 text-white" + /> +
+
+ + setPatientId(e.target.value)} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3 py-2 text-white font-mono" + /> +
+
+ +
+
+ + setPatientAge(e.target.value)} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3 py-2 text-white" + /> +
+
+ + +
+
+ +
+ + setDiagnosis(e.target.value)} + className="w-full bg-slate-950 border border-cyan-500/40 rounded-xl px-3 py-2.5 text-white font-semibold" + /> +
+
+ + {/* Vitals Observed */} +
+

+ Recorded Vitals +

+ +
+
+ + setBp(e.target.value)} className="w-full bg-slate-950 border border-white/15 rounded-xl px-2.5 py-2 text-white font-mono" /> +
+
+ + setPulse(e.target.value)} className="w-full bg-slate-950 border border-white/15 rounded-xl px-2.5 py-2 text-white font-mono" /> +
+
+ + setTemp(e.target.value)} className="w-full bg-slate-950 border border-white/15 rounded-xl px-2.5 py-2 text-white font-mono" /> +
+
+ + setWeight(e.target.value)} className="w-full bg-slate-950 border border-white/15 rounded-xl px-2.5 py-2 text-white font-mono" /> +
+
+
+ + {/* Medicines List */} +
+
+

+ Prescribed Medications ({medicines.length}) +

+ +
+ +
+ {medicines.map((med, idx) => ( +
+
+ Medication #{idx + 1} + {medicines.length > 1 && ( + + )} +
+ +
+
+ + updateMedicine(idx, 'name', e.target.value)} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3 py-2 text-white font-bold" + /> +
+
+ + updateMedicine(idx, 'dosage', e.target.value)} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3 py-2 text-white font-mono" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ updateMedicine(idx, 'instructions', e.target.value)} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3 py-1.5 text-xs text-slate-300" + /> +
+
+ ))} +
+
+ + {/* General Advice & Follow-up */} +
+
+ +