Spaces:
Runtime error
Runtime error
| require('dotenv').config(); | |
| const express = require('express'); | |
| const http = require('http'); | |
| const { Server } = require('socket.io'); | |
| const cors = require('cors'); | |
| const { v4: uuidv4 } = require('uuid'); | |
| const app = express(); | |
| const server = http.createServer(app); | |
| const allowedOrigins = process.env.FRONTEND_URL | |
| ? [process.env.FRONTEND_URL, 'http://localhost:5173', 'http://localhost:3000'] | |
| : ['http://localhost:5173', 'http://localhost:3000']; | |
| const io = new Server(server, { | |
| cors: { origin: allowedOrigins, methods: ['GET', 'POST'], credentials: true } | |
| }); | |
| app.use(cors({ origin: allowedOrigins, credentials: true })); | |
| app.use(express.json()); | |
| // ─── In-Memory State ─────────────────────────────────────────────────────────── | |
| let clinicState = { | |
| queue: [], | |
| currentToken: null, | |
| avgConsultationTime: 10, | |
| totalServed: 0, | |
| clinicOpen: true, | |
| lastUpdated: new Date(), | |
| announcements: [], // recent announcements ticker | |
| }; | |
| // Analytics tracking | |
| let analytics = { | |
| hourlyPatients: {}, // { "HH": count } | |
| waitTimeSamples: [], // actual wait times in minutes | |
| priorityCounts: { normal: 0, urgent: 0, elderly: 0 }, | |
| peakHour: null, | |
| avgActualWait: 0, | |
| totalToday: 0, | |
| sessionStart: new Date(), | |
| }; | |
| function recordAnalytics(patient) { | |
| const hour = new Date().getHours().toString().padStart(2, '0'); | |
| analytics.hourlyPatients[hour] = (analytics.hourlyPatients[hour] || 0) + 1; | |
| analytics.priorityCounts[patient.priority] = (analytics.priorityCounts[patient.priority] || 0) + 1; | |
| analytics.totalToday++; | |
| // Compute actual wait if calledAt and addedAt exist | |
| if (patient.calledAt && patient.addedAt) { | |
| const waitMin = Math.round((new Date(patient.calledAt) - new Date(patient.addedAt)) / 60000); | |
| if (waitMin >= 0) { | |
| analytics.waitTimeSamples.push(waitMin); | |
| analytics.avgActualWait = Math.round( | |
| analytics.waitTimeSamples.reduce((a, b) => a + b, 0) / analytics.waitTimeSamples.length | |
| ); | |
| } | |
| } | |
| // Peak hour | |
| const maxCount = Math.max(...Object.values(analytics.hourlyPatients)); | |
| analytics.peakHour = Object.keys(analytics.hourlyPatients).find(h => analytics.hourlyPatients[h] === maxCount); | |
| } | |
| function createPatient(name, phone = '', priority = 'normal', notes = '') { | |
| const allTokens = [...clinicState.queue.map(p => p.tokenNumber), clinicState.totalServed]; | |
| const tokenNumber = allTokens.length > 0 ? Math.max(...allTokens) + 1 : clinicState.totalServed + 1; | |
| return { | |
| id: uuidv4(), | |
| tokenNumber, | |
| name: name.trim(), | |
| phone: phone.trim(), | |
| priority, | |
| notes: notes.trim(), | |
| status: 'waiting', | |
| addedAt: new Date(), | |
| calledAt: null, | |
| completedAt: null, | |
| estimatedCallTime: null, | |
| }; | |
| } | |
| function getQueueStats() { | |
| const waiting = clinicState.queue.filter(p => p.status === 'waiting'); | |
| return { | |
| totalWaiting: waiting.length, | |
| urgentCount: waiting.filter(p => p.priority === 'urgent').length, | |
| elderlyCount: waiting.filter(p => p.priority === 'elderly').length, | |
| estimatedClearTime: waiting.length * clinicState.avgConsultationTime, | |
| }; | |
| } | |
| function getEstimatedCallTimes() { | |
| const waiting = clinicState.queue.filter(p => p.status === 'waiting'); | |
| const now = new Date(); | |
| waiting.forEach((p, i) => { | |
| const minsFromNow = i * clinicState.avgConsultationTime; | |
| clinicState.queue.find(q => q.id === p.id).estimatedCallTime = new Date(now.getTime() + minsFromNow * 60000); | |
| }); | |
| } | |
| function broadcastState() { | |
| clinicState.lastUpdated = new Date(); | |
| getEstimatedCallTimes(); | |
| const stats = getQueueStats(); | |
| io.emit('state_update', { | |
| queue: clinicState.queue, | |
| currentToken: clinicState.currentToken, | |
| avgConsultationTime: clinicState.avgConsultationTime, | |
| totalServed: clinicState.totalServed, | |
| clinicOpen: clinicState.clinicOpen, | |
| stats, | |
| lastUpdated: clinicState.lastUpdated, | |
| announcements: clinicState.announcements.slice(-5), | |
| analytics, | |
| }); | |
| } | |
| // ─── REST API ────────────────────────────────────────────────────────────────── | |
| app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: new Date() })); | |
| app.get('/api/state', (req, res) => { | |
| res.json({ | |
| queue: clinicState.queue, | |
| currentToken: clinicState.currentToken, | |
| avgConsultationTime: clinicState.avgConsultationTime, | |
| totalServed: clinicState.totalServed, | |
| clinicOpen: clinicState.clinicOpen, | |
| stats: getQueueStats(), | |
| lastUpdated: clinicState.lastUpdated, | |
| analytics, | |
| }); | |
| }); | |
| app.get('/api/analytics', (req, res) => res.json(analytics)); | |
| // Patient token lookup by name/phone | |
| app.get('/api/lookup', (req, res) => { | |
| const { q } = req.query; | |
| if (!q) return res.status(400).json({ error: 'Query required' }); | |
| const term = q.toLowerCase().trim(); | |
| const matches = clinicState.queue.filter(p => | |
| p.status === 'waiting' && | |
| (p.name.toLowerCase().includes(term) || p.phone.includes(term)) | |
| ); | |
| if (matches.length === 0) return res.json({ found: false }); | |
| const waiting = clinicState.queue.filter(p => p.status === 'waiting'); | |
| const results = matches.map(p => ({ | |
| ...p, | |
| position: waiting.findIndex(w => w.id === p.id) + 1, | |
| estimatedWait: waiting.findIndex(w => w.id === p.id) * clinicState.avgConsultationTime, | |
| })); | |
| res.json({ found: true, results }); | |
| }); | |
| // AI Chat | |
| app.post('/api/chat', async (req, res) => { | |
| const { message, conversationHistory = [] } = req.body; | |
| if (!message) return res.status(400).json({ error: 'Message required' }); | |
| const waiting = clinicState.queue.filter(p => p.status === 'waiting'); | |
| const stats = getQueueStats(); | |
| const apiKey = process.env.ANTHROPIC_API_KEY; | |
| if (!apiKey) { | |
| const lowerMsg = message.toLowerCase(); | |
| let reply = ''; | |
| if (lowerMsg.includes('wait') || lowerMsg.includes('time') || lowerMsg.includes('long')) { | |
| reply = `Currently ${waiting.length} patients are waiting. With ${clinicState.avgConsultationTime}-minute consultations, estimated total wait is ${stats.estimatedClearTime} minutes.`; | |
| } else if (lowerMsg.includes('current') || lowerMsg.includes('serving') || lowerMsg.includes('token')) { | |
| reply = clinicState.currentToken | |
| ? `Token #${clinicState.currentToken.tokenNumber} (${clinicState.currentToken.name}) is currently being served.` | |
| : 'No patient is currently being served. The doctor is available!'; | |
| } else if (lowerMsg.includes('queue') || lowerMsg.includes('how many') || lowerMsg.includes('patient')) { | |
| reply = `There are ${waiting.length} patients waiting. ${stats.urgentCount > 0 ? `${stats.urgentCount} urgent.` : ''} ${stats.elderlyCount > 0 ? `${stats.elderlyCount} elderly/special.` : ''}`; | |
| } else if (lowerMsg.includes('open') || lowerMsg.includes('close')) { | |
| reply = `The clinic is currently ${clinicState.clinicOpen ? 'OPEN' : 'CLOSED'}.`; | |
| } else if (lowerMsg.includes('served') || lowerMsg.includes('today') || lowerMsg.includes('total')) { | |
| reply = `${clinicState.totalServed} patients have been served today. ${analytics.avgActualWait > 0 ? `Average actual wait time: ${analytics.avgActualWait} minutes.` : ''}`; | |
| } else if (lowerMsg.includes('hello') || lowerMsg.includes('hi') || lowerMsg.includes('help')) { | |
| reply = `Hello! I'm the QueueCure AI assistant 🏥\n\nI can help with:\n• Current wait times & queue status\n• Your token position (tell me your name)\n• Clinic information\n• Patient statistics\n\nWhat would you like to know?`; | |
| } else if (lowerMsg.includes('next')) { | |
| reply = waiting.length > 0 | |
| ? `Next patient to be called is Token #${waiting[0].tokenNumber} — ${waiting[0].name} (${waiting[0].priority} priority).` | |
| : 'The queue is currently empty.'; | |
| } else { | |
| reply = `I can help with queue status, wait times, and clinic info. Currently ${waiting.length} patients are waiting. What would you like to know?`; | |
| } | |
| return res.json({ reply }); | |
| } | |
| try { | |
| const systemPrompt = `You are a helpful, friendly assistant for QueueCure clinic management system. | |
| Current clinic status: | |
| - Patients waiting: ${waiting.length} | |
| - Urgent patients: ${stats.urgentCount} | |
| - Currently serving: ${clinicState.currentToken ? `Token #${clinicState.currentToken.tokenNumber} - ${clinicState.currentToken.name}` : 'No one'} | |
| - Average consultation time: ${clinicState.avgConsultationTime} minutes | |
| - Total served today: ${clinicState.totalServed} | |
| - Clinic status: ${clinicState.clinicOpen ? 'Open' : 'Closed'} | |
| - Estimated total wait: ${stats.estimatedClearTime} minutes | |
| - Average actual wait (from data): ${analytics.avgActualWait} minutes | |
| Waiting patients: ${waiting.map((p, i) => `#${p.tokenNumber} ${p.name} (pos ${i + 1}, ~${i * clinicState.avgConsultationTime}min wait)`).join(', ') || 'None'} | |
| Be concise, warm, and helpful. If someone asks about their position, check the waiting list above.`; | |
| const messages = [...conversationHistory.slice(-6), { role: 'user', content: message }]; | |
| const response = await fetch('https://api.anthropic.com/v1/messages', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, | |
| body: JSON.stringify({ model: 'claude-haiku-4-5-20251001', max_tokens: 300, system: systemPrompt, messages }) | |
| }); | |
| const data = await response.json(); | |
| res.json({ reply: data.content?.[0]?.text || 'Sorry, could not process your request.' }); | |
| } catch (err) { | |
| console.error('AI error:', err); | |
| res.json({ reply: 'I\'m having connection issues. Please try again.' }); | |
| } | |
| }); | |
| // ─── Socket.io ────────────────────────────────────────────────────────────────── | |
| io.on('connection', (socket) => { | |
| console.log(`Connected: ${socket.id}`); | |
| getEstimatedCallTimes(); | |
| socket.emit('state_update', { | |
| queue: clinicState.queue, | |
| currentToken: clinicState.currentToken, | |
| avgConsultationTime: clinicState.avgConsultationTime, | |
| totalServed: clinicState.totalServed, | |
| clinicOpen: clinicState.clinicOpen, | |
| stats: getQueueStats(), | |
| lastUpdated: clinicState.lastUpdated, | |
| announcements: clinicState.announcements.slice(-5), | |
| analytics, | |
| }); | |
| socket.on('add_patient', ({ name, phone, priority, notes }, callback) => { | |
| if (!name?.trim() || name.trim().length < 2) return callback?.({ error: 'Valid patient name required' }); | |
| if (!clinicState.clinicOpen) return callback?.({ error: 'Clinic is closed' }); | |
| const patient = createPatient(name, phone, priority || 'normal', notes || ''); | |
| if (priority === 'urgent') { | |
| const lastUrgentIdx = clinicState.queue.reduce((acc, p, idx) => | |
| p.priority === 'urgent' && p.status === 'waiting' ? idx : acc, -1); | |
| clinicState.queue.splice(lastUrgentIdx + 1, 0, patient); | |
| } else { | |
| clinicState.queue.push(patient); | |
| } | |
| // Add ticker announcement | |
| clinicState.announcements.push({ type: 'added', name: patient.name, token: patient.tokenNumber, time: new Date() }); | |
| callback?.({ success: true, patient }); | |
| broadcastState(); | |
| }); | |
| socket.on('call_next', (_, callback) => { | |
| if (clinicState.currentToken) { | |
| const idx = clinicState.queue.findIndex(p => p.id === clinicState.currentToken.id); | |
| if (idx !== -1) { | |
| clinicState.queue[idx].status = 'done'; | |
| clinicState.queue[idx].completedAt = new Date(); | |
| recordAnalytics(clinicState.queue[idx]); | |
| } | |
| clinicState.totalServed++; | |
| } | |
| const nextPatient = clinicState.queue.find(p => p.status === 'waiting'); | |
| if (!nextPatient) { | |
| clinicState.currentToken = null; | |
| callback?.({ success: true, message: 'Queue empty', currentToken: null }); | |
| broadcastState(); | |
| return; | |
| } | |
| const idx = clinicState.queue.findIndex(p => p.id === nextPatient.id); | |
| clinicState.queue[idx].status = 'serving'; | |
| clinicState.queue[idx].calledAt = new Date(); | |
| clinicState.currentToken = clinicState.queue[idx]; | |
| clinicState.announcements.push({ type: 'called', name: nextPatient.name, token: nextPatient.tokenNumber, time: new Date() }); | |
| callback?.({ success: true, currentToken: clinicState.currentToken }); | |
| broadcastState(); | |
| io.emit('token_called', { | |
| tokenNumber: nextPatient.tokenNumber, | |
| name: nextPatient.name, | |
| priority: nextPatient.priority, | |
| }); | |
| }); | |
| socket.on('skip_patient', ({ patientId }, callback) => { | |
| const idx = clinicState.queue.findIndex(p => p.id === patientId); | |
| if (idx === -1) return callback?.({ error: 'Not found' }); | |
| clinicState.queue[idx].status = 'skipped'; | |
| if (clinicState.currentToken?.id === patientId) clinicState.currentToken = null; | |
| callback?.({ success: true }); | |
| broadcastState(); | |
| }); | |
| socket.on('remove_patient', ({ patientId }, callback) => { | |
| const idx = clinicState.queue.findIndex(p => p.id === patientId); | |
| if (idx === -1) return callback?.({ error: 'Not found' }); | |
| if (clinicState.currentToken?.id === patientId) clinicState.currentToken = null; | |
| clinicState.queue.splice(idx, 1); | |
| callback?.({ success: true }); | |
| broadcastState(); | |
| }); | |
| socket.on('update_avg_time', ({ minutes }, callback) => { | |
| const mins = parseInt(minutes); | |
| if (isNaN(mins) || mins < 1 || mins > 60) return callback?.({ error: 'Must be 1–60 minutes' }); | |
| clinicState.avgConsultationTime = mins; | |
| callback?.({ success: true }); | |
| broadcastState(); | |
| }); | |
| socket.on('toggle_clinic', (_, callback) => { | |
| clinicState.clinicOpen = !clinicState.clinicOpen; | |
| callback?.({ success: true, clinicOpen: clinicState.clinicOpen }); | |
| io.emit('clinic_status_changed', { clinicOpen: clinicState.clinicOpen }); | |
| broadcastState(); | |
| }); | |
| socket.on('reset_queue', (_, callback) => { | |
| clinicState.queue = []; | |
| clinicState.currentToken = null; | |
| clinicState.totalServed = 0; | |
| clinicState.announcements = []; | |
| analytics = { | |
| hourlyPatients: {}, waitTimeSamples: [], | |
| priorityCounts: { normal: 0, urgent: 0, elderly: 0 }, | |
| peakHour: null, avgActualWait: 0, totalToday: 0, sessionStart: new Date(), | |
| }; | |
| callback?.({ success: true }); | |
| broadcastState(); | |
| }); | |
| socket.on('complete_current', (_, callback) => { | |
| if (!clinicState.currentToken) return callback?.({ error: 'No current patient' }); | |
| const idx = clinicState.queue.findIndex(p => p.id === clinicState.currentToken.id); | |
| if (idx !== -1) { | |
| clinicState.queue[idx].status = 'done'; | |
| clinicState.queue[idx].completedAt = new Date(); | |
| recordAnalytics(clinicState.queue[idx]); | |
| } | |
| clinicState.totalServed++; | |
| clinicState.currentToken = null; | |
| callback?.({ success: true }); | |
| broadcastState(); | |
| }); | |
| socket.on('add_announcement', ({ text }, callback) => { | |
| clinicState.announcements.push({ type: 'custom', text, time: new Date() }); | |
| callback?.({ success: true }); | |
| broadcastState(); | |
| }); | |
| socket.on('disconnect', () => console.log(`Disconnected: ${socket.id}`)); | |
| }); | |
| const PORT = process.env.PORT || 3001; | |
| server.listen(PORT, '0.0.0.0', () => console.log(`QueueCure server on port ${PORT}`)); | |