From bc9aadc2c231b9b0a3d6c610305f2c24933fdc10 Mon Sep 17 00:00:00 2001 From: quartune <123062848+quartune@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:43:48 +0000 Subject: [PATCH] feat: add audit log viewer, creator marketplace, fraud alerts, and invoice recommendations Closes: #833 Closes: #831 Closes: #821 Closes: #817 --- src/__tests__/AuditLogViewer.test.tsx | 35 +++++++ src/__tests__/CreatorMarketplace.test.tsx | 31 ++++++ src/__tests__/InvoiceRecommendations.test.tsx | 39 ++++++++ .../SuspiciousActivityAlerts.test.tsx | 36 +++++++ src/components/audit/AuditLogViewer.tsx | 87 +++++++++++++++++ .../marketplace/CreatorMarketplace.tsx | 94 ++++++++++++++++++ .../InvoiceRecommendations.tsx | 42 ++++++++ .../security/SuspiciousActivityAlerts.tsx | 61 ++++++++++++ src/lib/auditLog.ts | 46 +++++++++ src/lib/creatorMarketplace.ts | 41 ++++++++ src/lib/invoiceRecommendations.ts | 57 +++++++++++ src/lib/suspiciousActivity.ts | 97 +++++++++++++++++++ 12 files changed, 666 insertions(+) create mode 100644 src/__tests__/AuditLogViewer.test.tsx create mode 100644 src/__tests__/CreatorMarketplace.test.tsx create mode 100644 src/__tests__/InvoiceRecommendations.test.tsx create mode 100644 src/__tests__/SuspiciousActivityAlerts.test.tsx create mode 100644 src/components/audit/AuditLogViewer.tsx create mode 100644 src/components/marketplace/CreatorMarketplace.tsx create mode 100644 src/components/recommendations/InvoiceRecommendations.tsx create mode 100644 src/components/security/SuspiciousActivityAlerts.tsx create mode 100644 src/lib/auditLog.ts create mode 100644 src/lib/creatorMarketplace.ts create mode 100644 src/lib/invoiceRecommendations.ts create mode 100644 src/lib/suspiciousActivity.ts diff --git a/src/__tests__/AuditLogViewer.test.tsx b/src/__tests__/AuditLogViewer.test.tsx new file mode 100644 index 0000000..62476ab --- /dev/null +++ b/src/__tests__/AuditLogViewer.test.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import AuditLogViewer from "@/components/audit/AuditLogViewer"; +import { auditEntriesToCsv, filterAuditEntries, type AuditEntry } from "@/lib/auditLog"; + +const entries: AuditEntry[] = [ + { id: "1", timestamp: 1000, actor: "GALICE", action: "Invoice created", category: "invoice", target: "INV-1" }, + { id: "2", timestamp: 3000, actor: "GBOB", action: "Signed in", category: "auth" }, + { id: "3", timestamp: 2000, actor: "GALICE", action: "Payment sent", category: "payment", details: 'note, "quoted"' }, +]; + +describe("auditLog", () => { + it("sorts newest first and filters by category, query and range", () => { + expect(filterAuditEntries(entries).map((e) => e.id)).toEqual(["2", "3", "1"]); + expect(filterAuditEntries(entries, { category: "auth" }).map((e) => e.id)).toEqual(["2"]); + expect(filterAuditEntries(entries, { query: "inv-1" }).map((e) => e.id)).toEqual(["1"]); + expect(filterAuditEntries(entries, { from: 1500, to: 2500 }).map((e) => e.id)).toEqual(["3"]); + }); + + it("escapes CSV cells", () => { + const csv = auditEntriesToCsv([entries[2]]); + expect(csv.split("\n")[1]).toContain('"note, ""quoted"""'); + }); +}); + +describe("AuditLogViewer", () => { + it("renders entries and filters by search and category", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + fireEvent.change(screen.getByLabelText("Search audit log"), { target: { value: "signed" } }); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + fireEvent.change(screen.getByLabelText("Filter by category"), { target: { value: "payment" } }); + expect(screen.getByText(/No audit entries/)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/CreatorMarketplace.test.tsx b/src/__tests__/CreatorMarketplace.test.tsx new file mode 100644 index 0000000..0da99a8 --- /dev/null +++ b/src/__tests__/CreatorMarketplace.test.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import CreatorMarketplace from "@/components/marketplace/CreatorMarketplace"; +import { discoverCreators, listCategories, type MarketplaceCreator } from "@/lib/creatorMarketplace"; + +const creators: MarketplaceCreator[] = [ + { address: "GA", name: "Ada", category: "Design", tags: ["logo"], rating: 4.2, completedInvoices: 30, verified: true }, + { address: "GB", name: "Bo", category: "Music", tags: ["mixing"], rating: 4.9, completedInvoices: 5, verified: false }, + { address: "GC", name: "Cy", category: "Design", tags: ["ui"], rating: 3.5, completedInvoices: 50, verified: true }, +]; + +describe("creatorMarketplace", () => { + it("sorts and filters creators", () => { + expect(discoverCreators(creators).map((c) => c.name)).toEqual(["Bo", "Ada", "Cy"]); + expect(discoverCreators(creators, { sort: "popular" }).map((c) => c.name)).toEqual(["Cy", "Ada", "Bo"]); + expect(discoverCreators(creators, { category: "Design", verifiedOnly: true, sort: "name" }).map((c) => c.name)).toEqual(["Ada", "Cy"]); + expect(discoverCreators(creators, { query: "MIX" }).map((c) => c.name)).toEqual(["Bo"]); + expect(listCategories(creators)).toEqual(["Design", "Music"]); + }); +}); + +describe("CreatorMarketplace", () => { + it("renders creators with profile links and supports filtering", () => { + render(); + expect(screen.getByRole("link", { name: "Ada" })).toHaveAttribute("href", "/creator/GA"); + fireEvent.click(screen.getByLabelText("Verified only")); + expect(screen.queryByText("Bo")).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Search creators"), { target: { value: "zzz" } }); + expect(screen.getByText("No creators found.")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/InvoiceRecommendations.test.tsx b/src/__tests__/InvoiceRecommendations.test.tsx new file mode 100644 index 0000000..d0090ec --- /dev/null +++ b/src/__tests__/InvoiceRecommendations.test.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import InvoiceRecommendations from "@/components/recommendations/InvoiceRecommendations"; +import { recommendInvoices, type PastInvoice } from "@/lib/invoiceRecommendations"; + +const DAY = 86_400_000; +const history: PastInvoice[] = [ + { id: "1", title: "Rent", amount: 900, recipients: ["GA", "GB"], createdAt: 0 }, + { id: "2", title: "rent ", amount: 900, recipients: ["GA", "GB"], createdAt: 30 * DAY }, + { id: "3", title: "Dinner", amount: 60, recipients: ["GA"], createdAt: 40 * DAY }, +]; + +describe("recommendInvoices", () => { + it("returns nothing for empty history", () => { + expect(recommendInvoices([])).toEqual([]); + }); + + it("suggests frequent recipients, typical amount and recurring invoices", () => { + const recs = recommendInvoices(history); + expect(recs[0]).toMatchObject({ type: "recipient", value: "GA", score: 1 }); + expect(recs.find((r) => r.type === "amount")?.value).toBe("900.00"); + expect(recs.find((r) => r.type === "recurring")?.label).toBe("Recurring every ~30 days"); + expect(recommendInvoices(history, 2)).toHaveLength(2); + }); +}); + +describe("InvoiceRecommendations", () => { + it("renders recommendations and applies one", () => { + const onApply = vi.fn(); + render(); + fireEvent.click(screen.getAllByRole("button", { name: "Use" })[0]); + expect(onApply).toHaveBeenCalledWith(expect.objectContaining({ value: "GA" })); + }); + + it("shows empty state", () => { + render(); + expect(screen.getByText(/Create a few invoices/)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/SuspiciousActivityAlerts.test.tsx b/src/__tests__/SuspiciousActivityAlerts.test.tsx new file mode 100644 index 0000000..a9830dc --- /dev/null +++ b/src/__tests__/SuspiciousActivityAlerts.test.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import SuspiciousActivityAlerts from "@/components/security/SuspiciousActivityAlerts"; +import { detectSuspiciousActivity, type ActivityEvent } from "@/lib/suspiciousActivity"; + +const normal: ActivityEvent[] = [ + { id: "a", actor: "GA", amount: 10, timestamp: 0 }, + { id: "b", actor: "GB", amount: 12, timestamp: 100_000 }, + { id: "c", actor: "GC", amount: 11, timestamp: 200_000 }, +]; + +describe("detectSuspiciousActivity", () => { + it("returns no alerts for normal activity", () => { + expect(detectSuspiciousActivity(normal)).toEqual([]); + }); + + it("flags velocity bursts, amount spikes and repeated amounts", () => { + const burst = Array.from({ length: 6 }, (_, i) => ({ id: `v${i}`, actor: "GX", amount: 5 + i, timestamp: i * 1000 })); + const repeats = Array.from({ length: 3 }, (_, i) => ({ id: `r${i}`, actor: "GR", amount: 7, timestamp: i * 600_000 })); + const spike = { id: "s", actor: "GS", amount: 10_000, timestamp: 0 }; + const alerts = detectSuspiciousActivity([...normal, ...burst, ...repeats, spike]); + expect(alerts.map((a) => a.rule)).toEqual(["velocity", "amount_spike", "repeated_amount"]); + expect(alerts[0].eventIds).toHaveLength(6); + }); +}); + +describe("SuspiciousActivityAlerts", () => { + it("shows empty state and dismisses alerts", () => { + const { rerender } = render(); + expect(screen.getByText("No suspicious activity detected.")).toBeInTheDocument(); + rerender(); + expect(screen.getByRole("alert")).toHaveTextContent(/medium risk/i); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/audit/AuditLogViewer.tsx b/src/components/audit/AuditLogViewer.tsx new file mode 100644 index 0000000..1fb19e8 --- /dev/null +++ b/src/components/audit/AuditLogViewer.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + auditEntriesToCsv, + filterAuditEntries, + type AuditCategory, + type AuditEntry, +} from "@/lib/auditLog"; + +const CATEGORIES: Array = ["all", "invoice", "payment", "auth", "settings", "admin"]; + +interface Props { + entries: AuditEntry[]; +} + +export default function AuditLogViewer({ entries }: Props) { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("all"); + const visible = useMemo(() => filterAuditEntries(entries, { query, category }), [entries, query, category]); + + const exportCsv = () => { + const url = URL.createObjectURL(new Blob([auditEntriesToCsv(visible)], { type: "text/csv" })); + const a = document.createElement("a"); + a.href = url; + a.download = "audit-log.csv"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+ setQuery(e.target.value)} + className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + + +
+ + {visible.length === 0 ? ( +

No audit entries match your filters.

+ ) : ( +
    + {visible.map((e) => ( +
  • + + {e.category} +
    +

    {e.action}

    + {e.details &&

    {e.details}

    } +
    + + {e.actor} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/marketplace/CreatorMarketplace.tsx b/src/components/marketplace/CreatorMarketplace.tsx new file mode 100644 index 0000000..b780dea --- /dev/null +++ b/src/components/marketplace/CreatorMarketplace.tsx @@ -0,0 +1,94 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { + discoverCreators, + listCategories, + type CreatorSort, + type MarketplaceCreator, +} from "@/lib/creatorMarketplace"; + +interface Props { + creators: MarketplaceCreator[]; +} + +export default function CreatorMarketplace({ creators }: Props) { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState("all"); + const [sort, setSort] = useState("rating"); + const [verifiedOnly, setVerifiedOnly] = useState(false); + const categories = useMemo(() => listCategories(creators), [creators]); + const results = useMemo( + () => discoverCreators(creators, { query, category, sort, verifiedOnly }), + [creators, query, category, sort, verifiedOnly], + ); + + return ( +
+
+ setQuery(e.target.value)} + className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm" + /> + + + +
+ + {results.length === 0 ? ( +

No creators found.

+ ) : ( +
    + {results.map((c) => ( +
  • + + {c.name} + + {c.verified && ✓ Verified} +

    {c.category}

    +

    + ★ {c.rating.toFixed(1)} · {c.completedInvoices} invoices +

    +
    + {c.tags.map((t) => ( + + {t} + + ))} +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/recommendations/InvoiceRecommendations.tsx b/src/components/recommendations/InvoiceRecommendations.tsx new file mode 100644 index 0000000..c2b0c25 --- /dev/null +++ b/src/components/recommendations/InvoiceRecommendations.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useMemo } from "react"; +import { recommendInvoices, type InvoiceRecommendation, type PastInvoice } from "@/lib/invoiceRecommendations"; + +interface Props { + history: PastInvoice[]; + limit?: number; + onApply?: (rec: InvoiceRecommendation) => void; +} + +export default function InvoiceRecommendations({ history, limit, onApply }: Props) { + const recs = useMemo(() => recommendInvoices(history, limit), [history, limit]); + + if (recs.length === 0) { + return

Create a few invoices to get recommendations.

; + } + + return ( +
    + {recs.map((r) => ( +
  • +
    +

    {r.label}

    +

    + {r.value} +

    +
    + {onApply && ( + + )} +
  • + ))} +
+ ); +} diff --git a/src/components/security/SuspiciousActivityAlerts.tsx b/src/components/security/SuspiciousActivityAlerts.tsx new file mode 100644 index 0000000..badc25e --- /dev/null +++ b/src/components/security/SuspiciousActivityAlerts.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + detectSuspiciousActivity, + type ActivityEvent, + type AlertSeverity, + type DetectionOptions, +} from "@/lib/suspiciousActivity"; + +const SEVERITY_STYLES: Record = { + high: "border-red-300 bg-red-50 text-red-800", + medium: "border-amber-300 bg-amber-50 text-amber-800", + low: "border-blue-300 bg-blue-50 text-blue-800", +}; + +interface Props { + events: ActivityEvent[]; + options?: DetectionOptions; +} + +export default function SuspiciousActivityAlerts({ events, options }: Props) { + const [dismissed, setDismissed] = useState>(new Set()); + const alerts = useMemo( + () => detectSuspiciousActivity(events, options).filter((a) => !dismissed.has(a.id)), + [events, options, dismissed], + ); + + if (alerts.length === 0) { + return

No suspicious activity detected.

; + } + + return ( +
    + {alerts.map((a) => ( +
  • +
    +

    + {a.severity} risk · {a.rule.replace("_", " ")} +

    +

    {a.message}

    +

    + {a.actor} +

    +
    + +
  • + ))} +
+ ); +} diff --git a/src/lib/auditLog.ts b/src/lib/auditLog.ts new file mode 100644 index 0000000..fac8627 --- /dev/null +++ b/src/lib/auditLog.ts @@ -0,0 +1,46 @@ +export type AuditCategory = "invoice" | "payment" | "auth" | "settings" | "admin"; + +export interface AuditEntry { + id: string; + timestamp: number; + actor: string; + action: string; + category: AuditCategory; + target?: string; + details?: string; +} + +export interface AuditFilter { + query?: string; + category?: AuditCategory | "all"; + from?: number; + to?: number; +} + +export function filterAuditEntries(entries: AuditEntry[], filter: AuditFilter = {}): AuditEntry[] { + const q = filter.query?.trim().toLowerCase() ?? ""; + return entries + .filter((e) => !filter.category || filter.category === "all" || e.category === filter.category) + .filter((e) => filter.from === undefined || e.timestamp >= filter.from) + .filter((e) => filter.to === undefined || e.timestamp <= filter.to) + .filter( + (e) => + !q || + [e.actor, e.action, e.target, e.details].some((f) => f?.toLowerCase().includes(q)), + ) + .sort((a, b) => b.timestamp - a.timestamp); +} + +function csvCell(value: string): string { + return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; +} + +export function auditEntriesToCsv(entries: AuditEntry[]): string { + const header = "timestamp,actor,category,action,target,details"; + const rows = entries.map((e) => + [new Date(e.timestamp).toISOString(), e.actor, e.category, e.action, e.target ?? "", e.details ?? ""] + .map(csvCell) + .join(","), + ); + return [header, ...rows].join("\n"); +} diff --git a/src/lib/creatorMarketplace.ts b/src/lib/creatorMarketplace.ts new file mode 100644 index 0000000..d09b67f --- /dev/null +++ b/src/lib/creatorMarketplace.ts @@ -0,0 +1,41 @@ +export interface MarketplaceCreator { + address: string; + name: string; + category: string; + tags: string[]; + rating: number; + completedInvoices: number; + verified: boolean; +} + +export type CreatorSort = "rating" | "popular" | "name"; + +export interface CreatorDiscoveryOptions { + query?: string; + category?: string; + verifiedOnly?: boolean; + sort?: CreatorSort; +} + +export function discoverCreators( + creators: MarketplaceCreator[], + { query = "", category = "all", verifiedOnly = false, sort = "rating" }: CreatorDiscoveryOptions = {}, +): MarketplaceCreator[] { + const q = query.trim().toLowerCase(); + const result = creators.filter( + (c) => + (category === "all" || c.category === category) && + (!verifiedOnly || c.verified) && + (!q || c.name.toLowerCase().includes(q) || c.tags.some((t) => t.toLowerCase().includes(q))), + ); + const comparators: Record number> = { + rating: (a, b) => b.rating - a.rating || b.completedInvoices - a.completedInvoices, + popular: (a, b) => b.completedInvoices - a.completedInvoices, + name: (a, b) => a.name.localeCompare(b.name), + }; + return result.sort(comparators[sort]); +} + +export function listCategories(creators: MarketplaceCreator[]): string[] { + return Array.from(new Set(creators.map((c) => c.category))).sort(); +} diff --git a/src/lib/invoiceRecommendations.ts b/src/lib/invoiceRecommendations.ts new file mode 100644 index 0000000..448e8b5 --- /dev/null +++ b/src/lib/invoiceRecommendations.ts @@ -0,0 +1,57 @@ +export interface PastInvoice { + id: string; + title: string; + amount: number; + recipients: string[]; + createdAt: number; +} + +export type RecommendationType = "recipient" | "amount" | "recurring"; + +export interface InvoiceRecommendation { + id: string; + type: RecommendationType; + label: string; + value: string; + score: number; +} + +const DAY_MS = 86_400_000; + +export function recommendInvoices(history: PastInvoice[], limit = 5): InvoiceRecommendation[] { + if (history.length === 0) return []; + const recs: InvoiceRecommendation[] = []; + + const recipientCounts = new Map(); + for (const inv of history) for (const r of inv.recipients) recipientCounts.set(r, (recipientCounts.get(r) ?? 0) + 1); + recipientCounts.forEach((count, address) => { + if (count >= 2) { + recs.push({ id: `recipient-${address}`, type: "recipient", label: "Frequent recipient", value: address, score: count / history.length }); + } + }); + + const amounts = history.map((i) => i.amount).sort((a, b) => a - b); + const mid = Math.floor(amounts.length / 2); + const typical = amounts.length % 2 ? amounts[mid] : (amounts[mid - 1] + amounts[mid]) / 2; + recs.push({ id: "amount-typical", type: "amount", label: "Typical amount", value: typical.toFixed(2), score: 0.5 }); + + const byTitle = new Map(); + for (const inv of history) { + const key = inv.title.trim().toLowerCase(); + byTitle.set(key, [...(byTitle.get(key) ?? []), inv]); + } + byTitle.forEach((list) => { + if (list.length < 2) return; + const sorted = [...list].sort((a, b) => a.createdAt - b.createdAt); + const avgGapDays = (sorted[sorted.length - 1].createdAt - sorted[0].createdAt) / (sorted.length - 1) / DAY_MS; + recs.push({ + id: `recurring-${sorted[0].id}`, + type: "recurring", + label: `Recurring every ~${Math.max(1, Math.round(avgGapDays))} days`, + value: sorted[sorted.length - 1].title, + score: Math.min(1, list.length / history.length + 0.2), + }); + }); + + return recs.sort((a, b) => b.score - a.score).slice(0, limit); +} diff --git a/src/lib/suspiciousActivity.ts b/src/lib/suspiciousActivity.ts new file mode 100644 index 0000000..1641da4 --- /dev/null +++ b/src/lib/suspiciousActivity.ts @@ -0,0 +1,97 @@ +export interface ActivityEvent { + id: string; + actor: string; + amount: number; + timestamp: number; +} + +export type AlertSeverity = "low" | "medium" | "high"; +export type AlertRule = "velocity" | "amount_spike" | "repeated_amount"; + +export interface SuspiciousActivityAlert { + id: string; + rule: AlertRule; + severity: AlertSeverity; + actor: string; + message: string; + eventIds: string[]; +} + +export interface DetectionOptions { + /** Max events per actor inside `velocityWindowMs` before flagging. */ + velocityLimit?: number; + velocityWindowMs?: number; + /** Amount above `spikeMultiplier` × median of all events is flagged. */ + spikeMultiplier?: number; + /** Same actor sending the same amount this many times is flagged. */ + repeatLimit?: number; +} + +function median(values: number[]): number { + if (values.length === 0) return 0; + const s = [...values].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +export function detectSuspiciousActivity( + events: ActivityEvent[], + { velocityLimit = 5, velocityWindowMs = 60_000, spikeMultiplier = 10, repeatLimit = 3 }: DetectionOptions = {}, +): SuspiciousActivityAlert[] { + const alerts: SuspiciousActivityAlert[] = []; + const byActor = new Map(); + for (const e of events) byActor.set(e.actor, [...(byActor.get(e.actor) ?? []), e]); + + byActor.forEach((list, actor) => { + const sorted = [...list].sort((a, b) => a.timestamp - b.timestamp); + for (let start = 0, end = 0; end < sorted.length; end++) { + while (sorted[end].timestamp - sorted[start].timestamp > velocityWindowMs) start++; + if (end - start + 1 > velocityLimit) { + const burst = sorted.slice(start, end + 1); + alerts.push({ + id: `velocity-${actor}`, + rule: "velocity", + severity: "high", + actor, + message: `${burst.length} transactions within ${Math.round(velocityWindowMs / 1000)}s`, + eventIds: burst.map((e) => e.id), + }); + break; + } + } + + const counts = new Map(); + for (const e of list) counts.set(e.amount, [...(counts.get(e.amount) ?? []), e]); + counts.forEach((same, amount) => { + if (same.length >= repeatLimit) { + alerts.push({ + id: `repeated-${actor}-${amount}`, + rule: "repeated_amount", + severity: "low", + actor, + message: `Same amount (${amount}) sent ${same.length} times`, + eventIds: same.map((e) => e.id), + }); + } + }); + }); + + const baseline = median(events.map((e) => e.amount)); + if (baseline > 0) { + for (const e of events) { + if (e.amount > baseline * spikeMultiplier) { + alerts.push({ + id: `spike-${e.id}`, + rule: "amount_spike", + severity: "medium", + actor: e.actor, + message: `Amount ${e.amount} is over ${spikeMultiplier}× the typical ${baseline}`, + eventIds: [e.id], + }); + } + } + } + + const rank: Record = { high: 0, medium: 1, low: 2 }; + return alerts.sort((a, b) => rank[a.severity] - rank[b.severity]); +}