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 { getSupabaseServiceClient } from '../config/supabase';
|
||||||
|
import { getPostgresPool } from '../config/supabase';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -86,3 +87,62 @@ export async function deleteProcessingEventsOlderThan(days: number): Promise<num
|
|||||||
|
|
||||||
return data ? data.length : 0;
|
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