diff --git a/backend/src/index.ts b/backend/src/index.ts index 39b7f48..12f2ecb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,7 @@ import documentRoutes from './routes/documents'; import vectorRoutes from './routes/vector'; import monitoringRoutes from './routes/monitoring'; import auditRoutes from './routes/documentAudit'; +import adminRoutes from './routes/admin'; import { jobQueueService } from './services/jobQueueService'; import { errorHandler, correlationIdMiddleware } from './middleware/errorHandler'; @@ -180,6 +181,7 @@ app.use('/documents', documentRoutes); app.use('/vector', vectorRoutes); app.use('/monitoring', monitoringRoutes); app.use('/api/audit', auditRoutes); +app.use('/admin', adminRoutes); import { onRequest } from 'firebase-functions/v2/https'; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..9dda9e1 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,152 @@ +import { Router, Response } from 'express'; +import { verifyFirebaseToken, FirebaseAuthenticatedRequest } from '../middleware/firebaseAuth'; +import { requireAdminEmail } from '../middleware/requireAdmin'; +import { addCorrelationId } from '../middleware/validation'; +import { HealthCheckModel } from '../models/HealthCheckModel'; +import { AlertEventModel } from '../models/AlertEventModel'; +import { getAnalyticsSummary } from '../services/analyticsService'; +import { logger } from '../utils/logger'; + +const router = Router(); + +// Service names must match what healthProbeService writes +const SERVICE_NAMES = ['document_ai', 'llm_api', 'supabase', 'firebase_auth'] as const; + +// Apply auth + admin check + correlation ID to all admin routes +router.use(addCorrelationId); +router.use(verifyFirebaseToken); +router.use(requireAdminEmail); + +/** + * GET /admin/health + * Returns latest health check for all four monitored services. + */ +router.get('/health', async (req: FirebaseAuthenticatedRequest, res: Response): Promise => { + try { + const results = await Promise.all( + SERVICE_NAMES.map(name => HealthCheckModel.findLatestByService(name)) + ); + + const data = results.map((record, index) => { + const serviceName = SERVICE_NAMES[index]!; + if (!record) { + return { + service: serviceName, + status: 'unknown' as const, + checkedAt: null, + latencyMs: null, + errorMessage: null, + }; + } + return { + service: record.service_name, + status: record.status, + checkedAt: record.checked_at, + latencyMs: record.latency_ms, + errorMessage: record.error_message, + }; + }); + + res.json({ success: true, data, correlationId: req.correlationId }); + } catch (error) { + logger.error('admin: GET /health failed', { + error: error instanceof Error ? error.message : String(error), + correlationId: req.correlationId, + }); + res.status(500).json({ + success: false, + error: 'Failed to retrieve health status', + correlationId: req.correlationId, + }); + } +}); + +/** + * GET /admin/analytics + * Returns document processing summary for a configurable time range. + * Query param: ?range=24h (default) or any NNh / NNd format. + */ +router.get('/analytics', async (req: FirebaseAuthenticatedRequest, res: Response): Promise => { + const range = (req.query['range'] as string) ?? '24h'; + + if (!/^\d+[hd]$/.test(range)) { + res.status(400).json({ + success: false, + error: 'Invalid range parameter. Use format: 24h, 7d, etc.', + correlationId: req.correlationId, + }); + return; + } + + try { + const summary = await getAnalyticsSummary(range); + res.json({ success: true, data: summary, correlationId: req.correlationId }); + } catch (error) { + logger.error('admin: GET /analytics failed', { + error: error instanceof Error ? error.message : String(error), + range, + correlationId: req.correlationId, + }); + res.status(500).json({ + success: false, + error: 'Failed to retrieve analytics summary', + correlationId: req.correlationId, + }); + } +}); + +/** + * GET /admin/alerts + * Returns all active (unacknowledged, unresolved) alert events. + */ +router.get('/alerts', async (req: FirebaseAuthenticatedRequest, res: Response): Promise => { + try { + const alerts = await AlertEventModel.findActive(); + res.json({ success: true, data: alerts, correlationId: req.correlationId }); + } catch (error) { + logger.error('admin: GET /alerts failed', { + error: error instanceof Error ? error.message : String(error), + correlationId: req.correlationId, + }); + res.status(500).json({ + success: false, + error: 'Failed to retrieve active alerts', + correlationId: req.correlationId, + }); + } +}); + +/** + * POST /admin/alerts/:id/acknowledge + * Marks an alert event as acknowledged. + */ +router.post('/alerts/:id/acknowledge', async (req: FirebaseAuthenticatedRequest, res: Response): Promise => { + const { id } = req.params; + + try { + const updatedAlert = await AlertEventModel.acknowledge(id!); + res.json({ success: true, data: updatedAlert, correlationId: req.correlationId }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.toLowerCase().includes('not found')) { + res.status(404).json({ + success: false, + error: `Alert not found: ${id}`, + correlationId: req.correlationId, + }); + return; + } + logger.error('admin: POST /alerts/:id/acknowledge failed', { + error: message, + id, + correlationId: req.correlationId, + }); + res.status(500).json({ + success: false, + error: 'Failed to acknowledge alert', + correlationId: req.correlationId, + }); + } +}); + +export default router;