Initial commit
This commit is contained in:
@@ -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;
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user