diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx index a59e15d..d4cc9d1 100644 --- a/src/app/(dashboard)/reports/page.tsx +++ b/src/app/(dashboard)/reports/page.tsx @@ -1,6 +1,11 @@ import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" import { getReportData } from "@/lib/reports" +import { parseReportFilters, filtersToQuery } from "@/lib/reports/filters" +import { PRESET_DATA_TYPES } from "@/lib/dataTypes" import { ReportToolbar } from "@/components/reports/ReportToolbar" +import { ReportFilterBar } from "@/components/reports/ReportFilterBar" +import { ReportCanvas, type ReportSectionEntry } from "@/components/reports/ReportCanvas" import { KeyFindingsSection } from "@/components/reports/KeyFindingsSection" import { ExposureSection } from "@/components/reports/ExposureSection" import { DataTypeSection } from "@/components/reports/DataTypeSection" @@ -10,19 +15,46 @@ import { EmployeeSection } from "@/components/reports/EmployeeSection" import { ComplianceSection } from "@/components/reports/ComplianceSection" function formatGeneratedAt(iso: string): string { - return new Date(iso).toLocaleString("en-US", { - dateStyle: "long", - timeStyle: "short", - }) + return new Date(iso).toLocaleString("en-US", { dateStyle: "long", timeStyle: "short" }) } -export default async function ReportsPage() { +type SearchParams = Record + +export default async function ReportsPage({ searchParams }: { searchParams: Promise }) { const session = await auth() - const data = await getReportData(session!.user.companyId) + const companyId = session!.user.companyId + + const sp = await searchParams + const params = new URLSearchParams() + for (const [k, v] of Object.entries(sp)) { + if (typeof v === "string") params.set(k, v) + } + const filters = parseReportFilters(params) + const filterQuery = filtersToQuery(filters) + + const [data, deptGroups] = await Promise.all([ + getReportData(companyId, filters), + prisma.employee.groupBy({ by: ["department"], where: { companyId } }), + ]) + + const departments = [ + ...deptGroups.map((d) => d.department).filter((d): d is string => d !== null).sort(), + ...(deptGroups.some((d) => d.department === null) ? ["Unknown"] : []), + ] const companyName = session!.user.name ?? "" + const sections: ReportSectionEntry[] = [ + { id: "findings", title: "Key Findings", 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: }, + ] + return ( -
+

DataShield Security Report

