- Discriminated union TimelineEntry with report/classification/audit variants - Ascending sort by createdAt with stable report<classification<audit tie-break
85 lines
2.8 KiB
TypeScript
85 lines
2.8 KiB
TypeScript
/**
|
|
* 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];
|
|
});
|
|
}
|