curaflow/backend/server.js

954 lines
39 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 Razorpay = require('razorpay');
const { upload, initStorage, uploadFile } = require('./storage');
const AgentManager = require('./agents/AgentManager');
const prescriptionManager = require('./prescriptionManager');
const razorpayInstance = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID || 'rzp_test_dummy_key_id',
key_secret: process.env.RAZORPAY_KEY_SECRET || 'dummy_secret_key',
});
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' });
}
});
// ── AI AGENTS CONFIGURATION ───────────────────────────────────────────────────
app.get('/api/clinic/agents', requireAuth('OWNER'), async (req, res) => {
try {
const result = await query(
'SELECT agent_type, is_active, config FROM agent_configs WHERE tenant_id = $1',
[req.user.tenantId]
);
res.json(result.rows);
} catch (err) {
console.error('[Agents API]', err);
res.status(500).json({ error: 'Failed to fetch agent configs' });
}
});
app.patch('/api/clinic/agents/:type', requireAuth('OWNER'), async (req, res) => {
const { type } = req.params;
const { isActive, config } = req.body;
try {
await query(
`INSERT INTO agent_configs (tenant_id, agent_type, is_active, config)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (tenant_id, agent_type)
DO UPDATE SET is_active = EXCLUDED.is_active, config = EXCLUDED.config`,
[req.user.tenantId, type.toUpperCase(), isActive, JSON.stringify(config || {})]
);
res.json({ success: true });
} catch (err) {
console.error('[Agents API Update]', err);
res.status(500).json({ error: 'Failed to update agent config' });
}
});
// ── CLINIC OPERATIONS (QUEUE & PATIENTS) ──────────────────────────────────────
/**
* GET /api/clinic/queue
* Fetch the live queue for the tenant
*/
app.get('/api/clinic/queue', requireAuth(), async (req, res) => {
try {
const tenantId = req.user.tenantId;
const result = await query(
`SELECT v.*, p.name as patient_name, p.whatsapp_number, p.age, p.gender, p.display_id
FROM visits v
JOIN patients p ON v.patient_id = p.id
WHERE v.tenant_id = $1 AND v.status IN ('waiting', 'in-room')
ORDER BY v.created_at ASC`,
[tenantId]
);
res.json(result.rows);
} catch (err) {
console.error('[Clinic Queue]', err);
res.status(500).json({ error: 'Failed to fetch queue' });
}
});
/**
* POST /api/clinic/walk-in
* Register a new walk-in patient and add to queue
*/
app.post('/api/clinic/walk-in', requireAuth(), async (req, res) => {
const { name, whatsappNumber, age, gender, fee } = req.body;
const tenantId = req.user.tenantId;
if (!name || !whatsappNumber) return res.status(400).json({ error: 'Name and WhatsApp number are required' });
try {
await query('BEGIN');
// Find or create patient
let patientResult = await query(
'SELECT id FROM patients WHERE tenant_id = $1 AND whatsapp_number = $2',
[tenantId, whatsappNumber]
);
let patientId;
if (patientResult.rows.length > 0) {
patientId = patientResult.rows[0].id;
} else {
// Generate display ID
const countRes = await query('SELECT count(*) FROM patients WHERE tenant_id = $1', [tenantId]);
const nextNum = parseInt(countRes.rows[0].count) + 1001;
const displayId = `CF-${nextNum}`;
const insertPatient = await query(
`INSERT INTO patients (tenant_id, display_id, name, whatsapp_number, age, gender)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[tenantId, displayId, name, whatsappNumber, age || null, gender || null]
);
patientId = insertPatient.rows[0].id;
}
// Generate Token Number (simple count-based for the day)
const todayStr = new Date().toISOString().split('T')[0];
const tokenRes = await query(
`SELECT count(*) FROM visits WHERE tenant_id = $1 AND DATE(created_at) = $2`,
[tenantId, todayStr]
);
const tokenNum = parseInt(tokenRes.rows[0].count) + 1;
const tokenStr = `#${tokenNum.toString().padStart(3, '0')}`;
// Insert Visit
const insertVisit = await query(
`INSERT INTO visits (tenant_id, patient_id, token_number, type, status, fee)
VALUES ($1, $2, $3, 'walk-in', 'waiting', $4) RETURNING *`,
[tenantId, patientId, tokenStr, fee || 0]
);
await query('COMMIT');
res.json({ success: true, visit: insertVisit.rows[0], token: tokenStr });
} catch (err) {
await query('ROLLBACK');
console.error('[Clinic Walk-in]', err);
res.status(500).json({ error: 'Failed to register walk-in' });
}
});
// ── 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, servicesCatalog } = 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 (servicesCatalog !== undefined) { fields.push(`services_catalog=$${i++}::jsonb`); values.push(JSON.stringify(servicesCatalog)); }
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' });
}
});
/**
* PATCH /api/doctor/profile — Doctor updates own profile
*/
app.patch('/api/doctor/profile', requireAuth('DOCTOR'), async (req, res) => {
const { blockedDates, qAndA } = req.body;
const userId = req.user.userId;
try {
const fields = [];
const values = [];
let i = 1;
if (blockedDates !== undefined) { fields.push(`blocked_dates=$${i++}::jsonb`); values.push(JSON.stringify(blockedDates)); }
if (qAndA !== undefined) { fields.push(`q_and_a=$${i++}::jsonb`); values.push(JSON.stringify(qAndA)); }
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(userId);
await query(`UPDATE users SET ${fields.join(',')} WHERE id=$${i}`, values);
res.json({ success: true });
} catch (err) {
console.error('[Doctor Profile]', err);
res.status(500).json({ error: 'Failed to update profile' });
}
});
/**
* POST /api/doctor/upload-media — Upload images/videos to MinIO
*/
app.post('/api/doctor/upload-media', requireAuth('DOCTOR'), upload.single('media'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const userId = req.user.userId;
const ext = path.extname(req.file.originalname) || '.jpg';
const fileName = `profile_${userId}_${Date.now()}${ext}`;
const fileUrl = await uploadFile(fileName, req.file.buffer, req.file.mimetype);
// Append to profile_media JSON
const userRow = await query('SELECT profile_media FROM users WHERE id = $1', [userId]);
const media = userRow.rows[0].profile_media || {};
const mediaList = media.uploads || [];
mediaList.push({ url: fileUrl, type: req.file.mimetype, name: req.file.originalname });
await query('UPDATE users SET profile_media = $1::jsonb WHERE id = $2', [JSON.stringify({ uploads: mediaList }), userId]);
res.json({ success: true, url: fileUrl });
} catch (err) {
console.error('[Media Upload]', err);
res.status(500).json({ error: 'Failed to upload media' });
}
});
// ── RAZORPAY PAYMENTS ─────────────────────────────────────────────────────────
app.post('/api/payments/create-order', requireAuth(), async (req, res) => {
const { amount, currency = 'INR', notes } = req.body;
if (!amount) return res.status(400).json({ error: 'Amount is required' });
try {
const options = {
amount: amount * 100, // Razorpay works in smallest currency unit (paise)
currency,
receipt: `rcpt_${Date.now()}`,
notes,
};
const order = await razorpayInstance.orders.create(options);
// Save transaction to DB
await query(
`INSERT INTO transactions (order_id, amount, currency, tenant_id, user_id)
VALUES ($1, $2, $3, $4, $5)`,
[order.id, amount, currency, req.user.tenantId, req.user.userId]
);
res.json({ success: true, orderId: order.id, amount: order.amount, currency: order.currency });
} catch (err) {
console.error('[Create Order Error]', err);
res.status(500).json({ error: 'Failed to create payment order' });
}
});
app.post('/api/payments/verify', requireAuth(), async (req, res) => {
const { razorpay_order_id, razorpay_payment_id, razorpay_signature } = req.body;
const secret = process.env.RAZORPAY_KEY_SECRET || 'dummy_secret_key';
const body = razorpay_order_id + '|' + razorpay_payment_id;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(body.toString())
.digest('hex');
if (expectedSignature === razorpay_signature) {
// Mark as paid
await query('UPDATE transactions SET status = $1, payment_id = $2 WHERE order_id = $3',
['PAID', razorpay_payment_id, razorpay_order_id]);
res.json({ success: true });
} else {
res.status(400).json({ error: 'Invalid signature' });
}
});
// ── 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 to = req.body.To || req.body.to;
const body = req.body.Body || req.body.body;
console.log(`[WhatsApp Webhook] Received message from ${from} to ${to}: ${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, to], 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' });
}
});
// ── Prescriptions API ─────────────────────────────────────────────────────────
app.post('/api/prescriptions', requireAuth('DOCTOR'), async (req, res) => {
try {
const { patientId, patientName, diagnosis, medicines, labTests } = req.body;
const tenantId = req.user.tenantId;
const doctorId = req.user.id;
const result = await prescriptionManager.generate(tenantId, doctorId, patientId, patientName, diagnosis, medicines, labTests);
// Push notification via whatsappService (optional)
if (whatsappService && whatsappService.sendMessage && req.body.patientPhone) {
await whatsappService.sendMessage(req.body.patientPhone, result.whatsappMessage);
}
res.json(result);
} catch (error) {
console.error('[Prescription API] Error:', error);
res.status(500).json({ error: 'Failed to create prescription' });
}
});
app.get('/api/prescriptions/pending', requireAuth(), async (req, res) => {
try {
const tenantId = req.user.tenantId;
const pending = await prescriptionManager.getPending(tenantId);
res.json({ success: true, prescriptions: pending });
} catch (error) {
console.error('[Prescription API] Error:', error);
res.status(500).json({ error: 'Failed to fetch pending prescriptions' });
}
});
app.post('/api/prescriptions/:id/status', requireAuth(), async (req, res) => {
try {
const tenantId = req.user.tenantId;
const { status } = req.body;
const updated = await prescriptionManager.updateStatus(tenantId, req.params.id, status);
if (!updated) {
return res.status(404).json({ error: 'Prescription not found' });
}
res.json({ success: true, prescription: updated });
} catch (error) {
console.error('[Prescription API] Error:', error);
res.status(500).json({ error: 'Failed to update prescription status' });
}
});
// ── START ─────────────────────────────────────────────────────────────────────
initSchema().then(() => {
initStorage().then(() => {
app.listen(port, '0.0.0.0', () => {
console.log(`Curio HMS running at http://0.0.0.0:${port}`);
});
});
});