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
+15
View File
@@ -0,0 +1,15 @@
DATABASE_URL=postgres://postgres:csh%402018@localhost:5432/ai_pharmacy
PORT=8080
JWT_SECRET=super_secret_pharmacy_key_2026
# S3 CONFIGURATION
S3_KEY=AKIA3GRU7BB33WCCTN4V
S3_SECRET=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
S3_REGION=ap-south-1
S3_BUCKET=a2zschoolfolio
S3_BASE_PATH=temp/
S3_PUBLIC_BASE_PATH=https://a2zschoolfolio.s3.ap-south-1.amazonaws.com/
GEMINI_API_KEY=AQ.Ab8RN6KkBPhZo9kmwV-mFBtriT3LF8vPWenXNfA2jSyVWWwMMQ
+11
View File
@@ -0,0 +1,11 @@
node_modules/
.env
.env.*
!.env.example
dist/
build/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
coverage/
+305
View File
@@ -0,0 +1,305 @@
{
"info": {
"name": "AI Pharmacy Core API",
"description": "Complete collection for Phase 1 APIs.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "base_url",
"value": "http://localhost:8080",
"type": "string"
},
{
"key": "access_token",
"value": "",
"type": "string"
}
],
"item": [
{
"name": "Health Check",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/health",
"host": [
"{{base_url}}"
],
"path": [
"health"
]
}
},
"response": []
},
{
"name": "1. Authentication",
"item": [
{
"name": "Get Access Token",
"event": [
{
"listen": "test",
"script": {
"exec": [
"var jsonData = pm.response.json();",
"if (jsonData.access_token) {",
" pm.collectionVariables.set(\"access_token\", jsonData.access_token);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"grant_type\": \"client_credentials\",\n \"client_id\": \"jayam_web_ai\",\n \"client_secret\": \"my_secret_key\"\n}"
},
"url": {
"raw": "{{base_url}}/v1/auth/token",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"auth",
"token"
]
}
}
}
]
},
{
"name": "2. Catalogue",
"item": [
{
"name": "Search Drugs",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [
{
"key": "X-Actor-Id",
"value": "usr_ph_1234"
}
],
"url": {
"raw": "{{base_url}}/v1/drugs/search?q=Losartan",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"drugs",
"search"
],
"query": [
{
"key": "q",
"value": "Losartan"
}
]
}
}
}
]
},
{
"name": "3. Inventory",
"item": [
{
"name": "Receive Goods (GRN)",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Actor-Id",
"value": "usr_ph_admin"
},
{
"key": "idempotency-key",
"value": "idemp-{{$timestamp}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"lines\": [\n {\n \"batch_no\": \"BAT-{{$timestamp}}\",\n \"product_id\": \"prd_4410\",\n \"expiry\": \"2028-12-31\",\n \"qty\": 500,\n \"purchase_price\": \"50.00\"\n }\n ]\n}"
},
"url": {
"raw": "{{base_url}}/v1/inventory/grn",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"inventory",
"grn"
]
}
}
},
{
"name": "Get Expiring Batches",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [
{
"key": "X-Actor-Id",
"value": "usr_ph_1234"
}
],
"url": {
"raw": "{{base_url}}/v1/inventory/expiring",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"inventory",
"expiring"
]
}
}
}
]
},
{
"name": "4. Dispensing",
"item": [
{
"name": "Dispense Order",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Actor-Id",
"value": "usr_ph_1234"
},
{
"key": "idempotency-key",
"value": "dispense-{{$timestamp}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"patient_id\": \"pat_101\",\n \"items\": [\n {\n \"product_id\": \"prd_4410\",\n \"qty\": 30\n }\n ]\n}"
},
"url": {
"raw": "{{base_url}}/v1/orders/dispense",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"orders",
"dispense"
]
}
}
}
]
},
{
"name": "5. Audit",
"item": [
{
"name": "Get Audit Logs",
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{access_token}}",
"type": "string"
}
]
},
"method": "GET",
"header": [
{
"key": "X-Actor-Id",
"value": "usr_ph_admin"
}
],
"url": {
"raw": "{{base_url}}/v1/audit/logs?limit=10",
"host": [
"{{base_url}}"
],
"path": [
"v1",
"audit",
"logs"
],
"query": [
{
"key": "limit",
"value": "10"
}
]
}
}
}
]
}
]
}
+1
View File
@@ -0,0 +1 @@
# AI_Pharmacy_Api
+17
View File
@@ -0,0 +1,17 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy'
});
async function run() {
try {
await pool.query('ALTER TABLE dispensing_records ADD COLUMN IF NOT EXISTS prescription_id VARCHAR(50)');
console.log('Successfully added prescription_id column.');
} catch (err) {
console.error('Error:', err);
} finally {
pool.end();
}
}
run();
+1
View File
@@ -0,0 +1 @@
dummy image content
+11
View File
@@ -0,0 +1,11 @@
import { GoogleGenAI } from '@google/genai';
import * as dotenv from 'dotenv';
dotenv.config();
const ai = new GoogleGenAI();
async function run() {
const models = await ai.models.list();
for await (const m of models) {
console.log(m.name);
}
}
run();
+3951
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "ai_pharmacy_ai",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@aws-sdk/client-s3": "^3.1094.0",
"@google/genai": "^2.13.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"pg": "^8.22.0",
"redis": "^6.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0",
"@types/node": "^26.1.1",
"@types/pg": "^8.20.0",
"ts-node-dev": "^2.0.0",
"tsx": "^4.23.1",
"typescript": "^7.0.2"
}
}
+167
View File
@@ -0,0 +1,167 @@
import { pool } from './pool';
async function initDB() {
const client = await pool.connect();
try {
await client.query('BEGIN');
console.log('Creating tables...');
// 1. Catalogue Module
await client.query(`
CREATE TABLE IF NOT EXISTS drugs (
id VARCHAR(50) PRIMARY KEY,
generic_name VARCHAR(255) NOT NULL,
atc_code VARCHAR(50),
schedule VARCHAR(20),
is_nti BOOLEAN DEFAULT false,
pregnancy_category VARCHAR(10)
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS products (
id VARCHAR(50) PRIMARY KEY,
drug_id VARCHAR(50) REFERENCES drugs(id),
brand_name VARCHAR(255) NOT NULL,
manufacturer VARCHAR(255),
strength VARCHAR(50),
form VARCHAR(50),
mrp DECIMAL(10, 2),
dpco_ceiling DECIMAL(10, 2)
);
`);
// 2. Inventory Module
await client.query(`
CREATE TABLE IF NOT EXISTS batches (
batch_no VARCHAR(100) PRIMARY KEY,
product_id VARCHAR(50) REFERENCES products(id),
expiry DATE NOT NULL,
qty_on_hand INTEGER NOT NULL CHECK (qty_on_hand >= 0),
purchase_price DECIMAL(10, 2),
status VARCHAR(50) DEFAULT 'available'
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS stock_movements (
id SERIAL PRIMARY KEY,
batch_no VARCHAR(100) REFERENCES batches(batch_no),
movement_type VARCHAR(50) NOT NULL, -- 'receipt', 'dispense', 'adjustment'
qty_delta INTEGER NOT NULL,
reference_id VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// 3. Dispensing & Audit
await client.query(`
CREATE TABLE IF NOT EXISTS dispensing_records (
id VARCHAR(50) PRIMARY KEY,
subject_ref VARCHAR(100) NOT NULL,
tenant_id VARCHAR(50) NOT NULL,
dispensed_by VARCHAR(100) NOT NULL,
prescription_id VARCHAR(50),
total_amount DECIMAL(10, 2) DEFAULT 0,
payment_method VARCHAR(50) DEFAULT 'cash',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS overrides_log (
id SERIAL PRIMARY KEY,
dispensing_id VARCHAR(50) REFERENCES dispensing_records(id),
code VARCHAR(50) NOT NULL,
reason TEXT NOT NULL,
actor_user_id VARCHAR(100) NOT NULL,
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS h1_registers (
id VARCHAR(50) PRIMARY KEY,
dispensing_id VARCHAR(50) REFERENCES dispensing_records(id),
patient_name VARCHAR(255) NOT NULL,
prescriber_name VARCHAR(255),
drug_name VARCHAR(255) NOT NULL,
batch_no VARCHAR(100),
qty INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS order_drafts (
id VARCHAR(50) PRIMARY KEY,
subject_ref VARCHAR(100),
prescription_ref VARCHAR(50),
items JSONB,
status VARCHAR(20) DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
call_id VARCHAR(100),
actor_user_id VARCHAR(100),
task_type VARCHAR(50),
details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// 4. Phase 2: Prescriptions
await client.query(`
CREATE TABLE IF NOT EXISTS prescriptions (
id VARCHAR(50) PRIMARY KEY,
image_path TEXT NOT NULL,
image_hash VARCHAR(64) NOT NULL,
status VARCHAR(20) DEFAULT 'processing',
extracted_data JSONB,
confirmed_data JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('Seeding initial data...');
// Seed Dummy Data for UI
await client.query(`
INSERT INTO drugs (id, generic_name, schedule) VALUES
('drg_001', 'Losartan Potassium', 'H'),
('drg_002', 'Sertraline', 'H')
ON CONFLICT (id) DO NOTHING;
`);
await client.query(`
INSERT INTO products (id, drug_id, brand_name, form, mrp) VALUES
('prd_4410', 'drg_001', 'Losar 50', 'tablet', 78.50),
('prd_5510', 'drg_002', 'Sertra 50', 'tablet', 110.00)
ON CONFLICT (id) DO NOTHING;
`);
// Add batches. Let's add a near-expiry batch so the UI alert triggers
await client.query(`
INSERT INTO batches (batch_no, product_id, expiry, qty_on_hand, purchase_price) VALUES
('B4421', 'prd_4410', CURRENT_DATE + INTERVAL '15 days', 240, 50.00),
('B4630', 'prd_5510', CURRENT_DATE + INTERVAL '200 days', 500, 80.00)
ON CONFLICT (batch_no) DO NOTHING;
`);
await client.query('COMMIT');
console.log('Database initialization and seeding completed successfully!');
} catch (error) {
await client.query('ROLLBACK');
console.error('Error initializing database:', error);
} finally {
client.release();
pool.end();
}
}
initDB();
+18
View File
@@ -0,0 +1,18 @@
import { Pool } from 'pg';
import dotenv from 'dotenv';
dotenv.config();
export const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/ai_pharmacy',
});
// Test connection
pool.on('connect', () => {
console.log('Connected to the PostgreSQL database.');
});
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
process.exit(-1);
});
+59
View File
@@ -0,0 +1,59 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { errorHandler } from './middleware/error';
// Import routers
import identityRouter from './modules/identity/router';
import catalogueRouter from './modules/catalogue/router';
import inventoryRouter from './modules/inventory/router';
import dispensingRouter from './modules/dispensing/router';
import auditRouter from './modules/audit/router';
import prescriptionRouter from './modules/prescription/router';
import aiRouter from './modules/ai/router';
const app = express();
const PORT = process.env.PORT || 8080;
app.use(helmet());
app.use(cors());
app.use(express.json());
// Detailed Network Logger Middleware
app.use((req, res, next) => {
const start = Date.now();
console.log(`\n======================================================`);
console.log(`[NETWORK REQUEST] ${req.method} ${req.originalUrl}`);
console.log(`[HEADERS]`, JSON.stringify(req.headers, null, 2));
if (req.body && Object.keys(req.body).length > 0) {
console.log(`[BODY]`, JSON.stringify(req.body, null, 2));
}
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`[NETWORK RESPONSE] ${req.method} ${req.originalUrl} - Status: ${res.statusCode} (${duration}ms)`);
console.log(`======================================================\n`);
});
next();
});
// API Routes
app.use('/v1/auth', identityRouter);
app.use('/v1', catalogueRouter);
app.use('/v1/inventory', inventoryRouter);
app.use('/v1/orders', dispensingRouter);
app.use('/v1/audit', auditRouter);
app.use('/v1/rx', prescriptionRouter);
app.use('/v1/ai', aiRouter);
// Health check
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Global Error Handler
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`Pharmacy Core API listening on port ${PORT}`);
});
+58
View File
@@ -0,0 +1,58 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_pharmacy_key_2026';
declare global {
namespace Express {
interface Request {
actorId?: string;
tenantId?: string;
}
}
}
export const requireAuth: RequestHandler = (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers.authorization;
const actorId = req.headers['x-actor-id'] as string;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Missing or invalid Authorization header.',
details: {},
retryable: false
});
return;
}
if (!actorId) {
res.status(403).json({
error: 'FORBIDDEN',
message: 'Missing X-Actor-Id header.',
details: {},
retryable: false
});
return;
}
const token = authHeader.split(' ')[1];
try {
// Cryptographically verify the token using our secret key
const payload = jwt.verify(token, JWT_SECRET) as any;
// Automatically attach the tenant (e.g., jayam_web_ai) to the request context
req.tenantId = payload.tenantId;
req.actorId = actorId;
next();
} catch (error) {
res.status(401).json({
error: 'UNAUTHORIZED',
message: 'Invalid or expired token.',
details: {},
retryable: false
});
}
};
+18
View File
@@ -0,0 +1,18 @@
import { Request, Response, NextFunction } from 'express';
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
console.error(err);
const status = err.status || 500;
const errorName = err.name || 'INTERNAL_SERVER_ERROR';
const message = err.message || 'An unexpected error occurred.';
const correlationId = req.headers['x-correlation-id'] || 'unknown';
res.status(status).json({
error: errorName,
message: message,
details: err.details || {},
correlation_id: correlationId,
retryable: err.retryable || false
});
};
+16
View File
@@ -0,0 +1,16 @@
import { Request, Response, NextFunction } from 'express';
export const requireHeaders = (req: Request, res: Response, next: NextFunction) => {
const actorId = req.headers['x-actor-id'];
if (!actorId) {
return next({
status: 400,
name: 'MISSING_HEADER',
message: 'X-Actor-Id header is required on all mutating endpoints.',
retryable: false
});
}
next();
};
+47
View File
@@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from 'express';
import { pool } from '../db/pool';
export const requireIdempotency = async (req: Request, res: Response, next: NextFunction) => {
const key = req.headers['idempotency-key'];
if (!key || typeof key !== 'string') {
return next({
status: 400,
name: 'MISSING_IDEMPOTENCY_KEY',
message: 'Idempotency-Key header is mandatory for this endpoint.',
retryable: false
});
}
try {
// Clean up old keys (older than 24 hours) - in production this would be a cron job
await pool.query(`DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'`);
const { rows } = await pool.query(`SELECT * FROM idempotency_keys WHERE key = $1`, [key]);
if (rows.length > 0) {
// Key exists, return previous response
const record = rows[0];
return res.status(record.response_status).json(record.response_body);
}
// Key doesn't exist, wrap res.json to capture response
const originalJson = res.json;
res.json = function(body: any) {
// Store the response asynchronously
pool.query(`
INSERT INTO idempotency_keys (key, method, path, response_body, response_status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (key) DO NOTHING
`, [key, req.method, req.originalUrl, body, res.statusCode]).catch(err => {
console.error('Failed to save idempotency key:', err);
});
return originalJson.call(this, body);
};
next();
} catch (error) {
next(error);
}
};
+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();
+41
View File
@@ -0,0 +1,41 @@
async function testAPI() {
console.log("1. Requesting Token for jayam_web_ai...");
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: "jayam_web_ai",
client_secret: "my_secret_key"
})
});
const tokenData = await tokenResponse.json();
if (!tokenData.access_token) {
console.error("Failed to get token!", tokenData);
return;
}
console.log("✅ Successfully received real JWT token!");
console.log("Token:", tokenData.access_token.substring(0, 30) + "...\n");
console.log("2. Using Token to search for drugs (Catalogue API)...");
const searchResponse = await fetch("http://localhost:8080/v1/drugs/search?q=Losartan", {
method: "GET",
headers: {
"Authorization": `Bearer ${tokenData.access_token}`,
"X-Actor-Id": "usr_ph_1234"
}
});
const searchData = await searchResponse.json();
console.log(`✅ API Response Status: ${searchResponse.status}`);
console.log("Drug Search Results:");
console.log(JSON.stringify(searchData, null, 2));
}
testAPI();
+12
View File
@@ -0,0 +1,12 @@
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy' });
pool.query("SELECT id, status, extracted_data FROM prescriptions ORDER BY created_at DESC LIMIT 2")
.then(res => {
console.log(res.rows);
pool.end();
})
.catch(err => {
console.error(err);
pool.end();
});
+16
View File
@@ -0,0 +1,16 @@
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy' });
pool.query("SELECT * FROM dispensing_records ORDER BY created_at DESC LIMIT 5")
.then(res => {
console.log('dispensing_records:', res.rows);
return pool.query("SELECT * FROM stock_movements ORDER BY created_at DESC LIMIT 5");
})
.then(res => {
console.log('stock_movements:', res.rows);
pool.end();
})
.catch(err => {
console.error(err);
pool.end();
});
+12
View File
@@ -0,0 +1,12 @@
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgres://postgres:csh%402018@localhost:5432/ai_pharmacy' });
pool.query("SELECT id, brand_name FROM products")
.then(res => {
console.log('products:', res.rows);
pool.end();
})
.catch(err => {
console.error(err);
pool.end();
});
+57
View File
@@ -0,0 +1,57 @@
async function testDispense() {
console.log("1. Logging in as jayam_web_ai...");
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: "jayam_web_ai",
client_secret: "my_secret_key"
})
});
const token = (await tokenResponse.json()).access_token;
console.log("\n2. Running Precheck for a pregnant patient...");
const precheckResponse = await fetch("http://localhost:8080/v1/orders/ord_123/precheck", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_ph_1234",
"Content-Type": "application/json"
},
body: JSON.stringify({
clinical_context: {
pregnancy: { status: "pregnant" }
},
items: [
{ product_id: "prd_4410", qty: 30 } // Losar 50
]
})
});
console.log("Precheck Results:", JSON.stringify(await precheckResponse.json(), null, 2));
console.log("\n3. Executing Dispense transaction (requesting 30 units of Losar 50)...");
const dispenseResponse = await fetch("http://localhost:8080/v1/orders/ord_123/dispense", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_ph_1234",
"Idempotency-Key": "random-uuid-here-" + Date.now(),
"Content-Type": "application/json"
},
body: JSON.stringify({
subject_ref: "pat_123",
items: [
{ product_id: "prd_4410", qty: 30 }
]
})
});
console.log("Dispense Status:", dispenseResponse.status);
console.log("Dispense Results:", JSON.stringify(await dispenseResponse.json(), null, 2));
}
testDispense();
+19
View File
@@ -0,0 +1,19 @@
require('dotenv').config();
const { GoogleGenAI } = require('@google/genai');
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
ai.models.generateContent({
model: 'gemini-3.6-flash',
contents: [
'Hello',
{
inlineData: {
data: Buffer.from('hello').toString("base64"),
mimeType: 'text/plain'
}
}
],
}).then(res => {
console.log('Success:', res.text);
}).catch(err => {
console.error('Error:', err);
});
+60
View File
@@ -0,0 +1,60 @@
import { pool } from './src/db/pool';
async function testGRN() {
console.log("1. Requesting Token...");
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: "jayam_web_ai",
client_secret: "my_secret_key"
})
});
const tokenData = await tokenResponse.json();
const token = tokenData.access_token;
if (!token) {
console.error("No token received", tokenData);
return;
}
const batchNo = "BAT-" + Date.now();
console.log(`2. Sending GRN for Losar 50 (prd_4410), Batch: ${batchNo}...`);
const grnResponse = await fetch("http://localhost:8080/v1/inventory/grn", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_ph_admin",
"idempotency-key": "idemp-" + Date.now()
},
body: JSON.stringify({
lines: [
{
batch_no: batchNo,
product_id: "prd_4410",
expiry: "2027-12-31",
qty: 500,
purchase_price: "50.00"
}
]
})
});
const grnData = await grnResponse.json();
console.log("GRN Response Status:", grnResponse.status);
console.log("GRN Response Data:", grnData);
console.log("\n3. Verifying Database...");
const batchRes = await pool.query("SELECT * FROM batches WHERE batch_no = $1", [batchNo]);
console.log("Batch in DB:", batchRes.rows);
const stockMoveRes = await pool.query("SELECT * FROM stock_movements WHERE batch_no = $1", [batchNo]);
console.log("Stock Movements in DB:", stockMoveRes.rows);
process.exit(0);
}
testGRN();
+29
View File
@@ -0,0 +1,29 @@
const http = require('http');
const options = {
hostname: 'localhost',
port: 8080,
path: '/v1/orders/history',
method: 'GET',
headers: {
'Authorization': 'Bearer test',
'X-Actor-Id': 'act_admin'
}
};
const req = http.request(options, res => {
console.log(`statusCode: ${res.statusCode}`);
let data = '';
res.on('data', d => {
data += d;
});
res.on('end', () => {
console.log(data);
});
});
req.on('error', error => {
console.error(error);
});
req.end();
+8
View File
@@ -0,0 +1,8 @@
require('dotenv').config();
const { GoogleGenAI } = require('@google/genai');
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
ai.models.list().then(res => {
console.log(res);
}).catch(err => {
console.error('Error:', err);
});
+116
View File
@@ -0,0 +1,116 @@
import fs from 'fs';
import path from 'path';
async function testPhase2() {
console.log("1. Creating dummy image file...");
const dummyFilePath = path.join(__dirname, 'dummy_rx.jpg');
fs.writeFileSync(dummyFilePath, 'dummy image content');
console.log("2. Requesting Token...");
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: "jayam_web_ai",
client_secret: "my_secret_key"
})
});
const tokenData = await tokenResponse.json();
const token = tokenData.access_token;
if (!token) {
console.error("No token received", tokenData);
return;
}
console.log("3. Uploading Prescription (POST /v1/prescriptions/upload)...");
// Use fetch with FormData to upload file
const formData = new FormData();
const fileBlob = new Blob([fs.readFileSync(dummyFilePath)], { type: 'image/jpeg' });
formData.append('image', fileBlob, 'dummy_rx.jpg');
const uploadResponse = await fetch("http://localhost:8080/v1/prescriptions/upload", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_ph_admin"
},
body: formData as any
});
const uploadData = await uploadResponse.json();
console.log("Upload Status:", uploadResponse.status);
console.log("Upload Data:", uploadData);
const rxId = uploadData.id;
if (uploadResponse.status !== 202) {
console.error("Upload failed");
return;
}
console.log("\n4. Polling for Extraction Results (waiting 6 seconds)...");
await new Promise(resolve => setTimeout(resolve, 6000));
const pollResponse = await fetch(`http://localhost:8080/v1/prescriptions/${rxId}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_ph_admin"
}
});
const pollData = await pollResponse.json();
console.log("Poll Status:", pollResponse.status);
console.log("Image Path (S3 URL):", pollData.image_path);
console.log("Extracted Data:", JSON.stringify(pollData.extracted_data, null, 2));
console.log("\n5. Testing Confirmation - Human Gate 1");
console.log("Attempting to confirm without explicit selection on low-confidence item...");
const badConfirmPayload = {
items: [
{ explicitly_confirmed: true },
{ explicitly_confirmed: false } // Fails Gate 1
]
};
const badConfirmResponse = await fetch(`http://localhost:8080/v1/prescriptions/${rxId}/confirm`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_ph_admin"
},
body: JSON.stringify(badConfirmPayload)
});
console.log("Bad Confirm Status (Expect 422):", badConfirmResponse.status);
console.log("Bad Confirm Response:", await badConfirmResponse.json());
console.log("\nAttempting to confirm properly...");
const goodConfirmPayload = {
items: [
{ explicitly_confirmed: true },
{ explicitly_confirmed: true, parsed: { brand: "Hydralazine 25mg" } }
]
};
const goodConfirmResponse = await fetch(`http://localhost:8080/v1/prescriptions/${rxId}/confirm`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_ph_admin"
},
body: JSON.stringify(goodConfirmPayload)
});
console.log("Good Confirm Status (Expect 200):", goodConfirmResponse.status);
console.log("Good Confirm Response:", await goodConfirmResponse.json());
process.exit(0);
}
testPhase2();
+61
View File
@@ -0,0 +1,61 @@
async function testRemaining() {
console.log("1. Logging in as jayam_web_ai...");
const tokenResponse = await fetch("http://localhost:8080/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: "jayam_web_ai",
client_secret: "my_secret_key"
})
});
const token = (await tokenResponse.json()).access_token;
console.log("\n2. Executing Goods Receipt Note (GRN)...");
const grnResponse = await fetch("http://localhost:8080/v1/inventory/grn", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_mgr_1234",
"Idempotency-Key": "grn-uuid-" + Date.now(),
"Content-Type": "application/json"
},
body: JSON.stringify({
supplier_id: "sup_120",
invoice_no: "INV/2026/9999",
invoice_date: "2026-07-24",
po_ref: "po_8891",
lines: [
{
product_id: "prd_4410",
batch_no: "B9999-" + Date.now(),
expiry: "2028-12-31",
qty: 500,
purchase_price: 54.20,
mrp: 78.50
}
]
})
});
console.log("GRN Status:", grnResponse.status);
console.log("GRN Results:", JSON.stringify(await grnResponse.json(), null, 2));
console.log("\n3. Fetching Audit Logs...");
const auditResponse = await fetch("http://localhost:8080/v1/audit/logs", {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
"X-Actor-Id": "usr_mgr_1234"
}
});
console.log("Audit Status:", auditResponse.status);
const auditData = await auditResponse.json();
console.log("Found Stock Movements:", auditData.stock_movements.length);
console.log("Latest Stock Movement:", JSON.stringify(auditData.stock_movements[0], null, 2));
}
testRemaining();
+11
View File
@@ -0,0 +1,11 @@
import { AIService } from './src/modules/ai/service';
import * as dotenv from 'dotenv';
dotenv.config();
const s = new AIService();
s.processVoiceCommand('low stock').then(res => {
console.log("SUCCESS:", res);
process.exit(0);
}).catch(err => {
console.error("FAIL:", err);
process.exit(1);
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2022",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
+46
View File
@@ -0,0 +1,46 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function updateDB() {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key VARCHAR(100) PRIMARY KEY,
method VARCHAR(10),
path VARCHAR(255),
response_body JSONB,
response_status INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS order_drafts (
id VARCHAR(50) PRIMARY KEY,
subject_ref VARCHAR(100) NOT NULL,
prescription_ref VARCHAR(50),
items JSONB DEFAULT '[]'::jsonb,
status VARCHAR(20) DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS inventory_adjustments (
id SERIAL PRIMARY KEY,
batch_no VARCHAR(100) REFERENCES batches(batch_no),
qty_delta INTEGER NOT NULL,
reason_code VARCHAR(50) NOT NULL,
notes TEXT,
approved_by VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('Core API schema updated successfully');
} catch (err) {
console.error('Error:', err);
} finally {
pool.end();
}
}
updateDB();
+31
View File
@@ -0,0 +1,31 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function updateDB() {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS h1_registers (
id VARCHAR(50) PRIMARY KEY,
dispensing_id VARCHAR(50) REFERENCES dispensing_records(id),
patient_name VARCHAR(255) NOT NULL,
prescriber_name VARCHAR(255),
drug_name VARCHAR(255) NOT NULL,
batch_no VARCHAR(100) NOT NULL,
qty INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE products ADD COLUMN IF NOT EXISTS hsn_code VARCHAR(20);
UPDATE products SET dpco_ceiling = 80.00 WHERE id = 'prd_4410';
UPDATE products SET dpco_ceiling = 120.00 WHERE id = 'prd_5510';
`);
console.log('Schema updated successfully');
} catch (err) {
console.error('Error:', err);
} finally {
pool.end();
}
}
updateDB();
+1
View File
@@ -0,0 +1 @@
dummy image content