feat(22-01): implement mergeTimeline for reports/classifications/audit merge

- Discriminated union TimelineEntry with report/classification/audit variants
- Ascending sort by createdAt with stable report<classification<audit tie-break
This commit is contained in:
lorentz 2026-07-16 14:28:27 -04:00
parent 37f6657839
commit d30a49f644

View file

@ -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<TimelineEntry['kind'], number> = {
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<TimelineEntry>((report) => ({
kind: 'report',
at: report.createdAt,
reportId: report.reportId,
ticketNumber: report.ticketNumber,
companyName: report.companyName,
})),
...classifications.map<TimelineEntry>((classification) => ({
kind: 'classification',
at: classification.createdAt,
verdict: classification.verdict,
confidence: classification.confidence,
})),
...auditEvents.map<TimelineEntry>((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];
});
}