require('dotenv').config(); const express = require('express'); const path = require('path'); const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const twilio = require('twilio'); const whatsappService = require('./whatsappService'); const botLogic = require('./botLogic'); const { getTenantConfig, updateTenantConfig } = require('./configManager'); const { getTemporalClient } = require('./temporalClient'); const { query, initSchema } = require('./db'); const { signToken, requireAuth } = require('./authMiddleware'); const app = express(); const port = 3000; app.use(express.json()); const staticRoot = path.join(__dirname, '../'); // ── Helpers ─────────────────────────────────────────────────────────────────── function setUtf8ContentType(res, filePath) { if (filePath.endsWith('.html')) res.setHeader('Content-Type', 'text/html; charset=utf-8'); else if (filePath.endsWith('.css')) res.setHeader('Content-Type', 'text/css; charset=utf-8'); else if (filePath.endsWith('.js')) res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); else if (filePath.endsWith('.json')) res.setHeader('Content-Type', 'application/json; charset=utf-8'); } function setNoCacheHeaders(res) { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } function sendHtmlPage(res, filename) { setNoCacheHeaders(res); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.sendFile(path.join(staticRoot, filename)); } // Slug-safe clinic ID generator from clinic name function slugify(name) { return name.toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .slice(0, 40); } async function uniqueSlug(base) { let slug = slugify(base); let attempt = slug; let counter = 2; while (true) { const existing = await query('SELECT id FROM tenants WHERE id = $1', [attempt]); if (existing.rows.length === 0) return attempt; attempt = `${slug}-${counter++}`; } } // ── Static files ────────────────────────────────────────────────────────────── app.use(express.static(staticRoot, { setHeaders: (res, filePath) => { setUtf8ContentType(res, filePath); if (filePath.endsWith('.html')) setNoCacheHeaders(res); } })); // ── Page routes ─────────────────────────────────────────────────────────────── app.get('/', (req, res) => sendHtmlPage(res, 'login.html')); app.get('/admin', (req, res) => sendHtmlPage(res, 'admin.html')); app.get('/dashboard', (req, res) => sendHtmlPage(res, 'dashboard.html')); app.get('/register', (req, res) => sendHtmlPage(res, 'register.html')); app.get('/super-admin', (req, res) => sendHtmlPage(res, 'super-admin.html')); // ── Multi-tenant Config API (DB-first, fallback to in-memory) ───────────────── app.get('/api/config/:tenantId', async (req, res) => { try { const row = await query('SELECT * FROM tenants WHERE id = $1 AND status = $2', [req.params.tenantId, 'active']); if (row.rows.length > 0) { const t = row.rows[0]; return res.json({ name: t.name, logo: t.logo, primaryColor: t.primary_color, secondaryColor: t.secondary_color, consultationFee: t.consultation_fee, currency: t.currency || '₹', plan: t.plan, specialty: t.specialty, city: t.city, workingHours: t.working_hours, daysOff: t.days_off, holidays: t.holidays, }); } } catch (err) { console.error('[Config API] DB error, falling back to in-memory:', err.message); } // Fallback to in-memory config res.json(getTenantConfig(req.params.tenantId)); }); // ── AUTH ROUTES ─────────────────────────────────────────────────────────────── app.use(express.urlencoded({ extended: true })); /** * POST /api/auth/register * Public — submit a clinic registration for super admin approval */ app.post('/api/auth/register', async (req, res) => { const { ownerName, clinicName, specialty, city, whatsappNumber, email, password, plan } = req.body; if (!ownerName || !clinicName || !whatsappNumber || !email || !password) { return res.status(400).json({ error: 'Missing required fields' }); } if (password.length < 8) { return res.status(400).json({ error: 'Password must be at least 8 characters' }); } try { // Check for duplicate email in users or pending const dupUser = await query('SELECT id FROM users WHERE email = $1', [email]); const dupPending = await query('SELECT id FROM pending_registrations WHERE email = $1 AND status = $2', [email, 'pending']); if (dupUser.rows.length > 0 || dupPending.rows.length > 0) { return res.status(409).json({ error: 'An account with this email already exists or is pending approval' }); } const hash = await bcrypt.hash(password, 10); const result = await query( `INSERT INTO pending_registrations (owner_name, clinic_name, specialty, city, whatsapp_number, email, password_hash, plan) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, [ownerName, clinicName, specialty || null, city || null, whatsappNumber, email, hash, plan || 'basic'] ); res.json({ success: true, registrationId: result.rows[0].id }); } catch (err) { console.error('[Register]', err); res.status(500).json({ error: 'Registration failed' }); } }); /** * POST /api/auth/login * Public — authenticate user, return JWT */ app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); try { const result = await query('SELECT * FROM users WHERE email = $1', [email]); if (result.rows.length === 0) { return res.status(401).json({ error: 'Invalid credentials' }); } const user = result.rows[0]; // Check tenant status (super admin has null tenant_id — always allowed) if (user.tenant_id) { const tenant = await query('SELECT status FROM tenants WHERE id = $1', [user.tenant_id]); if (tenant.rows.length === 0 || tenant.rows[0].status !== 'active') { return res.status(403).json({ error: 'Your clinic account is suspended. Contact Curio support.' }); } } if (user.status !== 'active') { return res.status(403).json({ error: 'Your account is inactive. Contact your clinic admin.' }); } const match = await bcrypt.compare(password, user.password_hash); if (!match) return res.status(401).json({ error: 'Invalid credentials' }); const token = signToken({ userId: user.id, email: user.email, name: user.name, role: user.role, tenantId: user.tenant_id, }); res.json({ success: true, token, role: user.role, tenantId: user.tenant_id, name: user.name }); } catch (err) { console.error('[Login]', err); res.status(500).json({ error: 'Login failed' }); } }); /** * GET /api/auth/me * Returns current user info (for page load verification) */ app.get('/api/auth/me', requireAuth(), (req, res) => { res.json({ user: req.user }); }); // ── SUPER ADMIN ROUTES ──────────────────────────────────────────────────────── /** * GET /api/super/stats */ app.get('/api/super/stats', requireAuth('SUPER_ADMIN'), async (req, res) => { try { const [clinics, pending, users] = await Promise.all([ query("SELECT COUNT(*) FROM tenants WHERE status = 'active'"), query("SELECT COUNT(*) FROM pending_registrations WHERE status = 'pending'"), query("SELECT COUNT(*) FROM users WHERE role != 'SUPER_ADMIN'"), ]); res.json({ activeClinics: parseInt(clinics.rows[0].count), pendingCount: parseInt(pending.rows[0].count), totalUsers: parseInt(users.rows[0].count), }); } catch (err) { console.error('[Super Stats]', err); res.status(500).json({ error: 'Failed to fetch stats' }); } }); /** * GET /api/super/clinics */ app.get('/api/super/clinics', requireAuth('SUPER_ADMIN'), async (req, res) => { try { const result = await query( `SELECT t.*, COUNT(u.id) AS user_count FROM tenants t LEFT JOIN users u ON u.tenant_id = t.id GROUP BY t.id ORDER BY t.created_at DESC` ); res.json(result.rows); } catch (err) { console.error('[Super Clinics]', err); res.status(500).json({ error: 'Failed to fetch clinics' }); } }); /** * POST /api/super/clinics * Direct clinic creation by super admin (bypasses pending flow) */ app.post('/api/super/clinics', requireAuth('SUPER_ADMIN'), async (req, res) => { const { ownerName, ownerEmail, ownerPassword, clinicName, specialty, city, whatsappNumber, plan, primaryColor, secondaryColor, consultationFee } = req.body; if (!ownerName || !ownerEmail || !ownerPassword || !clinicName) { return res.status(400).json({ error: 'Missing required fields' }); } try { const tenantId = await uniqueSlug(clinicName); const hash = await bcrypt.hash(ownerPassword, 10); await query('BEGIN'); try { await query( `INSERT INTO tenants (id, name, logo, primary_color, secondary_color, consultation_fee, plan, specialty, city, whatsapp_number) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, [tenantId, clinicName, clinicName[0].toUpperCase(), primaryColor || '#0F172A', secondaryColor || '#38BDF8', consultationFee || 500, plan || 'basic', specialty || null, city || null, whatsappNumber || null] ); const userResult = await query( `INSERT INTO users (tenant_id, email, password_hash, name, role) VALUES ($1,$2,$3,$4,'OWNER') RETURNING id`, [tenantId, ownerEmail, hash, ownerName] ); await query('COMMIT'); res.json({ success: true, tenantId, userId: userResult.rows[0].id }); } catch (inner) { await query('ROLLBACK'); throw inner; } } catch (err) { console.error('[Super Create Clinic]', err); if (err.code === '23505') return res.status(409).json({ error: 'Email already in use' }); res.status(500).json({ error: 'Failed to create clinic' }); } }); /** * PATCH /api/super/clinics/:id/status * Toggle active / suspended */ app.patch('/api/super/clinics/:id/status', requireAuth('SUPER_ADMIN'), async (req, res) => { const { status } = req.body; if (!['active', 'suspended'].includes(status)) { return res.status(400).json({ error: 'Status must be active or suspended' }); } try { await query('UPDATE tenants SET status = $1 WHERE id = $2', [status, req.params.id]); res.json({ success: true }); } catch (err) { console.error('[Super Clinic Status]', err); res.status(500).json({ error: 'Failed to update status' }); } }); /** * DELETE /api/super/clinics/:id */ app.delete('/api/super/clinics/:id', requireAuth('SUPER_ADMIN'), async (req, res) => { try { await query('DELETE FROM tenants WHERE id = $1', [req.params.id]); res.json({ success: true }); } catch (err) { console.error('[Super Delete Clinic]', err); res.status(500).json({ error: 'Failed to delete clinic' }); } }); /** * GET /api/super/clinics/:id/users */ app.get('/api/super/clinics/:id/users', requireAuth('SUPER_ADMIN'), async (req, res) => { try { const result = await query( `SELECT id, name, email, role, status, created_at FROM users WHERE tenant_id = $1 ORDER BY created_at`, [req.params.id] ); res.json(result.rows); } catch (err) { console.error('[Super Clinic Users]', err); res.status(500).json({ error: 'Failed to fetch users' }); } }); /** * POST /api/super/clinics/:id/users * Add a user to a clinic */ app.post('/api/super/clinics/:id/users', requireAuth('SUPER_ADMIN'), async (req, res) => { const { name, email, password, role } = req.body; const validRoles = ['OWNER', 'DOCTOR', 'RECEPTIONIST', 'PHARMACIST', 'LAB_TECH']; if (!name || !email || !password || !role) return res.status(400).json({ error: 'Missing required fields' }); if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' }); try { const hash = await bcrypt.hash(password, 10); const result = await query( `INSERT INTO users (tenant_id, email, password_hash, name, role) VALUES ($1,$2,$3,$4,$5) RETURNING id`, [req.params.id, email, hash, name, role] ); res.json({ success: true, userId: result.rows[0].id }); } catch (err) { if (err.code === '23505') return res.status(409).json({ error: 'Email already in use' }); console.error('[Super Add User]', err); res.status(500).json({ error: 'Failed to create user' }); } }); /** * PATCH /api/super/users/:userId/status */ app.patch('/api/super/users/:userId/status', requireAuth('SUPER_ADMIN'), async (req, res) => { const { status } = req.body; if (!['active', 'inactive'].includes(status)) return res.status(400).json({ error: 'Invalid status' }); try { await query('UPDATE users SET status = $1 WHERE id = $2 AND role != $3', [status, req.params.userId, 'SUPER_ADMIN']); res.json({ success: true }); } catch (err) { console.error('[Super User Status]', err); res.status(500).json({ error: 'Failed to update user status' }); } }); /** * DELETE /api/super/users/:userId */ app.delete('/api/super/users/:userId', requireAuth('SUPER_ADMIN'), async (req, res) => { try { await query('DELETE FROM users WHERE id = $1 AND role != $2', [req.params.userId, 'SUPER_ADMIN']); res.json({ success: true }); } catch (err) { console.error('[Super Delete User]', err); res.status(500).json({ error: 'Failed to delete user' }); } }); /** * GET /api/super/pending */ app.get('/api/super/pending', requireAuth('SUPER_ADMIN'), async (req, res) => { try { const result = await query( `SELECT id, owner_name, clinic_name, specialty, city, whatsapp_number, email, plan, status, created_at FROM pending_registrations ORDER BY created_at DESC` ); res.json(result.rows); } catch (err) { console.error('[Super Pending]', err); res.status(500).json({ error: 'Failed to fetch pending registrations' }); } }); /** * POST /api/super/pending/:id/approve * Creates tenant + owner user, marks pending as approved */ app.post('/api/super/pending/:id/approve', requireAuth('SUPER_ADMIN'), async (req, res) => { try { const result = await query('SELECT * FROM pending_registrations WHERE id = $1', [req.params.id]); if (result.rows.length === 0) return res.status(404).json({ error: 'Registration not found' }); const p = result.rows[0]; if (p.status !== 'pending') return res.status(400).json({ error: 'Already processed' }); const tenantId = await uniqueSlug(p.clinic_name); await query('BEGIN'); try { await query( `INSERT INTO tenants (id, name, logo, specialty, city, whatsapp_number, plan) VALUES ($1,$2,$3,$4,$5,$6,$7)`, [tenantId, p.clinic_name, p.clinic_name[0].toUpperCase(), p.specialty, p.city, p.whatsapp_number, p.plan] ); await query( `INSERT INTO users (tenant_id, email, password_hash, name, role) VALUES ($1,$2,$3,$4,'OWNER')`, [tenantId, p.email, p.password_hash, p.owner_name] ); await query( `UPDATE pending_registrations SET status = 'approved' WHERE id = $1`, [p.id] ); await query('COMMIT'); res.json({ success: true, tenantId }); } catch (inner) { await query('ROLLBACK'); throw inner; } } catch (err) { console.error('[Super Approve]', err); if (err.code === '23505') return res.status(409).json({ error: 'Email already registered' }); res.status(500).json({ error: 'Failed to approve registration' }); } }); /** * POST /api/super/pending/:id/reject */ app.post('/api/super/pending/:id/reject', requireAuth('SUPER_ADMIN'), async (req, res) => { try { await query("UPDATE pending_registrations SET status = 'rejected' WHERE id = $1", [req.params.id]); res.json({ success: true }); } catch (err) { console.error('[Super Reject]', err); res.status(500).json({ error: 'Failed to reject registration' }); } }); // ── CLINIC OWNER ROUTES ─────────────────────────────────────────────────────── /** * PATCH /api/clinic/settings — clinic owner updates own settings */ app.patch('/api/clinic/settings', requireAuth('OWNER'), async (req, res) => { const { consultationFee, workingHours, daysOff, holidays, primaryColor, secondaryColor } = req.body; const tenantId = req.user.tenantId; try { const fields = []; const values = []; let i = 1; if (consultationFee !== undefined) { fields.push(`consultation_fee=$${i++}`); values.push(consultationFee); } if (primaryColor !== undefined) { fields.push(`primary_color=$${i++}`); values.push(primaryColor); } if (secondaryColor !== undefined) { fields.push(`secondary_color=$${i++}`); values.push(secondaryColor); } if (workingHours !== undefined) { fields.push(`working_hours=$${i++}::jsonb`); values.push(JSON.stringify(workingHours)); } if (daysOff !== undefined) { fields.push(`days_off=$${i++}::jsonb`); values.push(JSON.stringify(daysOff)); } if (holidays !== undefined) { fields.push(`holidays=$${i++}::jsonb`); values.push(JSON.stringify(holidays)); } if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' }); values.push(tenantId); await query(`UPDATE tenants SET ${fields.join(',')} WHERE id=$${i}`, values); res.json({ success: true }); } catch (err) { console.error('[Clinic Settings]', err); res.status(500).json({ error: 'Failed to update settings' }); } }); // ── WHATSAPP WEBHOOK ────────────────────────────────────────────────────────── // Using twilio.webhook() middleware for request validation // Disable validation in local environment to allow testing via ngrok/local tools const shouldValidate = process.env.NODE_ENV === 'production'; app.post('/whatsapp/webhook', twilio.webhook({ validate: shouldValidate }), async (req, res) => { const from = req.body.From || req.body.from; const body = req.body.Body || req.body.body; console.log(`[WhatsApp Webhook] Received message from ${from}: ${body}`); if (!from || !body) return res.status(400).send('Missing from or body'); try { const temporalClient = await getTemporalClient(); const workflowId = `booking-${from.replace(/[^a-zA-Z0-9]/g, '')}`; let handle; try { handle = temporalClient.workflow.getHandle(workflowId); const desc = await handle.describe(); if (desc.status.name === 'RUNNING') { await handle.signal('receive_message', body); return res.type('text/xml').send(''); } } catch (err) { /* start new */ } await temporalClient.workflow.start('AppointmentBookingWorkflow', { args: [body, from], taskQueue: 'curaflow-tasks', workflowId, }); res.type('text/xml').send(''); } catch (err) { console.error('[WhatsApp Webhook] Temporal unavailable, falling back to local botLogic. Error:', err.message); // Fallback to local bot logic if Temporal is not running try { const botResponse = await botLogic.handleMessage(from, body); let replyText = botResponse.reply; // Format buttons for standard WhatsApp text since we aren't using Twilio Content Templates if (botResponse.buttons && botResponse.buttons.length > 0) { replyText += '\n\n' + botResponse.buttons.map((b, i) => `${i + 1}. ${b}`).join('\n'); } await whatsappService.sendWhatsApp(from, replyText); res.type('text/xml').send(''); } catch (botErr) { console.error('[WhatsApp Webhook] Fallback logic error:', botErr); res.status(500).send('Internal Server Error'); } } }); // ── MANUAL TOKEN NOTIFICATION ────────────────────────────────────────────────── app.post('/api/whatsapp/send-token', requireAuth(), async (req, res) => { const { patientPhone, tokenNumber, clinicName, slotTime, fee } = req.body; if (!patientPhone || !tokenNumber || !clinicName || !slotTime) { return res.status(400).json({ error: 'Missing required fields' }); } try { const result = await whatsappService.sendTokenConfirmation( patientPhone, tokenNumber, clinicName, slotTime, fee || 0 ); if (result.success) { res.json({ success: true, sid: result.sid }); } else { res.status(500).json({ error: result.error }); } } catch (err) { console.error('[Send Token]', err); res.status(500).json({ error: 'Failed to send token via WhatsApp' }); } }); // ── AI SCRIBE ───────────────────────────────────────────────────────────────── app.post('/api/ai/scribe/start', async (req, res) => { try { const { dictation } = req.body; if (!dictation) return res.status(400).json({ error: 'Dictation is required' }); const temporalClient = await getTemporalClient(); const jobId = `scribe-${crypto.randomUUID()}`; await temporalClient.workflow.start('ClinicalIntakeWorkflow', { args: [dictation], taskQueue: 'curaflow-tasks', workflowId: jobId, }); res.json({ success: true, jobId }); } catch (err) { console.error('[AI Scribe] Start Error:', err); res.status(500).json({ error: 'Failed to start AI Scribe job' }); } }); app.get('/api/ai/scribe/status/:jobId', async (req, res) => { try { const temporalClient = await getTemporalClient(); const handle = temporalClient.workflow.getHandle(req.params.jobId); const description = await handle.describe(); if (description.status.name === 'COMPLETED') { return res.json({ status: 'COMPLETED', result: await handle.result() }); } else if (description.status.name === 'FAILED') { return res.json({ status: 'FAILED' }); } res.json({ status: description.status.name }); } catch (err) { console.error('[AI Scribe] Status Error:', err); res.status(500).json({ error: 'Failed to get job status' }); } }); // ── LAB WATCHER ─────────────────────────────────────────────────────────────── app.post('/api/ai/lab/watch/start', async (req, res) => { try { const { patientId, baselineData } = req.body; if (!patientId) return res.status(400).json({ error: 'Patient ID is required' }); const temporalClient = await getTemporalClient(); const workflowId = `lab-watcher-${patientId}`; try { const handle = temporalClient.workflow.getHandle(workflowId); const desc = await handle.describe(); if (desc.status.name === 'RUNNING') return res.json({ success: true, message: 'Watcher already running' }); } catch (e) { /* start new */ } await temporalClient.workflow.start('LabResultWatcherWorkflow', { args: [baselineData || { baseline: 'Patient is healthy', medications: [] }], taskQueue: 'curaflow-tasks', workflowId, }); res.json({ success: true }); } catch (err) { console.error('[AI Lab Watcher] Start Error:', err); res.status(500).json({ error: 'Failed to start AI Lab Watcher' }); } }); app.post('/api/ai/lab/result/:patientId', async (req, res) => { try { const { result } = req.body; if (!result) return res.status(400).json({ error: 'Lab result string is required' }); const temporalClient = await getTemporalClient(); const handle = temporalClient.workflow.getHandle(`lab-watcher-${req.params.patientId}`); await handle.signal('add_lab_result', result); res.json({ success: true }); } catch (err) { console.error('[AI Lab Watcher] Signal Error:', err); res.status(500).json({ error: 'Failed to send lab result to AI Watcher' }); } }); app.get('/api/ai/lab/alerts/:patientId', async (req, res) => { try { const temporalClient = await getTemporalClient(); try { const handle = temporalClient.workflow.getHandle(`lab-watcher-${req.params.patientId}`); const alerts = await handle.query('get_alerts'); return res.json({ success: true, alerts }); } catch (e) { res.json({ success: true, alerts: [] }); } } catch (err) { console.error('[AI Lab Watcher] Query Error:', err); res.status(500).json({ error: 'Failed to fetch alerts' }); } }); // ── START ───────────────────────────────────────────────────────────────────── initSchema().then(() => { app.listen(port, '0.0.0.0', () => { console.log(`Curio HMS running at http://0.0.0.0:${port}`); }); });