/** * Phishing Campaign Timeline Merge (Phase 22, Wave 0, REVIEW-02) * * Pure transform: merges the three already-camelCased sources consumed by * `TimelineCard` (reports, classifications, audit_events — see * app/api/phishing/campaigns/[id]/route.ts's response shape and * lib/services/phishing-audit.ts's canonical event_type values) into a * single ascending-by-timestamp array. Server-merge approach chosen per * 22-RESEARCH.md Alternatives Considered. No DB/fetch imports — pure * function, takes already-fetched row arrays. */ export type TimelineEntry = | { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null } | { kind: 'classification'; at: string; verdict: string; confidence: string | null } | { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown }; interface ReportTimelineSource { createdAt: string; reportId: string; ticketNumber: string | null; companyName: string | null; } interface ClassificationTimelineSource { createdAt: string; verdict: string; confidence: string | null; } interface AuditTimelineSource { createdAt: string; eventType: string; actor: string | null; payload: unknown; } /** Fixed tie-break priority when two entries share an identical `at` timestamp. */ const KIND_PRIORITY: Record = { report: 0, classification: 1, audit: 2, }; /** * Maps each of the three sources into its `TimelineEntry` variant, * concatenates, and sorts ascending by `at` (oldest first — matches the * existing `ORDER BY created_at ASC` convention). Ties on identical * timestamps fall back to a fixed kind priority (report < classification < * audit) for a stable, deterministic order. */ export function mergeTimeline( reports: ReportTimelineSource[], classifications: ClassificationTimelineSource[], auditEvents: AuditTimelineSource[] ): TimelineEntry[] { const entries: TimelineEntry[] = [ ...reports.map((report) => ({ kind: 'report', at: report.createdAt, reportId: report.reportId, ticketNumber: report.ticketNumber, companyName: report.companyName, })), ...classifications.map((classification) => ({ kind: 'classification', at: classification.createdAt, verdict: classification.verdict, confidence: classification.confidence, })), ...auditEvents.map((event) => ({ kind: 'audit', at: event.createdAt, eventType: event.eventType, actor: event.actor, payload: event.payload, })), ]; return entries.sort((a, b) => { const diff = new Date(a.at).getTime() - new Date(b.at).getTime(); if (diff !== 0) return diff; return KIND_PRIORITY[a.kind] - KIND_PRIORITY[b.kind]; }); }