666 lines
27 KiB
JavaScript
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}`);
|
|
});
|
|
});
|