Archive milestone artifacts (roadmap, requirements, audit, phase directories) to .planning/milestones/. Evolve PROJECT.md with validated requirements and decision outcomes. Create MILESTONES.md and RETROSPECTIVE.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
11 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-api-layer | 01 | execute | 1 |
|
true |
|
|
Purpose: Enables the admin to query service health, view active alerts, acknowledge alerts, and see processing analytics through protected API routes. This is the data access layer that Phase 4 (frontend) will consume.
Output: Four working admin endpoints behind Firebase Auth + admin email verification, plus the getAnalyticsSummary() query function.
<execution_context> @/home/jonathan/.claude/get-shit-done/workflows/execute-plan.md @/home/jonathan/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-api-layer/03-RESEARCH.md@backend/src/middleware/firebaseAuth.ts @backend/src/models/HealthCheckModel.ts @backend/src/models/AlertEventModel.ts @backend/src/services/analyticsService.ts @backend/src/services/healthProbeService.ts @backend/src/routes/monitoring.ts @backend/src/index.ts
Task 1: Create requireAdmin middleware and getAnalyticsSummary function backend/src/middleware/requireAdmin.ts backend/src/services/analyticsService.ts **1. Create `backend/src/middleware/requireAdmin.ts`:**Create the admin email check middleware. This runs AFTER verifyFirebaseToken in the middleware chain, so req.user is already populated.
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();
}
Key constraints:
- Return 404 (not 403) for non-admin users — per locked decision, do not reveal admin routes exist
- Read env vars inside function body, not module level (Firebase Secrets timing, matches alertService pattern)
- Fail closed: if no admin email configured, deny all access with logged warning
2. Add getAnalyticsSummary() to backend/src/services/analyticsService.ts:
Add below the existing deleteProcessingEventsOlderThan function. Use getPostgresPool() (from ../config/supabase) for aggregate SQL — Supabase JS client does not support COUNT/AVG.
import { getPostgresPool } from '../config/supabase';
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
}
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(),
};
}
Note: Use $1::interval cast for parameterized interval — PostgreSQL requires explicit cast for interval parameters.
cd /home/jonathan/Coding/cim_summary/backend && npx tsc --noEmit 2>&1 | head -30
Check that requireAdmin.ts exports requireAdminEmail and analyticsService.ts exports getAnalyticsSummary
requireAdminEmail middleware returns 404 for non-admin users and calls next() for admin. getAnalyticsSummary queries document_processing_events with configurable time range and returns structured summary.
Follow the exact pattern from routes/monitoring.ts. Apply verifyFirebaseToken + requireAdminEmail + addCorrelationId as router-level middleware. Use the { success, data, correlationId } envelope pattern.
Service names MUST match what healthProbeService writes (confirmed from codebase): 'document_ai', 'llm_api', 'supabase', 'firebase_auth'.
Endpoints:
GET /health — Returns latest health check for all four services.
- Use
Promise.all(SERVICE_NAMES.map(name => HealthCheckModel.findLatestByService(name))). - For each result, map to
{ service, status, checkedAt, latencyMs, errorMessage }. IffindLatestByServicereturns null, usestatus: 'unknown'. - Return
{ success: true, data: [...], correlationId }.
GET /analytics — Returns processing summary.
- Accept
?range=24hquery param (default:'24h'). - Validate range matches
/^\d+[hd]$/— return 400 if invalid. - Call
getAnalyticsSummary(range). - Return
{ success: true, data: summary, correlationId }.
GET /alerts — Returns active alerts.
- Call
AlertEventModel.findActive()(no arguments = all active alerts). - Return
{ success: true, data: alerts, correlationId }.
POST /alerts/:id/acknowledge — Acknowledge an alert.
- Call
AlertEventModel.acknowledge(req.params.id). - If error message includes 'not found', return 404.
- Return
{ success: true, data: updatedAlert, correlationId }.
All error handlers follow the same pattern: logger.error(...) then res.status(500).json({ success: false, error: 'Human-readable message', correlationId }).
2. Mount in backend/src/index.ts:
Add import: import adminRoutes from './routes/admin';
Add route registration alongside existing routes (after the app.use('/api/audit', auditRoutes); line):
app.use('/admin', adminRoutes);
The /admin prefix is unique — no conflicts with existing routes.
cd /home/jonathan/Coding/cim_summary/backend && npx tsc --noEmit 2>&1 | head -30
Verify admin.ts has 4 route handlers, index.ts mounts /admin
Four admin endpoints (GET /health, GET /analytics, GET /alerts, POST /alerts/:id/acknowledge) are mounted behind Firebase Auth + admin email check. Non-admin users get 404. Response envelope matches existing codebase pattern.
<success_criteria>
- TypeScript compiles without errors
- All four admin endpoints defined with correct HTTP methods and paths
- Admin auth middleware returns 404 for non-admin, next() for admin
- Analytics summary uses getPostgresPool() for aggregate SQL, not Supabase JS client
- Response envelope matches
{ success, data, correlationId }pattern - No
console.log— all logging via Winston logger </success_criteria>