598 lines
16 KiB
JavaScript
598 lines
16 KiB
JavaScript
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;
|