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:
parent
37f6657839
commit
d30a49f644
1 changed files with 85 additions and 0 deletions
85
lib/services/phishing-timeline.ts
Normal file
85
lib/services/phishing-timeline.ts
Normal 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];
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue