almost complete the api.

This commit is contained in:
2026-08-03 10:22:42 +05:30
parent 65875a14c0
commit fd77985cf9
21 changed files with 904 additions and 220 deletions
+22
View File
@@ -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();
+47 -1
View File
@@ -7,6 +7,17 @@ async function initDB() {
console.log('Creating tables...'); 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 // 1. Catalogue Module
await client.query(` await client.query(`
CREATE TABLE IF NOT EXISTS drugs ( CREATE TABLE IF NOT EXISTS drugs (
@@ -28,15 +39,33 @@ async function initDB() {
strength VARCHAR(50), strength VARCHAR(50),
form VARCHAR(50), form VARCHAR(50),
mrp DECIMAL(10, 2), mrp DECIMAL(10, 2),
pack_size INTEGER DEFAULT 1,
dpco_ceiling DECIMAL(10, 2) dpco_ceiling DECIMAL(10, 2)
); );
`); `);
// 2. Inventory Module // 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(` await client.query(`
CREATE TABLE IF NOT EXISTS batches ( CREATE TABLE IF NOT EXISTS batches (
batch_no VARCHAR(100) PRIMARY KEY, batch_no VARCHAR(100) PRIMARY KEY,
tenant_id VARCHAR(50),
product_id VARCHAR(50) REFERENCES products(id), product_id VARCHAR(50) REFERENCES products(id),
supplier_id VARCHAR(50) REFERENCES suppliers(id),
expiry DATE NOT NULL, expiry DATE NOT NULL,
qty_on_hand INTEGER NOT NULL CHECK (qty_on_hand >= 0), qty_on_hand INTEGER NOT NULL CHECK (qty_on_hand >= 0),
purchase_price DECIMAL(10, 2), purchase_price DECIMAL(10, 2),
@@ -96,6 +125,7 @@ async function initDB() {
await client.query(` await client.query(`
CREATE TABLE IF NOT EXISTS order_drafts ( CREATE TABLE IF NOT EXISTS order_drafts (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
tenant_id VARCHAR(50),
subject_ref VARCHAR(100), subject_ref VARCHAR(100),
prescription_ref VARCHAR(50), prescription_ref VARCHAR(50),
items JSONB, 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(` await client.query(`
CREATE TABLE IF NOT EXISTS prescriptions ( CREATE TABLE IF NOT EXISTS prescriptions (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
tenant_id VARCHAR(50),
image_path TEXT NOT NULL, image_path TEXT NOT NULL,
image_hash VARCHAR(64) NOT NULL, image_hash VARCHAR(64) NOT NULL,
status VARCHAR(20) DEFAULT 'processing', status VARCHAR(20) DEFAULT 'processing',
+2
View File
@@ -11,6 +11,7 @@ import dispensingRouter from './modules/dispensing/router';
import auditRouter from './modules/audit/router'; import auditRouter from './modules/audit/router';
import prescriptionRouter from './modules/prescription/router'; import prescriptionRouter from './modules/prescription/router';
import aiRouter from './modules/ai/router'; import aiRouter from './modules/ai/router';
import integrationRouter from './modules/integration/router';
const app = express(); const app = express();
const PORT = process.env.PORT || 8080; const PORT = process.env.PORT || 8080;
@@ -45,6 +46,7 @@ app.use('/v1/orders', dispensingRouter);
app.use('/v1/audit', auditRouter); app.use('/v1/audit', auditRouter);
app.use('/v1/rx', prescriptionRouter); app.use('/v1/rx', prescriptionRouter);
app.use('/v1/ai', aiRouter); app.use('/v1/ai', aiRouter);
app.use('/v1/integration', integrationRouter);
// Health check // Health check
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
+18 -13
View File
@@ -26,25 +26,30 @@ export const requireAuth: RequestHandler = (req: Request, res: Response, next: N
return; return;
} }
if (!actorId) {
res.status(403).json({
error: 'FORBIDDEN',
message: 'Missing X-Actor-Id header.',
details: {},
retryable: false
});
return;
}
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
try { try {
// Cryptographically verify the token using our secret key // Cryptographically verify the token using our secret key
const payload = jwt.verify(token, JWT_SECRET) as any; const payload = jwt.verify(token, JWT_SECRET) as any;
// Automatically attach the tenant (e.g., jayam_web_ai) to the request context if (payload.userId) {
req.tenantId = payload.tenantId; // User JWT Token
req.actorId = actorId; (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(); next();
} catch (error) { } catch (error) {
+4 -2
View File
@@ -1,16 +1,18 @@
import { Router } from 'express'; import { Router } from 'express';
import { AIService } from './service'; import { AIService } from './service';
import { requireAuth } from '../../middleware/auth';
const router = Router(); const router = Router();
const aiService = new AIService(); const aiService = new AIService();
router.post('/voice', async (req, res, next) => { router.post('/voice', requireAuth, async (req, res, next) => {
try { try {
const { transcript } = req.body; const { transcript } = req.body;
if (!transcript) { if (!transcript) {
return res.status(400).json({ error: 'Transcript is required' }); 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); res.json(result);
} catch (error) { } catch (error) {
next(error); next(error);
+133 -101
View File
@@ -1,10 +1,10 @@
import { GoogleGenAI } from '@google/genai'; import OpenAI from 'openai';
import { pool } from '../../db/pool'; 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 { export class AIService {
async processVoiceCommand(transcript: string) { async processVoiceCommand(transcript: string, actorId: string) {
const prompt = `You are a helpful pharmacy AI assistant. const prompt = `You are a helpful pharmacy AI assistant.
You can understand English and Tanglish (Tamil written in English). You can understand English and Tanglish (Tamil written in English).
The user is speaking to you. Respond conversationally. Do not use markdown. 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 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 today's sales or revenue, use get_today_sales.
If the user asks about expiring medicines, use get_expiring_medicines. 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 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 a new medicine to the system, use add_medicine.
If the user asks to add stock for a medicine, use add_stock. 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 = { const aiTools: any = [
functionDeclarations: [ { 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." } },
name: "get_low_stock", { type: "function", function: { name: "get_today_sales", description: "Get the total revenue or sales for today." } },
description: "Get the count of medicines that are running low on stock.", { 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." } },
name: "get_total_medicines", { type: "function", function: { name: "get_total_suppliers", description: "Get the total number of wholesale distributors/suppliers." } },
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.",
},
{
name: "get_expiring_medicines",
description: "Get the count of medicines expiring within the next 30 days.",
},
{
name: "search_medicine", name: "search_medicine",
description: "Search for a specific medicine by name to check if it's in stock.", description: "Search for a specific medicine by name to check if it's in stock.",
parameters: { parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
type: "OBJECT", }},
properties: { { type: "function", function: {
query: { name: "search_supplier",
type: "STRING", description: "Search for a wholesale supplier by name.",
description: "The name of the medicine to search for (e.g., 'calpol', 'privika')." parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
} }},
}, { type: "function", function: {
required: ["query"]
}
},
{
name: "add_medicine", name: "add_medicine",
description: "Add a new medicine to the product catalog.", description: "Add a new medicine to the product catalog.",
parameters: { parameters: {
type: "OBJECT", type: "object",
properties: { properties: {
brand_name: { type: "STRING", description: "The brand name of the medicine (e.g., Dolo 650)." }, brand_name: { type: "string" },
mrp: { type: "NUMBER", description: "The Maximum Retail Price (MRP) in rupees." }, mrp: { type: "number" },
form: { type: "STRING", description: "The physical form of the medicine (e.g., tablet, syrup, injection)." } pack_size: { type: "number", description: "Number of units per pack" },
form: { type: "string" }
}, },
required: ["brand_name", "mrp"] required: ["brand_name", "mrp"]
} }
}, }},
{ { type: "function", function: {
name: "add_stock", name: "add_stock",
description: "Add stock (a new batch) for an existing medicine.", description: "Add stock (a new batch) for an existing medicine.",
parameters: { parameters: {
type: "OBJECT", type: "object",
properties: { properties: {
product_name: { type: "STRING", description: "The brand name of the medicine to add stock to." }, product_name: { type: "string" },
qty: { type: "NUMBER", description: "The quantity of stock to add." }, qty: { type: "number" }
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." }
}, },
required: ["product_name", "qty"] 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", name: "navigate_to",
description: "Navigate the user to a specific page.", description: "Navigate the user to a specific page.",
parameters: { parameters: {
type: "OBJECT", type: "object",
properties: { properties: {
target: { target: {
type: "STRING", type: "string",
description: "The page to navigate to. Allowed values: 'dashboard', 'pharmacy', 'inventory', 'prescriptions'" description: "Allowed values: 'dashboard', 'pharmacy', 'inventory', 'prescriptions', 'patients', 'suppliers', 'registers', 'stock-movements', 'history'"
} }
}, },
required: ["target"] required: ["target"]
} }
} }}
] ];
};
try { try {
const response = await ai.models.generateContent({ const response = await openai.chat.completions.create({
model: 'gemini-3.6-flash', model: 'gpt-4o',
contents: [prompt], messages: [
config: { { role: 'system', content: prompt },
tools: [aiTools as any], { role: 'user', content: transcript }
} ],
tools: aiTools,
tool_choice: "auto"
}); });
const functionCalls = response.functionCalls; const message = response.choices[0].message;
if (functionCalls && functionCalls.length > 0) { if (message.tool_calls && message.tool_calls.length > 0) {
const call = functionCalls[0]; const call = (message.tool_calls[0] as any).function;
let toolData = null; let toolData = null;
let actionCommand = 'NONE'; let actionCommand = 'NONE';
const args = call.arguments ? JSON.parse(call.arguments) : {};
if (call.name === 'get_low_stock') { 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 }; toolData = { items_running_low: rows[0].count };
actionCommand = 'VIEW_LOW_STOCK'; actionCommand = 'VIEW_LOW_STOCK';
} else if (call.name === 'get_total_medicines') { } else if (call.name === 'get_total_medicines') {
const { rows } = await pool.query('SELECT count(*) as count FROM products'); const { rows } = await pool.query('SELECT count(*) as count FROM products');
toolData = { total_unique_medicines: rows[0].count }; toolData = { total_unique_medicines: rows[0].count };
} else if (call.name === 'get_today_sales') { } 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 }; toolData = { todays_revenue_rupees: rows[0].total || 0 };
} else if (call.name === 'get_expiring_medicines') { } 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 }; toolData = { batches_expiring_in_30_days: rows[0].count };
actionCommand = 'VIEW_EXPIRING_STOCK'; 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') { } else if (call.name === 'search_medicine') {
const query = (call.args as any)?.query || ''; const query = args.query || '';
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT p.brand_name, COALESCE(SUM(b.qty_on_hand), 0) as total_stock `SELECT p.brand_name, COALESCE(SUM(b.qty_on_hand), 0) as total_stock
FROM products p FROM products p
@@ -134,73 +159,80 @@ User transcript: "${transcript}"`;
GROUP BY p.brand_name`, GROUP BY p.brand_name`,
[`%${query}%`] [`%${query}%`]
); );
if (rows.length > 0) { toolData = rows.length > 0 ? { search_results: rows } : { message: `No medicine found matching '${query}'.` };
toolData = { search_results: rows };
} else {
toolData = { message: `No medicine found matching '${query}'.` };
}
actionCommand = 'NAVIGATE_INVENTORY'; 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') { } else if (call.name === 'add_medicine') {
const args = call.args as any;
const id = `prd_${Date.now()}`; const id = `prd_${Date.now()}`;
await pool.query( 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]);
'INSERT INTO products (id, brand_name, form, mrp) VALUES ($1, $2, $3, $4)',
[id, args.brand_name, args.form || 'tablet', args.mrp || 0]
);
toolData = { success: true, message: `Added ${args.brand_name} successfully.` }; 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') { } 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}%`]); 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) { if (pRes.rows.length > 0) {
const pid = pRes.rows[0].id;
const batchNo = args.batch_no || `B${Date.now()}`; 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]; 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, pRes.rows[0].id, 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, pid, expiry, args.qty, 0]
);
toolData = { success: true, message: `Added ${args.qty} stock to ${pRes.rows[0].brand_name}.` }; toolData = { success: true, message: `Added ${args.qty} stock to ${pRes.rows[0].brand_name}.` };
actionCommand = 'NAVIGATE_INVENTORY'; actionCommand = 'NAVIGATE_INVENTORY';
} else { } 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') { } else if (call.name === 'navigate_to') {
const target = (call.args as any)?.target; const target = args.target?.toLowerCase();
toolData = { action: 'Navigating to ' + target }; toolData = { action: 'Navigating to ' + target };
if (target === 'dashboard') actionCommand = 'NAVIGATE_DASHBOARD'; if (target === 'dashboard') actionCommand = 'NAVIGATE_DASHBOARD';
if (target === 'pharmacy') actionCommand = 'NAVIGATE_PHARMACY'; 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 === '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. const finalPrompt = `You are a helpful pharmacy AI assistant.
User's query: "${transcript}" Database result for user query: ${JSON.stringify(toolData)}
Database result: ${JSON.stringify(toolData)}
CRITICAL INSTRUCTIONS: 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. 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"). 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({ const finalResponse = await openai.chat.completions.create({
model: 'gemini-3.6-flash', model: 'gpt-4o',
contents: [finalPrompt] messages: [
{ role: 'system', content: finalPrompt },
{ role: 'user', content: transcript }
]
}); });
return { return {
replyText: finalResponse.text || "Action completed.", replyText: finalResponse.choices[0].message.content || "Action completed.",
actionCommand actionCommand
}; };
} }
return { return {
replyText: response.text || "I didn't quite catch that.", replyText: message.content || "I didn't quite catch that.",
actionCommand: 'NONE' actionCommand: 'NONE'
}; };
} catch (e) { } catch (e) {
+57 -11
View File
@@ -33,11 +33,13 @@ router.get('/ai-calls', requireAuth, async (req, res, next) => {
// Also grab the stock movements for full audit visibility // Also grab the stock movements for full audit visibility
const { rows: stockMovements } = await pool.query(` const { rows: stockMovements } = await pool.query(`
SELECT id, batch_no, movement_type, qty_delta, reference_id, created_at SELECT sm.id, sm.batch_no, sm.movement_type, sm.qty_delta, sm.reference_id, sm.created_at
FROM stock_movements FROM stock_movements sm
ORDER BY created_at DESC JOIN batches b ON sm.batch_no = b.batch_no
LIMIT $1 WHERE b.tenant_id = $1
`, [limit ? parseInt(limit as string) : 50]); ORDER BY sm.created_at DESC
LIMIT $2
`, [req.actorId, limit ? parseInt(limit as string) : 50]);
res.status(200).json({ res.status(200).json({
ai_calls: rows, ai_calls: rows,
@@ -59,9 +61,10 @@ router.get('/overrides', requireAuth, async (req, res, next) => {
actor_user_id, actor_user_id,
occurred_at occurred_at
FROM overrides_log FROM overrides_log
WHERE actor_user_id = $1
ORDER BY occurred_at DESC ORDER BY occurred_at DESC
LIMIT 100 LIMIT 100
`); `, [req.actorId]);
// Grouping for summary // Grouping for summary
const by_actor: Record<string, number> = {}; const by_actor: Record<string, number> = {};
@@ -89,10 +92,12 @@ router.get('/h1-register', requireAuth, async (req, res, next) => {
try { try {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
id, dispensing_id, patient_name, prescriber_name, drug_name, batch_no, qty, created_at 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 FROM h1_registers h
ORDER BY created_at DESC 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 }); res.status(200).json({ register: rows });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -111,12 +116,53 @@ router.get('/sales-register', requireAuth, async (req, res, next) => {
d.total_amount, d.total_amount,
d.payment_method d.payment_method
FROM dispensing_records d FROM dispensing_records d
WHERE d.tenant_id = $1
ORDER BY d.created_at DESC ORDER BY d.created_at DESC
`); `, [req.actorId]);
res.status(200).json({ register: rows }); res.status(200).json({ register: rows });
} catch (error) { } catch (error) {
next(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; export default router;
+8 -4
View File
@@ -9,7 +9,8 @@ const service = new CatalogueService();
router.get('/drugs', requireAuth, async (req, res, next) => { router.get('/drugs', requireAuth, async (req, res, next) => {
try { try {
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50; 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 }); res.status(200).json({ drugs });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -19,7 +20,8 @@ router.get('/drugs', requireAuth, async (req, res, next) => {
// POST /v1/drugs // POST /v1/drugs
router.post('/drugs', requireAuth, async (req, res, next) => { router.post('/drugs', requireAuth, async (req, res, next) => {
try { 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); res.status(201).json(result);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -32,6 +34,7 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => {
const q = (req.query.q as string) || ''; const q = (req.query.q as string) || '';
const form = req.query.form ? (req.query.form as string) : undefined; const form = req.query.form ? (req.query.form as string) : undefined;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 10; const limit = req.query.limit ? parseInt(req.query.limit as string) : 10;
const actorId = req.actorId as string;
if (!q || q.length < 2) { if (!q || q.length < 2) {
res.status(400).json({ res.status(400).json({
@@ -41,7 +44,7 @@ router.get('/drugs/search', requireAuth, async (req, res, next) => {
return; return;
} }
const candidates = await service.searchDrugs(q, form, limit); const candidates = await service.searchDrugs(q, form, limit, actorId);
res.status(200).json({ candidates }); res.status(200).json({ candidates });
} catch (error) { } catch (error) {
next(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) => { router.get('/products/:product_id/batches', requireAuth, async (req, res, next) => {
try { try {
const productId = req.params.product_id as string; 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 }); res.status(200).json({ batches });
} catch (error) { } catch (error) {
next(error); next(error);
+21 -18
View File
@@ -1,7 +1,7 @@
import { pool } from '../../db/pool'; import { pool } from '../../db/pool';
export class CatalogueService { 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 = ` const query = `
SELECT SELECT
p.id as product_id, p.id as product_id,
@@ -15,19 +15,20 @@ export class CatalogueService {
d.pregnancy_category, d.pregnancy_category,
p.mrp, p.mrp,
p.dpco_ceiling, 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 FROM products p
JOIN drugs d ON p.drug_id = d.id 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 LIMIT $2
`; `;
const values = [`%${q}%`, limit]; const values = [`%${q}%`, limit, actorId];
const { rows } = await pool.query(query, values); const { rows } = await pool.query(query, values);
return rows; return rows;
} }
async getAllDrugs(limit: number = 50) { async getAllDrugs(limit: number = 50, actorId: string) {
const query = ` const query = `
SELECT SELECT
p.id as product_id, p.id as product_id,
@@ -41,17 +42,19 @@ export class CatalogueService {
d.pregnancy_category, d.pregnancy_category,
p.mrp, p.mrp,
p.dpco_ceiling, 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 FROM products p
JOIN drugs d ON p.drug_id = d.id 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 ORDER BY p.brand_name ASC
LIMIT $1 LIMIT $1
`; `;
const { rows } = await pool.query(query, [limit]); const { rows } = await pool.query(query, [limit, actorId]);
return rows; return rows;
} }
async getProductBatches(productId: string) { async getProductBatches(productId: string, actorId: string) {
const query = ` const query = `
SELECT SELECT
batch_no, batch_no,
@@ -64,35 +67,35 @@ export class CatalogueService {
ELSE 'available' ELSE 'available'
END as status END as status
FROM batches 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 ORDER BY expiry ASC
`; `;
const { rows } = await pool.query(query, [productId]); const { rows } = await pool.query(query, [productId, actorId]);
return rows; return rows;
} }
async addMedicine(payload: any) { async addMedicine(payload: any, actorId: string) {
const { generic_name, schedule, brand_name, manufacturer, strength, form, mrp } = payload; const { generic_name, schedule, brand_name, manufacturer, strength, form, mrp, pack_size } = payload;
// Check if generic exists // Check if generic exists
let drugId; 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) { if (drugs.length > 0) {
drugId = drugs[0].id; drugId = drugs[0].id;
} else { } else {
drugId = 'drg_' + Date.now(); drugId = 'drg_' + Date.now();
await pool.query( await pool.query(
`INSERT INTO drugs (id, generic_name, schedule) VALUES ($1, $2, $3)`, `INSERT INTO drugs (id, generic_name, schedule, tenant_id) VALUES ($1, $2, $3, $4)`,
[drugId, generic_name, schedule || 'H'] [drugId, generic_name, schedule || 'H', actorId]
); );
} }
const productId = 'prd_' + Date.now(); const productId = 'prd_' + Date.now();
await pool.query(` await pool.query(`
INSERT INTO products (id, drug_id, 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, [productId, drugId, brand_name, manufacturer, strength, form, mrp]); `, [productId, drugId, brand_name, manufacturer, strength, form, mrp, pack_size || 1, actorId]);
return { product_id: productId, drug_id: drugId }; return { product_id: productId, drug_id: drugId };
} }
+6 -6
View File
@@ -11,7 +11,7 @@ const service = new DispensingService();
// GET /v1/orders/history // GET /v1/orders/history
router.get('/history', requireAuth, async (req, res, next) => { router.get('/history', requireAuth, async (req, res, next) => {
try { try {
const history = await service.getDispensingHistory(); const history = await service.getDispensingHistory(req.actorId as string);
res.status(200).json({ history }); res.status(200).json({ history });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -21,7 +21,7 @@ router.get('/history', requireAuth, async (req, res, next) => {
// GET /v1/orders/h1-register // GET /v1/orders/h1-register
router.get('/h1-register', requireAuth, async (req, res, next) => { router.get('/h1-register', requireAuth, async (req, res, next) => {
try { try {
const records = await service.getH1Register(); const records = await service.getH1Register(req.actorId as string);
res.status(200).json({ records }); res.status(200).json({ records });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -31,7 +31,7 @@ router.get('/h1-register', requireAuth, async (req, res, next) => {
// POST /v1/orders // POST /v1/orders
router.post('/', requireAuth, requireHeaders, async (req, res, next) => { router.post('/', requireAuth, requireHeaders, async (req, res, next) => {
try { try {
const draft = await service.createOrderDraft(req.body); const draft = await service.createOrderDraft(req.body, req.actorId as string);
res.status(201).json(draft); res.status(201).json(draft);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -59,7 +59,7 @@ router.post('/:id/precheck', requireAuth, async (req, res, next) => {
return res.status(400).json({ error: 'CONTEXT_INCOMPLETE' }); 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); res.status(200).json(precheckResult);
} catch (error) { } catch (error) {
next(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 are not provided, fetch them from the draft order
if (!items || items.length === 0) { 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) { 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; 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 // 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); res.status(201).json(result);
} catch (error: any) { } catch (error: any) {
+38 -22
View File
@@ -2,14 +2,14 @@ import { pool } from '../../db/pool';
import { webhookService } from '../webhooks/service'; import { webhookService } from '../webhooks/service';
export class DispensingService { export class DispensingService {
async createOrderDraft(payload: any) { async createOrderDraft(payload: any, tenantId: string) {
const draftId = "ord_" + Date.now() + Math.floor(Math.random() * 1000); const draftId = "ord_" + Date.now() + Math.floor(Math.random() * 1000);
const items = payload.items ? JSON.stringify(payload.items) : '[]'; const items = payload.items ? JSON.stringify(payload.items) : '[]';
await pool.query(` await pool.query(`
INSERT INTO order_drafts (id, subject_ref, prescription_ref, items) INSERT INTO order_drafts (id, tenant_id, subject_ref, prescription_ref, items)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
`, [draftId, payload.subject_ref, payload.prescription_ref || null, items]); `, [draftId, tenantId, payload.subject_ref, payload.prescription_ref || null, items]);
return { order_draft_id: draftId, status: 'draft' }; return { order_draft_id: draftId, status: 'draft' };
} }
@@ -24,7 +24,7 @@ export class DispensingService {
return { order_draft_id: draftId, updated_items: items.length }; 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 // Phase 1: Basic hard/soft gate mock implementations based on clinical context
const blocks: any[] = []; const blocks: any[] = [];
const warnings: any[] = []; const warnings: any[] = [];
@@ -47,10 +47,11 @@ export class DispensingService {
// Check expiry soft gates from DB and calculate total amount // Check expiry soft gates from DB and calculate total amount
for (const item of items) { 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) { if (prodRes.rows.length > 0) {
const prod = prodRes.rows[0]; const prod = prodRes.rows[0];
const mrp = parseFloat(prod.mrp) || 0; const mrp = parseFloat(prod.mrp) || 0;
const packSize = parseInt(prod.pack_size, 10) || 1;
const dpco = parseFloat(prod.dpco_ceiling); const dpco = parseFloat(prod.dpco_ceiling);
if (!isNaN(dpco) && mrp > dpco) { if (!isNaN(dpco) && mrp > dpco) {
blocks.push({ blocks.push({
@@ -61,14 +62,14 @@ export class DispensingService {
}); });
tier = 'elevated'; tier = 'elevated';
} }
totalAmount += mrp * item.qty; totalAmount += (mrp / packSize) * item.qty;
} }
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT batch_no, expiry SELECT batch_no, expiry
FROM batches 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 ORDER BY expiry ASC LIMIT 1
`, [item.product_id]); `, [item.product_id, tenantId]);
if (rows.length > 0) { if (rows.length > 0) {
const batch = rows[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 client = await pool.connect();
const h1Entries: string[] = []; const h1Entries: string[] = [];
let dispensingId = ''; let dispensingId = '';
@@ -124,7 +125,7 @@ export class DispensingService {
// Fetch product and drug details first // Fetch product and drug details first
const prodRes = await client.query(` 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 FROM products p
JOIN drugs d ON p.drug_id = d.id JOIN drugs d ON p.drug_id = d.id
WHERE p.id = $1 WHERE p.id = $1
@@ -136,6 +137,7 @@ export class DispensingService {
const prod = prodRes.rows[0]; const prod = prodRes.rows[0];
const mrp = parseFloat(prod.mrp) || 0; const mrp = parseFloat(prod.mrp) || 0;
const packSize = parseInt(prod.pack_size, 10) || 1;
const dpco = parseFloat(prod.dpco_ceiling); const dpco = parseFloat(prod.dpco_ceiling);
if (!isNaN(dpco) && mrp > dpco) { if (!isNaN(dpco) && mrp > dpco) {
throw { throw {
@@ -144,16 +146,16 @@ export class DispensingService {
message: `Billed price for ${prod.brand_name} exceeds DPCO ceiling of ${dpco}.` 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 // FEFO Allocation: Order by expiry ASC
const { rows: batches } = await client.query(` const { rows: batches } = await client.query(`
SELECT batch_no, qty_on_hand, expiry SELECT batch_no, qty_on_hand, expiry
FROM batches 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 ORDER BY expiry ASC
FOR UPDATE FOR UPDATE
`, [item.product_id]); `, [item.product_id, tenantId]);
let allocatedTotal = 0; let allocatedTotal = 0;
@@ -253,11 +255,11 @@ export class DispensingService {
} }
} }
async getDispensingHistory() { async getDispensingHistory(tenantId: string) {
const query = ` const query = `
SELECT SELECT
d.id as dispensing_id, 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.dispensed_by,
d.created_at, d.created_at,
d.prescription_id, d.prescription_id,
@@ -272,22 +274,36 @@ export class DispensingService {
) )
) as items ) as items
FROM dispensing_records d 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 stock_movements sm ON sm.reference_id = d.id
JOIN batches b ON b.batch_no = sm.batch_no JOIN batches b ON b.batch_no = sm.batch_no
JOIN products p ON p.id = b.product_id 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 ORDER BY d.created_at DESC
`; `;
const { rows } = await pool.query(query); const { rows } = await pool.query(query, [tenantId]);
return rows; return rows;
} }
async getH1Register() { async getH1Register(tenantId: string) {
const query = ` const query = `
SELECT * FROM h1_registers SELECT
ORDER BY created_at DESC 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; return rows;
} }
} }
+81 -3
View File
@@ -1,9 +1,87 @@
import { Router } from 'express'; import { Router } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { IdentityService } from './service';
import { requireAuth } from '../../middleware/auth';
const router = Router(); const router = Router();
const identityService = new IdentityService();
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026'; 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 // POST /v1/auth/token
router.post('/token', (req, res) => { router.post('/token', (req, res) => {
const { grant_type, client_id, client_secret } = req.body; 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) { if (client_id && validClients.includes(client_id) && client_secret) {
// Generate a secure signed token specifically for this hospital // Generate a secure signed token specifically for this hospital
const token = jwt.sign( const token = jwt.sign(
{ {
tenantId: client_id, tenantId: client_id,
scopes: ['rx:read', 'rx:write', 'dispense:human', 'catalogue:read'] scopes: ['rx:read', 'rx:write', 'dispense:human', 'catalogue:read']
}, },
JWT_SECRET, JWT_SECRET,
{ expiresIn: '8h' } { expiresIn: '8h' }
+113
View File
@@ -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);
}
}
+152
View File
@@ -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 });
}
}
}
+49
View File
@@ -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;
+58
View File
@@ -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();
}
}
}
+7 -2
View File
@@ -3,10 +3,13 @@ import { requireAuth } from '../../middleware/auth';
import { requireHeaders } from '../../middleware/headers'; import { requireHeaders } from '../../middleware/headers';
import { requireIdempotency } from '../../middleware/idempotency'; import { requireIdempotency } from '../../middleware/idempotency';
import { InventoryService } from './service'; import { InventoryService } from './service';
import supplierRouter from './supplierRouter';
const router = Router(); const router = Router();
const service = new InventoryService(); const service = new InventoryService();
router.use(supplierRouter);
// POST /v1/inventory/grn // POST /v1/inventory/grn
router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req, res, next) => { router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req, res, next) => {
try { try {
@@ -25,7 +28,8 @@ router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req,
router.get('/expiring', requireAuth, async (req, res, next) => { router.get('/expiring', requireAuth, async (req, res, next) => {
try { try {
const days = req.query.days ? parseInt(req.query.days as string) : 30; 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 }); res.status(200).json({ batches });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -36,7 +40,8 @@ router.get('/expiring', requireAuth, async (req, res, next) => {
router.get('/low-stock', requireAuth, async (req, res, next) => { router.get('/low-stock', requireAuth, async (req, res, next) => {
try { try {
const threshold = req.query.threshold ? parseInt(req.query.threshold as string) : 50; 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 }); res.status(200).json({ batches });
} catch (error) { } catch (error) {
next(error); next(error);
+18 -10
View File
@@ -54,7 +54,7 @@ export class InventoryService {
}; };
} }
async getExpiringBatches(daysThreshold: number = 30) { async getExpiringBatches(daysThreshold: number = 30, actorId: string) {
const query = ` const query = `
SELECT SELECT
b.batch_no, b.batch_no,
@@ -67,14 +67,15 @@ export class InventoryService {
JOIN products p ON b.product_id = p.id JOIN products p ON b.product_id = p.id
WHERE b.qty_on_hand > 0 WHERE b.qty_on_hand > 0
AND b.expiry <= CURRENT_DATE + $1::int AND b.expiry <= CURRENT_DATE + $1::int
AND b.tenant_id = $2
ORDER BY b.expiry ASC ORDER BY b.expiry ASC
`; `;
const { rows } = await pool.query(query, [daysThreshold]); const { rows } = await pool.query(query, [daysThreshold, actorId]);
return rows; return rows;
} }
async getLowStockBatches(qtyThreshold: number = 50) { async getLowStockBatches(qtyThreshold: number = 50, actorId: string) {
const query = ` const query = `
SELECT SELECT
b.batch_no, b.batch_no,
@@ -87,10 +88,11 @@ export class InventoryService {
JOIN products p ON b.product_id = p.id JOIN products p ON b.product_id = p.id
WHERE b.qty_on_hand > 0 WHERE b.qty_on_hand > 0
AND b.qty_on_hand <= $1::int AND b.qty_on_hand <= $1::int
AND b.tenant_id = $2
ORDER BY b.qty_on_hand ASC ORDER BY b.qty_on_hand ASC
`; `;
const { rows } = await pool.query(query, [qtyThreshold]); const { rows } = await pool.query(query, [qtyThreshold, actorId]);
return rows; 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) // Insert or Update Batch (Upsert)
await client.query(` await client.query(`
INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price) INSERT INTO batches (batch_no, tenant_id, product_id, expiry, qty_on_hand, purchase_price, supplier_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (batch_no) DO UPDATE ON CONFLICT (batch_no) DO UPDATE
SET qty_on_hand = batches.qty_on_hand + EXCLUDED.qty_on_hand, SET qty_on_hand = batches.qty_on_hand + EXCLUDED.qty_on_hand,
purchase_price = EXCLUDED.purchase_price purchase_price = EXCLUDED.purchase_price,
`, [line.batch_no, line.product_id, line.expiry, line.qty, line.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(` await client.query(`
INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id) INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id)
VALUES ($1, 'receipt', $2, $3) VALUES ($1, 'receipt', $2, $3)
`, [line.batch_no, line.qty, grnId]); `, [line.batch_no, totalLooseUnits, grnId]);
batchesCreated.push(line.batch_no); batchesCreated.push(line.batch_no);
} }
+38
View File
@@ -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;
+6 -4
View File
@@ -30,7 +30,8 @@ router.post('/', requireAuth, upload.single('image'), async (req, res, next) =>
// GET /v1/prescriptions // GET /v1/prescriptions
router.get('/', requireAuth, async (req, res, next) => { router.get('/', requireAuth, async (req, res, next) => {
try { 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 }); res.status(200).json({ prescriptions });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -40,7 +41,8 @@ router.get('/', requireAuth, async (req, res, next) => {
// GET /v1/prescriptions/:id // GET /v1/prescriptions/:id
router.get('/:id', requireAuth, async (req, res, next) => { router.get('/:id', requireAuth, async (req, res, next) => {
try { 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' }); if (!record) return res.status(404).json({ error: 'NOT_FOUND' });
res.status(200).json(record); res.status(200).json(record);
@@ -52,8 +54,8 @@ router.get('/:id', requireAuth, async (req, res, next) => {
// POST /v1/prescriptions/:id/confirm // POST /v1/prescriptions/:id/confirm
router.post('/:id/confirm', requireAuth, async (req, res, next) => { router.post('/:id/confirm', requireAuth, async (req, res, next) => {
try { try {
const actorId = req.actorId; const tenantId = req.headers['x-tenant-id'] || 'act_admin';
const result = await service.confirmPrescription(req.params.id as string, req.body, actorId!); const result = await service.confirmPrescription(req.params.id as string, req.body, String(tenantId));
res.status(200).json(result); res.status(200).json(result);
} catch (error: any) { } catch (error: any) {
+26 -23
View File
@@ -53,9 +53,9 @@ export class PrescriptionService {
await client.query('BEGIN'); await client.query('BEGIN');
await client.query(` await client.query(`
INSERT INTO prescriptions (id, image_path, image_hash, status) INSERT INTO prescriptions (id, tenant_id, image_path, image_hash, status)
VALUES ($1, $2, $3, 'processing') VALUES ($1, $2, $3, $4, 'processing')
`, [id, s3Url, hash]); `, [id, actorId, s3Url, hash]);
// Enqueue async extraction job // Enqueue async extraction job
this.enqueueExtraction(id, file); this.enqueueExtraction(id, file);
@@ -74,8 +74,8 @@ export class PrescriptionService {
async enqueueExtraction(id: string, file: Express.Multer.File) { async enqueueExtraction(id: string, file: Express.Multer.File) {
setTimeout(async () => { setTimeout(async () => {
try { try {
const { GoogleGenAI } = require('@google/genai'); const OpenAI = require('openai');
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const prompt = `You are an AI assistant for a pharmacy. const prompt = `You are an AI assistant for a pharmacy.
Extract the information from this handwritten or printed medical prescription. Extract the information from this handwritten or printed medical prescription.
@@ -94,20 +94,23 @@ export class PrescriptionService {
] ]
}`; }`;
const response = await ai.models.generateContent({ const base64Image = file.buffer.toString('base64');
model: 'gemini-3.6-flash', const dataUri = `data:${file.mimetype};base64,${base64Image}`;
contents: [
prompt, const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ {
inlineData: { role: 'user',
data: file.buffer.toString("base64"), content: [
mimeType: file.mimetype { 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 // Clean up markdown if the AI includes it by mistake
const cleanJsonText = rawText.replace(/```json/g, '').replace(/```/g, '').trim(); const cleanJsonText = rawText.replace(/```json/g, '').replace(/```/g, '').trim();
const extractedData = JSON.parse(cleanJsonText); const extractedData = JSON.parse(cleanJsonText);
@@ -123,25 +126,25 @@ export class PrescriptionService {
console.error("Extraction job failed for", id, err); console.error("Extraction job failed for", id, err);
// Fallback to error status // Fallback to error status
try { 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) {} } catch(e) {}
} }
}, 100); // slight delay to allow response to return to client first }, 100); // slight delay to allow response to return to client first
} }
async getPrescription(id: string) { async getPrescription(id: string, actorId: string) {
const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE id = $1`, [id]); const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE id = $1 AND tenant_id = $2`, [id, actorId]);
return rows[0]; return rows[0];
} }
async getAllPrescriptions() { async getAllPrescriptions(actorId: string) {
const { rows } = await pool.query(`SELECT * FROM prescriptions ORDER BY created_at DESC`); const { rows } = await pool.query(`SELECT * FROM prescriptions WHERE tenant_id = $1 ORDER BY created_at DESC`, [actorId]);
return rows; return rows;
} }
async confirmPrescription(id: string, confirmedData: any, actorId: string) { async confirmPrescription(id: string, confirmedData: any, actorId: string) {
// Human Gate 1: Validate that unconfirmed fields are explicitly reviewed // 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]; const record = rows[0];
if (!record) throw { status: 404, error: 'NOT_FOUND' }; if (!record) throw { status: 404, error: 'NOT_FOUND' };
@@ -167,8 +170,8 @@ export class PrescriptionService {
await pool.query(` await pool.query(`
UPDATE prescriptions UPDATE prescriptions
SET status = 'confirmed', confirmed_data = $1 SET status = 'confirmed', confirmed_data = $1
WHERE id = $2 WHERE id = $2 AND tenant_id = $3
`, [confirmedData, id]); `, [confirmedData, id, actorId]);
return { id, status: 'confirmed' }; return { id, status: 'confirmed' };
} }