diff --git a/backend/src/middleware/requireAdmin.ts b/backend/src/middleware/requireAdmin.ts new file mode 100644 index 0000000..8e36617 --- /dev/null +++ b/backend/src/middleware/requireAdmin.ts @@ -0,0 +1,33 @@ +import { Response, NextFunction } from 'express'; +import { FirebaseAuthenticatedRequest } from './firebaseAuth'; +import { logger } from '../utils/logger'; + +export function requireAdminEmail( + req: FirebaseAuthenticatedRequest, + res: Response, + next: NextFunction +): void { + // Read inside function, not at module level — Firebase Secrets not available at module load time + const adminEmail = process.env['ADMIN_EMAIL'] ?? process.env['EMAIL_WEEKLY_RECIPIENT']; + + if (!adminEmail) { + logger.warn('requireAdminEmail: neither ADMIN_EMAIL nor EMAIL_WEEKLY_RECIPIENT is configured — denying all admin access'); + res.status(404).json({ error: 'Not found' }); + return; + } + + const userEmail = req.user?.email; + + if (!userEmail || userEmail !== adminEmail) { + // 404 — do not reveal admin routes exist (per locked decision) + logger.warn('requireAdminEmail: access denied', { + uid: req.user?.uid ?? 'unauthenticated', + email: userEmail ?? 'none', + path: req.path, + }); + res.status(404).json({ error: 'Not found' }); + return; + } + + next(); +} diff --git a/backend/src/services/analyticsService.ts b/backend/src/services/analyticsService.ts index 5719213..bed2e53 100644 --- a/backend/src/services/analyticsService.ts +++ b/backend/src/services/analyticsService.ts @@ -1,4 +1,5 @@ import { getSupabaseServiceClient } from '../config/supabase'; +import { getPostgresPool } from '../config/supabase'; import { logger } from '../utils/logger'; // ============================================================================= @@ -86,3 +87,62 @@ export async function deleteProcessingEventsOlderThan(days: number): Promise { + const interval = parseRange(range); + const pool = getPostgresPool(); + + const { rows } = await pool.query<{ + total_uploads: string; + succeeded: string; + failed: string; + avg_processing_ms: string | null; + }>(` + SELECT + COUNT(*) FILTER (WHERE event_type = 'upload_started') AS total_uploads, + COUNT(*) FILTER (WHERE event_type = 'completed') AS succeeded, + COUNT(*) FILTER (WHERE event_type = 'failed') AS failed, + AVG(duration_ms) FILTER (WHERE event_type = 'completed') AS avg_processing_ms + FROM document_processing_events + WHERE created_at >= NOW() - $1::interval + `, [interval]); + + const row = rows[0]!; + const total = parseInt(row.total_uploads, 10); + const succeeded = parseInt(row.succeeded, 10); + const failed = parseInt(row.failed, 10); + + return { + range, + totalUploads: total, + succeeded, + failed, + successRate: total > 0 ? succeeded / total : 0, + avgProcessingMs: row.avg_processing_ms ? parseFloat(row.avg_processing_ms) : null, + generatedAt: new Date().toISOString(), + }; +}