curaflow/backend/server.js

666 lines
27 KiB
JavaScript

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('<Response></Response>');
}
} catch (err) { /* start new */ }
await temporalClient.workflow.start('AppointmentBookingWorkflow', {
args: [body, from], taskQueue: 'curaflow-tasks', workflowId,
});
res.type('text/xml').send('<Response></Response>');
} 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('<Response></Response>');
} 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}`);
});
});