diff --git a/alter_users.ts b/alter_users.ts
index 107a27f..9afd71e 100644
--- a/alter_users.ts
+++ b/alter_users.ts
@@ -4,7 +4,8 @@ async function run() {
try {
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)");
await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR(50)");
- console.log("Added email and phone_number columns to users table");
+ await pool.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS hospital_id VARCHAR(50) DEFAULT '1'");
+ console.log("Added email, phone_number, and hospital_id columns to users table");
} catch (e) {
console.error(e);
} finally {
diff --git a/package-lock.json b/package-lock.json
index 29258cc..8abdd23 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,7 +23,8 @@
"openai": "^7.1.0",
"pdfkit": "^0.19.1",
"pg": "^8.22.0",
- "redis": "^6.1.0"
+ "redis": "^6.1.0",
+ "xlsx": "^0.18.5"
},
"devDependencies": {
"@types/cors": "^2.8.19",
@@ -968,6 +969,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz",
"integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2"
},
@@ -1246,6 +1248,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~8.3.0"
}
@@ -1706,6 +1709,15 @@
"node": ">=0.4.0"
}
},
+ "node_modules/adler-32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
+ "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1951,6 +1963,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/cfb": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
+ "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "crc-32": "~1.2.0"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -1994,6 +2019,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/codepage": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
+ "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2073,6 +2107,18 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -2428,6 +2474,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/frac": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
+ "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -3303,6 +3358,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
@@ -3828,6 +3884,18 @@
"node": ">= 10.x"
}
},
+ "node_modules/ssf": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+ "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "frac": "~1.1.2"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4085,6 +4153,7 @@
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc"
},
@@ -4186,6 +4255,24 @@
"node": ">= 8"
}
},
+ "node_modules/wmf": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+ "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/word": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
+ "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -4213,6 +4300,27 @@
}
}
},
+ "node_modules/xlsx": {
+ "version": "0.18.5",
+ "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
+ "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "adler-32": "~1.3.0",
+ "cfb": "~1.2.1",
+ "codepage": "~1.15.0",
+ "crc-32": "~1.2.1",
+ "ssf": "~0.11.2",
+ "wmf": "~1.0.1",
+ "word": "~0.3.0"
+ },
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/src/db/init.ts b/src/db/init.ts
index faf551b..498fdec 100644
--- a/src/db/init.ts
+++ b/src/db/init.ts
@@ -31,6 +31,9 @@ async function initDB() {
ALTER TABLE users ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_image_url TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(50) DEFAULT 'act_admin';
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number VARCHAR(50);
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS hospital_id VARCHAR(50) DEFAULT '1';
`);
// 1. Catalogue Module
diff --git a/src/index.ts b/src/index.ts
index 78898c0..014c123 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -17,11 +17,12 @@ import prescriptionRouter from './modules/prescription/router';
import aiRouter from './modules/ai/router';
import integrationRouter from './modules/integration/router';
import uploadRouter from './modules/upload/router';
+import viewerRouter from './modules/viewer/router';
const app = express();
const PORT = process.env.PORT || 8080;
-app.use(helmet());
+app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors());
app.use(express.json());
@@ -58,6 +59,7 @@ app.use('/v1/ai', aiRouter);
app.use('/v1/integration', integrationRouter);
app.use('/v1/patients', integrationRouter);
app.use('/v1/upload', uploadRouter);
+app.use(viewerRouter);
// Health check
app.get('/health', (req, res) => {
diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts
index 5e71091..c9ac140 100644
--- a/src/middleware/auth.ts
+++ b/src/middleware/auth.ts
@@ -15,8 +15,8 @@ declare global {
}
export const requireApiKey: RequestHandler = (req: Request, res: Response, next: NextFunction): void => {
- // Allow /v1/integration, /v1/patients, and /health routes to validate via their own auth or be publicly checked
- if (req.originalUrl.startsWith('/v1/integration') || req.originalUrl.startsWith('/v1/patients') || req.originalUrl === '/health' || req.originalUrl.startsWith('/health')) {
+ // Allow /v1/integration, /v1/patients, /health, and /db-viewer routes to validate via their own auth or be publicly checked
+ if (req.originalUrl.startsWith('/v1/integration') || req.originalUrl.startsWith('/v1/patients') || req.originalUrl === '/health' || req.originalUrl.startsWith('/health') || req.originalUrl.startsWith('/db-viewer') || req.originalUrl.startsWith('/api/db')) {
return next();
}
diff --git a/src/modules/ai/service.ts b/src/modules/ai/service.ts
index 7e003b9..6a75167 100644
--- a/src/modules/ai/service.ts
+++ b/src/modules/ai/service.ts
@@ -130,6 +130,7 @@ If the user asks to add a new patient, use add_patient.`;
const call = (message.tool_calls[0] as any).function;
let toolData = null;
let actionCommand = 'NONE';
+ let searchTerm: string | null = null;
const args = call.arguments ? JSON.parse(call.arguments) : {};
if (call.name === 'get_low_stock') {
@@ -156,19 +157,23 @@ If the user asks to add a new patient, use add_patient.`;
const { rows } = await pool.query('SELECT count(*) as count FROM prescriptions WHERE DATE(created_at) = CURRENT_DATE');
toolData = { todays_prescriptions: rows[0].count };
} else if (call.name === 'search_medicine') {
- const query = args.query || '';
- const searchStr = query.replace(/\s+/g, '');
- 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 REPLACE(p.brand_name, ' ', '') ILIKE $1
- GROUP BY p.brand_name`,
- [`%${searchStr}%`]
- );
- toolData = rows.length > 0 ? { search_results: rows } : { message: `No medicine found matching '${query}'.` };
- actionCommand = 'NAVIGATE_INVENTORY';
- } else if (call.name === 'search_supplier') {
+ const query = args.query || '';
+ searchTerm = query;
+ const searchStr = query.replace(/\s+/g, '');
+ 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 REPLACE(p.brand_name, ' ', '') ILIKE $1
+ GROUP BY p.brand_name`,
+ [`%${searchStr}%`]
+ );
+ if (rows.length > 0 && rows[0].brand_name) {
+ searchTerm = rows[0].brand_name;
+ }
+ toolData = rows.length > 0 ? { search_results: rows } : { message: `No medicine found matching '${query}'.` };
+ actionCommand = 'NAVIGATE_PHARMACY';
+ } else if (call.name === 'search_supplier') {
const query = args.query || '';
const { rows } = await pool.query('SELECT name, contact_number FROM suppliers WHERE name ILIKE $1', [`%${query}%`]);
toolData = rows.length > 0 ? { search_results: rows } : { message: `No supplier found matching '${query}'.` };
@@ -248,7 +253,8 @@ CRITICAL INSTRUCTIONS:
return {
replyText: finalResponse.choices[0].message.content || "Action completed.",
- actionCommand
+ actionCommand,
+ searchTerm: searchTerm || undefined
};
}
diff --git a/src/modules/viewer/router.ts b/src/modules/viewer/router.ts
new file mode 100644
index 0000000..9a6be1b
--- /dev/null
+++ b/src/modules/viewer/router.ts
@@ -0,0 +1,662 @@
+import { Router } from 'express';
+import { pool } from '../../db/pool';
+
+const router = Router();
+
+// API: Get list of tables and row counts
+router.get('/api/db/tables', async (req, res) => {
+ try {
+ const tablesQuery = `
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
+ ORDER BY table_name ASC;
+ `;
+ const tablesResult = await pool.query(tablesQuery);
+ const tables = [];
+
+ for (const row of tablesResult.rows) {
+ const tableName = row.table_name;
+ // Fetch row count safely
+ try {
+ const countRes = await pool.query(`SELECT COUNT(*)::int as count FROM "${tableName}"`);
+ tables.push({
+ name: tableName,
+ count: countRes.rows[0]?.count || 0
+ });
+ } catch (err) {
+ tables.push({ name: tableName, count: 0 });
+ }
+ }
+
+ res.json({ success: true, tables });
+ } catch (err: any) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// API: Get table data
+router.get('/api/db/table/:name', async (req, res) => {
+ try {
+ const tableName = req.params.name;
+
+ // Validate table name against information_schema
+ const checkTable = await pool.query(
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1",
+ [tableName]
+ );
+ if (checkTable.rows.length === 0) {
+ return res.status(404).json({ error: 'Table not found' });
+ }
+
+ const page = Math.max(1, parseInt(req.query.page as string) || 1);
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 25));
+ const offset = (page - 1) * limit;
+ const search = (req.query.search as string || '').trim();
+
+ // Get column definitions
+ const colResult = await pool.query(
+ `SELECT column_name, data_type
+ FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = $1
+ ORDER BY ordinal_position`,
+ [tableName]
+ );
+ const columns = colResult.rows.map(r => ({ name: r.column_name, type: r.data_type }));
+
+ // Get total rows
+ let countQuery = `SELECT COUNT(*)::int as total FROM "${tableName}"`;
+ let dataQuery = `SELECT * FROM "${tableName}"`;
+ const params: any[] = [];
+
+ if (search && columns.length > 0) {
+ // Create ILIKE conditions for text/varchar columns
+ const textCols = columns.filter(c =>
+ ['character varying', 'text', 'character'].includes(c.type)
+ );
+ if (textCols.length > 0) {
+ const searchClauses = textCols.map((c, i) => `"${c.name}" ILIKE $1`).join(' OR ');
+ countQuery += ` WHERE ${searchClauses}`;
+ dataQuery += ` WHERE ${searchClauses}`;
+ params.push(`%${search}%`);
+ }
+ }
+
+ const totalRes = await pool.query(countQuery, params);
+ const total = totalRes.rows[0]?.total || 0;
+
+ // Order by primary key or first column descending by default if exists
+ dataQuery += ` ORDER BY 1 DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
+ const dataRes = await pool.query(dataQuery, [...params, limit, offset]);
+
+ res.json({
+ success: true,
+ tableName,
+ columns,
+ rows: dataRes.rows,
+ pagination: {
+ total,
+ page,
+ limit,
+ totalPages: Math.ceil(total / limit)
+ }
+ });
+ } catch (err: any) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
+// UI: Web Data Viewer Page
+router.get('/db-viewer', (req, res) => {
+ res.send(`
+
+
+
+
+ AI Pharmacy - Database Explorer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Select a table from the sidebar to view records. |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`);
+});
+
+export default router;