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

+ +
+
+ + + +
+
+ +
+
+
+ + + + + + + + +
Select a table from the sidebar to view records.
+
+ +
+
+
+ + + + + + +`); +}); + +export default router;