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:
admin
2026-02-24 15:43:53 -05:00
parent ad464cb633
commit 301d0bf159
2 changed files with 93 additions and 0 deletions

View 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();
}

View File

@@ -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(),
};
}