add core backend modules including database initialization, authentication, AI service, and database viewer
This commit is contained in:
+2
-1
@@ -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 {
|
||||
|
||||
Generated
+109
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+20
-14
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Pharmacy - Database Explorer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #090d16;
|
||||
--card-bg: #111827;
|
||||
--card-border: #1f293d;
|
||||
--accent: #06b6d4;
|
||||
--accent-glow: rgba(6, 182, 212, 0.25);
|
||||
--accent-hover: #0891b2;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #94a3b8;
|
||||
--highlight: #38bdf8;
|
||||
--danger: #f43f5e;
|
||||
--success: #10b981;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: #0d121f;
|
||||
border-right: 1px solid var(--card-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
background: linear-gradient(135deg, #06b6d4, #3b82f6);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
box-shadow: 0 0 15px var(--accent-glow);
|
||||
}
|
||||
|
||||
.brand h2 { font-size: 1.1rem; font-weight: 600; letter-spacing: -0.3px; }
|
||||
.brand span { font-size: 0.75rem; color: var(--accent); display: block; font-weight: 500; }
|
||||
|
||||
.search-box {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
background: #151d30;
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-box input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-glow);
|
||||
}
|
||||
|
||||
.table-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.table-item:hover {
|
||||
background: #172138;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.table-item.active {
|
||||
background: linear-gradient(90deg, rgba(6, 182, 212, 0.15), rgba(59, 130, 246, 0.1));
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #1f293d;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.table-item.active .badge {
|
||||
background: var(--accent);
|
||||
color: #041019;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Main Area */
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
background: #0d121f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.table-title h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.table-title .meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #172138;
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text);
|
||||
padding: 7px 14px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #202c4b;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), #0284c7);
|
||||
color: #041019;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* Table Content Container */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #151d30;
|
||||
padding: 12px 16px;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #1a2235;
|
||||
color: #e2e8f0;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: #141c2e;
|
||||
}
|
||||
|
||||
.cell-null {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cell-json {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
background: #090e1a;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
color: #38bdf8;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Modal for JSON */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal.show { display: flex; }
|
||||
.modal-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
width: 650px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #38bdf8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--card-border);
|
||||
background: #0d121f;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.page-btns { display: flex; gap: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">AI</div>
|
||||
<div>
|
||||
<h2>Pharmacy DB</h2>
|
||||
<span>PostgreSQL 18</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="tableFilter" placeholder="Filter tables..." oninput="filterTables()">
|
||||
</div>
|
||||
<ul class="table-list" id="tableList">
|
||||
<li style="padding: 15px; color: #64748b; text-align: center;">Loading tables...</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="table-title">
|
||||
<h1 id="currentTableTitle">Select a table</h1>
|
||||
<span class="meta" id="tableMeta"></span>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<input type="text" id="dataSearch" placeholder="Search rows..." style="background:#151d30; border:1px solid var(--card-border); color:var(--text); padding:7px 12px; border-radius:8px; font-size:0.85rem; outline:none;" onkeypress="handleSearch(event)">
|
||||
<button class="btn" onclick="fetchTableData(currentTable, 1)">Refresh</button>
|
||||
<button class="btn btn-primary" onclick="exportCsv()">Export CSV</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content-area">
|
||||
<div class="table-card">
|
||||
<div style="overflow-x: auto; max-height: calc(100vh - 190px);">
|
||||
<table id="dataTable">
|
||||
<thead>
|
||||
<tr id="tableHead">
|
||||
<th>Select a table from the sidebar to view records.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="paginationBar" style="display: none;">
|
||||
<span id="pageInfo">Page 1 of 1</span>
|
||||
<div class="page-btns">
|
||||
<button class="btn" id="prevBtn" onclick="prevPage()">Previous</button>
|
||||
<button class="btn" id="nextBtn" onclick="nextPage()">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JSON Modal -->
|
||||
<div class="modal" id="jsonModal" onclick="closeModal(event)">
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">Field Value</h3>
|
||||
<button class="btn" onclick="document.getElementById('jsonModal').classList.remove('show')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allTables = [];
|
||||
let currentTable = '';
|
||||
let currentPage = 1;
|
||||
let currentTotalPages = 1;
|
||||
let currentRows = [];
|
||||
let currentColumns = [];
|
||||
|
||||
async function loadTables() {
|
||||
try {
|
||||
const res = await fetch('/api/db/tables');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
allTables = data.tables;
|
||||
renderTableList(allTables);
|
||||
// Auto select first table if exists
|
||||
const first = allTables.find(t => t.name === 'users') || allTables[0];
|
||||
if (first) {
|
||||
selectTable(first.name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching tables:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableList(tables) {
|
||||
const list = document.getElementById('tableList');
|
||||
list.innerHTML = tables.map(t => \`
|
||||
<li class="table-item \${t.name === currentTable ? 'active' : ''}" onclick="selectTable('\${t.name}')">
|
||||
<span>\${t.name}</span>
|
||||
<span class="badge">\${t.count}</span>
|
||||
</li>
|
||||
\`).join('');
|
||||
}
|
||||
|
||||
function filterTables() {
|
||||
const term = document.getElementById('tableFilter').value.toLowerCase();
|
||||
const filtered = allTables.filter(t => t.name.toLowerCase().includes(term));
|
||||
renderTableList(filtered);
|
||||
}
|
||||
|
||||
function selectTable(name) {
|
||||
currentTable = name;
|
||||
currentPage = 1;
|
||||
document.getElementById('currentTableTitle').textContent = name;
|
||||
document.getElementById('dataSearch').value = '';
|
||||
renderTableList(allTables);
|
||||
fetchTableData(name, 1);
|
||||
}
|
||||
|
||||
function handleSearch(e) {
|
||||
if (e.key === 'Enter') {
|
||||
fetchTableData(currentTable, 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTableData(tableName, page = 1) {
|
||||
if (!tableName) return;
|
||||
const search = document.getElementById('dataSearch').value.trim();
|
||||
const url = \`/api/db/table/\${tableName}?page=\${page}&limit=25&search=\${encodeURIComponent(search)}\`;
|
||||
|
||||
const thead = document.getElementById('tableHead');
|
||||
const tbody = document.getElementById('tableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center; padding: 40px; color:#64748b;">Loading rows...</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
currentRows = data.rows;
|
||||
currentColumns = data.columns;
|
||||
currentPage = data.pagination.page;
|
||||
currentTotalPages = data.pagination.totalPages;
|
||||
|
||||
document.getElementById('tableMeta').textContent = \`\${data.pagination.total} records total\`;
|
||||
|
||||
// Render Head
|
||||
thead.innerHTML = data.columns.map(c => \`<th>\${c.name} <span style="font-size:0.7rem; color:#64748b;">(\${c.type})</span></th>\`).join('');
|
||||
|
||||
// Render Body
|
||||
if (data.rows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="' + data.columns.length + '" style="text-align:center; padding: 40px; color:#64748b;">No records found.</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = data.rows.map(row => {
|
||||
return '<tr>' + data.columns.map(col => {
|
||||
const val = row[col.name];
|
||||
if (val === null || val === undefined) {
|
||||
return '<td class="cell-null">null</td>';
|
||||
}
|
||||
if (typeof val === 'object') {
|
||||
const jsonStr = JSON.stringify(val);
|
||||
return \`<td><span class="cell-json" onclick='showJson(\${JSON.stringify(col.name)}, \${JSON.stringify(val)})'>\${escapeHtml(jsonStr)}</span></td>\`;
|
||||
}
|
||||
return \`<td title="\${escapeHtml(String(val))}">\${escapeHtml(String(val))}</td>\`;
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
document.getElementById('paginationBar').style.display = 'flex';
|
||||
document.getElementById('pageInfo').textContent = \`Page \${currentPage} of \${currentTotalPages || 1} (\${data.pagination.total} rows)\`;
|
||||
document.getElementById('prevBtn').disabled = currentPage <= 1;
|
||||
document.getElementById('nextBtn').disabled = currentPage >= currentTotalPages;
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = \`<tr><td colspan="10" style="color:var(--danger); padding:20px;">Error: \${err.message}</td></tr>\`;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) fetchTableData(currentTable, currentPage - 1);
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage < currentTotalPages) fetchTableData(currentTable, currentPage + 1);
|
||||
}
|
||||
|
||||
function showJson(colName, obj) {
|
||||
document.getElementById('modalTitle').textContent = colName;
|
||||
document.getElementById('modalContent').textContent = JSON.stringify(obj, null, 2);
|
||||
document.getElementById('jsonModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal(e) {
|
||||
if (e.target.id === 'jsonModal') {
|
||||
document.getElementById('jsonModal').classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
if (!currentRows || currentRows.length === 0) return alert('No data to export');
|
||||
const headers = currentColumns.map(c => c.name);
|
||||
const rows = currentRows.map(r =>
|
||||
headers.map(h => {
|
||||
const val = r[h];
|
||||
const str = typeof val === 'object' && val !== null ? JSON.stringify(val) : String(val ?? '');
|
||||
return '"' + str.replace(/"/g, '""') + '"';
|
||||
}).join(',')
|
||||
);
|
||||
const csv = [headers.join(','), ...rows].join('\\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = \`\${currentTable}_\${Date.now()}.csv\`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
loadTables();
|
||||
</script>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user