From 77f23e12cdf8359b751e6dbbc080c6dd6d799d4e Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 12 Apr 2026 23:40:57 +0000 Subject: [PATCH] Logging: add missing audit logs (setup, contacts, task create/note, renewal settings); add /api/metrics Prometheus endpoint --- .../app/api/admin/renewal-settings/route.ts | 9 + .../app/api/clients/[id]/contacts/route.ts | 9 + .../src/app/api/clients/[id]/setup/route.ts | 10 ++ ondeck/src/app/api/metrics/route.ts | 169 ++++++++++++++++++ ondeck/src/app/api/tasks/[id]/notes/route.ts | 10 ++ ondeck/src/app/api/tasks/route.ts | 10 ++ ondeck/src/middleware.ts | 5 + 7 files changed, 222 insertions(+) create mode 100644 ondeck/src/app/api/metrics/route.ts diff --git a/ondeck/src/app/api/admin/renewal-settings/route.ts b/ondeck/src/app/api/admin/renewal-settings/route.ts index 421d616..2a330b6 100644 --- a/ondeck/src/app/api/admin/renewal-settings/route.ts +++ b/ondeck/src/app/api/admin/renewal-settings/route.ts @@ -64,6 +64,15 @@ export async function PUT(request: NextRequest) { }), ]) + await prisma.auditLog.create({ + data: { + userId: (session.user as any).id, + action: 'RENEWAL_SETTINGS_UPDATED', + entityType: 'AppSetting', + newValues: { windowDays, rule }, + }, + }) + return NextResponse.json({ windowDays, rule }) } catch (error) { console.error('Renewal settings PUT error:', error) diff --git a/ondeck/src/app/api/clients/[id]/contacts/route.ts b/ondeck/src/app/api/clients/[id]/contacts/route.ts index 25856cf..222c64a 100644 --- a/ondeck/src/app/api/clients/[id]/contacts/route.ts +++ b/ondeck/src/app/api/clients/[id]/contacts/route.ts @@ -40,5 +40,14 @@ export async function POST( const contact = await prisma.clientContact.create({ data: { clientId: id, label: label.trim(), name: name.trim(), phone: phone || null, email: email || null, notes: notes || null }, }) + await prisma.auditLog.create({ + data: { + userId: (session.user as any).id, + action: 'CLIENT_CONTACT_CREATED', + entityType: 'ClientContact', + entityId: contact.id, + newValues: { clientId: id, label: contact.label, name: contact.name }, + }, + }) return NextResponse.json(contact, { status: 201 }) } diff --git a/ondeck/src/app/api/clients/[id]/setup/route.ts b/ondeck/src/app/api/clients/[id]/setup/route.ts index e947669..92ff9b0 100644 --- a/ondeck/src/app/api/clients/[id]/setup/route.ts +++ b/ondeck/src/app/api/clients/[id]/setup/route.ts @@ -126,6 +126,16 @@ async function handleSave( select: { id: true, name: true, renewalDate: true, setupCompletedAt: true, claimsAdvocateId: true }, }) + await prisma.auditLog.create({ + data: { + userId: (session.user as any).id, + action: isDraft ? 'CLIENT_SETUP_DRAFT' : 'CLIENT_SETUP_COMPLETED', + entityType: 'Client', + entityId: clientId, + newValues: { groupCount: body.groups.length, isDraft }, + }, + }) + return NextResponse.json({ client: updated }) } catch (error) { console.error('Setup save error:', error) diff --git a/ondeck/src/app/api/metrics/route.ts b/ondeck/src/app/api/metrics/route.ts new file mode 100644 index 0000000..86a55e1 --- /dev/null +++ b/ondeck/src/app/api/metrics/route.ts @@ -0,0 +1,169 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' + +/** + * GET /api/metrics + * Prometheus text-format metrics endpoint. + * Protected by METRICS_SECRET header (Bearer token) or CRON_SECRET as fallback. + * Scrape config example: + * - job_name: horizon + * bearer_token: + * static_configs: + * - targets: ['horizon.seubert.cloud'] + * metrics_path: /api/metrics + * scheme: https + */ +export async function GET(request: NextRequest) { + const authHeader = request.headers.get('authorization') || '' + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader + const secret = process.env.METRICS_SECRET || process.env.CRON_SECRET + + if (!secret || token !== secret) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + try { + const now = new Date() + const todayStart = new Date(now); todayStart.setHours(0, 0, 0, 0) + const weekAgo = new Date(now.getTime() - 7 * 86400000) + const monthAgo = new Date(now.getTime() - 30 * 86400000) + + const [ + totalClients, + totalPolicies, + activePolicies, + totalGroups, + totalUsers, + activeUsers, + totalTasks, + tasksByStatus, + tasksByDept, + overdueTasks, + tasksCreatedToday, + tasksCompletedToday, + tasksCreatedWeek, + tasksCompletedWeek, + auditLogsToday, + auditLogsWeek, + syncRuns, + lastSyncStatus, + importRuns, + lastImportStatus, + setupQueueCount, + ] = await Promise.all([ + prisma.client.count(), + prisma.policy.count(), + prisma.policy.count({ where: { status: 'Active' } }), + prisma.policyGroup.count(), + prisma.user.count(), + prisma.user.count({ where: { isActive: true } }), + prisma.task.count(), + prisma.task.groupBy({ by: ['status'], _count: { id: true } }), + prisma.task.groupBy({ by: ['department'], _count: { id: true } }), + prisma.task.count({ where: { dueDate: { lt: now }, status: { notIn: ['COMPLETED', 'NA', 'CANCELLED'] } } }), + prisma.task.count({ where: { createdAt: { gte: todayStart } } }), + prisma.task.count({ where: { completedAt: { gte: todayStart } } }), + prisma.task.count({ where: { createdAt: { gte: weekAgo } } }), + prisma.task.count({ where: { completedAt: { gte: weekAgo } } }), + prisma.auditLog.count({ where: { createdAt: { gte: todayStart } } }), + prisma.auditLog.count({ where: { createdAt: { gte: weekAgo } } }), + prisma.syncLog.count(), + prisma.syncLog.findFirst({ orderBy: { startedAt: 'desc' }, select: { status: true, startedAt: true } }), + prisma.shapeImportRun.count(), + prisma.shapeImportRun.findFirst({ orderBy: { startedAt: 'desc' }, select: { status: true, startedAt: true } }), + prisma.client.count({ + where: { + designation: { name: { in: ['Shape', 'Shape 2'] } }, + OR: [{ claimsAdvocateId: null }, { setupCompletedAt: null }], + }, + }), + ]) + + const lines: string[] = [] + + const gauge = (name: string, help: string, value: number, labels?: Record) => { + const labelStr = labels + ? '{' + Object.entries(labels).map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`).join(',') + '}' + : '' + lines.push(`# HELP ${name} ${help}`) + lines.push(`# TYPE ${name} gauge`) + lines.push(`${name}${labelStr} ${value}`) + } + + const counter = (name: string, help: string, value: number, labels?: Record) => { + const labelStr = labels + ? '{' + Object.entries(labels).map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`).join(',') + '}' + : '' + lines.push(`# HELP ${name} ${help}`) + lines.push(`# TYPE ${name} counter`) + lines.push(`${name}${labelStr} ${value}`) + } + + // ── Clients ──────────────────────────────────────────────────────────── + gauge('horizon_clients_total', 'Total number of clients', totalClients) + gauge('horizon_clients_setup_queue', 'Clients pending setup (no advocate or group setup)', setupQueueCount) + + // ── Policies ─────────────────────────────────────────────────────────── + gauge('horizon_policies_total', 'Total number of policies', totalPolicies) + gauge('horizon_policies_active', 'Active policies', activePolicies) + gauge('horizon_policy_groups_total', 'Total policy groups', totalGroups) + + // ── Users ────────────────────────────────────────────────────────────── + gauge('horizon_users_total', 'Total users', totalUsers) + gauge('horizon_users_active', 'Active users', activeUsers) + + // ── Tasks ────────────────────────────────────────────────────────────── + gauge('horizon_tasks_total', 'Total tasks', totalTasks) + gauge('horizon_tasks_overdue', 'Overdue tasks (past due date, not terminal)', overdueTasks) + + lines.push('# HELP horizon_tasks_by_status Tasks grouped by status') + lines.push('# TYPE horizon_tasks_by_status gauge') + for (const row of tasksByStatus) { + lines.push(`horizon_tasks_by_status{status="${row.status}"} ${row._count.id}`) + } + + lines.push('# HELP horizon_tasks_by_department Tasks grouped by department') + lines.push('# TYPE horizon_tasks_by_department gauge') + for (const row of tasksByDept) { + lines.push(`horizon_tasks_by_department{department="${row.department}"} ${row._count.id}`) + } + + counter('horizon_tasks_created_today', 'Tasks created today', tasksCreatedToday) + counter('horizon_tasks_completed_today', 'Tasks completed today', tasksCompletedToday) + counter('horizon_tasks_created_7d', 'Tasks created in last 7 days', tasksCreatedWeek) + counter('horizon_tasks_completed_7d', 'Tasks completed in last 7 days', tasksCompletedWeek) + + // ── Audit ────────────────────────────────────────────────────────────── + counter('horizon_audit_events_today', 'Audit log entries today', auditLogsToday) + counter('horizon_audit_events_7d', 'Audit log entries in last 7 days', auditLogsWeek) + + // ── Sync ─────────────────────────────────────────────────────────────── + gauge('horizon_sync_runs_total', 'Total AFW sync runs', syncRuns) + if (lastSyncStatus) { + gauge('horizon_sync_last_success', 'Last sync was successful (1=yes, 0=no)', + lastSyncStatus.status === 'completed' ? 1 : 0) + gauge('horizon_sync_last_run_age_seconds', 'Seconds since last sync run', + Math.floor((now.getTime() - new Date(lastSyncStatus.startedAt).getTime()) / 1000)) + } + + // ── Shape Import ─────────────────────────────────────────────────────── + gauge('horizon_shape_import_runs_total', 'Total Shape import runs', importRuns) + if (lastImportStatus) { + gauge('horizon_shape_import_last_success', 'Last Shape import was successful (1=yes, 0=no)', + lastImportStatus.status === 'completed' ? 1 : 0) + gauge('horizon_shape_import_last_run_age_seconds', 'Seconds since last Shape import', + Math.floor((now.getTime() - new Date(lastImportStatus.startedAt).getTime()) / 1000)) + } + + // ── Scrape meta ──────────────────────────────────────────────────────── + gauge('horizon_scrape_timestamp_seconds', 'Unix timestamp of this scrape', Math.floor(now.getTime() / 1000)) + + return new NextResponse(lines.join('\n') + '\n', { + status: 200, + headers: { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }, + }) + } catch (error: any) { + console.error('Metrics error:', error) + return new NextResponse('Internal server error', { status: 500 }) + } +} diff --git a/ondeck/src/app/api/tasks/[id]/notes/route.ts b/ondeck/src/app/api/tasks/[id]/notes/route.ts index 52f0210..94c0e87 100644 --- a/ondeck/src/app/api/tasks/[id]/notes/route.ts +++ b/ondeck/src/app/api/tasks/[id]/notes/route.ts @@ -74,6 +74,16 @@ export async function POST( }) } + await prisma.auditLog.create({ + data: { + userId, + action: 'TASK_NOTE_ADDED', + entityType: 'Task', + entityId: id, + newValues: { noteId: note.id, statusChange: status ?? null }, + }, + }) + return NextResponse.json(note, { status: 201 }) } catch (error) { console.error('Task notes POST error:', error) diff --git a/ondeck/src/app/api/tasks/route.ts b/ondeck/src/app/api/tasks/route.ts index d3dce67..56dd5b2 100644 --- a/ondeck/src/app/api/tasks/route.ts +++ b/ondeck/src/app/api/tasks/route.ts @@ -193,6 +193,16 @@ export async function POST(request: NextRequest) { }, }) + await prisma.auditLog.create({ + data: { + userId: (session.user as any).id, + action: 'TASK_CREATED', + entityType: 'Task', + entityId: task.id, + newValues: { title: task.title, clientId, isAdHoc: isAdHoc ?? false }, + }, + }) + return NextResponse.json(task, { status: 201 }) } catch (error) { console.error('Create task API error:', error) diff --git a/ondeck/src/middleware.ts b/ondeck/src/middleware.ts index d49db64..bd63678 100644 --- a/ondeck/src/middleware.ts +++ b/ondeck/src/middleware.ts @@ -47,6 +47,11 @@ export default withAuth( return true } + // Allow metrics endpoint (secured by Bearer token) + if (path === '/api/metrics') { + return true + } + // Require authentication for all other paths return !!token },