diff --git a/src/db/create_users_table.ts b/src/db/create_users_table.ts new file mode 100644 index 0000000..7defd65 --- /dev/null +++ b/src/db/create_users_table.ts @@ -0,0 +1,22 @@ +import { pool } from './pool'; + +async function createUsersTable() { + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(50) PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'pharmacist', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + console.log('Users table created successfully.'); + } catch (err) { + console.error('Error creating users table:', err); + } finally { + await pool.end(); + } +} + +createUsersTable(); diff --git a/src/db/init.ts b/src/db/init.ts index d70d20f..caf02c3 100644 --- a/src/db/init.ts +++ b/src/db/init.ts @@ -7,6 +7,17 @@ async function initDB() { console.log('Creating tables...'); + // 0. Users / Auth Module + await client.query(` + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(50) PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'pharmacist', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + // 1. Catalogue Module await client.query(` CREATE TABLE IF NOT EXISTS drugs ( @@ -28,15 +39,33 @@ async function initDB() { strength VARCHAR(50), form VARCHAR(50), mrp DECIMAL(10, 2), + pack_size INTEGER DEFAULT 1, dpco_ceiling DECIMAL(10, 2) ); `); // 2. Inventory Module + await client.query(` + CREATE TABLE IF NOT EXISTS suppliers ( + id VARCHAR(50) PRIMARY KEY, + tenant_id VARCHAR(50), + name VARCHAR(255) NOT NULL, + contact_number VARCHAR(50), + email VARCHAR(255), + gstin VARCHAR(50), + dl_number VARCHAR(100), + address TEXT, + contact_person VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + await client.query(` CREATE TABLE IF NOT EXISTS batches ( batch_no VARCHAR(100) PRIMARY KEY, + tenant_id VARCHAR(50), product_id VARCHAR(50) REFERENCES products(id), + supplier_id VARCHAR(50) REFERENCES suppliers(id), expiry DATE NOT NULL, qty_on_hand INTEGER NOT NULL CHECK (qty_on_hand >= 0), purchase_price DECIMAL(10, 2), @@ -96,6 +125,7 @@ async function initDB() { await client.query(` CREATE TABLE IF NOT EXISTS order_drafts ( id VARCHAR(50) PRIMARY KEY, + tenant_id VARCHAR(50), subject_ref VARCHAR(100), prescription_ref VARCHAR(50), items JSONB, @@ -116,10 +146,26 @@ async function initDB() { ); `); - // 4. Phase 2: Prescriptions + // 4. Phase 2: Prescriptions and Patients + await client.query(` + CREATE TABLE IF NOT EXISTS patients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id VARCHAR(50), + hospital_patient_id VARCHAR(100) UNIQUE NOT NULL, + full_name VARCHAR(255) NOT NULL, + contact_number VARCHAR(50), + email VARCHAR(255), + allergies TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `); + + await client.query(` CREATE TABLE IF NOT EXISTS prescriptions ( id VARCHAR(50) PRIMARY KEY, + tenant_id VARCHAR(50), image_path TEXT NOT NULL, image_hash VARCHAR(64) NOT NULL, status VARCHAR(20) DEFAULT 'processing', diff --git a/src/index.ts b/src/index.ts index 1ea038e..820375d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import dispensingRouter from './modules/dispensing/router'; import auditRouter from './modules/audit/router'; import prescriptionRouter from './modules/prescription/router'; import aiRouter from './modules/ai/router'; +import integrationRouter from './modules/integration/router'; const app = express(); const PORT = process.env.PORT || 8080; @@ -45,6 +46,7 @@ app.use('/v1/orders', dispensingRouter); app.use('/v1/audit', auditRouter); app.use('/v1/rx', prescriptionRouter); app.use('/v1/ai', aiRouter); +app.use('/v1/integration', integrationRouter); // Health check app.get('/health', (req, res) => { diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 5a8edc1..f88369f 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -26,25 +26,30 @@ export const requireAuth: RequestHandler = (req: Request, res: Response, next: N return; } - if (!actorId) { - res.status(403).json({ - error: 'FORBIDDEN', - message: 'Missing X-Actor-Id header.', - details: {}, - retryable: false - }); - return; - } - const token = authHeader.split(' ')[1]; try { // Cryptographically verify the token using our secret key const payload = jwt.verify(token, JWT_SECRET) as any; - // Automatically attach the tenant (e.g., jayam_web_ai) to the request context - req.tenantId = payload.tenantId; - req.actorId = actorId; + if (payload.userId) { + // User JWT Token + (req as any).user = payload; + req.actorId = payload.userId; + } else { + // M2M Tenant JWT Token + if (!actorId) { + res.status(403).json({ + error: 'FORBIDDEN', + message: 'Missing X-Actor-Id header for M2M token.', + details: {}, + retryable: false + }); + return; + } + req.tenantId = payload.tenantId; + req.actorId = actorId; + } next(); } catch (error) { diff --git a/src/modules/ai/router.ts b/src/modules/ai/router.ts index 6f9443c..923e13e 100644 --- a/src/modules/ai/router.ts +++ b/src/modules/ai/router.ts @@ -1,16 +1,18 @@ import { Router } from 'express'; import { AIService } from './service'; +import { requireAuth } from '../../middleware/auth'; const router = Router(); const aiService = new AIService(); -router.post('/voice', async (req, res, next) => { +router.post('/voice', requireAuth, async (req, res, next) => { try { const { transcript } = req.body; if (!transcript) { return res.status(400).json({ error: 'Transcript is required' }); } - const result = await aiService.processVoiceCommand(transcript); + const actorId = req.actorId as string; + const result = await aiService.processVoiceCommand(transcript, actorId); res.json(result); } catch (error) { next(error); diff --git a/src/modules/ai/service.ts b/src/modules/ai/service.ts index 515e6de..2544273 100644 --- a/src/modules/ai/service.ts +++ b/src/modules/ai/service.ts @@ -1,10 +1,10 @@ -import { GoogleGenAI } from '@google/genai'; +import OpenAI from 'openai'; import { pool } from '../../db/pool'; -const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export class AIService { - async processVoiceCommand(transcript: string) { + async processVoiceCommand(transcript: string, actorId: string) { const prompt = `You are a helpful pharmacy AI assistant. You can understand English and Tanglish (Tamil written in English). The user is speaking to you. Respond conversationally. Do not use markdown. @@ -12,120 +12,145 @@ If the user asks about low stock, use get_low_stock. If the user asks about total medicines, use get_total_medicines. If the user asks about today's sales or revenue, use get_today_sales. If the user asks about expiring medicines, use get_expiring_medicines. -If the user asks to open or navigate to a page (e.g. "open prescriptions", "take me to pharmacy"), use navigate_to with the appropriate target. +If the user asks about patients (e.g. how many patients), use get_total_patients. +If the user asks about prescriptions for today, use get_todays_prescriptions. +If the user asks about suppliers or distributors, use get_total_suppliers. +If the user asks to open or navigate to a page (e.g. "open prescriptions", "take me to suppliers", "show patients"), use navigate_to with the appropriate target. If the user asks to search for a specific medicine or asks if a medicine is available (e.g., "search privika", "do we have calpol"), use search_medicine. +If the user asks to search for a supplier, use search_supplier. If the user asks to add a new medicine to the system, use add_medicine. If the user asks to add stock for a medicine, use add_stock. -User transcript: "${transcript}"`; +If the user asks to add a new supplier, use add_supplier. +If the user asks to add a new patient, use add_patient.`; - const aiTools = { - functionDeclarations: [ - { - name: "get_low_stock", - description: "Get the count of medicines that are running low on stock.", - }, - { - name: "get_total_medicines", - description: "Get the total number of unique medicines in the system.", - }, - { - name: "get_today_sales", - description: "Get the total revenue or sales for today.", - }, - { - name: "get_expiring_medicines", - description: "Get the count of medicines expiring within the next 30 days.", - }, - { + const aiTools: any = [ + { type: "function", function: { name: "get_low_stock", description: "Get the count of medicines that are running low on stock." } }, + { type: "function", function: { name: "get_total_medicines", description: "Get the total number of unique medicines in the system." } }, + { type: "function", function: { name: "get_today_sales", description: "Get the total revenue or sales for today." } }, + { type: "function", function: { name: "get_expiring_medicines", description: "Get the count of medicines expiring within the next 30 days." } }, + { type: "function", function: { name: "get_total_patients", description: "Get the total number of registered patients." } }, + { type: "function", function: { name: "get_todays_prescriptions", description: "Get the number of prescriptions dispensed today." } }, + { type: "function", function: { name: "get_total_suppliers", description: "Get the total number of wholesale distributors/suppliers." } }, + { type: "function", function: { name: "search_medicine", description: "Search for a specific medicine by name to check if it's in stock.", - parameters: { - type: "OBJECT", - properties: { - query: { - type: "STRING", - description: "The name of the medicine to search for (e.g., 'calpol', 'privika')." - } - }, - required: ["query"] - } - }, - { + parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } + }}, + { type: "function", function: { + name: "search_supplier", + description: "Search for a wholesale supplier by name.", + parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } + }}, + { type: "function", function: { name: "add_medicine", description: "Add a new medicine to the product catalog.", parameters: { - type: "OBJECT", + type: "object", properties: { - brand_name: { type: "STRING", description: "The brand name of the medicine (e.g., Dolo 650)." }, - mrp: { type: "NUMBER", description: "The Maximum Retail Price (MRP) in rupees." }, - form: { type: "STRING", description: "The physical form of the medicine (e.g., tablet, syrup, injection)." } + brand_name: { type: "string" }, + mrp: { type: "number" }, + pack_size: { type: "number", description: "Number of units per pack" }, + form: { type: "string" } }, required: ["brand_name", "mrp"] } - }, - { + }}, + { type: "function", function: { name: "add_stock", description: "Add stock (a new batch) for an existing medicine.", parameters: { - type: "OBJECT", + type: "object", properties: { - product_name: { type: "STRING", description: "The brand name of the medicine to add stock to." }, - qty: { type: "NUMBER", description: "The quantity of stock to add." }, - batch_no: { type: "STRING", description: "The batch number. Omit if the user doesn't provide one." }, - expiry: { type: "STRING", description: "The expiry date. Omit if the user doesn't provide one." } + product_name: { type: "string" }, + qty: { type: "number" } }, required: ["product_name", "qty"] } - }, - { + }}, + { type: "function", function: { + name: "add_supplier", + description: "Register a new wholesale distributor/supplier.", + parameters: { + type: "object", + properties: { + name: { type: "string" }, + phone: { type: "string" } + }, + required: ["name"] + } + }}, + { type: "function", function: { + name: "add_patient", + description: "Register a new patient.", + parameters: { + type: "object", + properties: { + name: { type: "string" }, + phone: { type: "string" } + }, + required: ["name"] + } + }}, + { type: "function", function: { name: "navigate_to", description: "Navigate the user to a specific page.", parameters: { - type: "OBJECT", + type: "object", properties: { target: { - type: "STRING", - description: "The page to navigate to. Allowed values: 'dashboard', 'pharmacy', 'inventory', 'prescriptions'" + type: "string", + description: "Allowed values: 'dashboard', 'pharmacy', 'inventory', 'prescriptions', 'patients', 'suppliers', 'registers', 'stock-movements', 'history'" } }, required: ["target"] } - } - ] - }; + }} + ]; try { - const response = await ai.models.generateContent({ - model: 'gemini-3.6-flash', - contents: [prompt], - config: { - tools: [aiTools as any], - } + const response = await openai.chat.completions.create({ + model: 'gpt-4o', + messages: [ + { role: 'system', content: prompt }, + { role: 'user', content: transcript } + ], + tools: aiTools, + tool_choice: "auto" }); - const functionCalls = response.functionCalls; + const message = response.choices[0].message; - if (functionCalls && functionCalls.length > 0) { - const call = functionCalls[0]; + if (message.tool_calls && message.tool_calls.length > 0) { + const call = (message.tool_calls[0] as any).function; let toolData = null; let actionCommand = 'NONE'; + const args = call.arguments ? JSON.parse(call.arguments) : {}; if (call.name === 'get_low_stock') { - const { rows } = await pool.query('SELECT count(*) as count FROM batches WHERE qty_on_hand <= 50'); + const { rows } = await pool.query('SELECT count(*) as count FROM batches WHERE qty_on_hand <= 50 AND qty_on_hand > 0 AND tenant_id = $1', [actorId]); toolData = { items_running_low: rows[0].count }; actionCommand = 'VIEW_LOW_STOCK'; } else if (call.name === 'get_total_medicines') { const { rows } = await pool.query('SELECT count(*) as count FROM products'); toolData = { total_unique_medicines: rows[0].count }; } else if (call.name === 'get_today_sales') { - const { rows } = await pool.query(`SELECT sum(total_amount) as total FROM dispensing_records WHERE DATE(created_at) = CURRENT_DATE`); + const { rows } = await pool.query(`SELECT sum(total_amount) as total FROM dispensing_records WHERE DATE(created_at) = CURRENT_DATE AND tenant_id = $1`, [actorId]); toolData = { todays_revenue_rupees: rows[0].total || 0 }; } else if (call.name === 'get_expiring_medicines') { - const { rows } = await pool.query(`SELECT count(*) as count FROM batches WHERE expiry <= CURRENT_DATE + interval '30 days'`); + const { rows } = await pool.query(`SELECT count(*) as count FROM batches WHERE expiry <= CURRENT_DATE + interval '30 days' AND qty_on_hand > 0 AND tenant_id = $1`, [actorId]); toolData = { batches_expiring_in_30_days: rows[0].count }; actionCommand = 'VIEW_EXPIRING_STOCK'; + } else if (call.name === 'get_total_patients') { + const { rows } = await pool.query('SELECT count(*) as count FROM patients'); + toolData = { total_patients: rows[0].count }; + } else if (call.name === 'get_total_suppliers') { + const { rows } = await pool.query('SELECT count(*) as count FROM suppliers'); + toolData = { total_suppliers: rows[0].count }; + } else if (call.name === 'get_todays_prescriptions') { + const { rows } = await pool.query('SELECT count(*) as count FROM prescriptions WHERE DATE(created_at) = CURRENT_DATE'); + toolData = { todays_prescriptions: rows[0].count }; } else if (call.name === 'search_medicine') { - const query = (call.args as any)?.query || ''; + const query = args.query || ''; const { rows } = await pool.query( `SELECT p.brand_name, COALESCE(SUM(b.qty_on_hand), 0) as total_stock FROM products p @@ -134,73 +159,80 @@ User transcript: "${transcript}"`; GROUP BY p.brand_name`, [`%${query}%`] ); - if (rows.length > 0) { - toolData = { search_results: rows }; - } else { - toolData = { message: `No medicine found matching '${query}'.` }; - } + toolData = rows.length > 0 ? { search_results: rows } : { message: `No medicine found matching '${query}'.` }; actionCommand = 'NAVIGATE_INVENTORY'; + } else if (call.name === 'search_supplier') { + const query = args.query || ''; + const { rows } = await pool.query('SELECT name, contact_number FROM suppliers WHERE name ILIKE $1', [`%${query}%`]); + toolData = rows.length > 0 ? { search_results: rows } : { message: `No supplier found matching '${query}'.` }; + actionCommand = 'NAVIGATE_SUPPLIERS'; } else if (call.name === 'add_medicine') { - const args = call.args as any; const id = `prd_${Date.now()}`; - await pool.query( - 'INSERT INTO products (id, brand_name, form, mrp) VALUES ($1, $2, $3, $4)', - [id, args.brand_name, args.form || 'tablet', args.mrp || 0] - ); + await pool.query('INSERT INTO products (id, brand_name, form, mrp, pack_size) VALUES ($1, $2, $3, $4, $5)', [id, args.brand_name, args.form || 'tablet', args.mrp || 0, args.pack_size || 1]); toolData = { success: true, message: `Added ${args.brand_name} successfully.` }; - actionCommand = 'NAVIGATE_CATALOGUE'; // Assume there's a catalogue or inventory view + actionCommand = 'NAVIGATE_CATALOGUE'; } else if (call.name === 'add_stock') { - const args = call.args as any; const pRes = await pool.query('SELECT id, brand_name FROM products WHERE brand_name ILIKE $1 LIMIT 1', [`%${args.product_name}%`]); - if (pRes.rows.length > 0) { - const pid = pRes.rows[0].id; const batchNo = args.batch_no || `B${Date.now()}`; - // Default expiry to 1 year from now if not provided const expiry = args.expiry || new Date(Date.now() + 31536000000).toISOString().split('T')[0]; - - await pool.query( - 'INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price) VALUES ($1, $2, $3, $4, $5)', - [batchNo, pid, expiry, args.qty, 0] - ); + await pool.query('INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price) VALUES ($1, $2, $3, $4, $5)', [batchNo, pRes.rows[0].id, expiry, args.qty, 0]); toolData = { success: true, message: `Added ${args.qty} stock to ${pRes.rows[0].brand_name}.` }; actionCommand = 'NAVIGATE_INVENTORY'; } else { - toolData = { success: false, message: `Could not find product ${args.product_name} in database. Please add the medicine first.` }; + toolData = { success: false, message: `Could not find product ${args.product_name} in database.` }; } + } else if (call.name === 'add_supplier') { + const id = `sup_${Date.now()}`; + await pool.query('INSERT INTO suppliers (id, name, contact_number) VALUES ($1, $2, $3)', [id, args.name, args.phone || '']); + toolData = { success: true, message: `Added supplier ${args.name} successfully.` }; + actionCommand = 'NAVIGATE_SUPPLIERS'; + } else if (call.name === 'add_patient') { + const id = `pat_${Date.now()}`; + await pool.query('INSERT INTO patients (id, name, phone) VALUES ($1, $2, $3)', [id, args.name, args.phone || '']); + toolData = { success: true, message: `Added patient ${args.name} successfully.` }; + actionCommand = 'NAVIGATE_PATIENTS'; } else if (call.name === 'navigate_to') { - const target = (call.args as any)?.target; + const target = args.target?.toLowerCase(); toolData = { action: 'Navigating to ' + target }; if (target === 'dashboard') actionCommand = 'NAVIGATE_DASHBOARD'; if (target === 'pharmacy') actionCommand = 'NAVIGATE_PHARMACY'; - if (target === 'inventory') actionCommand = 'NAVIGATE_INVENTORY'; + if (target === 'inventory' || target === 'catalogue') actionCommand = 'NAVIGATE_INVENTORY'; if (target === 'prescriptions') actionCommand = 'NAVIGATE_PRESCRIPTIONS'; + if (target === 'patients') actionCommand = 'NAVIGATE_PATIENTS'; + if (target === 'suppliers') actionCommand = 'NAVIGATE_SUPPLIERS'; + if (target === 'registers') actionCommand = 'NAVIGATE_REGISTERS'; + if (target === 'stock-movements') actionCommand = 'NAVIGATE_STOCK_MOVEMENTS'; + if (target === 'history') actionCommand = 'NAVIGATE_HISTORY'; } - // Step 2: Ask Gemini to generate the final response in the user's language + // Step 2: Ask GPT-4o to generate the final response in the user's language const finalPrompt = `You are a helpful pharmacy AI assistant. -User's query: "${transcript}" -Database result: ${JSON.stringify(toolData)} +Database result for user query: ${JSON.stringify(toolData)} CRITICAL INSTRUCTIONS: -1. Detect the language of the User's query (it may be English, Tamil script, or Tanglish - Tamil in English letters). +1. Detect the language of the User's original query (it may be English, Tamil script, or Tanglish - Tamil in English letters). 2. You MUST reply in the EXACT SAME LANGUAGE as the user. 3. If the user spoke Tanglish (e.g., "evlo irukku"), you MUST reply in Tanglish (e.g., "Motham 10 irukku"). -4. NEVER reply in English unless the user spoke in English. Keep it conversational and concise.`; +4. NEVER reply in English unless the user spoke in English. Keep it conversational and concise. +5. Do not use markdown styling.`; - const finalResponse = await ai.models.generateContent({ - model: 'gemini-3.6-flash', - contents: [finalPrompt] + const finalResponse = await openai.chat.completions.create({ + model: 'gpt-4o', + messages: [ + { role: 'system', content: finalPrompt }, + { role: 'user', content: transcript } + ] }); return { - replyText: finalResponse.text || "Action completed.", + replyText: finalResponse.choices[0].message.content || "Action completed.", actionCommand }; } return { - replyText: response.text || "I didn't quite catch that.", + replyText: message.content || "I didn't quite catch that.", actionCommand: 'NONE' }; } catch (e) { diff --git a/src/modules/audit/router.ts b/src/modules/audit/router.ts index 4f90ced..55db901 100644 --- a/src/modules/audit/router.ts +++ b/src/modules/audit/router.ts @@ -33,11 +33,13 @@ router.get('/ai-calls', requireAuth, async (req, res, next) => { // Also grab the stock movements for full audit visibility const { rows: stockMovements } = await pool.query(` - SELECT id, batch_no, movement_type, qty_delta, reference_id, created_at - FROM stock_movements - ORDER BY created_at DESC - LIMIT $1 - `, [limit ? parseInt(limit as string) : 50]); + SELECT sm.id, sm.batch_no, sm.movement_type, sm.qty_delta, sm.reference_id, sm.created_at + FROM stock_movements sm + JOIN batches b ON sm.batch_no = b.batch_no + WHERE b.tenant_id = $1 + ORDER BY sm.created_at DESC + LIMIT $2 + `, [req.actorId, limit ? parseInt(limit as string) : 50]); res.status(200).json({ ai_calls: rows, @@ -59,9 +61,10 @@ router.get('/overrides', requireAuth, async (req, res, next) => { actor_user_id, occurred_at FROM overrides_log + WHERE actor_user_id = $1 ORDER BY occurred_at DESC LIMIT 100 - `); + `, [req.actorId]); // Grouping for summary const by_actor: Record = {}; @@ -89,10 +92,12 @@ router.get('/h1-register', requireAuth, async (req, res, next) => { try { const { rows } = await pool.query(` SELECT - id, dispensing_id, patient_name, prescriber_name, drug_name, batch_no, qty, created_at - FROM h1_registers - ORDER BY created_at DESC - `); + h.id, h.dispensing_id, h.patient_name, h.prescriber_name, h.drug_name, h.batch_no, h.qty, h.created_at + FROM h1_registers h + JOIN dispensing_records d ON h.dispensing_id = d.id + WHERE d.tenant_id = $1 + ORDER BY h.created_at DESC + `, [req.actorId]); res.status(200).json({ register: rows }); } catch (error) { next(error); @@ -111,12 +116,53 @@ router.get('/sales-register', requireAuth, async (req, res, next) => { d.total_amount, d.payment_method FROM dispensing_records d + WHERE d.tenant_id = $1 ORDER BY d.created_at DESC - `); + `, [req.actorId]); res.status(200).json({ register: rows }); } catch (error) { next(error); } }); +// GET /v1/audit/dashboard-metrics +router.get('/dashboard-metrics', requireAuth, async (req, res, next) => { + try { + const actorId = req.actorId; + const { rows: patients } = await pool.query('SELECT COUNT(DISTINCT subject_ref) FROM dispensing_records WHERE tenant_id = $1', [actorId]); + const { rows: prescriptions } = await pool.query('SELECT COUNT(*) FROM dispensing_records WHERE tenant_id = $1', [actorId]); + const { rows: inventory } = await pool.query('SELECT COUNT(DISTINCT product_id) FROM batches WHERE tenant_id = $1 AND qty_on_hand > 0', [actorId]); + const { rows: dispensedToday } = await pool.query(` + SELECT COUNT(*) FROM dispensing_records + WHERE DATE(created_at) = CURRENT_DATE AND tenant_id = $1 + `, [actorId]); + + const { rows: trendData } = await pool.query(` + SELECT + TO_CHAR(DATE(created_at), 'Mon DD') as date, + SUM(CASE WHEN movement_type = 'receipt' THEN qty_delta ELSE 0 END) as received, + SUM(CASE WHEN movement_type = 'dispense' THEN ABS(qty_delta) ELSE 0 END) as dispensed + FROM stock_movements sm + JOIN batches b ON sm.batch_no = b.batch_no + WHERE sm.created_at >= CURRENT_DATE - INTERVAL '6 days' AND b.tenant_id = $1 + GROUP BY DATE(sm.created_at) + ORDER BY DATE(sm.created_at) ASC + `, [actorId]); + + res.status(200).json({ + totalPatients: parseInt(patients[0].count) || 0, + totalPrescriptions: parseInt(prescriptions[0].count) || 0, + inventoryItems: parseInt(inventory[0].count) || 0, + dispensedToday: parseInt(dispensedToday[0].count) || 0, + trend: trendData.map(t => ({ + date: t.date, + received: parseInt(t.received) || 0, + dispensed: parseInt(t.dispensed) || 0 + })) + }); + } catch (error) { + next(error); + } +}); + export default router; diff --git a/src/modules/catalogue/router.ts b/src/modules/catalogue/router.ts index b372959..ba846bf 100644 --- a/src/modules/catalogue/router.ts +++ b/src/modules/catalogue/router.ts @@ -9,7 +9,8 @@ const service = new CatalogueService(); router.get('/drugs', requireAuth, async (req, res, next) => { try { const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; - const drugs = await service.getAllDrugs(limit); + const actorId = req.actorId as string; + const drugs = await service.getAllDrugs(limit, actorId); res.status(200).json({ drugs }); } catch (error) { next(error); @@ -19,7 +20,8 @@ router.get('/drugs', requireAuth, async (req, res, next) => { // POST /v1/drugs router.post('/drugs', requireAuth, async (req, res, next) => { try { - const result = await service.addMedicine(req.body); + const actorId = req.actorId as string; + const result = await service.addMedicine(req.body, actorId); res.status(201).json(result); } catch (error) { next(error); @@ -32,6 +34,7 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => { const q = (req.query.q as string) || ''; const form = req.query.form ? (req.query.form as string) : undefined; const limit = req.query.limit ? parseInt(req.query.limit as string) : 10; + const actorId = req.actorId as string; if (!q || q.length < 2) { res.status(400).json({ @@ -41,7 +44,7 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => { return; } - const candidates = await service.searchDrugs(q, form, limit); + const candidates = await service.searchDrugs(q, form, limit, actorId); res.status(200).json({ candidates }); } catch (error) { next(error); @@ -52,7 +55,8 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => { router.get('/products/:product_id/batches', requireAuth, async (req, res, next) => { try { const productId = req.params.product_id as string; - const batches = await service.getProductBatches(productId); + const actorId = req.actorId as string; + const batches = await service.getProductBatches(productId, actorId); res.status(200).json({ batches }); } catch (error) { next(error); diff --git a/src/modules/catalogue/service.ts b/src/modules/catalogue/service.ts index 9e8ae86..fdd662b 100644 --- a/src/modules/catalogue/service.ts +++ b/src/modules/catalogue/service.ts @@ -1,7 +1,7 @@ import { pool } from '../../db/pool'; export class CatalogueService { - async searchDrugs(q: string, form?: string, limit: number = 10) { + async searchDrugs(q: string, form: string | undefined, limit: number = 10, actorId: string) { const query = ` SELECT p.id as product_id, @@ -15,19 +15,20 @@ export class CatalogueService { d.pregnancy_category, p.mrp, p.dpco_ceiling, - (SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE) as total_stock + (SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE AND tenant_id = $3) as total_stock FROM products p JOIN drugs d ON p.drug_id = d.id - WHERE p.brand_name ILIKE $1 OR d.generic_name ILIKE $1 + WHERE (p.brand_name ILIKE $1 OR d.generic_name ILIKE $1) + AND (p.tenant_id = $3 OR p.tenant_id IS NULL) LIMIT $2 `; - const values = [`%${q}%`, limit]; + const values = [`%${q}%`, limit, actorId]; const { rows } = await pool.query(query, values); return rows; } - async getAllDrugs(limit: number = 50) { + async getAllDrugs(limit: number = 50, actorId: string) { const query = ` SELECT p.id as product_id, @@ -41,17 +42,19 @@ export class CatalogueService { d.pregnancy_category, p.mrp, p.dpco_ceiling, - (SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE) as total_stock + (SELECT purchase_price FROM batches WHERE product_id = p.id AND tenant_id = $2 ORDER BY expiry DESC LIMIT 1) as purchase_price, + (SELECT COALESCE(SUM(qty_on_hand), 0) FROM batches WHERE product_id = p.id AND expiry > CURRENT_DATE AND tenant_id = $2) as total_stock FROM products p JOIN drugs d ON p.drug_id = d.id + WHERE p.tenant_id = $2 OR EXISTS (SELECT 1 FROM batches WHERE product_id = p.id AND tenant_id = $2) ORDER BY p.brand_name ASC LIMIT $1 `; - const { rows } = await pool.query(query, [limit]); + const { rows } = await pool.query(query, [limit, actorId]); return rows; } - async getProductBatches(productId: string) { + async getProductBatches(productId: string, actorId: string) { const query = ` SELECT batch_no, @@ -64,35 +67,35 @@ export class CatalogueService { ELSE 'available' END as status FROM batches - WHERE product_id = $1 AND qty_on_hand > 0 + WHERE product_id = $1 AND qty_on_hand > 0 AND tenant_id = $2 ORDER BY expiry ASC `; - const { rows } = await pool.query(query, [productId]); + const { rows } = await pool.query(query, [productId, actorId]); return rows; } - async addMedicine(payload: any) { - const { generic_name, schedule, brand_name, manufacturer, strength, form, mrp } = payload; + async addMedicine(payload: any, actorId: string) { + const { generic_name, schedule, brand_name, manufacturer, strength, form, mrp, pack_size } = payload; // Check if generic exists let drugId; - const { rows: drugs } = await pool.query(`SELECT id FROM drugs WHERE generic_name ILIKE $1`, [generic_name]); + const { rows: drugs } = await pool.query(`SELECT id FROM drugs WHERE generic_name ILIKE $1 AND (tenant_id = $2 OR tenant_id IS NULL)`, [generic_name, actorId]); if (drugs.length > 0) { drugId = drugs[0].id; } else { drugId = 'drg_' + Date.now(); await pool.query( - `INSERT INTO drugs (id, generic_name, schedule) VALUES ($1, $2, $3)`, - [drugId, generic_name, schedule || 'H'] + `INSERT INTO drugs (id, generic_name, schedule, tenant_id) VALUES ($1, $2, $3, $4)`, + [drugId, generic_name, schedule || 'H', actorId] ); } const productId = 'prd_' + Date.now(); await pool.query(` - INSERT INTO products (id, drug_id, brand_name, manufacturer, strength, form, mrp) - VALUES ($1, $2, $3, $4, $5, $6, $7) - `, [productId, drugId, brand_name, manufacturer, strength, form, mrp]); + INSERT INTO products (id, drug_id, brand_name, manufacturer, strength, form, mrp, pack_size, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, [productId, drugId, brand_name, manufacturer, strength, form, mrp, pack_size || 1, actorId]); return { product_id: productId, drug_id: drugId }; } diff --git a/src/modules/dispensing/router.ts b/src/modules/dispensing/router.ts index 7647087..e5a5973 100644 --- a/src/modules/dispensing/router.ts +++ b/src/modules/dispensing/router.ts @@ -11,7 +11,7 @@ const service = new DispensingService(); // GET /v1/orders/history router.get('/history', requireAuth, async (req, res, next) => { try { - const history = await service.getDispensingHistory(); + const history = await service.getDispensingHistory(req.actorId as string); res.status(200).json({ history }); } catch (error) { next(error); @@ -21,7 +21,7 @@ router.get('/history', requireAuth, async (req, res, next) => { // GET /v1/orders/h1-register router.get('/h1-register', requireAuth, async (req, res, next) => { try { - const records = await service.getH1Register(); + const records = await service.getH1Register(req.actorId as string); res.status(200).json({ records }); } catch (error) { next(error); @@ -31,7 +31,7 @@ router.get('/h1-register', requireAuth, async (req, res, next) => { // POST /v1/orders router.post('/', requireAuth, requireHeaders, async (req, res, next) => { try { - const draft = await service.createOrderDraft(req.body); + const draft = await service.createOrderDraft(req.body, req.actorId as string); res.status(201).json(draft); } catch (error) { next(error); @@ -59,7 +59,7 @@ router.post('/:id/precheck', requireAuth, async (req, res, next) => { return res.status(400).json({ error: 'CONTEXT_INCOMPLETE' }); } - const precheckResult = await service.runGates(clinical_context, items); + const precheckResult = await service.runGates(clinical_context, items, req.actorId as string); res.status(200).json(precheckResult); } catch (error) { next(error); @@ -84,7 +84,7 @@ router.post('/:id/dispense', requireAuth, async (req, res, next) => { // If items are not provided, fetch them from the draft order if (!items || items.length === 0) { - const draftRes = await pool.query('SELECT items FROM order_drafts WHERE id = $1', [prescriptionId]); + const draftRes = await pool.query('SELECT items FROM order_drafts WHERE id = $1 AND tenant_id = $2', [prescriptionId, req.actorId]); if (draftRes.rows.length > 0 && draftRes.rows[0].items) { items = typeof draftRes.rows[0].items === 'string' ? JSON.parse(draftRes.rows[0].items) : draftRes.rows[0].items; } @@ -95,7 +95,7 @@ router.post('/:id/dispense', requireAuth, async (req, res, next) => { } // Pass to service which runs the SERIALIZABLE transaction - const result = await service.executeDispense(subject_ref, prescriber_name || 'Unknown Prescriber', items, actorId as string, req.tenantId as string | undefined, prescriptionId as string | undefined, payment_method as string, overrides || []); + const result = await service.executeDispense(subject_ref, prescriber_name || 'Unknown Prescriber', items, actorId as string, actorId as string, prescriptionId as string | undefined, payment_method as string, overrides || []); res.status(201).json(result); } catch (error: any) { diff --git a/src/modules/dispensing/service.ts b/src/modules/dispensing/service.ts index d5555d1..f03c29b 100644 --- a/src/modules/dispensing/service.ts +++ b/src/modules/dispensing/service.ts @@ -2,14 +2,14 @@ import { pool } from '../../db/pool'; import { webhookService } from '../webhooks/service'; export class DispensingService { - async createOrderDraft(payload: any) { + async createOrderDraft(payload: any, tenantId: string) { const draftId = "ord_" + Date.now() + Math.floor(Math.random() * 1000); const items = payload.items ? JSON.stringify(payload.items) : '[]'; await pool.query(` - INSERT INTO order_drafts (id, subject_ref, prescription_ref, items) - VALUES ($1, $2, $3, $4) - `, [draftId, payload.subject_ref, payload.prescription_ref || null, items]); + INSERT INTO order_drafts (id, tenant_id, subject_ref, prescription_ref, items) + VALUES ($1, $2, $3, $4, $5) + `, [draftId, tenantId, payload.subject_ref, payload.prescription_ref || null, items]); return { order_draft_id: draftId, status: 'draft' }; } @@ -24,7 +24,7 @@ export class DispensingService { return { order_draft_id: draftId, updated_items: items.length }; } - async runGates(clinicalContext: any, items: { product_id: string, qty: number }[]) { + async runGates(clinicalContext: any, items: { product_id: string, qty: number }[], tenantId: string) { // Phase 1: Basic hard/soft gate mock implementations based on clinical context const blocks: any[] = []; const warnings: any[] = []; @@ -47,10 +47,11 @@ export class DispensingService { // Check expiry soft gates from DB and calculate total amount for (const item of items) { - const prodRes = await pool.query(`SELECT mrp, dpco_ceiling FROM products WHERE id = $1`, [item.product_id]); + const prodRes = await pool.query(`SELECT mrp, dpco_ceiling, pack_size FROM products WHERE id = $1`, [item.product_id]); if (prodRes.rows.length > 0) { const prod = prodRes.rows[0]; const mrp = parseFloat(prod.mrp) || 0; + const packSize = parseInt(prod.pack_size, 10) || 1; const dpco = parseFloat(prod.dpco_ceiling); if (!isNaN(dpco) && mrp > dpco) { blocks.push({ @@ -61,14 +62,14 @@ export class DispensingService { }); tier = 'elevated'; } - totalAmount += mrp * item.qty; + totalAmount += (mrp / packSize) * item.qty; } const { rows } = await pool.query(` SELECT batch_no, expiry FROM batches - WHERE product_id = $1 AND qty_on_hand > 0 + WHERE product_id = $1 AND tenant_id = $2 AND qty_on_hand > 0 ORDER BY expiry ASC LIMIT 1 - `, [item.product_id]); + `, [item.product_id, tenantId]); if (rows.length > 0) { const batch = rows[0]; @@ -104,7 +105,7 @@ export class DispensingService { }; } - async executeDispense(subjectRef: string, prescriberName: string, items: any[], actorId: string, tenantId?: string, prescriptionId?: string, paymentMethod: string = 'cash', overrides: any[] = []) { + async executeDispense(subjectRef: string, prescriberName: string, items: any[], actorId: string, tenantId: string, prescriptionId?: string, paymentMethod: string = 'cash', overrides: any[] = []) { const client = await pool.connect(); const h1Entries: string[] = []; let dispensingId = ''; @@ -124,7 +125,7 @@ export class DispensingService { // Fetch product and drug details first const prodRes = await client.query(` - SELECT p.brand_name, p.mrp, p.dpco_ceiling, d.schedule, d.generic_name + SELECT p.brand_name, p.mrp, p.pack_size, p.dpco_ceiling, d.schedule, d.generic_name FROM products p JOIN drugs d ON p.drug_id = d.id WHERE p.id = $1 @@ -136,6 +137,7 @@ export class DispensingService { const prod = prodRes.rows[0]; const mrp = parseFloat(prod.mrp) || 0; + const packSize = parseInt(prod.pack_size, 10) || 1; const dpco = parseFloat(prod.dpco_ceiling); if (!isNaN(dpco) && mrp > dpco) { throw { @@ -144,16 +146,16 @@ export class DispensingService { message: `Billed price for ${prod.brand_name} exceeds DPCO ceiling of ${dpco}.` }; } - totalAmount += mrp * item.qty; + totalAmount += (mrp / packSize) * item.qty; // FEFO Allocation: Order by expiry ASC const { rows: batches } = await client.query(` SELECT batch_no, qty_on_hand, expiry FROM batches - WHERE product_id = $1 AND qty_on_hand > 0 AND expiry > CURRENT_DATE + WHERE product_id = $1 AND tenant_id = $2 AND qty_on_hand > 0 AND expiry > CURRENT_DATE ORDER BY expiry ASC FOR UPDATE - `, [item.product_id]); + `, [item.product_id, tenantId]); let allocatedTotal = 0; @@ -253,11 +255,11 @@ export class DispensingService { } } - async getDispensingHistory() { + async getDispensingHistory(tenantId: string) { const query = ` SELECT d.id as dispensing_id, - d.subject_ref as patient_name, + COALESCE(pat.full_name, d.subject_ref) as patient_name, d.dispensed_by, d.created_at, d.prescription_id, @@ -272,22 +274,36 @@ export class DispensingService { ) ) as items FROM dispensing_records d + LEFT JOIN patients pat ON pat.hospital_patient_id = d.subject_ref OR pat.id::text = d.subject_ref JOIN stock_movements sm ON sm.reference_id = d.id JOIN batches b ON b.batch_no = sm.batch_no JOIN products p ON p.id = b.product_id - GROUP BY d.id + WHERE d.tenant_id = $1 + GROUP BY d.id, pat.full_name ORDER BY d.created_at DESC `; - const { rows } = await pool.query(query); + const { rows } = await pool.query(query, [tenantId]); return rows; } - async getH1Register() { + async getH1Register(tenantId: string) { const query = ` - SELECT * FROM h1_registers - ORDER BY created_at DESC + SELECT + h.id, + h.dispensing_id, + COALESCE(pat.full_name, h.patient_name) as patient_name, + h.prescriber_name, + h.drug_name, + h.batch_no, + h.qty, + h.created_at + FROM h1_registers h + JOIN dispensing_records d ON h.dispensing_id = d.id + LEFT JOIN patients pat ON pat.hospital_patient_id = h.patient_name OR pat.id::text = h.patient_name + WHERE d.tenant_id = $1 + ORDER BY h.created_at DESC `; - const { rows } = await pool.query(query); + const { rows } = await pool.query(query, [tenantId]); return rows; } } diff --git a/src/modules/identity/router.ts b/src/modules/identity/router.ts index fd2b69d..fc17005 100644 --- a/src/modules/identity/router.ts +++ b/src/modules/identity/router.ts @@ -1,9 +1,87 @@ import { Router } from 'express'; import jwt from 'jsonwebtoken'; +import { IdentityService } from './service'; +import { requireAuth } from '../../middleware/auth'; const router = Router(); +const identityService = new IdentityService(); const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026'; +// GET /v1/auth/me (Get Profile) +router.get('/me', requireAuth, async (req, res) => { + try { + const userId = req.actorId as string; + const profile = await identityService.getUserProfile(userId); + res.status(200).json({ success: true, profile }); + } catch (error: any) { + res.status(404).json({ error: error.message }); + } +}); + +// PUT /v1/auth/me (Update Profile) +router.put('/me', requireAuth, async (req, res) => { + try { + const userId = req.actorId as string; + const profile = await identityService.updateUserProfile(userId, req.body); + res.status(200).json({ success: true, profile }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } +}); + +// POST /v1/auth/register (Create a new pharmacist) +router.post('/register', async (req, res) => { + try { + const { hospital_id, username, password, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address } = req.body; + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + + const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/; + if (!passwordRegex.test(password)) { + return res.status(400).json({ + error: 'Password must be at least 8 characters long, contain at least one uppercase letter, one lowercase letter, one number, and one special character.' + }); + } + + const user = await identityService.registerUser({ + hospital_id, + username, + passwordPlain: password, + pharmacy_name, + dl_number, + gstin, + pharmacist_reg_number, + address + }); + res.status(201).json({ success: true, user }); + } catch (error: any) { + res.status(400).json({ error: error.message }); + } +}); + +// POST /v1/auth/login (Pharmacist Login) +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + if (!username || !password) { + return res.status(400).json({ error: 'Username and password are required' }); + } + + const data = await identityService.login(username, password); + res.status(200).json({ success: true, ...data }); + } catch (error: any) { + res.status(401).json({ error: error.message }); + } +}); + +// POST /v1/auth/logout (Pharmacist Logout) +router.post('/logout', (req, res) => { + // For stateless JWT, true logout happens on the client by deleting the token. + // We provide this endpoint for consistency and future expansion (e.g., token blacklisting). + res.status(200).json({ success: true, message: 'Logged out successfully' }); +}); + // POST /v1/auth/token router.post('/token', (req, res) => { const { grant_type, client_id, client_secret } = req.body; @@ -18,9 +96,9 @@ router.post('/token', (req, res) => { if (client_id && validClients.includes(client_id) && client_secret) { // Generate a secure signed token specifically for this hospital const token = jwt.sign( - { - tenantId: client_id, - scopes: ['rx:read', 'rx:write', 'dispense:human', 'catalogue:read'] + { + tenantId: client_id, + scopes: ['rx:read', 'rx:write', 'dispense:human', 'catalogue:read'] }, JWT_SECRET, { expiresIn: '8h' } diff --git a/src/modules/identity/service.ts b/src/modules/identity/service.ts new file mode 100644 index 0000000..661a198 --- /dev/null +++ b/src/modules/identity/service.ts @@ -0,0 +1,113 @@ +import { pool } from '../../db/pool'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026'; + +export class IdentityService { + async registerUser(payload: { + hospital_id?: string | number; + username: string; + passwordPlain: string; + pharmacy_name?: string; + dl_number?: string; + gstin?: string; + pharmacist_reg_number?: string; + address?: string; + }) { + const { hospital_id, username, passwordPlain, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address } = payload; + + // 1. Check if user already exists + const existing = await pool.query('SELECT id FROM users WHERE username = $1', [username]); + if (existing.rows.length > 0) { + throw new Error('Username already exists'); + } + + // 2. Hash password + const saltRounds = 10; + const passwordHash = await bcrypt.hash(passwordPlain, saltRounds); + + const id = "usr_" + Date.now(); + + const tenantId = hospital_id ? String(hospital_id) : 'act_admin'; + + await pool.query( + `INSERT INTO users (id, username, password_hash, role, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [id, username, passwordHash, 'pharmacist', pharmacy_name, dl_number, gstin, pharmacist_reg_number, address, tenantId] + ); + + return { id, username, role: 'pharmacist', tenant_id: tenantId }; + } + + async login(username: string, passwordPlain: string) { + // 1. Find user + const result = await pool.query('SELECT * FROM users WHERE username = $1', [username]); + if (result.rows.length === 0) { + throw new Error('Invalid credentials'); + } + + const user = result.rows[0]; + + // 2. Verify password + const isValid = await bcrypt.compare(passwordPlain, user.password_hash); + if (!isValid) { + throw new Error('Invalid credentials'); + } + + // 3. Issue JWT Token + const token = jwt.sign( + { + userId: user.id, + username: user.username, + role: user.role, + tenant_id: user.tenant_id + }, + JWT_SECRET, + { expiresIn: '12h' } // Usually 8-12 hours for a shift + ); + + return { + token, + user: { + id: user.id, + username: user.username, + role: user.role, + tenant_id: user.tenant_id + } + }; + } + + async getUserProfile(userId: string) { + const result = await pool.query( + 'SELECT id, username, role, pharmacy_name, dl_number, gstin, pharmacist_reg_number, address FROM users WHERE id = $1', + [userId] + ); + if (result.rows.length === 0) { + throw new Error('User not found'); + } + return result.rows[0]; + } + + async updateUserProfile(userId: string, data: { + pharmacy_name?: string; + dl_number?: string; + gstin?: string; + pharmacist_reg_number?: string; + address?: string; + }) { + // Only update allowed fields + await pool.query( + `UPDATE users + SET pharmacy_name = COALESCE($1, pharmacy_name), + dl_number = COALESCE($2, dl_number), + gstin = COALESCE($3, gstin), + pharmacist_reg_number = COALESCE($4, pharmacist_reg_number), + address = COALESCE($5, address) + WHERE id = $6`, + [data.pharmacy_name, data.dl_number, data.gstin, data.pharmacist_reg_number, data.address, userId] + ); + + return this.getUserProfile(userId); + } +} diff --git a/src/modules/integration/controller.ts b/src/modules/integration/controller.ts new file mode 100644 index 0000000..df0371f --- /dev/null +++ b/src/modules/integration/controller.ts @@ -0,0 +1,152 @@ +import { Request, Response } from 'express'; +import { IntegrationService } from './service'; + +export class IntegrationController { + + // Endpoint to handle patient sync webhooks from Hospital API + static async syncHospitalPatient(req: Request, res: Response) { + try { + const payload = req.body; + + // Basic validation + if (!payload.hospital_patient_id || !payload.full_name) { + return res.status(400).json({ error: 'Missing required patient fields' }); + } + + // Save or update the patient in Pharmacy DB + const result = await IntegrationService.saveOrUpdatePatient(payload); + + res.status(200).json({ + message: 'Patient synced successfully', + data: result + }); + } catch (error: any) { + console.error('[IntegrationController] Error syncing patient:', error); + res.status(500).json({ error: error.message }); + } + } + + // Endpoint to fetch all patients synced to Pharmacy DB + static async getAllPatients(req: Request, res: Response) { + try { + const tenantId = req.headers['x-tenant-id'] || 'act_admin'; + const patients = await IntegrationService.getAllPatients(String(tenantId)); + res.status(200).json({ + message: 'Patients fetched successfully', + data: patients + }); + } catch (error: any) { + console.error('[IntegrationController] Error fetching patients:', error); + res.status(500).json({ error: error.message }); + } + } + + // --- Telegaruda Endpoints --- + static async receivePrescription(req: Request, res: Response) { + try { + const payload = req.body; + + const patientId = payload.patient_id || payload.patientId; + const medicines = payload.values || payload.medicines; + + if (!patientId || !medicines) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const id = "rx_" + Date.now() + Math.floor(Math.random() * 1000); + + // Map Hospital API payload to Pharmacy UI expected format + const formattedItems = medicines.map((med: any) => { + const m = med.medicine_taken?.m || '0'; + const a = med.medicine_taken?.a || '0'; + const e = med.medicine_taken?.e || '0'; + const n = med.medicine_taken?.n || '0'; + return { + parsed: { + qty: parseInt(med.total_qty) || 0, + dose: `${m}-${a}-${e}-${n}`, + form: med.medicine_type?.toLowerCase() || 'tablet', + brand: med.medicine_name, + total_qty_str: med.total_qty, + duration: med.total_medicine_duration || med.medicine_duration, + instruction: med.medicine_instruction + }, + verbatim: `${med.medicine_name} - ${med.medicine_instruction}`, + confidence: 1.0, + confusable_with: [], + explicitly_confirmed: true + }; + }); + + let patientName = payload.patientName || payload.patient_name; + if (!patientName && patientId) { + try { + const client = await require('../../db/pool').pool.connect(); + const pResult = await client.query('SELECT full_name FROM patients WHERE hospital_patient_id = $1', [String(patientId)]); + if (pResult.rows.length > 0) { + patientName = pResult.rows[0].full_name; + } + client.release(); + } catch(e) { console.log('Error fetching patient name:', e); } + } + + const structuredData = { + items: formattedItems, + patient: { + age: 30, // Dummy age, update if you add age to payload later + name: patientName || "" + }, + prescriber: { + name: payload.providerName || payload.doctorName || payload.doctor_name || "", + registration_number: "N/A" + }, + image_quality: "digital_webhook" + }; + + const query = ` + INSERT INTO prescriptions (id, image_path, image_hash, status, extracted_data, confirmed_data, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `; + const values = [ + id, + 'E-Prescription (Digital Webhook)', + 'verified-webhook-hash', + 'pending_review', + JSON.stringify(structuredData), + null, // Leave confirmed_data null so pharmacist can review it + payload.hospital_id ? String(payload.hospital_id) : 'act_admin' + ]; + + await require('../../db/pool').pool.query(query, values); + + res.status(201).json({ message: 'Prescription received and saved', data: payload, id }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } + + static async fetchPatientDetails(req: Request, res: Response) { + try { + const patientId = String(req.params.patientId); + res.status(200).json({ message: 'Mock details', data: { patientId } }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } + + static async getAllPrescriptions(req: Request, res: Response) { + try { + const tenantId = req.headers['x-tenant-id'] || 'act_admin'; + const client = await require('../../db/pool').pool.connect(); + try { + const query = `SELECT * FROM prescriptions WHERE tenant_id = $1 ORDER BY created_at DESC;`; + const result = await client.query(query, [String(tenantId)]); + res.status(200).json({ message: 'All prescriptions', data: result.rows }); + } finally { + client.release(); + } + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } +} diff --git a/src/modules/integration/router.ts b/src/modules/integration/router.ts new file mode 100644 index 0000000..1e518d4 --- /dev/null +++ b/src/modules/integration/router.ts @@ -0,0 +1,49 @@ +import { Router } from 'express'; +import { IntegrationController } from './controller'; + +const router = Router(); + +// Middleware to protect integration routes using custom headers +router.use((req, res, next) => { + // Allow GET endpoints for browser testing if you want, but strictly protect POSTs. + if (req.method === 'GET') { + return next(); + } + + const client = req.headers['x-api-client']; + const secret = req.headers['x-api-secret']; + + // JWT_SECRET is the key we generated earlier in .env + const EXPECTED_SECRET = process.env.JWT_SECRET; + + if (!client || !secret) { + return res.status(401).json({ error: 'Unauthorized: Missing API credentials' }); + } + + if (client !== 'hospital-api' && client !== 'telegaruda') { + return res.status(401).json({ error: 'Unauthorized: Invalid client ID' }); + } + + if (secret !== EXPECTED_SECRET) { + return res.status(401).json({ error: 'Unauthorized: Invalid client secret' }); + } + + next(); +}); + +// Endpoint for Hospital API to sync patient data (Webhook receiver) +router.post('/hospital/patient-sync', IntegrationController.syncHospitalPatient); + +// Endpoint to get all synced patients in Pharmacy +router.get('/hospital/patients', IntegrationController.getAllPatients); + +// Endpoint for Telegaruda to push prescriptions +router.post('/telegaruda/prescription', IntegrationController.receivePrescription); + +// Endpoint for Pharmacy to fetch patient details from Telegaruda +router.get('/telegaruda/patient/:patientId', IntegrationController.fetchPatientDetails); + +// Endpoint to view all synced prescriptions (For testing) +router.get('/hospital/prescriptions', IntegrationController.getAllPrescriptions); + +export default router; diff --git a/src/modules/integration/service.ts b/src/modules/integration/service.ts new file mode 100644 index 0000000..e29c34a --- /dev/null +++ b/src/modules/integration/service.ts @@ -0,0 +1,58 @@ +import { pool } from '../../db/pool'; + +export class IntegrationService { + + /** + * UPSERT a patient into the Pharmacy DB coming from the Hospital API webhook. + */ + static async saveOrUpdatePatient(payload: any) { + console.log('[IntegrationService] Syncing patient from Hospital API:', payload.hospital_patient_id); + + const client = await pool.connect(); + try { + const allergies = payload.allergies ? JSON.stringify(payload.allergies) : null; + + const query = ` + INSERT INTO patients (hospital_patient_id, full_name, contact_number, email, allergies, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (hospital_patient_id) + DO UPDATE SET + full_name = EXCLUDED.full_name, + contact_number = EXCLUDED.contact_number, + email = EXCLUDED.email, + allergies = EXCLUDED.allergies, + tenant_id = EXCLUDED.tenant_id, + updated_at = CURRENT_TIMESTAMP + RETURNING id, hospital_patient_id, full_name; + `; + + const values = [ + payload.hospital_patient_id, + payload.full_name, + payload.contact_number || null, + payload.email || null, + allergies, + payload.hospital_id ? String(payload.hospital_id) : 'act_admin' // fallback tenant + ]; + + const result = await client.query(query, values); + return result.rows[0]; + } finally { + client.release(); + } + } + + /** + * Fetch all synced patients from the Pharmacy DB. + */ + static async getAllPatients(tenantId: string) { + const client = await pool.connect(); + try { + const query = `SELECT * FROM patients WHERE tenant_id = $1 ORDER BY created_at DESC;`; + const result = await client.query(query, [tenantId]); + return result.rows; + } finally { + client.release(); + } + } +} diff --git a/src/modules/inventory/router.ts b/src/modules/inventory/router.ts index b1a376f..68931e4 100644 --- a/src/modules/inventory/router.ts +++ b/src/modules/inventory/router.ts @@ -3,10 +3,13 @@ import { requireAuth } from '../../middleware/auth'; import { requireHeaders } from '../../middleware/headers'; import { requireIdempotency } from '../../middleware/idempotency'; import { InventoryService } from './service'; +import supplierRouter from './supplierRouter'; const router = Router(); const service = new InventoryService(); +router.use(supplierRouter); + // POST /v1/inventory/grn router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req, res, next) => { try { @@ -25,7 +28,8 @@ router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req, router.get('/expiring', requireAuth, async (req, res, next) => { try { const days = req.query.days ? parseInt(req.query.days as string) : 30; - const batches = await service.getExpiringBatches(days); + const actorId = req.actorId as string; + const batches = await service.getExpiringBatches(days, actorId); res.status(200).json({ batches }); } catch (error) { next(error); @@ -36,7 +40,8 @@ router.get('/expiring', requireAuth, async (req, res, next) => { router.get('/low-stock', requireAuth, async (req, res, next) => { try { const threshold = req.query.threshold ? parseInt(req.query.threshold as string) : 50; - const batches = await service.getLowStockBatches(threshold); + const actorId = req.actorId as string; + const batches = await service.getLowStockBatches(threshold, actorId); res.status(200).json({ batches }); } catch (error) { next(error); diff --git a/src/modules/inventory/service.ts b/src/modules/inventory/service.ts index 47cb0d2..ace89b1 100644 --- a/src/modules/inventory/service.ts +++ b/src/modules/inventory/service.ts @@ -54,7 +54,7 @@ export class InventoryService { }; } - async getExpiringBatches(daysThreshold: number = 30) { + async getExpiringBatches(daysThreshold: number = 30, actorId: string) { const query = ` SELECT b.batch_no, @@ -67,14 +67,15 @@ export class InventoryService { JOIN products p ON b.product_id = p.id WHERE b.qty_on_hand > 0 AND b.expiry <= CURRENT_DATE + $1::int + AND b.tenant_id = $2 ORDER BY b.expiry ASC `; - const { rows } = await pool.query(query, [daysThreshold]); + const { rows } = await pool.query(query, [daysThreshold, actorId]); return rows; } - async getLowStockBatches(qtyThreshold: number = 50) { + async getLowStockBatches(qtyThreshold: number = 50, actorId: string) { const query = ` SELECT b.batch_no, @@ -87,10 +88,11 @@ export class InventoryService { JOIN products p ON b.product_id = p.id WHERE b.qty_on_hand > 0 AND b.qty_on_hand <= $1::int + AND b.tenant_id = $2 ORDER BY b.qty_on_hand ASC `; - const { rows } = await pool.query(query, [qtyThreshold]); + const { rows } = await pool.query(query, [qtyThreshold, actorId]); return rows; } @@ -113,20 +115,26 @@ export class InventoryService { }; } + // Lookup pack_size for this product to convert strips/boxes into individual loose units + const prodRes = await client.query(`SELECT pack_size FROM products WHERE id = $1`, [line.product_id]); + const packSize = (prodRes.rows.length > 0 && parseInt(prodRes.rows[0].pack_size, 10)) ? parseInt(prodRes.rows[0].pack_size, 10) : 1; + const totalLooseUnits = line.qty * packSize; + // Insert or Update Batch (Upsert) await client.query(` - INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO batches (batch_no, tenant_id, product_id, expiry, qty_on_hand, purchase_price, supplier_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (batch_no) DO UPDATE SET qty_on_hand = batches.qty_on_hand + EXCLUDED.qty_on_hand, - purchase_price = EXCLUDED.purchase_price - `, [line.batch_no, line.product_id, line.expiry, line.qty, line.purchase_price]); + purchase_price = EXCLUDED.purchase_price, + supplier_id = EXCLUDED.supplier_id + `, [line.batch_no, actorId, line.product_id, line.expiry, totalLooseUnits, line.purchase_price, payload.supplier_id || null]); - // Log Receipt Movement + // Log Receipt Movement (in loose units) await client.query(` INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id) VALUES ($1, 'receipt', $2, $3) - `, [line.batch_no, line.qty, grnId]); + `, [line.batch_no, totalLooseUnits, grnId]); batchesCreated.push(line.batch_no); } diff --git a/src/modules/inventory/supplierRouter.ts b/src/modules/inventory/supplierRouter.ts new file mode 100644 index 0000000..97b7d62 --- /dev/null +++ b/src/modules/inventory/supplierRouter.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth'; +import { pool } from '../../db/pool'; + +const router = Router(); + +// GET /v1/inventory/suppliers +router.get('/suppliers', requireAuth, async (req, res, next) => { + try { + const { rows } = await pool.query('SELECT * FROM suppliers WHERE tenant_id = $1 ORDER BY name ASC', [req.actorId]); + res.status(200).json({ suppliers: rows }); + } catch (error) { + next(error); + } +}); + +// POST /v1/inventory/suppliers +router.post('/suppliers', requireAuth, async (req, res, next) => { + try { + const { name, contact_number, email, gstin, dl_number, address, contact_person } = req.body; + if (!name) { + return res.status(400).json({ error: 'Name is required' }); + } + + const id = 'sup_' + Date.now() + Math.floor(Math.random() * 1000); + + await pool.query(` + INSERT INTO suppliers (id, tenant_id, name, contact_number, email, gstin, dl_number, address, contact_person) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, [id, req.actorId, name, contact_number, email, gstin, dl_number, address, contact_person]); + + res.status(201).json({ id, name, contact_number, email, gstin, dl_number, address, contact_person }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/modules/prescription/router.ts b/src/modules/prescription/router.ts index 1f7e451..302f3ec 100644 --- a/src/modules/prescription/router.ts +++ b/src/modules/prescription/router.ts @@ -30,7 +30,8 @@ router.post('/', requireAuth, upload.single('image'), async (req, res, next) => // GET /v1/prescriptions router.get('/', requireAuth, async (req, res, next) => { try { - const prescriptions = await service.getAllPrescriptions(); + const tenantId = req.headers['x-tenant-id'] || 'act_admin'; + const prescriptions = await service.getAllPrescriptions(String(tenantId)); res.status(200).json({ prescriptions }); } catch (error) { next(error); @@ -40,7 +41,8 @@ router.get('/', requireAuth, async (req, res, next) => { // GET /v1/prescriptions/:id router.get('/:id', requireAuth, async (req, res, next) => { try { - const record = await service.getPrescription(req.params.id as string); + const tenantId = req.headers['x-tenant-id'] || 'act_admin'; + const record = await service.getPrescription(req.params.id as string, String(tenantId)); if (!record) return res.status(404).json({ error: 'NOT_FOUND' }); res.status(200).json(record); @@ -52,8 +54,8 @@ router.get('/:id', requireAuth, async (req, res, next) => { // POST /v1/prescriptions/:id/confirm router.post('/:id/confirm', requireAuth, async (req, res, next) => { try { - const actorId = req.actorId; - const result = await service.confirmPrescription(req.params.id as string, req.body, actorId!); + const tenantId = req.headers['x-tenant-id'] || 'act_admin'; + const result = await service.confirmPrescription(req.params.id as string, req.body, String(tenantId)); res.status(200).json(result); } catch (error: any) { diff --git a/src/modules/prescription/service.ts b/src/modules/prescription/service.ts index 45b3938..08256a9 100644 --- a/src/modules/prescription/service.ts +++ b/src/modules/prescription/service.ts @@ -53,9 +53,9 @@ export class PrescriptionService { await client.query('BEGIN'); await client.query(` - INSERT INTO prescriptions (id, image_path, image_hash, status) - VALUES ($1, $2, $3, 'processing') - `, [id, s3Url, hash]); + INSERT INTO prescriptions (id, tenant_id, image_path, image_hash, status) + VALUES ($1, $2, $3, $4, 'processing') + `, [id, actorId, s3Url, hash]); // Enqueue async extraction job this.enqueueExtraction(id, file); @@ -74,8 +74,8 @@ export class PrescriptionService { async enqueueExtraction(id: string, file: Express.Multer.File) { setTimeout(async () => { try { - const { GoogleGenAI } = require('@google/genai'); - const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); + const OpenAI = require('openai'); + const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const prompt = `You are an AI assistant for a pharmacy. Extract the information from this handwritten or printed medical prescription. @@ -94,20 +94,23 @@ export class PrescriptionService { ] }`; - const response = await ai.models.generateContent({ - model: 'gemini-3.6-flash', - contents: [ - prompt, + const base64Image = file.buffer.toString('base64'); + const dataUri = `data:${file.mimetype};base64,${base64Image}`; + + const response = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ { - inlineData: { - data: file.buffer.toString("base64"), - mimeType: file.mimetype - } + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: dataUri } } + ] } - ], + ] }); - const rawText = response.text || "{}"; + const rawText = response.choices[0].message.content || "{}"; // Clean up markdown if the AI includes it by mistake const cleanJsonText = rawText.replace(/```json/g, '').replace(/```/g, '').trim(); const extractedData = JSON.parse(cleanJsonText); @@ -123,25 +126,25 @@ export class PrescriptionService { console.error("Extraction job failed for", id, err); // Fallback to error status try { - await pool.query(`UPDATE prescriptions SET status = 'error' WHERE id = $1`, [id]); + await pool.query(`UPDATE prescriptions SET status = 'failed' WHERE id = $1`, [id]); } catch(e) {} } }, 100); // slight delay to allow response to return to client first } - async getPrescription(id: string) { - const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE id = $1`, [id]); + async getPrescription(id: string, actorId: string) { + const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE id = $1 AND tenant_id = $2`, [id, actorId]); return rows[0]; } - async getAllPrescriptions() { - const { rows } = await pool.query(`SELECT * FROM prescriptions ORDER BY created_at DESC`); + async getAllPrescriptions(actorId: string) { + const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE tenant_id = $1 ORDER BY created_at DESC`, [actorId]); return rows; } async confirmPrescription(id: string, confirmedData: any, actorId: string) { // Human Gate 1: Validate that unconfirmed fields are explicitly reviewed - const { rows } = await pool.query(`SELECT status, extracted_data FROM prescriptions WHERE id = $1`, [id]); + const { rows } = await pool.query(`SELECT status, extracted_data FROM prescriptions WHERE id = $1 AND tenant_id = $2`, [id, actorId]); const record = rows[0]; if (!record) throw { status: 404, error: 'NOT_FOUND' }; @@ -167,8 +170,8 @@ export class PrescriptionService { await pool.query(` UPDATE prescriptions SET status = 'confirmed', confirmed_data = $1 - WHERE id = $2 - `, [confirmedData, id]); + WHERE id = $2 AND tenant_id = $3 + `, [confirmedData, id, actorId]); return { id, status: 'confirmed' }; }