diff --git a/backend/authMiddleware.js b/backend/authMiddleware.js
new file mode 100644
index 0000000..555af29
--- /dev/null
+++ b/backend/authMiddleware.js
@@ -0,0 +1,58 @@
+/**
+ * authMiddleware.js — JWT sign/verify helpers + Express middleware for Curio HMS.
+ */
+
+const jwt = require('jsonwebtoken');
+
+const JWT_SECRET = process.env.JWT_SECRET || 'curio_dev_secret_change_in_production';
+const JWT_EXPIRY = '7d';
+
+/**
+ * Sign a JWT with the given payload.
+ * @param {{ userId, email, role, tenantId }} payload
+ * @returns {string} signed JWT
+ */
+function signToken(payload) {
+ return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
+}
+
+/**
+ * Express middleware: verifies JWT and enforces role access.
+ * @param {...string} roles - allowed roles; empty = any authenticated user
+ */
+function requireAuth(...roles) {
+ return (req, res, next) => {
+ const header = req.headers['authorization'] || req.headers['x-auth-token'];
+ if (!header) return res.status(401).json({ error: 'Authentication required' });
+
+ const token = header.startsWith('Bearer ') ? header.slice(7) : header;
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET);
+ req.user = decoded;
+
+ if (roles.length > 0 && !roles.includes(decoded.role)) {
+ return res.status(403).json({ error: 'Insufficient permissions' });
+ }
+ next();
+ } catch (err) {
+ return res.status(401).json({ error: 'Invalid or expired token' });
+ }
+ };
+}
+
+/**
+ * Middleware: verifies JWT but does NOT reject unauthenticated requests.
+ * Attaches req.user if valid token present.
+ */
+function optionalAuth(req, res, next) {
+ const header = req.headers['authorization'] || req.headers['x-auth-token'];
+ if (header) {
+ const token = header.startsWith('Bearer ') ? header.slice(7) : header;
+ try {
+ req.user = jwt.verify(token, JWT_SECRET);
+ } catch (_) { /* ignore */ }
+ }
+ next();
+}
+
+module.exports = { signToken, requireAuth, optionalAuth };
diff --git a/backend/configManager.js b/backend/configManager.js
index 6282c60..909c494 100644
--- a/backend/configManager.js
+++ b/backend/configManager.js
@@ -1,64 +1,40 @@
/**
- * ConfigManager: Central configuration for multi-tenant hospital settings and white-labeling.
+ * configManager.js — In-memory fallback config for multi-tenant hospital settings.
+ * The server.js DB layer takes priority; this is the fallback for legacy/dev use.
*/
-class ConfigManager {
- constructor() {
- this.tenants = {
- 'default': {
- name: "Curio Clinic",
- logo: "C",
- primaryColor: "#0F172A",
- secondaryColor: "#38BDF8",
- consultationFee: 500,
- currency: "₹",
- workingHours: [
- { start: "09:00", end: "13:00" },
- { start: "17:00", end: "22:00" }
- ],
- daysOff: [0], // 0 = Sunday
- holidays: ["2024-12-25", "2025-01-01"]
- },
- 'sharma-clinic': {
- name: "Dr. Sharma's Cardiology",
- logo: "S",
- primaryColor: "#1E3A8A",
- secondaryColor: "#60A5FA",
- consultationFee: 800,
- currency: "₹",
- workingHours: [
- { start: "10:00", end: "14:00" },
- { start: "18:00", end: "21:00" }
- ],
- daysOff: [0, 6], // Sat, Sun
- holidays: []
- },
- 'apollo-hospitals': {
- name: "Apollo Multispecialty",
- logo: "A",
- primaryColor: "#065F46",
- secondaryColor: "#34D399",
- consultationFee: 1200,
- currency: "₹",
- workingHours: [
- { start: "09:00", end: "22:00" }
- ],
- daysOff: [],
- holidays: ["2024-10-02"]
- }
- };
+const TENANTS = {
+ 'default': {
+ name: "Curio Clinic", logo: "C",
+ primaryColor: "#0F172A", secondaryColor: "#38BDF8",
+ consultationFee: 500, currency: "₹",
+ workingHours: [{ start: "09:00", end: "13:00" }, { start: "17:00", end: "22:00" }],
+ daysOff: [0], holidays: ["2024-12-25", "2025-01-01"]
+ },
+ 'sharma-clinic': {
+ name: "Dr. Sharma's Cardiology", logo: "S",
+ primaryColor: "#1E3A8A", secondaryColor: "#60A5FA",
+ consultationFee: 800, currency: "₹",
+ workingHours: [{ start: "10:00", end: "14:00" }, { start: "18:00", end: "21:00" }],
+ daysOff: [0, 6], holidays: []
+ },
+ 'apollo-hospitals': {
+ name: "Apollo Multispecialty", logo: "A",
+ primaryColor: "#065F46", secondaryColor: "#34D399",
+ consultationFee: 1200, currency: "₹",
+ workingHours: [{ start: "09:00", end: "22:00" }],
+ daysOff: [], holidays: ["2024-10-02"]
}
+};
- getTenantConfig(tenantId) {
- return this.tenants[tenantId] || this.tenants['default'];
- }
-
- updateTenantConfig(tenantId, newConfig) {
- if (!this.tenants[tenantId]) {
- this.tenants[tenantId] = { ...this.tenants['default'] };
- }
- this.tenants[tenantId] = { ...this.tenants[tenantId], ...newConfig };
- }
+function getTenantConfig(tenantId) {
+ return TENANTS[tenantId] || TENANTS['default'];
}
-module.exports = new ConfigManager();
+function updateTenantConfig(tenantId, newConfig) {
+ if (!TENANTS[tenantId]) TENANTS[tenantId] = { ...TENANTS['default'] };
+ TENANTS[tenantId] = { ...TENANTS[tenantId], ...newConfig };
+}
+
+module.exports = { getTenantConfig, updateTenantConfig };
+
diff --git a/backend/db.js b/backend/db.js
new file mode 100644
index 0000000..6a60efb
--- /dev/null
+++ b/backend/db.js
@@ -0,0 +1,154 @@
+/**
+ * db.js — PostgreSQL connection pool + schema initialisation for Curio HMS.
+ * Tables: tenants, users, pending_registrations
+ */
+
+const { Pool } = require('pg');
+const bcrypt = require('bcryptjs');
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL || 'postgres://postgres:curio_secret@curio-db:5432/curio',
+ ssl: false,
+});
+
+// ── Schema ──────────────────────────────────────────────────────────────────
+
+const SCHEMA_SQL = `
+CREATE TABLE IF NOT EXISTS tenants (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ logo TEXT DEFAULT 'C',
+ primary_color TEXT DEFAULT '#0F172A',
+ secondary_color TEXT DEFAULT '#38BDF8',
+ consultation_fee INTEGER DEFAULT 500,
+ currency TEXT DEFAULT '₹',
+ plan TEXT DEFAULT 'basic',
+ status TEXT DEFAULT 'active',
+ whatsapp_number TEXT,
+ specialty TEXT,
+ city TEXT,
+ working_hours JSONB DEFAULT '[{"start":"09:00","end":"13:00"},{"start":"17:00","end":"22:00"}]',
+ days_off JSONB DEFAULT '[0]',
+ holidays JSONB DEFAULT '[]',
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ name TEXT NOT NULL,
+ role TEXT NOT NULL CHECK (role IN ('SUPER_ADMIN','OWNER','DOCTOR','RECEPTIONIST','PHARMACIST','LAB_TECH')),
+ status TEXT DEFAULT 'active',
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS pending_registrations (
+ id SERIAL PRIMARY KEY,
+ owner_name TEXT NOT NULL,
+ clinic_name TEXT NOT NULL,
+ specialty TEXT,
+ city TEXT,
+ whatsapp_number TEXT NOT NULL,
+ email TEXT NOT NULL,
+ password_hash TEXT NOT NULL,
+ plan TEXT DEFAULT 'basic',
+ status TEXT DEFAULT 'pending',
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+CREATE INDEX IF NOT EXISTS idx_pending_status ON pending_registrations(status);
+`;
+
+// ── Seed super admin ─────────────────────────────────────────────────────────
+
+async function seedSuperAdmin() {
+ const email = process.env.SUPER_ADMIN_EMAIL || 'admin@curio.app';
+ const password = process.env.SUPER_ADMIN_PASSWORD || 'superadmin';
+
+ const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
+ if (existing.rows.length > 0) return;
+
+ const hash = await bcrypt.hash(password, 10);
+ await pool.query(
+ `INSERT INTO users (tenant_id, email, password_hash, name, role)
+ VALUES (NULL, $1, $2, 'Super Admin', 'SUPER_ADMIN')`,
+ [email, hash]
+ );
+ console.log(`[DB] Super admin seeded: ${email}`);
+}
+
+// ── Seed legacy in-memory tenants into DB ─────────────────────────────────────
+
+async function seedLegacyTenants() {
+ const legacy = [
+ {
+ id: 'default',
+ name: 'Curio Clinic',
+ logo: 'C',
+ primary_color: '#0F172A',
+ secondary_color: '#38BDF8',
+ consultation_fee: 500,
+ plan: 'basic',
+ working_hours: JSON.stringify([{ start: '09:00', end: '13:00' }, { start: '17:00', end: '22:00' }]),
+ days_off: JSON.stringify([0]),
+ },
+ {
+ id: 'sharma-clinic',
+ name: "Dr. Sharma's Cardiology",
+ logo: 'S',
+ primary_color: '#1E3A8A',
+ secondary_color: '#60A5FA',
+ consultation_fee: 800,
+ plan: 'pro',
+ working_hours: JSON.stringify([{ start: '10:00', end: '14:00' }, { start: '18:00', end: '21:00' }]),
+ days_off: JSON.stringify([0, 6]),
+ },
+ {
+ id: 'apollo-hospitals',
+ name: 'Apollo Multispecialty',
+ logo: 'A',
+ primary_color: '#065F46',
+ secondary_color: '#34D399',
+ consultation_fee: 1200,
+ plan: 'enterprise',
+ working_hours: JSON.stringify([{ start: '09:00', end: '22:00' }]),
+ days_off: JSON.stringify([]),
+ },
+ ];
+
+ for (const t of legacy) {
+ await pool.query(
+ `INSERT INTO tenants (id, name, logo, primary_color, secondary_color, consultation_fee, plan, working_hours, days_off)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::jsonb)
+ ON CONFLICT (id) DO NOTHING`,
+ [t.id, t.name, t.logo, t.primary_color, t.secondary_color, t.consultation_fee, t.plan, t.working_hours, t.days_off]
+ );
+ }
+ console.log('[DB] Legacy tenants seeded.');
+}
+
+// ── Init ──────────────────────────────────────────────────────────────────────
+
+async function initSchema() {
+ try {
+ await pool.query(SCHEMA_SQL);
+ console.log('[DB] Schema ready.');
+ await seedSuperAdmin();
+ await seedLegacyTenants();
+ } catch (err) {
+ console.error('[DB] Schema init failed:', err.message);
+ // Don't crash — let server start even if DB is temporarily unavailable
+ }
+}
+
+// ── Exports ───────────────────────────────────────────────────────────────────
+
+function query(sql, params) {
+ return pool.query(sql, params);
+}
+
+module.exports = { query, pool, initSchema };
diff --git a/backend/queueManager.js b/backend/queueManager.js
index db3a3f4..e9a875d 100644
--- a/backend/queueManager.js
+++ b/backend/queueManager.js
@@ -5,7 +5,7 @@
* - 5 Walk-in slots
*/
-const configManager = require('./configManager');
+const { getTenantConfig } = require('./configManager');
const notificationManager = require('./notificationManager');
class QueueManager {
@@ -14,7 +14,7 @@ class QueueManager {
}
getSlotKey(tenantId, date, time) {
- const config = configManager.getTenantConfig(tenantId);
+ const config = getTenantConfig(tenantId);
const duration = config.slotDuration || 30;
const [hours, minutes] = time.split(':');
const roundedMins = parseInt(minutes) < duration ? '00' : duration;
@@ -22,7 +22,7 @@ class QueueManager {
}
isAvailable(tenantId, date, time) {
- const config = configManager.getTenantConfig(tenantId);
+ const config = getTenantConfig(tenantId);
const d = new Date(date + "T00:00:00");
// Check days off
@@ -53,7 +53,7 @@ class QueueManager {
getSlotStatus(tenantId, date, time) {
const key = this.getSlotKey(tenantId, date, time);
if (!this.slots[key]) {
- const config = configManager.getTenantConfig(tenantId);
+ const config = getTenantConfig(tenantId);
this.slots[key] = {
online: 0,
walkin: 0,
@@ -69,7 +69,7 @@ class QueueManager {
return { success: false, message: "The clinic is closed at this time / on this day." };
}
const status = this.getSlotStatus(tenantId, date, time);
- const config = configManager.getTenantConfig(tenantId);
+ const config = getTenantConfig(tenantId);
if (status.online < status.maxOnline) {
status.online++;
return {
@@ -88,7 +88,7 @@ class QueueManager {
return { success: false, message: "The clinic is closed at this time / on this day." };
}
const status = this.getSlotStatus(tenantId, date, time);
- const config = configManager.getTenantConfig(tenantId);
+ const config = getTenantConfig(tenantId);
if (status.walkin < status.maxWalkin) {
status.walkin++;
return {
@@ -102,7 +102,7 @@ class QueueManager {
}
getDailyUpdates(patientId, tenantId = 'default') {
- const config = configManager.getTenantConfig(tenantId);
+ const config = getTenantConfig(tenantId);
return [
`Good morning! Your appointment with ${config.name} is scheduled for today.`,
"Queue Update: The clinic is running 10 mins behind schedule. Please plan accordingly.",
@@ -121,7 +121,7 @@ class QueueManager {
}
notifyAvailableSlot(tenantId, date, time) {
- const config = configManager.getTenantConfig(tenantId);
+ const config = getTenantConfig(tenantId);
console.log(`[Queue] Triggering notifications for open slot: ${time}`);
// Mock finding a patient with a later slot
diff --git a/backend/server.js b/backend/server.js
index 8361b64..c61eae1 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1,9 +1,14 @@
const express = require('express');
const path = require('path');
-const botLogic = require('./botLogic');
-const configManager = require('./configManager');
-const { getTemporalClient } = require('./temporalClient');
const crypto = require('crypto');
+const bcrypt = require('bcryptjs');
+
+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;
@@ -11,17 +16,13 @@ app.use(express.json());
const staticRoot = path.join(__dirname, '../');
-/** UTF-8 Content-Type for text assets (fixes emoji/₹ mojibake when proxy omits charset). */
+// ── 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');
- }
+ 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) {
@@ -36,98 +37,498 @@ function sendHtmlPage(res, filename) {
res.sendFile(path.join(staticRoot, filename));
}
-// Serve static files
+// 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);
- }
+ if (filePath.endsWith('.html')) setNoCacheHeaders(res);
}
}));
-// Primary 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'));
+// ── Page routes ───────────────────────────────────────────────────────────────
-// Multi-tenant Config API
-app.get('/api/config/:tenantId', (req, res) => {
- const config = configManager.getTenantConfig(req.params.tenantId);
- res.json(config);
+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));
});
-// Official Twilio Webhook parsing middleware
+// ── AUTH ROUTES ───────────────────────────────────────────────────────────────
+
app.use(express.urlencoded({ extended: true }));
-// WhatsApp Webhook (Twilio)
-app.post('/whatsapp/webhook', async (req, res) => {
- // Twilio sends urlencoded fields: From, Body
- // Or our mock sends json: from, body
- 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");
+/**
+ * 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 ──────────────────────────────────────────────────────────
+
+app.post('/whatsapp/webhook', 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();
- // Use the phone number as the unique Workflow ID
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') {
- // Ongoing conversation! Send a signal with the new message
- console.log(`[WhatsApp Webhook] Signaling existing workflow ${workflowId}`);
await handle.signal('receive_message', body);
-
- // Twilio expects a valid TwiML response. Empty response means no immediate automated reply.
- // Our Temporal worker will asynchronously send a reply back via Twilio API.
- res.type('text/xml').send('
Login to manage your clinic OPD and accounts.
- + + + - + +