import express from 'express'; import axios from 'axios'; import dotenv from 'dotenv'; import Database from 'better-sqlite3'; import helmet from 'helmet'; import cors from 'cors'; import morgan from 'morgan'; import rateLimit from 'express-rate-limit'; import { v4 as uuidv4 } from 'uuid'; import compression from 'compression'; import winston from 'winston'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const logsDir = path.join(__dirname, 'logs'); // Ensure log directory exists before Winston file transports initialize. if (!fs.existsSync(logsDir)) { fs.mkdirSync(logsDir, { recursive: true }); } // Initialize Express App const app = express(); const PORT = process.env.PORT || 3000; const NODE_ENV = process.env.NODE_ENV || 'development'; // Logger Setup const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.json(), defaultMeta: { service: 'abdm-bridge-gateway' }, transports: [ new winston.transports.File({ filename: path.join(logsDir, 'error.log'), level: 'error' }), new winston.transports.File({ filename: path.join(logsDir, 'combined.log') }), ], }); if (NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } // Middleware app.use(helmet()); // Security headers app.use(compression()); // GZIP compression app.use(cors()); // CORS app.use(morgan('combined', { stream: { write: msg => logger.info(msg) } })); // Request logging app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ limit: '10mb', extended: true })); // Rate Limiting const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // limit each IP to 100 requests per windowMs message: 'Too many requests from this IP, please try again later.', }); app.use('/api/', limiter); // Authentication Middleware const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) { logger.warn('Missing authorization token', { ip: req.ip }); return res.status(401).json({ ok: 0, error: 'Missing authorization token' }); } const expectedToken = process.env.BRIDGE_SYNC_TOKEN; if (token !== expectedToken) { logger.warn('Invalid authorization token', { ip: req.ip }); return res.status(403).json({ ok: 0, error: 'Invalid authorization token' }); } next(); }; // Request ID Middleware app.use((req, res, next) => { req.id = uuidv4(); res.setHeader('X-Request-ID', req.id); next(); }); // ============================================ // ABDM Gateway Endpoints // ============================================ /** * Health Check Endpoint * GET /api/v3/health */ app.get('/api/v3/health', (req, res) => { logger.info('Health check requested', { requestId: req.id }); res.json({ status: 'ok', timestamp: new Date().toISOString(), service: 'abdm-bridge-gateway', version: '1.0.0', uptime: process.uptime(), }); }); /** * ABHA Validation Endpoint * POST /api/v3/abha/validate * Proxies ABHA validation to NHA ABDM M3 API */ app.post('/api/v3/abha/validate', authenticateToken, async (req, res) => { try { const { abha_id, abha_address } = req.body; logger.info('ABHA validation request', { requestId: req.id, abha_id: abha_id ? abha_id.substring(0, 5) + '***' : 'null' }); if (!abha_id && !abha_address) { return res.status(400).json({ ok: 0, error: 'Either abha_id or abha_address required' }); } // Call ABDM M3 API const abdmResponse = await axios.post( `${process.env.ABDM_M3_URL}/abha/validate`, { abha_id: abha_id, abha_address: abha_address, request_id: req.id, }, { headers: { 'Authorization': `Bearer ${process.env.ABDM_M3_TOKEN}`, 'Content-Type': 'application/json', 'X-Client-ID': process.env.BRIDGE_SOURCE_CODE, }, timeout: parseInt(process.env.BRIDGE_SYNC_TIMEOUT || '30') * 1000, } ); logger.info('ABHA validation successful', { requestId: req.id }); res.json({ ok: 1, data: abdmResponse.data, timestamp: new Date().toISOString(), }); } catch (error) { logger.error('ABHA validation failed', { requestId: req.id, error: error.message }); res.status(error.response?.status || 500).json({ ok: 0, error: error.response?.data?.error || error.message, request_id: req.id, }); } }); /** * Consent Request Endpoint * POST /api/v3/consent/request * Initiates consent request in ABDM */ app.post('/api/v3/consent/request', authenticateToken, async (req, res) => { try { const { patient_abha, purpose, hi_types, date_range_from, date_range_to } = req.body; logger.info('Consent request initiated', { requestId: req.id, patient_abha: patient_abha ? patient_abha.substring(0, 5) + '***' : 'null' }); if (!patient_abha || !hi_types || !Array.isArray(hi_types)) { return res.status(400).json({ ok: 0, error: 'patient_abha, purpose, and hi_types array required' }); } const consentId = `CONS-${Date.now()}-${uuidv4().substring(0, 8)}`; // Call ABDM M3 API const abdmResponse = await axios.post( `${process.env.ABDM_M3_URL}/consent/request`, { consent_id: consentId, patient_abha: patient_abha, purpose: purpose || 'treatment', hi_types: hi_types, date_range_from: date_range_from, date_range_to: date_range_to, request_id: req.id, }, { headers: { 'Authorization': `Bearer ${process.env.ABDM_M3_TOKEN}`, 'Content-Type': 'application/json', 'X-Client-ID': process.env.BRIDGE_SOURCE_CODE, }, timeout: parseInt(process.env.BRIDGE_SYNC_TIMEOUT || '30') * 1000, } ); logger.info('Consent request successful', { requestId: req.id, consentId }); res.json({ ok: 1, consent_id: consentId, data: abdmResponse.data, timestamp: new Date().toISOString(), }); } catch (error) { logger.error('Consent request failed', { requestId: req.id, error: error.message }); res.status(error.response?.status || 500).json({ ok: 0, error: error.response?.data?.error || error.message, request_id: req.id, }); } }); /** * Bundle Push Endpoint * POST /api/v3/bundle/push * Pushes FHIR bundle to ABDM */ app.post('/api/v3/bundle/push', authenticateToken, async (req, res) => { try { const { fhir_bundle, consent_id, hi_type } = req.body; logger.info('Bundle push requested', { requestId: req.id, hi_type, consent_id: consent_id ? consent_id.substring(0, 10) + '***' : 'null' }); if (!fhir_bundle || !consent_id || !hi_type) { return res.status(400).json({ ok: 0, error: 'fhir_bundle, consent_id, and hi_type required' }); } const bundleId = `BUN-${Date.now()}-${uuidv4().substring(0, 8)}`; // Call ABDM M3 API const abdmResponse = await axios.post( `${process.env.ABDM_M3_URL}/bundle/push`, { bundle_id: bundleId, hi_type: hi_type, consent_id: consent_id, fhir_bundle: fhir_bundle, request_id: req.id, }, { headers: { 'Authorization': `Bearer ${process.env.ABDM_M3_TOKEN}`, 'Content-Type': 'application/json', 'X-Client-ID': process.env.BRIDGE_SOURCE_CODE, }, timeout: parseInt(process.env.BRIDGE_SYNC_TIMEOUT || '30') * 1000, } ); logger.info('Bundle push successful', { requestId: req.id, bundleId }); res.json({ ok: 1, bundle_id: bundleId, data: abdmResponse.data, timestamp: new Date().toISOString(), }); } catch (error) { logger.error('Bundle push failed', { requestId: req.id, error: error.message }); res.status(error.response?.status || 500).json({ ok: 0, error: error.response?.data?.error || error.message, request_id: req.id, }); } }); /** * SNOMED CT Search Endpoint * GET /api/v3/snomed/search * Proxies to CSNOtk terminology service */ app.get('/api/v3/snomed/search', authenticateToken, async (req, res) => { try { const { term, return_limit = 10 } = req.query; logger.info('SNOMED search requested', { requestId: req.id, term }); if (!term) { return res.status(400).json({ ok: 0, error: 'term parameter required' }); } const csnotkResponse = await axios.get( `${process.env.SNOMED_SERVICE_URL}/search/suggest`, { params: { term: term, returnlimit: return_limit, }, timeout: parseInt(process.env.SNOMED_TIMEOUT || '10') * 1000, } ); logger.info('SNOMED search successful', { requestId: req.id }); res.json({ ok: 1, data: csnotkResponse.data, timestamp: new Date().toISOString(), }); } catch (error) { logger.error('SNOMED search failed', { requestId: req.id, error: error.message }); res.status(error.response?.status || 500).json({ ok: 0, error: error.response?.data?.error || error.message, request_id: req.id, }); } }); /** * Bundle Status Endpoint * GET /api/v3/bundle/:bundleId/status * Checks status of previously pushed bundle */ app.get('/api/v3/bundle/:bundleId/status', authenticateToken, async (req, res) => { try { const { bundleId } = req.params; logger.info('Bundle status requested', { requestId: req.id, bundleId }); const abdmResponse = await axios.get( `${process.env.ABDM_M3_URL}/bundle/${bundleId}/status`, { headers: { 'Authorization': `Bearer ${process.env.ABDM_M3_TOKEN}`, 'X-Client-ID': process.env.BRIDGE_SOURCE_CODE, }, timeout: parseInt(process.env.BRIDGE_SYNC_TIMEOUT || '30') * 1000, } ); logger.info('Bundle status retrieved', { requestId: req.id }); res.json({ ok: 1, data: abdmResponse.data, timestamp: new Date().toISOString(), }); } catch (error) { logger.error('Bundle status check failed', { requestId: req.id, error: error.message }); res.status(error.response?.status || 500).json({ ok: 0, error: error.response?.data?.error || error.message, request_id: req.id, }); } }); /** * Gateway Status Endpoint * GET /api/v3/gateway/status * Returns gateway service and dependency health */ app.get('/api/v3/gateway/status', authenticateToken, async (req, res) => { try { const status = { gateway: 'ok', abdm_m3: 'checking', snomed_service: 'checking', timestamp: new Date().toISOString(), }; // Check ABDM M3 connectivity try { await axios.get( `${process.env.ABDM_M3_URL}/health`, { timeout: 5000 } ); status.abdm_m3 = 'ok'; } catch (e) { logger.warn('ABDM M3 health check failed', { error: e.message }); status.abdm_m3 = 'unreachable'; } // Check SNOMED service connectivity try { await axios.get( `${process.env.SNOMED_SERVICE_URL}/health`, { timeout: 5000 } ); status.snomed_service = 'ok'; } catch (e) { logger.warn('SNOMED service health check failed', { error: e.message }); status.snomed_service = 'unreachable'; } logger.info('Gateway status checked', { requestId: req.id }); res.json({ ok: status.abdm_m3 === 'ok' && status.snomed_service === 'ok' ? 1 : 0, data: status, }); } catch (error) { logger.error('Gateway status check failed', { requestId: req.id, error: error.message }); res.status(500).json({ ok: 0, error: error.message, request_id: req.id, }); } }); /** * Drug Masters Endpoint * GET /api/v3/drugs/masters * Serves normalised HMS master lists (company, formulation, short_formulation, generic) * from the local CDCI SQLite database imported into the gateway. * Data is NOT proxied to ABDM M3 — it comes from the gateway's own CDCI dataset. * * Query params: * master = company | formulation | short_formulation | generic (required) * q = optional contains-filter on name * limit = 1–500 (default 100) * offset = pagination offset (default 0) */ app.get('/api/v3/drugs/masters', authenticateToken, (req, res) => { try { const { master = 'company', q = '', limit = '100', offset = '0' } = req.query; const validMasters = ['company', 'formulation', 'short_formulation', 'generic']; if (!validMasters.includes(master)) { return res.status(400).json({ ok: 0, error_code: 'INVALID_MASTER_TYPE', message: `Invalid master type. Valid values: ${validMasters.join(', ')}`, request_id: req.id, }); } const parsedLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 500); const parsedOffset = Math.max(parseInt(offset, 10) || 0, 0); logger.info('Drug masters requested', { requestId: req.id, master, limit: parsedLimit, offset: parsedOffset }); // Open the CDCI SQLite database (path set via CDCI_DB_PATH in .env) const dbPath = process.env.CDCI_DB_PATH || path.join(__dirname, 'data', 'cdci.db'); let db; try { db = new Database(dbPath, { readonly: true, fileMustExist: true }); } catch (dbErr) { logger.error('CDCI DB open failed', { requestId: req.id, error: dbErr.message, dbPath }); return res.status(503).json({ ok: 0, error_code: 'CDCI_DB_UNAVAILABLE', message: 'CDCI database not available. Contact gateway admin.', request_id: req.id, }); } // Table mapping — each master type maps to a CDCI table const tableMap = { company: 'cdci_manufacturers', formulation: 'cdci_formulations', short_formulation: 'cdci_short_formulations', generic: 'cdci_generics', }; const table = tableMap[master]; try { const filterClause = q ? `WHERE name LIKE ?` : ''; const filterParam = q ? [`%${q}%`] : []; const totalRow = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table} ${filterClause}`).get(...filterParam); const total = totalRow?.cnt ?? 0; const rows = db.prepare( `SELECT identifier, name, updated_at FROM ${table} ${filterClause} ORDER BY name ASC LIMIT ? OFFSET ?` ).all(...filterParam, parsedLimit, parsedOffset); logger.info('Drug masters served', { requestId: req.id, master, count: rows.length, total }); res.json({ ok: 1, master, count: rows.length, total, items: rows, request_id: req.id, }); } finally { db.close(); } } catch (error) { logger.error('Drug masters endpoint error', { requestId: req.id, error: error.message }); res.status(500).json({ ok: 0, error_code: 'SERVER_ERROR', message: NODE_ENV === 'production' ? 'Internal server error' : error.message, request_id: req.id, }); } }); // ============================================ // Error Handlers // ============================================ // 404 Handler app.use((req, res) => { logger.warn('Not found', { path: req.path, method: req.method }); res.status(404).json({ ok: 0, error: 'Endpoint not found', }); }); // Global Error Handler app.use((err, req, res, next) => { logger.error('Unhandled error', { requestId: req.id, error: err.message, stack: err.stack }); res.status(500).json({ ok: 0, error: NODE_ENV === 'production' ? 'Internal server error' : err.message, request_id: req.id, }); }); // ============================================ // Server Startup // ============================================ app.listen(PORT, () => { logger.info(`🚀 ABDM Gateway Server running on port ${PORT}`, { environment: NODE_ENV, bridgeSourceCode: process.env.BRIDGE_SOURCE_CODE, }); }); // Graceful Shutdown process.on('SIGTERM', () => { logger.info('SIGTERM signal received: closing HTTP server'); process.exit(0); }); process.on('SIGINT', () => { logger.info('SIGINT signal received: closing HTTP server'); process.exit(0); }); export default app;