diff --git a/lib/services/phishing-timeline.ts b/lib/services/phishing-timeline.ts new file mode 100644 index 0000000..837d0c7 --- /dev/null +++ b/lib/services/phishing-timeline.ts @@ -0,0 +1,85 @@ +/** + * 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]; + }); +}