feat(03-01): add requireAdminEmail middleware and getAnalyticsSummary function
- requireAdmin.ts: returns 404 (not 403) for non-admin users per locked decision - Reads env vars inside function body to avoid Firebase Secrets timing issue - Fails closed if neither ADMIN_EMAIL nor EMAIL_WEEKLY_RECIPIENT configured - analyticsService.ts: adds AnalyticsSummary interface and getAnalyticsSummary() - Uses getPostgresPool() for aggregate SQL (COUNT/AVG not supported by Supabase JS) - Parameterized interval with $1::interval cast for PostgreSQL compatibility
This commit is contained in:
33
backend/src/middleware/requireAdmin.ts
Normal file
33
backend/src/middleware/requireAdmin.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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<num
|
||||
|
||||
return data ? data.length : 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AnalyticsSummary — aggregate query for admin API
|
||||
// =============================================================================
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
range: string;
|
||||
totalUploads: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
avgProcessingMs: number | null;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
function parseRange(range: string): string {
|
||||
if (/^\d+h$/.test(range)) return range.replace('h', ' hours');
|
||||
if (/^\d+d$/.test(range)) return range.replace('d', ' days');
|
||||
return '24 hours'; // fallback default
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a processing summary aggregate for the given time range.
|
||||
* Uses getPostgresPool() for aggregate SQL — Supabase JS client does not support COUNT/AVG.
|
||||
*/
|
||||
export async function getAnalyticsSummary(range: string = '24h'): Promise<AnalyticsSummary> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user