@@ -30,24 +62,26 @@ export default async function ReportsPage() {

-
+

Reports

-

- Exposure, employees, trends and compliance overview -

+

Exposure, employees, trends and compliance overview

- + +
+ +
+ ({ key: t.key, label: t.label }))} />
-
- - - - - - - + {/* Interactive grid (screen) */} + + + {/* Print-only stacked layout */} +
+ {sections.map((s) => ( +
{s.content}
+ ))}
) diff --git a/src/app/api/reports/export/route.ts b/src/app/api/reports/export/route.ts index 9be8b77..08220e4 100644 --- a/src/app/api/reports/export/route.ts +++ b/src/app/api/reports/export/route.ts @@ -1,5 +1,6 @@ import { requireAuth } from "@/lib/apiAuth" import { getReportData } from "@/lib/reports" +import { parseReportFilters } from "@/lib/reports/filters" import { reportCsv, type CsvSection } from "@/lib/reports/csv" const SECTIONS: CsvSection[] = [ @@ -20,10 +21,12 @@ export async function GET(request: Request): Promise { const { session, error } = await requireAuth() if (error) return error - const section = new URL(request.url).searchParams.get("section") ?? "all" + const sp = new URL(request.url).searchParams + const section = sp.get("section") ?? "all" if (!isSection(section)) return new Response("Invalid section", { status: 400 }) - const data = await getReportData(session.user.companyId) + const filters = parseReportFilters(sp) + const data = await getReportData(session.user.companyId, filters) const csv = reportCsv(section, data) return new Response(csv, { diff --git a/src/components/reports/ReportCanvas.tsx b/src/components/reports/ReportCanvas.tsx new file mode 100644 index 0000000..99f21b0 --- /dev/null +++ b/src/components/reports/ReportCanvas.tsx @@ -0,0 +1,298 @@ +"use client" + +import { useState, useCallback, useEffect, useRef, type ReactNode } from "react" +import { + DndContext, + DragOverlay, + closestCenter, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core" +import { + SortableContext, + rectSortingStrategy, + useSortable, + arrayMove, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { Settings2, Check, GripVertical, Eye, EyeOff, RotateCcw } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +const STORAGE_KEY = "datashield:reports:layout:v2" + +export type Span = 4 | 6 | 12 + +export type ReportSectionEntry = { + id: string + title: string + content: ReactNode + defaultSpan?: Span +} + +type Saved = { order: string[]; spans: Record; hidden: string[] } + +const SPAN_CLASS: Record = { + 4: "col-span-12 md:col-span-4", + 6: "col-span-12 md:col-span-6", + 12: "col-span-12", +} + +const SPAN_OPTIONS: { value: Span; label: string }[] = [ + { value: 4, label: "1/3" }, + { value: 6, label: "1/2" }, + { value: 12, label: "Full" }, +] + +function SortableSection({ + section, + span, + editing, + onSpan, +}: { + section: ReportSectionEntry + span: Span + editing: boolean + onSpan: (span: Span) => void +}) { + const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = + useSortable({ id: section.id, disabled: !editing }) + + return ( +
+ {editing && ( +
+
+ {SPAN_OPTIONS.map((o) => ( + + ))} +
+ +
+ )} + {section.content} +
+ ) +} + +export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) { + const [editing, setEditing] = useState(false) + const [order, setOrder] = useState(() => sections.map((s) => s.id)) + const [spans, setSpans] = useState>( + () => Object.fromEntries(sections.map((s) => [s.id, s.defaultSpan ?? 12])), + ) + const [hidden, setHidden] = useState([]) + const [sectionsMenuOpen, setSectionsMenuOpen] = useState(false) + const [activeId, setActiveId] = useState(null) + const [overlayW, setOverlayW] = useState() + const menuRef = useRef(null) + const gridRef = useRef(null) + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })) + + // Hydrate from localStorage after mount (avoids SSR mismatch). + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return + const saved: Saved = JSON.parse(raw) + const ids = new Set(sections.map((s) => s.id)) + const savedOrder = (saved.order ?? []).filter((id) => ids.has(id)) + const missing = sections.map((s) => s.id).filter((id) => !savedOrder.includes(id)) + setOrder([...savedOrder, ...missing]) + setSpans((prev) => ({ ...prev, ...saved.spans })) + setHidden((saved.hidden ?? []).filter((id) => ids.has(id))) + } catch { + /* ignore corrupt storage */ + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!sectionsMenuOpen) return + const handler = (e: MouseEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setSectionsMenuOpen(false) + } + document.addEventListener("mousedown", handler) + return () => document.removeEventListener("mousedown", handler) + }, [sectionsMenuOpen]) + + const persist = useCallback((next: Partial) => { + try { + const cur: Saved = { order, spans, hidden, ...next } + localStorage.setItem(STORAGE_KEY, JSON.stringify(cur)) + } catch { + /* storage unavailable */ + } + }, [order, spans, hidden]) + + const byId = new Map(sections.map((s) => [s.id, s])) + const visibleOrder = order.filter((id) => !hidden.includes(id)) + + const onDragStart = (e: DragStartEvent) => { + setActiveId(e.active.id as string) + const el = gridRef.current?.querySelector(`[data-section-id="${e.active.id}"]`) + setOverlayW(el?.getBoundingClientRect().width) + } + + const onDragEnd = (e: DragEndEvent) => { + setActiveId(null) + const { active, over } = e + if (!over || active.id === over.id) return + setOrder((prev) => { + const next = arrayMove(prev, prev.indexOf(active.id as string), prev.indexOf(over.id as string)) + persist({ order: next }) + return next + }) + } + + const setSpan = (id: string, span: Span) => { + setSpans((prev) => { + const next = { ...prev, [id]: span } + persist({ spans: next }) + return next + }) + } + + const toggleHidden = (id: string) => { + setHidden((prev) => { + const next = prev.includes(id) ? prev.filter((h) => h !== id) : [...prev, id] + persist({ hidden: next }) + return next + }) + } + + const reset = () => { + const o = sections.map((s) => s.id) + const sp = Object.fromEntries(sections.map((s) => [s.id, s.defaultSpan ?? 12])) as Record + setOrder(o) + setSpans(sp) + setHidden([]) + persist({ order: o, spans: sp, hidden: [] }) + } + + return ( +
+ {/* Toolbar */} +
+ {editing && ( + <> +

Drag to reorder, set width, toggle sections

+
+ + {sectionsMenuOpen && ( +
+ {sections.map((s) => { + const isHidden = hidden.includes(s.id) + return ( + + ) + })} +
+ )} +
+ + + )} + {editing ? ( + + ) : ( + + )} +
+ + {/* Flow grid (screen) */} + setActiveId(null)} + > + +
+ {visibleOrder.map((id) => { + const section = byId.get(id) + if (!section) return null + return ( + setSpan(id, span)} + /> + ) + })} +
+
+ + {activeId ? ( +
+ {byId.get(activeId)?.content} +
+ ) : null} +
+
+
+ ) +} diff --git a/src/components/reports/ReportFilterBar.tsx b/src/components/reports/ReportFilterBar.tsx new file mode 100644 index 0000000..e696993 --- /dev/null +++ b/src/components/reports/ReportFilterBar.tsx @@ -0,0 +1,102 @@ +"use client" + +import { useRouter, usePathname } from "next/navigation" +import { useTransition } from "react" +import { X, Filter } from "lucide-react" +import { NO_DEPARTMENT, filtersToQuery, hasActiveFilters, type ReportFilters } from "@/lib/reports/filters" + +type DataTypeOption = { key: string; label: string } + +const fieldCls = + "h-8 rounded-md border border-border bg-card px-2 text-xs text-foreground outline-none focus:border-primary" + +export function ReportFilterBar({ + filters, + departments, + dataTypes, +}: { + filters: ReportFilters + departments: string[] + dataTypes: DataTypeOption[] +}) { + const router = useRouter() + const pathname = usePathname() + const [pending, startTransition] = useTransition() + + const apply = (next: ReportFilters) => { + const qs = filtersToQuery(next) + startTransition(() => router.push(qs ? `${pathname}?${qs}` : pathname, { scroll: false })) + } + + const set = (patch: Partial) => apply({ ...filters, ...patch }) + + return ( +
+ + + Filters + + + + + + + + + + {hasActiveFilters(filters) && ( + + )} + {pending && ...} +
+ ) +} diff --git a/src/components/reports/ReportToolbar.tsx b/src/components/reports/ReportToolbar.tsx index fc54131..5c8a5c3 100644 --- a/src/components/reports/ReportToolbar.tsx +++ b/src/components/reports/ReportToolbar.tsx @@ -21,8 +21,9 @@ function formatTimestamp(iso: string): string { }) } -export function ReportToolbar({ generatedAt }: { generatedAt: string }) { +export function ReportToolbar({ generatedAt, filterQuery = "" }: { generatedAt: string; filterQuery?: string }) { const [open, setOpen] = useState(false) + const suffix = filterQuery ? `&${filterQuery}` : "" return (
@@ -47,7 +48,7 @@ export function ReportToolbar({ generatedAt }: { generatedAt: string }) { {CSV_SECTIONS.map((s) => ( setOpen(false)} className="block rounded-md px-2.5 py-1.5 text-sm text-foreground hover:bg-muted" diff --git a/src/lib/employees.ts b/src/lib/employees.ts index 4fd3e91..a7aeec5 100644 --- a/src/lib/employees.ts +++ b/src/lib/employees.ts @@ -1,4 +1,10 @@ import { prisma } from "@/lib/prisma" +import type { Prisma } from "@prisma/client" + +export type GetEmployeesOpts = { + where?: Prisma.EmployeeWhereInput + recordWhere?: Prisma.BreachRecordWhereInput +} export type RiskLevel = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "OK" @@ -24,11 +30,12 @@ export type EmployeeRow = { breachRecords: BreachRecordDetail[] } -export async function getEmployees(companyId: string): Promise { +export async function getEmployees(companyId: string, opts?: GetEmployeesOpts): Promise { const employees = await prisma.employee.findMany({ - where: { companyId }, + where: { companyId, ...opts?.where }, include: { breachRecords: { + where: opts?.recordWhere, include: { breach: true }, orderBy: { detectedAt: "desc" }, }, diff --git a/src/lib/reports/by-employee.ts b/src/lib/reports/by-employee.ts index 034969a..c845e4f 100644 --- a/src/lib/reports/by-employee.ts +++ b/src/lib/reports/by-employee.ts @@ -1,8 +1,12 @@ import { getEmployees } from "@/lib/employees" +import { breachRecordSome, employeeWhere, type ReportFilters } from "./filters" import type { EmployeeReportRow } from "./types" -export async function getEmployeeBreakdown(companyId: string): Promise { - const employees = await getEmployees(companyId) +export async function getEmployeeBreakdown(companyId: string, f: ReportFilters): Promise { + const employees = await getEmployees(companyId, { + where: employeeWhere(companyId, f), + recordWhere: breachRecordSome(f), + }) return employees.map((e) => ({ name: `${e.firstName} ${e.lastName}`, diff --git a/src/lib/reports/compliance.ts b/src/lib/reports/compliance.ts index 5429dda..cfade7a 100644 --- a/src/lib/reports/compliance.ts +++ b/src/lib/reports/compliance.ts @@ -1,26 +1,23 @@ import { prisma } from "@/lib/prisma" import { rate } from "./utils" +import { alertWhere, employeeWhere, exposedEmployeeWhere, type ReportFilters } from "./filters" import type { ComplianceSummary } from "./types" -export async function getCompliance(companyId: string): Promise { +export async function getCompliance(companyId: string, f: ReportFilters): Promise { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const base = alertWhere(companyId, f) const [monitored, exposed, total, open, acknowledged, resolved, criticalOpen, staleCriticalOpen] = await Promise.all([ - prisma.employee.count({ where: { companyId } }), - prisma.employee.count({ where: { companyId, breachRecords: { some: {} } } }), - prisma.alert.count({ where: { companyId } }), - prisma.alert.count({ where: { companyId, status: "OPEN" } }), - prisma.alert.count({ where: { companyId, status: "ACKNOWLEDGED" } }), - prisma.alert.count({ where: { companyId, status: "RESOLVED" } }), - prisma.alert.count({ where: { companyId, status: "OPEN", severity: "CRITICAL" } }), + prisma.employee.count({ where: employeeWhere(companyId, f) }), + prisma.employee.count({ where: exposedEmployeeWhere(companyId, f) }), + prisma.alert.count({ where: base }), + prisma.alert.count({ where: { ...base, status: "OPEN" } }), + prisma.alert.count({ where: { ...base, status: "ACKNOWLEDGED" } }), + prisma.alert.count({ where: { ...base, status: "RESOLVED" } }), + prisma.alert.count({ where: { ...base, status: "OPEN", severity: "CRITICAL" } }), prisma.alert.count({ - where: { - companyId, - status: "OPEN", - severity: "CRITICAL", - createdAt: { lt: thirtyDaysAgo }, - }, + where: { ...base, status: "OPEN", severity: "CRITICAL", createdAt: { lt: thirtyDaysAgo } }, }), ]) diff --git a/src/lib/reports/data-types.ts b/src/lib/reports/data-types.ts index b4180c8..8f1308e 100644 --- a/src/lib/reports/data-types.ts +++ b/src/lib/reports/data-types.ts @@ -2,13 +2,14 @@ import { prisma } from "@/lib/prisma" import { CRITICAL_DATA } from "@/lib/employees" import { PRESET_DATA_TYPES } from "@/lib/dataTypes" import { rate } from "./utils" +import { breachRecordWhere, type ReportFilters } from "./filters" import type { DataTypeExposure } from "./types" const LABELS = new Map(PRESET_DATA_TYPES.map((t) => [t.key, t.label])) -export async function getDataTypeExposure(companyId: string): Promise { +export async function getDataTypeExposure(companyId: string, f: ReportFilters): Promise { const records = await prisma.breachRecord.findMany({ - where: { employee: { companyId } }, + where: breachRecordWhere(companyId, f), select: { exposedData: true }, }) diff --git a/src/lib/reports/departments.ts b/src/lib/reports/departments.ts index 5fe9ed5..6aaac79 100644 --- a/src/lib/reports/departments.ts +++ b/src/lib/reports/departments.ts @@ -1,11 +1,15 @@ import { prisma } from "@/lib/prisma" import { rate } from "./utils" +import { breachRecordSome, employeeWhere, type ReportFilters } from "./filters" import type { DepartmentRow } from "./types" -export async function getDepartmentBreakdown(companyId: string): Promise { +export async function getDepartmentBreakdown(companyId: string, f: ReportFilters): Promise { const employees = await prisma.employee.findMany({ - where: { companyId }, - select: { department: true, _count: { select: { breachRecords: true } } }, + where: employeeWhere(companyId, f), + select: { + department: true, + breachRecords: { where: breachRecordSome(f), select: { id: true }, take: 1 }, + }, }) const depts = new Map() @@ -13,7 +17,7 @@ export async function getDepartmentBreakdown(companyId: string): Promise 0) entry.exposed++ + if (e.breachRecords.length > 0) entry.exposed++ depts.set(name, entry) }) diff --git a/src/lib/reports/exposure.ts b/src/lib/reports/exposure.ts index 80681f4..99c2458 100644 --- a/src/lib/reports/exposure.ts +++ b/src/lib/reports/exposure.ts @@ -1,22 +1,30 @@ import { prisma } from "@/lib/prisma" import { calculateRiskScore, getRiskLevel } from "@/lib/risk" import { rate } from "./utils" +import { + alertWhere, + breachRecordSome, + employeeWhere, + exposedEmployeeWhere, + type ReportFilters, +} from "./filters" import type { ExposureSummary, TopBreach } from "./types" type Severity = "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" -function countOpenAlerts(companyId: string, severity: Severity): Promise { - return prisma.alert.count({ where: { companyId, status: "OPEN", severity } }) +function countOpenAlerts(companyId: string, f: ReportFilters, severity: Severity): Promise { + return prisma.alert.count({ where: { ...alertWhere(companyId, f), status: "OPEN", severity } }) } -async function getTopBreaches(companyId: string): Promise { +async function getTopBreaches(companyId: string, f: ReportFilters): Promise { + const recordFilter = { employee: employeeWhere(companyId, f), ...breachRecordSome(f) } const breaches = await prisma.breach.findMany({ - where: { records: { some: { employee: { companyId } } } }, + where: { records: { some: recordFilter } }, select: { name: true, source: true, breachDate: true, - records: { where: { employee: { companyId } }, select: { employeeId: true } }, + records: { where: recordFilter, select: { employeeId: true } }, }, }) @@ -31,22 +39,23 @@ async function getTopBreaches(companyId: string): Promise { .slice(0, 10) } -export async function getExposureSummary(companyId: string): Promise { +export async function getExposureSummary(companyId: string, f: ReportFilters): Promise { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const recentRecord = { ...breachRecordSome(f), detectedAt: { gte: thirtyDaysAgo } } const [total, exposed, breaches, critical, high, medium, low, recent, topBreaches] = await Promise.all([ - prisma.employee.count({ where: { companyId } }), - prisma.employee.count({ where: { companyId, breachRecords: { some: {} } } }), - prisma.breach.count({ where: { records: { some: { employee: { companyId } } } } }), - countOpenAlerts(companyId, "CRITICAL"), - countOpenAlerts(companyId, "HIGH"), - countOpenAlerts(companyId, "MEDIUM"), - countOpenAlerts(companyId, "LOW"), - prisma.breachRecord.count({ - where: { employee: { companyId }, detectedAt: { gte: thirtyDaysAgo } }, + prisma.employee.count({ where: employeeWhere(companyId, f) }), + prisma.employee.count({ where: exposedEmployeeWhere(companyId, f) }), + prisma.breach.count({ + where: { records: { some: { employee: employeeWhere(companyId, f), ...breachRecordSome(f) } } }, }), - getTopBreaches(companyId), + countOpenAlerts(companyId, f, "CRITICAL"), + countOpenAlerts(companyId, f, "HIGH"), + countOpenAlerts(companyId, f, "MEDIUM"), + countOpenAlerts(companyId, f, "LOW"), + prisma.breachRecord.count({ where: { employee: employeeWhere(companyId, f), ...recentRecord } }), + getTopBreaches(companyId, f), ]) const riskScore = calculateRiskScore({ diff --git a/src/lib/reports/filters.ts b/src/lib/reports/filters.ts new file mode 100644 index 0000000..002357b --- /dev/null +++ b/src/lib/reports/filters.ts @@ -0,0 +1,101 @@ +import type { Prisma } from "@prisma/client" + +// Sentinel for the "Unknown" department bucket (employees with department = null). +export const NO_DEPARTMENT = "__none__" + +export type ReportFilters = { + from: string | null // ISO date (YYYY-MM-DD) + to: string | null + department: string | null + dataType: string | null +} + +export const EMPTY_FILTERS: ReportFilters = { + from: null, + to: null, + department: null, + dataType: null, +} + +export function parseReportFilters(params: URLSearchParams): ReportFilters { + const get = (k: string) => { + const v = params.get(k) + return v && v.trim() !== "" ? v : null + } + return { + from: get("from"), + to: get("to"), + department: get("department"), + dataType: get("dataType"), + } +} + +export function hasActiveFilters(f: ReportFilters): boolean { + return Boolean(f.from || f.to || f.department || f.dataType) +} + +// Serialize back to a query string (for export links etc.). Skips empty values. +export function filtersToQuery(f: ReportFilters): string { + const p = new URLSearchParams() + if (f.from) p.set("from", f.from) + if (f.to) p.set("to", f.to) + if (f.department) p.set("department", f.department) + if (f.dataType) p.set("dataType", f.dataType) + return p.toString() +} + +function dateRange(f: ReportFilters): Prisma.DateTimeFilter | undefined { + if (!f.from && !f.to) return undefined + const range: Prisma.DateTimeFilter = {} + if (f.from) range.gte = new Date(`${f.from}T00:00:00.000`) + if (f.to) range.lte = new Date(`${f.to}T23:59:59.999`) + return range +} + +function departmentClause(f: ReportFilters): { department: string | null } | undefined { + if (!f.department) return undefined + return { department: f.department === NO_DEPARTMENT ? null : f.department } +} + +// Record-level constraints (date + exposed data type), no employee clause. +export function breachRecordSome(f: ReportFilters): Prisma.BreachRecordWhereInput { + const where: Prisma.BreachRecordWhereInput = {} + const range = dateRange(f) + if (range) where.detectedAt = range + if (f.dataType) where.exposedData = { has: f.dataType } + return where +} + +// Employee population filtered by department. +export function employeeWhere(companyId: string, f: ReportFilters): Prisma.EmployeeWhereInput { + return { companyId, ...departmentClause(f) } +} + +// Employees considered "exposed" within the active filters: matching department AND +// having at least one breach record inside the date / data-type constraints. +export function exposedEmployeeWhere(companyId: string, f: ReportFilters): Prisma.EmployeeWhereInput { + const some = breachRecordSome(f) + return { + companyId, + ...departmentClause(f), + breachRecords: { some: Object.keys(some).length ? some : {} }, + } +} + +// Breach records scoped to the company + all active filters. +export function breachRecordWhere(companyId: string, f: ReportFilters): Prisma.BreachRecordWhereInput { + return { + employee: employeeWhere(companyId, f), + ...breachRecordSome(f), + } +} + +// Alerts scoped to the company + date range + department (via linked employee). +export function alertWhere(companyId: string, f: ReportFilters): Prisma.AlertWhereInput { + const where: Prisma.AlertWhereInput = { companyId } + const range = dateRange(f) + if (range) where.createdAt = range + const dept = departmentClause(f) + if (dept) where.employee = dept + return where +} diff --git a/src/lib/reports/index.ts b/src/lib/reports/index.ts index b491192..4d9675a 100644 --- a/src/lib/reports/index.ts +++ b/src/lib/reports/index.ts @@ -5,19 +5,24 @@ import { getEmployeeBreakdown } from "./by-employee" import { getTrends } from "./trends" import { getCompliance } from "./compliance" import { buildFindings } from "./findings" +import { EMPTY_FILTERS, type ReportFilters } from "./filters" import type { ReportData } from "./types" export type { ReportData } from "./types" +export type { ReportFilters } from "./filters" -export async function getReportData(companyId: string): Promise { +export async function getReportData( + companyId: string, + filters: ReportFilters = EMPTY_FILTERS, +): Promise { const [exposure, dataTypes, departments, employees, trends, compliance] = await Promise.all([ - getExposureSummary(companyId), - getDataTypeExposure(companyId), - getDepartmentBreakdown(companyId), - getEmployeeBreakdown(companyId), - getTrends(companyId), - getCompliance(companyId), + getExposureSummary(companyId, filters), + getDataTypeExposure(companyId, filters), + getDepartmentBreakdown(companyId, filters), + getEmployeeBreakdown(companyId, filters), + getTrends(companyId, filters), + getCompliance(companyId, filters), ]) return { diff --git a/src/lib/reports/trends.ts b/src/lib/reports/trends.ts index b3fefa9..cce7205 100644 --- a/src/lib/reports/trends.ts +++ b/src/lib/reports/trends.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/prisma" import { monthKey } from "./utils" +import { employeeWhere, NO_DEPARTMENT, type ReportFilters } from "./filters" import type { MonthlyPoint, Trends } from "./types" function emptyMonths(): Map { @@ -13,18 +14,26 @@ function emptyMonths(): Map { return months } -export async function getTrends(companyId: string): Promise { +export async function getTrends(companyId: string, f: ReportFilters): Promise { const since = new Date() since.setMonth(since.getMonth() - 11) since.setDate(1) + const alertDept = f.department + ? { employee: { department: f.department === NO_DEPARTMENT ? null : f.department } } + : {} + const [records, alerts] = await Promise.all([ prisma.breachRecord.findMany({ - where: { employee: { companyId }, detectedAt: { gte: since } }, + where: { + employee: employeeWhere(companyId, f), + detectedAt: { gte: since }, + ...(f.dataType && { exposedData: { has: f.dataType } }), + }, select: { detectedAt: true }, }), prisma.alert.findMany({ - where: { companyId, createdAt: { gte: since } }, + where: { companyId, createdAt: { gte: since }, ...alertDept }, select: { createdAt: true }, }), ])