954 lines
39 KiB
JavaScript
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}`);
|
|
});
|
|
});
|
|
});
|