Initial commit

This commit is contained in:
2026-07-29 11:38:27 +05:30
commit a01a4c5d82
45 changed files with 6728 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import { Router } from 'express';
import { AIService } from './service';
const router = Router();
const aiService = new AIService();
router.post('/voice', 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);
res.json(result);
} catch (error) {
next(error);
}
});
export default router;
+214
View File
@@ -0,0 +1,214 @@
import { GoogleGenAI } from '@google/genai';
import { pool } from '../../db/pool';
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
export class AIService {
async processVoiceCommand(transcript: 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.
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 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 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}"`;
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.",
},
{
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"]
}
},
{
name: "add_medicine",
description: "Add a new medicine to the product catalog.",
parameters: {
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)." }
},
required: ["brand_name", "mrp"]
}
},
{
name: "add_stock",
description: "Add stock (a new batch) for an existing medicine.",
parameters: {
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." }
},
required: ["product_name", "qty"]
}
},
{
name: "navigate_to",
description: "Navigate the user to a specific page.",
parameters: {
type: "OBJECT",
properties: {
target: {
type: "STRING",
description: "The page to navigate to. Allowed values: 'dashboard', 'pharmacy', 'inventory', 'prescriptions'"
}
},
required: ["target"]
}
}
]
};
try {
const response = await ai.models.generateContent({
model: 'gemini-3.6-flash',
contents: [prompt],
config: {
tools: [aiTools as any],
}
});
const functionCalls = response.functionCalls;
if (functionCalls && functionCalls.length > 0) {
const call = functionCalls[0];
let toolData = null;
let actionCommand = 'NONE';
if (call.name === 'get_low_stock') {
const { rows } = await pool.query('SELECT count(*) as count FROM batches WHERE qty_on_hand <= 50');
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`);
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'`);
toolData = { batches_expiring_in_30_days: rows[0].count };
actionCommand = 'VIEW_EXPIRING_STOCK';
} else if (call.name === 'search_medicine') {
const query = (call.args as any)?.query || '';
const { rows } = await pool.query(
`SELECT p.brand_name, COALESCE(SUM(b.qty_on_hand), 0) as total_stock
FROM products p
LEFT JOIN batches b ON p.id = b.product_id
WHERE p.brand_name ILIKE $1
GROUP BY p.brand_name`,
[`%${query}%`]
);
if (rows.length > 0) {
toolData = { search_results: rows };
} else {
toolData = { message: `No medicine found matching '${query}'.` };
}
actionCommand = 'NAVIGATE_INVENTORY';
} 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]
);
toolData = { success: true, message: `Added ${args.brand_name} successfully.` };
actionCommand = 'NAVIGATE_CATALOGUE'; // Assume there's a catalogue or inventory view
} 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]
);
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.` };
}
} else if (call.name === 'navigate_to') {
const target = (call.args as any)?.target;
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 === 'prescriptions') actionCommand = 'NAVIGATE_PRESCRIPTIONS';
}
// Step 2: Ask Gemini 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)}
CRITICAL INSTRUCTIONS:
1. Detect the language of the User's 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.`;
const finalResponse = await ai.models.generateContent({
model: 'gemini-3.6-flash',
contents: [finalPrompt]
});
return {
replyText: finalResponse.text || "Action completed.",
actionCommand
};
}
return {
replyText: response.text || "I didn't quite catch that.",
actionCommand: 'NONE'
};
} catch (e) {
console.error("AI Error:", e);
return {
replyText: "Sorry, I am having trouble connecting to the AI system.",
actionCommand: 'NONE'
};
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth';
import { pool } from '../../db/pool';
const router = Router();
// GET /v1/audit/ai-calls
router.get('/ai-calls', requireAuth, async (req, res, next) => {
try {
const { from, to, limit } = req.query;
let query = `
SELECT id, call_id, actor_user_id, task_type, details, created_at
FROM audit_logs
WHERE 1=1
`;
const values: any[] = [];
if (from) {
values.push(from);
query += ` AND created_at >= $${values.length}`;
}
if (to) {
values.push(to);
query += ` AND created_at <= $${values.length}`;
}
values.push(limit ? parseInt(limit as string) : 50);
query += ` ORDER BY created_at DESC LIMIT $${values.length}`;
const { rows } = await pool.query(query, values);
// 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]);
res.status(200).json({
ai_calls: rows,
stock_movements: stockMovements
});
} catch (error) {
next(error);
}
});
// GET /v1/audit/overrides
router.get('/overrides', requireAuth, async (req, res, next) => {
try {
const { rows } = await pool.query(`
SELECT
dispensing_id,
code,
reason,
actor_user_id,
occurred_at
FROM overrides_log
ORDER BY occurred_at DESC
LIMIT 100
`);
// Grouping for summary
const by_actor: Record<string, number> = {};
const by_code: Record<string, number> = {};
for (const row of rows) {
by_actor[row.actor_user_id] = (by_actor[row.actor_user_id] || 0) + 1;
by_code[row.code] = (by_code[row.code] || 0) + 1;
}
res.status(200).json({
overrides: rows,
summary: {
by_actor,
by_code
}
});
} catch (error) {
next(error);
}
});
// GET /v1/audit/h1-register
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
`);
res.status(200).json({ register: rows });
} catch (error) {
next(error);
}
});
// GET /v1/audit/sales-register
router.get('/sales-register', requireAuth, async (req, res, next) => {
try {
const { rows } = await pool.query(`
SELECT
d.id as invoice_no,
d.created_at as invoice_date,
d.subject_ref as patient,
d.dispensed_by,
d.total_amount,
d.payment_method
FROM dispensing_records d
ORDER BY d.created_at DESC
`);
res.status(200).json({ register: rows });
} catch (error) {
next(error);
}
});
export default router;
+62
View File
@@ -0,0 +1,62 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth';
import { CatalogueService } from './service';
const router = Router();
const service = new CatalogueService();
// GET /v1/drugs
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);
res.status(200).json({ drugs });
} catch (error) {
next(error);
}
});
// POST /v1/drugs
router.post('/drugs', requireAuth, async (req, res, next) => {
try {
const result = await service.addMedicine(req.body);
res.status(201).json(result);
} catch (error) {
next(error);
}
});
// GET /v1/drugs/search
router.get('/drugs/search', requireAuth, async (req, res, next) => {
try {
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;
if (!q || q.length < 2) {
res.status(400).json({
error: 'BAD_REQUEST',
message: 'Search query "q" is required and must be at least 2 characters.',
});
return;
}
const candidates = await service.searchDrugs(q, form, limit);
res.status(200).json({ candidates });
} catch (error) {
next(error);
}
});
// GET /v1/products/:product_id/batches
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);
res.status(200).json({ batches });
} catch (error) {
next(error);
}
});
export default router;
+99
View File
@@ -0,0 +1,99 @@
import { pool } from '../../db/pool';
export class CatalogueService {
async searchDrugs(q: string, form?: string, limit: number = 10) {
const query = `
SELECT
p.id as product_id,
p.brand_name,
d.generic_name,
p.strength,
p.form,
p.manufacturer,
d.schedule,
d.is_nti,
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
FROM products p
JOIN drugs d ON p.drug_id = d.id
WHERE p.brand_name ILIKE $1 OR d.generic_name ILIKE $1
LIMIT $2
`;
const values = [`%${q}%`, limit];
const { rows } = await pool.query(query, values);
return rows;
}
async getAllDrugs(limit: number = 50) {
const query = `
SELECT
p.id as product_id,
p.brand_name,
d.generic_name,
p.strength,
p.form,
p.manufacturer,
d.schedule,
d.is_nti,
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
FROM products p
JOIN drugs d ON p.drug_id = d.id
ORDER BY p.brand_name ASC
LIMIT $1
`;
const { rows } = await pool.query(query, [limit]);
return rows;
}
async getProductBatches(productId: string) {
const query = `
SELECT
batch_no,
expiry,
qty_on_hand,
EXTRACT(DAY FROM (expiry - CURRENT_DATE)) as days_to_expiry,
CASE
WHEN expiry <= CURRENT_DATE THEN 'expired'
WHEN expiry <= CURRENT_DATE + interval '30 days' THEN 'near_expiry'
ELSE 'available'
END as status
FROM batches
WHERE product_id = $1 AND qty_on_hand > 0
ORDER BY expiry ASC
`;
const { rows } = await pool.query(query, [productId]);
return rows;
}
async addMedicine(payload: any) {
const { generic_name, schedule, brand_name, manufacturer, strength, form, mrp } = payload;
// Check if generic exists
let drugId;
const { rows: drugs } = await pool.query(`SELECT id FROM drugs WHERE generic_name ILIKE $1`, [generic_name]);
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']
);
}
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]);
return { product_id: productId, drug_id: drugId };
}
}
+109
View File
@@ -0,0 +1,109 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth';
import { requireHeaders } from '../../middleware/headers';
import { requireIdempotency } from '../../middleware/idempotency';
import { DispensingService } from './service';
import { pool } from '../../db/pool';
const router = Router();
const service = new DispensingService();
// GET /v1/orders/history
router.get('/history', requireAuth, async (req, res, next) => {
try {
const history = await service.getDispensingHistory();
res.status(200).json({ history });
} catch (error) {
next(error);
}
});
// GET /v1/orders/h1-register
router.get('/h1-register', requireAuth, async (req, res, next) => {
try {
const records = await service.getH1Register();
res.status(200).json({ records });
} catch (error) {
next(error);
}
});
// POST /v1/orders
router.post('/', requireAuth, requireHeaders, async (req, res, next) => {
try {
const draft = await service.createOrderDraft(req.body);
res.status(201).json(draft);
} catch (error) {
next(error);
}
});
// PATCH /v1/orders/:id/items
router.patch('/:id/items', requireAuth, requireHeaders, async (req, res, next) => {
try {
const draft = await service.updateOrderItems(req.params.id as string, req.body.items);
res.status(200).json(draft);
} catch (error) {
next(error);
}
});
// POST /v1/orders/:id/precheck
router.post('/:id/precheck', requireAuth, async (req, res, next) => {
try {
const { clinical_context } = req.body;
// We would fetch the order items from the DB here, but for now we accept them from body or mock
const items = req.body.items || [];
if (!clinical_context) {
return res.status(400).json({ error: 'CONTEXT_INCOMPLETE' });
}
const precheckResult = await service.runGates(clinical_context, items);
res.status(200).json(precheckResult);
} catch (error) {
next(error);
}
});
// POST /v1/orders/:id/dispense
router.post('/:id/dispense', requireAuth, async (req, res, next) => {
try {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'MISSING_IDEMPOTENCY_KEY' });
}
const actorId = req.actorId;
if (!actorId) {
return res.status(403).json({ error: 'MISSING_ACTOR_ID' });
}
const prescriptionId = req.params.id;
let { subject_ref, prescriber_name, items, payment_method, overrides } = req.body;
// 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]);
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;
}
}
if (!items || items.length === 0) {
return res.status(400).json({ error: 'NO_ITEMS_PROVIDED' });
}
// 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 || []);
res.status(201).json(result);
} catch (error: any) {
if (error.status === 409) {
return res.status(409).json(error);
}
next(error);
}
});
export default router;
+293
View File
@@ -0,0 +1,293 @@
import { pool } from '../../db/pool';
import { webhookService } from '../webhooks/service';
export class DispensingService {
async createOrderDraft(payload: any) {
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]);
return { order_draft_id: draftId, status: 'draft' };
}
async updateOrderItems(draftId: string, items: any[]) {
await pool.query(`
UPDATE order_drafts
SET items = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`, [JSON.stringify(items), draftId]);
return { order_draft_id: draftId, updated_items: items.length };
}
async runGates(clinicalContext: any, items: { product_id: string, qty: number }[]) {
// Phase 1: Basic hard/soft gate mock implementations based on clinical context
const blocks: any[] = [];
const warnings: any[] = [];
let tier = 'routine';
let totalAmount = 0;
if (clinicalContext?.pregnancy?.status === 'pregnant') {
blocks.push({
code: 'PREGNANCY_CATEGORY_D_X',
severity: 'hard',
detail: 'Fetotoxic in pregnancy.',
overridable: false
});
tier = 'elevated';
}
if (clinicalContext?.allergies?.length > 0) {
tier = 'elevated';
}
// 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]);
if (prodRes.rows.length > 0) {
const prod = prodRes.rows[0];
const mrp = parseFloat(prod.mrp) || 0;
const dpco = parseFloat(prod.dpco_ceiling);
if (!isNaN(dpco) && mrp > dpco) {
blocks.push({
code: 'DPCO_CEILING_EXCEEDED',
severity: 'hard',
detail: `Billed price (MRP ${mrp}) exceeds DPCO ceiling (${dpco}).`,
overridable: false
});
tier = 'elevated';
}
totalAmount += mrp * item.qty;
}
const { rows } = await pool.query(`
SELECT batch_no, expiry
FROM batches
WHERE product_id = $1 AND qty_on_hand > 0
ORDER BY expiry ASC LIMIT 1
`, [item.product_id]);
if (rows.length > 0) {
const batch = rows[0];
const daysToExpiry = (new Date(batch.expiry).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
if (daysToExpiry <= 30 && daysToExpiry > 0) {
warnings.push({
code: 'NEAR_EXPIRY',
batch_no: batch.batch_no,
expiry: batch.expiry,
overridable: true
});
tier = tier === 'routine' ? 'standard' : tier;
} else if (daysToExpiry <= 0) {
blocks.push({
code: 'EXPIRED_BATCH',
severity: 'hard',
detail: 'Batch past expiry',
overridable: false
});
tier = 'elevated';
}
}
}
return {
blocks,
interactions: [],
warnings,
confirmation_tier: tier,
total_amount: totalAmount,
context_as_of: clinicalContext?.as_of || new Date().toISOString()
};
}
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 = '';
try {
// API spec requires SERIALIZABLE transaction for absolute safety against concurrent dispensings
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
dispensingId = "dsp_" + Date.now() + Math.floor(Math.random() * 1000);
let totalAmount = 0;
const pendingQueries: { query: string, params: any[] }[] = [];
const allocations = [];
for (const item of items) {
let remainingQtyToAllocate = item.qty;
// 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
FROM products p
JOIN drugs d ON p.drug_id = d.id
WHERE p.id = $1
`, [item.product_id]);
if (prodRes.rows.length === 0) {
throw { status: 400, error: 'INVALID_PRODUCT', message: `Product ${item.product_id} not found.` };
}
const prod = prodRes.rows[0];
const mrp = parseFloat(prod.mrp) || 0;
const dpco = parseFloat(prod.dpco_ceiling);
if (!isNaN(dpco) && mrp > dpco) {
throw {
status: 400,
error: 'DPCO_CEILING_EXCEEDED',
message: `Billed price for ${prod.brand_name} exceeds DPCO ceiling of ${dpco}.`
};
}
totalAmount += mrp * 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
ORDER BY expiry ASC
FOR UPDATE
`, [item.product_id]);
let allocatedTotal = 0;
for (const batch of batches) {
if (remainingQtyToAllocate <= 0) break;
const takeQty = Math.min(batch.qty_on_hand, remainingQtyToAllocate);
remainingQtyToAllocate -= takeQty;
allocatedTotal += takeQty;
// Decrement stock in DB (DB CHECK constraint ensures qty_on_hand >= 0)
await client.query(`
UPDATE batches
SET qty_on_hand = qty_on_hand - $1
WHERE product_id = $2 AND batch_no = $3
`, [takeQty, item.product_id, batch.batch_no]);
// Write Stock Movement Audit (matches schema)
pendingQueries.push({
query: `
INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id)
VALUES ($1, 'dispense', $2, $3)
`,
params: [batch.batch_no, -takeQty, dispensingId]
});
// Write H1 Register Entry if applicable
if (prod.schedule === 'H1' || prod.schedule === 'H') {
const h1Id = "h1_" + Date.now() + Math.floor(Math.random() * 1000);
pendingQueries.push({
query: `
INSERT INTO h1_registers (id, dispensing_id, patient_name, prescriber_name, drug_name, batch_no, qty)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
params: [h1Id, dispensingId, subjectRef, prescriberName, `${prod.brand_name} (${prod.generic_name})`, batch.batch_no, takeQty]
});
h1Entries.push(h1Id);
}
allocations.push({
product_id: item.product_id,
batch_no: batch.batch_no,
qty: takeQty,
expiry: batch.expiry
});
}
if (allocatedTotal < item.qty) {
throw {
status: 409,
error: 'INSUFFICIENT_STOCK',
details: { product_id: item.product_id, requested: item.qty, available: allocatedTotal }
};
}
}
// Record the dispensing transaction with billing details now that totalAmount is calculated
await client.query(`
INSERT INTO dispensing_records (id, tenant_id, subject_ref, dispensed_by, prescription_id, total_amount, payment_method)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [dispensingId, tenantId || 'unknown', subjectRef, actorId, prescriptionId || null, totalAmount, paymentMethod]);
// Execute accumulated stock movements and H1 registers now that dispensing_records exists
for (const pq of pendingQueries) {
await client.query(pq.query, pq.params);
}
// Log Overrides
for (const override of overrides) {
await client.query(`
INSERT INTO overrides_log (dispensing_id, code, reason, actor_user_id)
VALUES ($1, $2, $3, $4)
`, [dispensingId, override.code, override.reason, actorId]);
}
await client.query('COMMIT');
// Dispatch webhook
await webhookService.dispatchEvent('dispensing.completed', `evt_${dispensingId}`, {
dispensing_id: dispensingId,
subject_ref: subjectRef,
occurred_at: new Date().toISOString()
});
return {
dispensing_id: dispensingId,
batch_allocations: allocations,
h1_register_entry_ids: h1Entries,
invoice_id: "inv_" + Math.floor(Math.random() * 10000)
};
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
async getDispensingHistory() {
const query = `
SELECT
d.id as dispensing_id,
d.subject_ref as patient_name,
d.dispensed_by,
d.created_at,
d.prescription_id,
d.total_amount,
d.payment_method,
json_agg(
json_build_object(
'batch_no', sm.batch_no,
'qty', ABS(sm.qty_delta),
'brand_name', p.brand_name,
'strength', p.strength
)
) as items
FROM dispensing_records d
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
ORDER BY d.created_at DESC
`;
const { rows } = await pool.query(query);
return rows;
}
async getH1Register() {
const query = `
SELECT * FROM h1_registers
ORDER BY created_at DESC
`;
const { rows } = await pool.query(query);
return rows;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { Router } from 'express';
import jwt from 'jsonwebtoken';
const router = Router();
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026';
// POST /v1/auth/token
router.post('/token', (req, res) => {
const { grant_type, client_id, client_secret } = req.body;
if (grant_type !== 'client_credentials') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
// Recognize our specific external tenants
const validClients = ['jayam_web_ai', 'telegaruda'];
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']
},
JWT_SECRET,
{ expiresIn: '8h' }
);
return res.status(200).json({
access_token: token,
token_type: 'Bearer',
expires_in: 28800,
scopes: ['rx:read', 'rx:write', 'dispense:human', 'catalogue:read']
});
}
return res.status(401).json({ error: 'invalid_client' });
});
export default router;
+66
View File
@@ -0,0 +1,66 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth';
import { requireHeaders } from '../../middleware/headers';
import { requireIdempotency } from '../../middleware/idempotency';
import { InventoryService } from './service';
const router = Router();
const service = new InventoryService();
// POST /v1/inventory/grn
router.post('/grn', requireAuth, requireHeaders, requireIdempotency, async (req, res, next) => {
try {
const actorId = req.actorId as string;
const result = await service.receiveGoods(req.body, actorId);
res.status(201).json(result);
} catch (error: any) {
if (error.status === 422) {
return res.status(422).json(error);
}
next(error);
}
});
// GET /v1/inventory/expiring
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);
res.status(200).json({ batches });
} catch (error) {
next(error);
}
});
// GET /v1/inventory/low-stock
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);
res.status(200).json({ batches });
} catch (error) {
next(error);
}
});
// POST /v1/inventory/adjustments
router.post('/adjustments', requireAuth, requireHeaders, async (req, res, next) => {
try {
const result = await service.adjustStock(req.body, req.actorId as string);
res.status(201).json(result);
} catch (error) {
next(error);
}
});
// GET /v1/inventory/recall/:batch_no
router.get('/recall/:batch_no', requireAuth, async (req, res, next) => {
try {
const result = await service.recallTrace(req.params.batch_no as string);
res.status(200).json(result);
} catch (error) {
next(error);
}
});
export default router;
+149
View File
@@ -0,0 +1,149 @@
import { pool } from '../../db/pool';
export class InventoryService {
async adjustStock(payload: any, actorId: string) {
const { batch_no, qty_delta, reason_code, notes } = payload;
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
await client.query(`
UPDATE batches
SET qty_on_hand = qty_on_hand + $1
WHERE batch_no = $2
`, [qty_delta, batch_no]);
await client.query(`
INSERT INTO inventory_adjustments (batch_no, qty_delta, reason_code, notes, approved_by)
VALUES ($1, $2, $3, $4, $5)
`, [batch_no, qty_delta, reason_code, notes, actorId]);
await client.query(`
INSERT INTO stock_movements (batch_no, movement_type, qty_delta, reference_id)
VALUES ($1, 'adjustment', $2, 'adj_' || extract(epoch from now()))
`, [batch_no, qty_delta]);
await client.query('COMMIT');
return { status: 'success', batch_no, adjusted: qty_delta };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
async recallTrace(batch_no: string) {
const { rows } = await pool.query(`
SELECT d.id as dispensing_id, d.tenant_id, d.subject_ref, sm.qty_delta as qty, d.created_at as dispensed_at
FROM stock_movements sm
JOIN dispensing_records d ON sm.reference_id = d.id
WHERE sm.batch_no = $1 AND sm.movement_type = 'dispense'
`, [batch_no]);
return {
batch_no,
dispensings: rows.map(r => ({
dispensing_id: r.dispensing_id,
tenant_id: r.tenant_id,
subject_ref: r.subject_ref,
qty: Math.abs(r.qty),
dispensed_at: r.dispensed_at
})),
total_affected: rows.length
};
}
async getExpiringBatches(daysThreshold: number = 30) {
const query = `
SELECT
b.batch_no,
b.product_id,
p.brand_name,
b.qty_on_hand,
b.expiry,
(b.expiry - CURRENT_DATE) as days_to_expiry
FROM batches b
JOIN products p ON b.product_id = p.id
WHERE b.qty_on_hand > 0
AND b.expiry <= CURRENT_DATE + $1::int
ORDER BY b.expiry ASC
`;
const { rows } = await pool.query(query, [daysThreshold]);
return rows;
}
async getLowStockBatches(qtyThreshold: number = 50) {
const query = `
SELECT
b.batch_no,
b.product_id,
p.brand_name,
b.qty_on_hand,
b.expiry,
(b.expiry - CURRENT_DATE) as days_to_expiry
FROM batches b
JOIN products p ON b.product_id = p.id
WHERE b.qty_on_hand > 0
AND b.qty_on_hand <= $1::int
ORDER BY b.qty_on_hand ASC
`;
const { rows } = await pool.query(query, [qtyThreshold]);
return rows;
}
async receiveGoods(payload: any, actorId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const batchesCreated = [];
const grnId = "grn_" + Date.now() + Math.floor(Math.random() * 1000);
for (const line of payload.lines) {
// Validate expiry
if (new Date(line.expiry) <= new Date()) {
throw {
status: 422,
error: 'INVALID_EXPIRY',
details: { batch_no: line.batch_no, expiry: line.expiry, reason: 'expiry_in_past' }
};
}
// 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)
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]);
// Log Receipt Movement
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]);
batchesCreated.push(line.batch_no);
}
await client.query('COMMIT');
return {
grn_id: grnId,
batches_created: batchesCreated,
three_way_match: { status: "matched", discrepancies: [] }
};
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'path';
import { requireAuth } from '../../middleware/auth';
import { PrescriptionService } from './service';
const router = Router();
const service = new PrescriptionService();
// Setup Multer for memory storage (for uploading to S3)
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
// POST /v1/rx
router.post('/', requireAuth, upload.single('image'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'MISSING_IMAGE', message: 'No image file uploaded.' });
}
const actorId = req.actorId;
const result = await service.uploadPrescription(req.file, actorId!);
res.status(202).json(result); // 202 Accepted (processing asynchronously)
} catch (error) {
next(error);
}
});
// GET /v1/prescriptions
router.get('/', requireAuth, async (req, res, next) => {
try {
const prescriptions = await service.getAllPrescriptions();
res.status(200).json({ prescriptions });
} catch (error) {
next(error);
}
});
// GET /v1/prescriptions/:id
router.get('/:id', requireAuth, async (req, res, next) => {
try {
const record = await service.getPrescription(req.params.id as string);
if (!record) return res.status(404).json({ error: 'NOT_FOUND' });
res.status(200).json(record);
} catch (error) {
next(error);
}
});
// 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!);
res.status(200).json(result);
} catch (error: any) {
if (error.status === 422 || error.status === 400 || error.status === 404) {
return res.status(error.status).json(error);
}
next(error);
}
});
export default router;
+175
View File
@@ -0,0 +1,175 @@
import { pool } from '../../db/pool';
import crypto from 'crypto';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
let s3Client: S3Client | null = null;
if (process.env.S3_KEY && process.env.S3_SECRET) {
s3Client = new S3Client({
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_KEY,
secretAccessKey: process.env.S3_SECRET
}
});
}
export class PrescriptionService {
// Hash file buffer to ensure provable unalterability
hashBuffer(buffer: Buffer): string {
const hashSum = crypto.createHash('sha256');
hashSum.update(buffer);
return hashSum.digest('hex');
}
async uploadPrescription(file: Express.Multer.File, actorId: string) {
const hash = this.hashBuffer(file.buffer);
const id = "rx_" + Date.now() + Math.floor(Math.random() * 1000);
// Construct S3 key
const fileName = `${Date.now()}-${file.originalname}`;
let s3Url = '';
if (process.env.S3_KEY && process.env.S3_SECRET) {
const s3Key = `${process.env.S3_BASE_PATH || ''}${fileName}`;
s3Url = `${process.env.S3_PUBLIC_BASE_PATH || ''}${s3Key}`;
// Upload to S3
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: s3Key,
Body: file.buffer,
ContentType: file.mimetype
});
await s3Client!.send(command);
} else {
console.warn("S3 Credentials not found, mocking S3 upload.");
s3Url = `https://mock-s3-bucket.s3.amazonaws.com/${fileName}`;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`
INSERT INTO prescriptions (id, image_path, image_hash, status)
VALUES ($1, $2, $3, 'processing')
`, [id, s3Url, hash]);
// Enqueue async extraction job
this.enqueueExtraction(id, file);
await client.query('COMMIT');
return { id, status: 'processing', hash };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// Real-time Background Vision Model Extraction using Gemini
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 prompt = `You are an AI assistant for a pharmacy.
Extract the information from this handwritten or printed medical prescription.
Return ONLY a JSON response in the following format, with NO markdown formatting, NO backticks, NO markdown JSON block, just the raw JSON object string:
{
"prescriber": { "name": "string", "registration_number": "string" },
"patient": { "name": "string", "age": number },
"image_quality": "good" | "poor",
"items": [
{
"verbatim": "string (the exact written text)",
"parsed": { "brand": "string", "form": "tablet/capsule/syrup/etc", "dose": "string (e.g. 1-0-1)", "qty": number },
"confidence": number (0.0 to 1.0),
"confusable_with": ["string"]
}
]
}`;
const response = await ai.models.generateContent({
model: 'gemini-3.6-flash',
contents: [
prompt,
{
inlineData: {
data: file.buffer.toString("base64"),
mimeType: file.mimetype
}
}
],
});
const rawText = response.text || "{}";
// Clean up markdown if the AI includes it by mistake
const cleanJsonText = rawText.replace(/```json/g, '').replace(/```/g, '').trim();
const extractedData = JSON.parse(cleanJsonText);
await pool.query(`
UPDATE prescriptions
SET status = 'pending_review', extracted_data = $1
WHERE id = $2
`, [extractedData, id]);
console.log(`[AI Scan Complete] Prescription ${id} processed successfully.`);
} catch (err) {
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]);
} 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]);
return rows[0];
}
async getAllPrescriptions() {
const { rows } = await pool.query(`SELECT * FROM prescriptions ORDER BY created_at DESC`);
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 record = rows[0];
if (!record) throw { status: 404, error: 'NOT_FOUND' };
if (record.status !== 'pending_review') throw { status: 400, error: 'INVALID_STATUS' };
const originalData = record.extracted_data;
// Check if any low confidence items were not explicitly reviewed
for (let i = 0; i < originalData.items.length; i++) {
const origItem = originalData.items[i];
if (origItem.confidence < 0.85 || origItem.confusable_with.length > 0) {
// The pharmacist must supply a confirmed item for this index
if (!confirmedData.items || !confirmedData.items[i] || !confirmedData.items[i].explicitly_confirmed) {
throw {
status: 422,
error: 'MISSING_CONFIRMATION',
message: `Item at index ${i} requires explicit positive selection due to low confidence or confusable candidates.`
};
}
}
}
await pool.query(`
UPDATE prescriptions
SET status = 'confirmed', confirmed_data = $1
WHERE id = $2
`, [confirmedData, id]);
return { id, status: 'confirmed' };
}
}
+47
View File
@@ -0,0 +1,47 @@
import crypto from 'crypto';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'test_secret_for_webhooks';
// For demo purposes, we log it or hit a dummy URL.
const WEBHOOK_URL = process.env.WEBHOOK_URL || 'http://localhost:8081/webhooks/pharmacy';
export class WebhookService {
async dispatchEvent(event: string, idempotencyKey: string, payload: any) {
const timestamp = Math.floor(Date.now() / 1000);
const bodyString = JSON.stringify(payload);
const dataToSign = `${timestamp}.${bodyString}`;
const signature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(dataToSign)
.digest('hex');
const signatureHeader = `t=${timestamp},v1=${signature}`;
console.log(`[WEBHOOK] Dispatching event: ${event} (Idempotency: ${idempotencyKey})`);
try {
// Assuming Node 20+, native fetch is available
const response = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Pharmacy-Event': event,
'X-Pharmacy-Signature': signatureHeader,
'Idempotency-Key': idempotencyKey,
},
body: bodyString,
});
if (!response.ok) {
console.error(`[WEBHOOK] Dispatch failed with status: ${response.status}`);
} else {
console.log(`[WEBHOOK] Successfully dispatched event: ${event}`);
}
} catch (error: any) {
console.error(`[WEBHOOK] Failed to dispatch webhook: ${error.message}`);
// In a real system, this would queue for retry with exponential backoff.
}
}
}
export const webhookService = new WebhookService();