diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx index d4cc9d1..7334d15 100644 --- a/src/app/(dashboard)/reports/page.tsx +++ b/src/app/(dashboard)/reports/page.tsx @@ -45,12 +45,12 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom const sections: ReportSectionEntry[] = [ { id: "findings", title: "Key Findings", defaultSpan: 12, content: }, - { id: "exposure", title: "Exposure", defaultSpan: 12, content: }, + { id: "exposure", title: "Exposure", defaultSpan: 12, content: }, { id: "datatypes", title: "Data Types", defaultSpan: 6, content: }, { id: "departments", title: "Departments", defaultSpan: 6, content: }, { id: "trends", title: "Trends", defaultSpan: 12, content: }, { id: "employees", title: "Employees", defaultSpan: 12, content: }, - { id: "compliance", title: "Compliance", defaultSpan: 12, content: }, + { id: "compliance", title: "Compliance", defaultSpan: 12, content: }, ] return ( diff --git a/src/components/dashboard/StatCard.tsx b/src/components/dashboard/StatCard.tsx index 483fefb..59587bd 100644 --- a/src/components/dashboard/StatCard.tsx +++ b/src/components/dashboard/StatCard.tsx @@ -1,5 +1,12 @@ import { cn } from "@/lib/utils" -import type { LucideIcon } from "lucide-react" +import { ArrowUp, ArrowDown, Minus, type LucideIcon } from "lucide-react" + +export type StatDelta = { + value: number + label?: string + // Which direction is "good" (green). Security metrics usually want "down". + goodWhen?: "up" | "down" +} interface StatCardProps { label: string @@ -7,6 +14,7 @@ interface StatCardProps { description?: string icon: LucideIcon variant?: "default" | "critical" | "high" | "medium" | "ok" + delta?: StatDelta } const variants = { @@ -43,6 +51,7 @@ export function StatCard({ description, icon: Icon, variant = "default", + delta, }: StatCardProps) { const v = variants[variant] @@ -57,6 +66,7 @@ export function StatCard({ {description && (

{description}

)} + {delta && }
@@ -65,3 +75,24 @@ export function StatCard({
) } + +function DeltaPill({ delta }: { delta: StatDelta }) { + const { value, label, goodWhen = "up" } = delta + const Icon = value > 0 ? ArrowUp : value < 0 ? ArrowDown : Minus + const direction = value > 0 ? "up" : value < 0 ? "down" : "flat" + const tone = + direction === "flat" + ? "text-muted-foreground" + : direction === goodWhen + ? "text-severity-ok" + : "text-severity-high" + + return ( +

+ + {value > 0 ? "+" : ""} + {value} + {label && {label}} +

+ ) +} diff --git a/src/components/reports/ComplianceSection.tsx b/src/components/reports/ComplianceSection.tsx index 3e4ca72..6c055c5 100644 --- a/src/components/reports/ComplianceSection.tsx +++ b/src/components/reports/ComplianceSection.tsx @@ -1,9 +1,9 @@ import { ShieldCheck, BellRing, CheckCircle2, AlertTriangle } from "lucide-react" import { StatCard } from "@/components/dashboard/StatCard" import { ReportSection } from "./ReportSection" -import type { ComplianceSummary } from "@/lib/reports/types" +import type { ComplianceSummary, ReportDeltas } from "@/lib/reports/types" -export function ComplianceSection({ data }: { data: ComplianceSummary }) { +export function ComplianceSection({ data, deltas }: { data: ComplianceSummary; deltas: ReportDeltas }) { return ( - + + - - {/* Flow grid (screen) */} + {/* Flow grid (screen). Rows stretch and dense flow backfills holes, so 1/3 and + 1/2 tiles fill empty space instead of leaving gaps. */} setActiveId(null)} > -
+
{visibleOrder.map((id) => { const section = byId.get(id) if (!section) return null diff --git a/src/components/reports/ReportSection.tsx b/src/components/reports/ReportSection.tsx index 9f3c280..4d163cc 100644 --- a/src/components/reports/ReportSection.tsx +++ b/src/components/reports/ReportSection.tsx @@ -8,7 +8,7 @@ interface ReportSectionProps { export function ReportSection({ title, description, children }: ReportSectionProps) { return ( -
+

{title}

{description &&

{description}

} diff --git a/src/lib/reports/csv.ts b/src/lib/reports/csv.ts index bd6d674..4c52d19 100644 --- a/src/lib/reports/csv.ts +++ b/src/lib/reports/csv.ts @@ -38,7 +38,16 @@ function exposureCsv(d: ReportData): string { ["breach", "source", "breach date", "affected employees"], e.topBreaches.map((b) => [b.name, b.source, b.breachDate, b.affectedEmployees]) ) - return `${summary}\n\nTop breaches\n${breaches}` + const dl = d.deltas + const deltas = toCsv( + ["change", "current", "previous"], + [ + ["Newly exposed employees", dl.newlyExposed.current, dl.newlyExposed.previous], + ["Breaches with new detections", dl.newBreaches.current, dl.newBreaches.previous], + ["New alerts", dl.newAlerts.current, dl.newAlerts.previous], + ] + ) + return `${summary}\n\nTop breaches\n${breaches}\n\nPeriod change (${dl.windowLabel})\n${deltas}` } function dataTypesCsv(d: ReportData): string { diff --git a/src/lib/reports/deltas.ts b/src/lib/reports/deltas.ts new file mode 100644 index 0000000..49fa2d3 --- /dev/null +++ b/src/lib/reports/deltas.ts @@ -0,0 +1,100 @@ +import { prisma } from "@/lib/prisma" +import { employeeWhere, NO_DEPARTMENT, type ReportFilters } from "./filters" +import type { Delta, ReportDeltas } from "./types" + +const DAY = 24 * 60 * 60 * 1000 + +type Window = { curStart: Date; curEnd: Date; prevStart: Date; prevEnd: Date; label: string } + +// Comparison windows derived from the active filters. With an explicit date range +// we compare it against the equal-length window immediately before it; otherwise we +// compare the trailing 30 days against the 30 days before that. +function windows(f: ReportFilters): Window { + if (f.from && f.to) { + const curStart = new Date(`${f.from}T00:00:00.000`) + const curEnd = new Date(`${f.to}T23:59:59.999`) + const len = curEnd.getTime() - curStart.getTime() + return { + curStart, + curEnd, + prevStart: new Date(curStart.getTime() - len), + prevEnd: curStart, + label: "vs previous period", + } + } + const now = new Date() + const curStart = new Date(now.getTime() - 30 * DAY) + return { + curStart, + curEnd: now, + prevStart: new Date(now.getTime() - 60 * DAY), + prevEnd: curStart, + label: "in last 30 days", + } +} + +function recordInWindow(f: ReportFilters, start: Date, end: Date) { + return { + detectedAt: { gte: start, lt: end }, + ...(f.dataType && { exposedData: { has: f.dataType } }), + } +} + +function alertDept(f: ReportFilters) { + return f.department + ? { employee: { department: f.department === NO_DEPARTMENT ? null : f.department } } + : {} +} + +async function newlyExposed(companyId: string, f: ReportFilters, start: Date, end: Date): Promise { + return prisma.employee.count({ + where: { + ...employeeWhere(companyId, f), + breachRecords: { some: recordInWindow(f, start, end) }, + // First-time exposure: no breach record (of the same data-type scope) before the window. + NOT: { + breachRecords: { + some: { detectedAt: { lt: start }, ...(f.dataType && { exposedData: { has: f.dataType } }) }, + }, + }, + }, + }) +} + +function newBreaches(companyId: string, f: ReportFilters, start: Date, end: Date): Promise { + return prisma.breach.count({ + where: { records: { some: { employee: employeeWhere(companyId, f), ...recordInWindow(f, start, end) } } }, + }) +} + +function newAlerts(companyId: string, f: ReportFilters, start: Date, end: Date): Promise { + return prisma.alert.count({ + where: { companyId, createdAt: { gte: start, lt: end }, ...alertDept(f) }, + }) +} + +export async function getDeltas(companyId: string, f: ReportFilters): Promise { + const w = windows(f) + + const [ + exposedCur, exposedPrev, + breachesCur, breachesPrev, + alertsCur, alertsPrev, + ] = await Promise.all([ + newlyExposed(companyId, f, w.curStart, w.curEnd), + newlyExposed(companyId, f, w.prevStart, w.prevEnd), + newBreaches(companyId, f, w.curStart, w.curEnd), + newBreaches(companyId, f, w.prevStart, w.prevEnd), + newAlerts(companyId, f, w.curStart, w.curEnd), + newAlerts(companyId, f, w.prevStart, w.prevEnd), + ]) + + const delta = (current: number, previous: number): Delta => ({ current, previous }) + + return { + windowLabel: w.label, + newlyExposed: delta(exposedCur, exposedPrev), + newBreaches: delta(breachesCur, breachesPrev), + newAlerts: delta(alertsCur, alertsPrev), + } +} diff --git a/src/lib/reports/findings.ts b/src/lib/reports/findings.ts index 2776f7e..1d2c3f3 100644 --- a/src/lib/reports/findings.ts +++ b/src/lib/reports/findings.ts @@ -4,6 +4,7 @@ import type { DataTypeExposure, ExposureSummary, Finding, + ReportDeltas, } from "./types" const ORDER: Record = { @@ -85,14 +86,41 @@ function dataFindings(types: DataTypeExposure[]): Finding[] { return out } +function trendWord(current: number, previous: number): string { + if (current > previous) return `up from ${previous}` + if (current < previous) return `down from ${previous}` + return `unchanged from ${previous}` +} + +function deltaFindings(d: ReportDeltas): Finding[] { + const out: Finding[] = [] + const { newlyExposed, newBreaches } = d + + if (newlyExposed.current > 0) + out.push({ + severity: newlyExposed.current > newlyExposed.previous ? "high" : "medium", + message: `${plural(newlyExposed.current, "employee")} newly exposed in the latest period (${trendWord(newlyExposed.current, newlyExposed.previous)}).`, + }) + + if (newBreaches.current > 0) + out.push({ + severity: newBreaches.current > newBreaches.previous ? "medium" : "info", + message: `${plural(newBreaches.current, "breach")} with new detections in the latest period (${trendWord(newBreaches.current, newBreaches.previous)}).`, + }) + + return out +} + export function buildFindings( exposure: ExposureSummary, compliance: ComplianceSummary, - dataTypes: DataTypeExposure[] + dataTypes: DataTypeExposure[], + deltas: ReportDeltas ): Finding[] { return [ ...alertFindings(compliance), ...exposureFindings(exposure), ...dataFindings(dataTypes), + ...deltaFindings(deltas), ].sort((a, b) => ORDER[a.severity] - ORDER[b.severity]) } diff --git a/src/lib/reports/index.ts b/src/lib/reports/index.ts index 4d9675a..c06b7ce 100644 --- a/src/lib/reports/index.ts +++ b/src/lib/reports/index.ts @@ -5,6 +5,7 @@ import { getEmployeeBreakdown } from "./by-employee" import { getTrends } from "./trends" import { getCompliance } from "./compliance" import { buildFindings } from "./findings" +import { getDeltas } from "./deltas" import { EMPTY_FILTERS, type ReportFilters } from "./filters" import type { ReportData } from "./types" @@ -15,7 +16,7 @@ export async function getReportData( companyId: string, filters: ReportFilters = EMPTY_FILTERS, ): Promise { - const [exposure, dataTypes, departments, employees, trends, compliance] = + const [exposure, dataTypes, departments, employees, trends, compliance, deltas] = await Promise.all([ getExposureSummary(companyId, filters), getDataTypeExposure(companyId, filters), @@ -23,16 +24,18 @@ export async function getReportData( getEmployeeBreakdown(companyId, filters), getTrends(companyId, filters), getCompliance(companyId, filters), + getDeltas(companyId, filters), ]) return { generatedAt: new Date().toISOString(), - findings: buildFindings(exposure, compliance, dataTypes), + findings: buildFindings(exposure, compliance, dataTypes, deltas), exposure, dataTypes, departments, employees, trends, compliance, + deltas, } } diff --git a/src/lib/reports/types.ts b/src/lib/reports/types.ts index 602a155..b0600d4 100644 --- a/src/lib/reports/types.ts +++ b/src/lib/reports/types.ts @@ -80,6 +80,18 @@ export type Finding = { message: string } +export type Delta = { + current: number + previous: number +} + +export type ReportDeltas = { + windowLabel: string + newlyExposed: Delta + newBreaches: Delta + newAlerts: Delta +} + export type ReportData = { generatedAt: string findings: Finding[] @@ -89,4 +101,5 @@ export type ReportData = { employees: EmployeeReportRow[] trends: Trends compliance: ComplianceSummary + deltas: ReportDeltas }