diff --git a/apps/web/src/app/(app)/admin/activity-log/page.tsx b/apps/web/src/app/(app)/admin/activity-log/page.tsx new file mode 100644 index 0000000..292f6f6 --- /dev/null +++ b/apps/web/src/app/(app)/admin/activity-log/page.tsx @@ -0,0 +1,5 @@ +import { ActivityLogClient } from "~/components/admin/ActivityLogClient.js"; + +export default function ActivityLogPage() { + return ; +} diff --git a/apps/web/src/components/admin/ActivityLogClient.tsx b/apps/web/src/components/admin/ActivityLogClient.tsx new file mode 100644 index 0000000..f3cdd97 --- /dev/null +++ b/apps/web/src/components/admin/ActivityLogClient.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import Link from "next/link"; +import type { Route } from "next"; +import { trpc } from "~/lib/trpc/client.js"; + +// ─── Constants ────────────────────────────────────────────────────────────── + +const ACTION_BADGES: Record = { + CREATE: { label: "Create", className: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400" }, + UPDATE: { label: "Update", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" }, + DELETE: { label: "Delete", className: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400" }, + SHIFT: { label: "Shift", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400" }, + IMPORT: { label: "Import", className: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400" }, +}; + +const ENTITY_TYPE_OPTIONS = [ + "Project", + "Resource", + "Allocation", + "Blueprint", + "Vacation", + "Role", + "Estimate", + "EstimateVersion", + "ScopeItem", + "DemandLine", + "Comment", +]; + +const ACTION_OPTIONS = ["CREATE", "UPDATE", "DELETE", "SHIFT", "IMPORT"]; + +const ENTITY_LINKS: Record string> = { + Project: (id) => `/projects/${id}`, + Resource: (id) => `/resources/${id}`, + Allocation: (id) => `/allocations?allocationId=${id}`, + Blueprint: (_id) => `/admin/blueprints`, + Vacation: (_id) => `/vacations`, + Role: (_id) => `/roles`, + Estimate: (id) => `/estimates/${id}`, +}; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function relativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - new Date(date).getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHr = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHr / 24); + + if (diffSec < 60) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHr < 24) return `${diffHr}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return new Date(date).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +function userInitials(name: string | null | undefined, email: string): string { + if (name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase(); + return name.slice(0, 2).toUpperCase(); + } + return email.slice(0, 2).toUpperCase(); +} + +type DiffEntry = { old: unknown; new: unknown }; +type Changes = { + before?: Record; + after?: Record; + diff?: Record; + metadata?: Record; +}; + +function parseChanges(changes: unknown): Changes { + if (!changes || typeof changes !== "object") return {}; + return changes as Changes; +} + +function formatValue(val: unknown): string { + if (val === null || val === undefined) return "(empty)"; + if (typeof val === "boolean") return val ? "Yes" : "No"; + if (typeof val === "object") return JSON.stringify(val); + return String(val); +} + +// ─── Sub-components ───────────────────────────────────────────────────────── + +function ActionBadge({ action }: { action: string }) { + const badge = ACTION_BADGES[action] ?? { label: action, className: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" }; + return ( + + {badge.label} + + ); +} + +function DiffView({ changes }: { changes: Changes }) { + const diff = changes.diff; + if (!diff || Object.keys(diff).length === 0) { + return

No field-level diff available.

; + } + + return ( +
+ {Object.entries(diff).map(([field, { old: oldVal, new: newVal }]) => ( +
+ {field} + + {formatValue(oldVal)} + + + + {formatValue(newVal)} + +
+ ))} +
+ ); +} + +function SummaryCards({ summary }: { summary: { byEntityType: Record; total: number } }) { + const sorted = useMemo(() => { + return Object.entries(summary.byEntityType) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + }, [summary.byEntityType]); + + return ( +
+
+

Total (7d)

+

{summary.total}

+
+ {sorted.map(([type, count]) => ( +
+

{type}

+

{count}

+
+ ))} +
+ ); +} + +// ─── Main Component ───────────────────────────────────────────────────────── + +export function ActivityLogClient() { + // Filters + const [entityType, setEntityType] = useState(""); + const [action, setAction] = useState(""); + const [userId, setUserId] = useState(""); + const [search, setSearch] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + // Expanded entry + const [expandedId, setExpandedId] = useState(null); + + // Summary (last 7 days) + const sevenDaysAgo = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() - 7); + return d; + }, []); + + const { data: summary } = trpc.auditLog.getActivitySummary.useQuery( + { startDate: sevenDaysAgo }, + { staleTime: 60_000 }, + ); + + // Users for filter dropdown + type UserListItem = { id: string; name: string | null; email: string }; + const { data: users = [] } = trpc.user.list.useQuery(undefined, { staleTime: 300_000 }) as { data: UserListItem[] }; + + // Build query input + const queryInput = useMemo(() => { + const input: Record = { limit: 50 }; + if (entityType) input.entityType = entityType; + if (action) input.action = action; + if (userId) input.userId = userId; + if (search) input.search = search; + if (startDate) input.startDate = new Date(startDate); + if (endDate) input.endDate = new Date(endDate + "T23:59:59"); + return input; + }, [entityType, action, userId, search, startDate, endDate]); + + type AuditListPage = { items: Array<{ + id: string; + entityType: string; + entityId: string; + action: string; + changes: unknown; + createdAt: Date; + source: string | null; + entityName: string | null; + summary: string | null; + user: { id: string; name: string | null; email: string } | null; + }>; nextCursor?: string }; + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + // Keep as any to avoid tRPC TS depth limits with useInfiniteQuery + } = (trpc.auditLog.list.useInfiniteQuery as any)( + queryInput, + { + getNextPageParam: (lastPage: AuditListPage) => lastPage.nextCursor ?? undefined, + initialCursor: undefined, + staleTime: 30_000, + }, + ) as { + data: { pages: AuditListPage[] } | undefined; + isLoading: boolean; + fetchNextPage: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + }; + + const allEntries = useMemo(() => { + if (!data) return []; + return data.pages.flatMap((page) => page.items); + }, [data]); + + const toggleExpand = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }, []); + + const totalCount = summary?.total ?? 0; + + return ( +
+ {/* Header */} +
+

Activity Log

+

+ {totalCount.toLocaleString()} changes recorded in the last 7 days +

+
+ + {/* Summary Cards */} + {summary && } + + {/* Filter Bar */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + setStartDate(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" + /> +
+ +
+ + setEndDate(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" + /> +
+ +
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" + /> +
+ + +
+ + {/* Timeline List */} +
+ {isLoading && ( +
+
+
+ )} + + {!isLoading && allEntries.length === 0 && ( +
+ + + +

No activity found

+

Try adjusting your filters or date range.

+
+ )} + + {allEntries.map((entry) => { + const changes = parseChanges(entry.changes); + const isExpanded = expandedId === entry.id; + const entityLink = ENTITY_LINKS[entry.entityType]?.(entry.entityId); + + return ( +
+ + + {/* Expanded Diff */} + {isExpanded && ( +
+ +
+ )} +
+ ); + })} + + {/* Load More */} + {hasNextPage && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 2066930..b63398f 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -76,6 +76,9 @@ function NotificationsIcon() { function BroadcastIcon() { return ; } +function ActivityLogIcon() { + return ; +} function AdminIcon() { return ; } @@ -189,6 +192,7 @@ const adminNavEntries: AdminEntry[] = [ { href: "/admin/notifications", label: "Broadcasts", icon: }, { href: "/admin/webhooks", label: "Webhooks", icon: }, { href: "/admin/dispo-imports", label: "Dispo Import", icon: }, + { href: "/admin/activity-log", label: "Activity Log", icon: }, ]; /** diff --git a/apps/web/src/components/ui/EntityHistory.tsx b/apps/web/src/components/ui/EntityHistory.tsx new file mode 100644 index 0000000..4ad591c --- /dev/null +++ b/apps/web/src/components/ui/EntityHistory.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useState, useCallback } from "react"; +import Link from "next/link"; +import type { Route } from "next"; +import { trpc } from "~/lib/trpc/client.js"; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const ACTION_BADGES: Record = { + CREATE: { label: "Create", className: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400" }, + UPDATE: { label: "Update", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" }, + DELETE: { label: "Delete", className: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400" }, + SHIFT: { label: "Shift", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400" }, + IMPORT: { label: "Import", className: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400" }, +}; + +function relativeTime(date: Date | string): string { + const now = new Date(); + const diffMs = now.getTime() - new Date(date).getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHr = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHr / 24); + + if (diffSec < 60) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHr < 24) return `${diffHr}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return new Date(date).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +function formatValue(val: unknown): string { + if (val === null || val === undefined) return "(empty)"; + if (typeof val === "boolean") return val ? "Yes" : "No"; + if (typeof val === "object") return JSON.stringify(val); + return String(val); +} + +type DiffEntry = { old: unknown; new: unknown }; +type Changes = { + before?: Record; + after?: Record; + diff?: Record; +}; + +function parseChanges(changes: unknown): Changes { + if (!changes || typeof changes !== "object") return {}; + return changes as Changes; +} + +// ─── Component ────────────────────────────────────────────────────────────── + +interface EntityHistoryProps { + entityType: string; + entityId: string; + limit?: number; +} + +type AuditEntry = { + id: string; + entityType: string; + entityId: string; + action: string; + changes: unknown; + createdAt: Date | string; + source: string | null; + entityName: string | null; + summary: string | null; + user: { id: string; name: string | null; email: string } | null; +}; + +export function EntityHistory({ entityType, entityId, limit = 10 }: EntityHistoryProps) { + const [expandedId, setExpandedId] = useState(null); + + const { data: entries = [], isLoading } = trpc.auditLog.getByEntity.useQuery( + { entityType, entityId, limit }, + { staleTime: 30_000 }, + ) as { data: AuditEntry[]; isLoading: boolean }; + + const toggleExpand = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }, []); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (entries.length === 0) { + return ( +
+ No history recorded yet. +
+ ); + } + + return ( +
+

Change History

+ + {/* Timeline */} +
+ {/* Vertical line */} +
+ + {entries.map((entry) => { + const changes = parseChanges(entry.changes); + const isExpanded = expandedId === entry.id; + const badge = ACTION_BADGES[entry.action] ?? { label: entry.action, className: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" }; + + return ( +
+ {/* Dot */} +
+ + + + {/* Expanded diff */} + {isExpanded && changes.diff && Object.keys(changes.diff).length > 0 && ( +
+ {Object.entries(changes.diff).map(([field, { old: oldVal, new: newVal }]) => ( +
+ {field} + + {formatValue(oldVal)} + + + + {formatValue(newVal)} + +
+ ))} +
+ )} +
+ ); + })} +
+ + {/* Link to full log */} +
+ + View all in Activity Log + +
+
+ ); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d12df5b..16fdccb 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -10,3 +10,4 @@ export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js"; export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js"; export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js"; export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js"; +export { createAuditEntry, computeDiff, generateSummary } from "./lib/audit.js"; diff --git a/packages/api/src/lib/audit.ts b/packages/api/src/lib/audit.ts new file mode 100644 index 0000000..5d31dbe --- /dev/null +++ b/packages/api/src/lib/audit.ts @@ -0,0 +1,130 @@ +import type { PrismaClient, Prisma } from "@planarchy/db"; +import { logger } from "./logger.js"; + +type AuditAction = "CREATE" | "UPDATE" | "DELETE" | "SHIFT" | "IMPORT"; +type AuditSource = "ui" | "api" | "ai" | "import" | "cron"; + +interface CreateAuditEntryParams { + db: PrismaClient; + entityType: string; + entityId: string; + entityName?: string; + action: AuditAction; + userId?: string; + before?: Record; + after?: Record; + source?: AuditSource; + summary?: string; + metadata?: Record; +} + +const INTERNAL_FIELDS = new Set(["id", "createdAt", "updatedAt"]); + +/** + * Compare two snapshots and return only the changed fields. + * Skips internal fields (id, createdAt, updatedAt). + * Uses JSON.stringify for nested object comparison. + */ +export function computeDiff( + before: Record, + after: Record, +): Record { + const diff: Record = {}; + + const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]); + + for (const key of allKeys) { + if (INTERNAL_FIELDS.has(key)) continue; + + const oldVal = before[key]; + const newVal = after[key]; + + // Compare by JSON serialization to handle nested objects/arrays + const oldStr = JSON.stringify(oldVal) ?? "undefined"; + const newStr = JSON.stringify(newVal) ?? "undefined"; + + if (oldStr !== newStr) { + diff[key] = { old: oldVal, new: newVal }; + } + } + + return diff; +} + +/** + * Auto-generate a human-readable summary from the action and diff. + */ +export function generateSummary( + action: string, + entityType: string, + diff?: Record, +): string { + switch (action) { + case "CREATE": + return `Created ${entityType}`; + case "DELETE": + return `Deleted ${entityType}`; + case "SHIFT": + return `Shifted ${entityType}`; + case "IMPORT": + return `Imported ${entityType}`; + case "UPDATE": { + if (!diff || Object.keys(diff).length === 0) { + return `Updated ${entityType}`; + } + const fields = Object.keys(diff); + if (fields.length <= 3) { + return `Updated ${fields.join(", ")}`; + } + return `Updated ${fields.slice(0, 3).join(", ")} and ${fields.length - 3} more`; + } + default: + return `${action} ${entityType}`; + } +} + +/** + * Create an audit log entry. Fire-and-forget — errors are logged but never thrown. + * + * If both `before` and `after` are provided, a diff is computed automatically. + * If no `summary` is given, one is generated from the action and diff. + */ +export async function createAuditEntry(params: CreateAuditEntryParams): Promise { + try { + const { db, entityType, entityId, entityName, action, userId, before, after, source, metadata } = params; + + // Compute diff if both snapshots are available + const diff = before && after ? computeDiff(before, after) : undefined; + + // Skip UPDATE entries where nothing actually changed + if (action === "UPDATE" && diff && Object.keys(diff).length === 0) { + return; + } + + // Auto-generate summary if not provided + const summary = params.summary ?? generateSummary(action, entityType, diff); + + // Build the changes JSONB payload + const changes: Record = {}; + if (before) changes.before = before; + if (after) changes.after = after; + if (diff) changes.diff = diff; + if (metadata) changes.metadata = metadata; + + await db.auditLog.create({ + data: { + entityType, + entityId, + action, + userId: userId ?? null, + changes: changes as unknown as Prisma.InputJsonValue, + source: source ?? null, + entityName: entityName ?? null, + summary, + }, + }); + } catch (error) { + // Fire-and-forget: log but never propagate + logger.error({ err: error, entityType: params.entityType, entityId: params.entityId }, "Failed to create audit entry"); + } +} diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index cd417f5..6f0956e 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -1351,6 +1351,40 @@ export const TOOL_DEFINITIONS: ToolDef[] = [ }, }, }, + { + type: "function", + function: { + name: "query_change_history", + description: "Search the activity history for changes to projects, resources, allocations, vacations, or any entity. Can filter by entity type, entity name, user, date range, or action type.", + parameters: { + type: "object", + properties: { + entityType: { type: "string", description: "Filter by entity type (e.g. 'Project', 'Resource', 'Allocation', 'Vacation', 'Role', 'Estimate')" }, + search: { type: "string", description: "Search in entity name or summary text" }, + userId: { type: "string", description: "Filter by user ID who made the change" }, + daysBack: { type: "integer", description: "How many days back to search. Default: 7" }, + action: { type: "string", description: "Filter by action type: CREATE, UPDATE, DELETE, SHIFT, IMPORT" }, + limit: { type: "integer", description: "Max results. Default: 20" }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "get_entity_timeline", + description: "Get the complete change history for a specific entity (project, resource, etc). Shows who made what changes and when.", + parameters: { + type: "object", + properties: { + entityType: { type: "string", description: "Entity type (e.g. 'Project', 'Resource', 'Allocation')" }, + entityId: { type: "string", description: "Entity ID" }, + limit: { type: "integer", description: "Max results. Default: 50" }, + }, + required: ["entityType", "entityId"], + }, + }, + }, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -5339,6 +5373,112 @@ const executors = { body: updated.body.slice(0, 100), }; }, + + async query_change_history(params: { + entityType?: string; + search?: string; + userId?: string; + daysBack?: number; + action?: string; + limit?: number; + }, ctx: ToolContext) { + const limit = Math.min(params.limit ?? 20, 50); + const daysBack = params.daysBack ?? 7; + + const startDate = new Date(); + startDate.setDate(startDate.getDate() - daysBack); + + const where: Record = { + createdAt: { gte: startDate }, + }; + + if (params.entityType) where.entityType = params.entityType; + if (params.action) where.action = params.action; + if (params.userId) where.userId = params.userId; + + if (params.search) { + where.OR = [ + { entityName: { contains: params.search, mode: "insensitive" } }, + { summary: { contains: params.search, mode: "insensitive" } }, + { entityType: { contains: params.search, mode: "insensitive" } }, + ]; + } + + const entries = await ctx.db.auditLog.findMany({ + where, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + if (entries.length === 0) { + return `No changes found in the last ${daysBack} days matching your criteria.`; + } + + const lines = entries.map((e) => { + const who = e.user?.name ?? e.user?.email ?? "System"; + const when = e.createdAt.toISOString().slice(0, 16).replace("T", " "); + const name = e.entityName ? ` "${e.entityName}"` : ""; + const summary = e.summary ? ` — ${e.summary}` : ""; + return `[${when}] ${who}: ${e.action} ${e.entityType}${name}${summary}`; + }); + + return `Found ${entries.length} changes (last ${daysBack} days):\n\n${lines.join("\n")}`; + }, + + async get_entity_timeline(params: { + entityType: string; + entityId: string; + limit?: number; + }, ctx: ToolContext) { + const limit = Math.min(params.limit ?? 50, 200); + + const entries = await ctx.db.auditLog.findMany({ + where: { + entityType: params.entityType, + entityId: params.entityId, + }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + + if (entries.length === 0) { + return `No change history found for ${params.entityType} ${params.entityId}.`; + } + + const entityName = entries[0]?.entityName ?? params.entityId; + + const lines = entries.map((e) => { + const who = e.user?.name ?? e.user?.email ?? "System"; + const when = e.createdAt.toISOString().slice(0, 16).replace("T", " "); + const summary = e.summary ?? e.action; + const source = e.source ? ` (via ${e.source})` : ""; + + // Include changed fields summary for UPDATE actions + const changes = e.changes as Record | null; + const diff = changes?.diff as Record | undefined; + let diffSummary = ""; + if (diff && Object.keys(diff).length > 0) { + const fields = Object.entries(diff) + .slice(0, 3) + .map(([k, v]) => `${k}: ${JSON.stringify(v.old)} → ${JSON.stringify(v.new)}`) + .join("; "); + diffSummary = `\n Changed: ${fields}`; + if (Object.keys(diff).length > 3) { + diffSummary += ` (+${Object.keys(diff).length - 3} more)`; + } + } + + return `[${when}] ${who}${source}: ${summary}${diffSummary}`; + }); + + return `Change history for ${params.entityType} "${entityName}" (${entries.length} entries):\n\n${lines.join("\n")}`; + }, }; // ─── Executor ─────────────────────────────────────────────────────────────── diff --git a/packages/api/src/router/audit-log.ts b/packages/api/src/router/audit-log.ts new file mode 100644 index 0000000..a720d24 --- /dev/null +++ b/packages/api/src/router/audit-log.ts @@ -0,0 +1,213 @@ +import { z } from "zod"; +import { createTRPCRouter, controllerProcedure } from "../trpc.js"; + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export const auditLogRouter = createTRPCRouter({ + /** + * Paginated, filterable list of audit log entries. + * Cursor-based pagination using createdAt + id. + */ + list: controllerProcedure + .input( + z.object({ + entityType: z.string().optional(), + entityId: z.string().optional(), + userId: z.string().optional(), + action: z.string().optional(), + source: z.string().optional(), + startDate: z.date().optional(), + endDate: z.date().optional(), + search: z.string().optional(), + limit: z.number().min(1).max(100).default(50), + cursor: z.string().optional(), // id of the last item + }), + ) + .query(async ({ ctx, input }) => { + const { entityType, entityId, userId, action, source, startDate, endDate, search, limit, cursor } = input; + + const where: Record = {}; + + if (entityType) where.entityType = entityType; + if (entityId) where.entityId = entityId; + if (userId) where.userId = userId; + if (action) where.action = action; + if (source) where.source = source; + + if (startDate || endDate) { + const createdAt: Record = {}; + if (startDate) createdAt.gte = startDate; + if (endDate) createdAt.lte = endDate; + where.createdAt = createdAt; + } + + if (search) { + where.OR = [ + { entityName: { contains: search, mode: "insensitive" } }, + { summary: { contains: search, mode: "insensitive" } }, + { entityType: { contains: search, mode: "insensitive" } }, + ]; + } + + const items = await ctx.db.auditLog.findMany({ + where, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + let nextCursor: string | undefined; + if (items.length > limit) { + const next = items.pop(); + nextCursor = next?.id; + } + + return { items, nextCursor }; + }), + + /** + * Get all audit entries for a specific entity (e.g. a project or resource). + */ + getByEntity: controllerProcedure + .input( + z.object({ + entityType: z.string(), + entityId: z.string(), + limit: z.number().min(1).max(200).default(50), + }), + ) + .query(async ({ ctx, input }) => { + return ctx.db.auditLog.findMany({ + where: { + entityType: input.entityType, + entityId: input.entityId, + }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + }); + }), + + /** + * Timeline view: entries grouped by date (YYYY-MM-DD). + */ + getTimeline: controllerProcedure + .input( + z.object({ + startDate: z.date().optional(), + endDate: z.date().optional(), + limit: z.number().min(1).max(500).default(200), + }), + ) + .query(async ({ ctx, input }) => { + const where: Record = {}; + + if (input.startDate || input.endDate) { + const createdAt: Record = {}; + if (input.startDate) createdAt.gte = input.startDate; + if (input.endDate) createdAt.lte = input.endDate; + where.createdAt = createdAt; + } + + const entries = await ctx.db.auditLog.findMany({ + where, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + }); + + // Group by date string (YYYY-MM-DD) + const grouped: Record = {}; + for (const entry of entries) { + const dateKey = entry.createdAt.toISOString().slice(0, 10); + if (!grouped[dateKey]) grouped[dateKey] = []; + grouped[dateKey].push(entry); + } + + return grouped; + }), + + /** + * Activity summary: counts by entity type, action, and user for a date range. + */ + getActivitySummary: controllerProcedure + .input( + z.object({ + startDate: z.date().optional(), + endDate: z.date().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const where: Record = {}; + + if (input.startDate || input.endDate) { + const createdAt: Record = {}; + if (input.startDate) createdAt.gte = input.startDate; + if (input.endDate) createdAt.lte = input.endDate; + where.createdAt = createdAt; + } + + // Run aggregation queries in parallel + const [byEntityTypeRaw, byActionRaw, byUserRaw, total] = await Promise.all([ + ctx.db.auditLog.groupBy({ + by: ["entityType"], + where, + _count: { id: true }, + }), + ctx.db.auditLog.groupBy({ + by: ["action"], + where, + _count: { id: true }, + }), + ctx.db.auditLog.groupBy({ + by: ["userId"], + where, + _count: { id: true }, + orderBy: { _count: { id: "desc" } }, + take: 20, + }), + ctx.db.auditLog.count({ where }), + ]); + + // Convert to simple Record + const byEntityType: Record = {}; + for (const row of byEntityTypeRaw) { + byEntityType[row.entityType] = row._count.id; + } + + const byAction: Record = {}; + for (const row of byActionRaw) { + byAction[row.action] = row._count.id; + } + + // Resolve user names for the top users + const userIds = byUserRaw + .map((row) => row.userId) + .filter((id): id is string => id !== null); + + const users = userIds.length > 0 + ? await ctx.db.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, email: true }, + }) + : []; + + const userMap = new Map(users.map((u) => [u.id, u.name ?? u.email])); + + const byUser = byUserRaw + .filter((row) => row.userId !== null) + .map((row) => ({ + name: userMap.get(row.userId!) ?? "Unknown", + count: row._count.id, + })); + + return { byEntityType, byAction, byUser, total }; + }), +}); diff --git a/packages/api/src/router/blueprint.ts b/packages/api/src/router/blueprint.ts index 8af44e9..5d1a567 100644 --- a/packages/api/src/router/blueprint.ts +++ b/packages/api/src/router/blueprint.ts @@ -3,6 +3,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; +import { createAuditEntry } from "../lib/audit.js"; export const blueprintRouter = createTRPCRouter({ list: protectedProcedure @@ -35,7 +36,7 @@ export const blueprintRouter = createTRPCRouter({ create: adminProcedure .input(CreateBlueprintSchema) .mutation(async ({ ctx, input }) => { - return ctx.db.blueprint.create({ + const blueprint = await ctx.db.blueprint.create({ data: { name: input.name, target: input.target, @@ -45,17 +46,30 @@ export const blueprintRouter = createTRPCRouter({ validationRules: input.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue, } as unknown as Parameters[0]["data"], }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Blueprint", + entityId: blueprint.id, + entityName: blueprint.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: { name: input.name, target: input.target, description: input.description }, + source: "ui", + }); + + return blueprint; }), update: adminProcedure .input(z.object({ id: z.string(), data: UpdateBlueprintSchema })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const before = await findUniqueOrThrow( ctx.db.blueprint.findUnique({ where: { id: input.id } }), "Blueprint", ); - return ctx.db.blueprint.update({ + const updated = await ctx.db.blueprint.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), @@ -65,30 +79,71 @@ export const blueprintRouter = createTRPCRouter({ ...(input.data.validationRules !== undefined ? { validationRules: input.data.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}), } as unknown as Parameters[0]["data"], }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Blueprint", + entityId: input.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: before as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), /** Dedicated mutation for saving role presets — separate from field defs to avoid Zod depth issues */ updateRolePresets: adminProcedure .input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const before = await findUniqueOrThrow( ctx.db.blueprint.findUnique({ where: { id: input.id } }), "Blueprint", ); - return ctx.db.blueprint.update({ + const updated = await ctx.db.blueprint.update({ where: { id: input.id }, data: { rolePresets: input.rolePresets as unknown as import("@planarchy/db").Prisma.InputJsonValue }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Blueprint", + entityId: input.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: { rolePresets: before.rolePresets }, + after: { rolePresets: input.rolePresets }, + source: "ui", + summary: "Updated role presets", + }); + + return updated; }), delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Soft delete — mark as inactive - return ctx.db.blueprint.update({ + const deleted = await ctx.db.blueprint.update({ where: { id: input.id }, data: { isActive: false }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Blueprint", + entityId: input.id, + entityName: deleted.name, + action: "DELETE", + userId: ctx.dbUser?.id, + source: "ui", + }); + + return deleted; }), batchDelete: adminProcedure @@ -100,6 +155,19 @@ export const blueprintRouter = createTRPCRouter({ ctx.db.blueprint.update({ where: { id }, data: { isActive: false } }), ), ); + + for (const bp of updated) { + void createAuditEntry({ + db: ctx.db, + entityType: "Blueprint", + entityId: bp.id, + entityName: bp.name, + action: "DELETE", + userId: ctx.dbUser?.id, + source: "ui", + }); + } + return { count: updated.length }; }), @@ -122,9 +190,23 @@ export const blueprintRouter = createTRPCRouter({ setGlobal: adminProcedure .input(z.object({ id: z.string(), isGlobal: z.boolean() })) .mutation(async ({ ctx, input }) => { - return ctx.db.blueprint.update({ + const updated = await ctx.db.blueprint.update({ where: { id: input.id }, data: { isGlobal: input.isGlobal }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Blueprint", + entityId: input.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + after: { isGlobal: input.isGlobal }, + source: "ui", + summary: input.isGlobal ? "Set blueprint as global" : "Removed global flag from blueprint", + }); + + return updated; }), }); diff --git a/packages/api/src/router/calculation-rules.ts b/packages/api/src/router/calculation-rules.ts index cf8e790..a5e0729 100644 --- a/packages/api/src/router/calculation-rules.ts +++ b/packages/api/src/router/calculation-rules.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { PROJECT_BRIEF_SELECT } from "../db/selects.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; +import { createAuditEntry } from "../lib/audit.js"; export const calculationRuleRouter = createTRPCRouter({ list: controllerProcedure.query(async ({ ctx }) => { @@ -38,7 +39,7 @@ export const calculationRuleRouter = createTRPCRouter({ create: managerProcedure .input(CreateCalculationRuleSchema) .mutation(async ({ ctx, input }) => { - return ctx.db.calculationRule.create({ + const rule = await ctx.db.calculationRule.create({ data: { name: input.name, triggerType: input.triggerType, @@ -52,13 +53,26 @@ export const calculationRuleRouter = createTRPCRouter({ isActive: input.isActive, }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "CalculationRule", + entityId: rule.id, + entityName: rule.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: rule as unknown as Record, + source: "ui", + }); + + return rule; }), update: managerProcedure .input(UpdateCalculationRuleSchema) .mutation(async ({ ctx, input }) => { const { id, ...data } = input; - await findUniqueOrThrow( + const before = await findUniqueOrThrow( ctx.db.calculationRule.findUnique({ where: { id } }), "CalculationRule", ); @@ -76,20 +90,46 @@ export const calculationRuleRouter = createTRPCRouter({ if (data.priority !== undefined) updateData.priority = data.priority; if (data.isActive !== undefined) updateData.isActive = data.isActive; - return ctx.db.calculationRule.update({ + const updated = await ctx.db.calculationRule.update({ where: { id }, data: updateData, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "CalculationRule", + entityId: id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: before as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), delete: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const rule = await findUniqueOrThrow( ctx.db.calculationRule.findUnique({ where: { id: input.id } }), "CalculationRule", ); await ctx.db.calculationRule.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "CalculationRule", + entityId: input.id, + entityName: rule.name, + action: "DELETE", + userId: ctx.dbUser?.id, + before: rule as unknown as Record, + source: "ui", + }); + return { success: true }; }), }); diff --git a/packages/api/src/router/client.ts b/packages/api/src/router/client.ts index 6ea304c..12f0bdb 100644 --- a/packages/api/src/router/client.ts +++ b/packages/api/src/router/client.ts @@ -2,6 +2,7 @@ import { CreateClientSchema, UpdateClientSchema } from "@planarchy/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; import type { ClientTree } from "@planarchy/shared"; @@ -97,7 +98,7 @@ export const clientRouter = createTRPCRouter({ } } - return ctx.db.client.create({ + const created = await ctx.db.client.create({ data: { name: input.name, ...(input.code ? { code: input.code } : {}), @@ -106,6 +107,19 @@ export const clientRouter = createTRPCRouter({ ...(input.tags ? { tags: input.tags } : {}), }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Client", + entityId: created.id, + entityName: created.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: created as unknown as Record, + source: "ui", + }); + + return created; }), update: managerProcedure @@ -123,7 +137,9 @@ export const clientRouter = createTRPCRouter({ } } - return ctx.db.client.update({ + const before = existing as unknown as Record; + + const updated = await ctx.db.client.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), @@ -134,15 +150,44 @@ export const clientRouter = createTRPCRouter({ ...(input.data.tags !== undefined ? { tags: input.data.tags } : {}), }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Client", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), deactivate: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - return ctx.db.client.update({ + const updated = await ctx.db.client.update({ where: { id: input.id }, data: { isActive: false }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Client", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: { isActive: true }, + after: { isActive: false }, + source: "ui", + summary: "Deactivated Client", + }); + + return updated; }), delete: adminProcedure @@ -167,7 +212,20 @@ export const clientRouter = createTRPCRouter({ message: `Cannot delete client with ${client._count.children} child client(s). Remove children first.`, }); } - return ctx.db.client.delete({ where: { id: input.id } }); + await ctx.db.client.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Client", + entityId: client.id, + entityName: client.name, + action: "DELETE", + userId: ctx.dbUser?.id, + before: client as unknown as Record, + source: "ui", + }); + + return client; }), batchUpdateSortOrder: managerProcedure @@ -181,6 +239,20 @@ export const clientRouter = createTRPCRouter({ }), ), ); + + for (const item of input) { + void createAuditEntry({ + db: ctx.db, + entityType: "Client", + entityId: item.id, + action: "UPDATE", + userId: ctx.dbUser?.id, + after: { sortOrder: item.sortOrder }, + source: "ui", + summary: "Updated sort order", + }); + } + return { ok: true }; }), }); diff --git a/packages/api/src/router/country.ts b/packages/api/src/router/country.ts index 08f1b1d..5db86de 100644 --- a/packages/api/src/router/country.ts +++ b/packages/api/src/router/country.ts @@ -8,6 +8,7 @@ import { Prisma } from "@planarchy/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; /** Convert nullable JSON to Prisma-compatible value (null → Prisma.JsonNull). */ @@ -52,7 +53,7 @@ export const countryRouter = createTRPCRouter({ if (existing) { throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.code}" already exists` }); } - return ctx.db.country.create({ + const created = await ctx.db.country.create({ data: { code: input.code, name: input.name, @@ -61,6 +62,19 @@ export const countryRouter = createTRPCRouter({ }, include: { metroCities: true }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Country", + entityId: created.id, + entityName: created.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: created as unknown as Record, + source: "ui", + }); + + return created; }), update: adminProcedure @@ -78,7 +92,9 @@ export const countryRouter = createTRPCRouter({ } } - return ctx.db.country.update({ + const before = existing as unknown as Record; + + const updated = await ctx.db.country.update({ where: { id: input.id }, data: { ...(input.data.code !== undefined ? { code: input.data.code } : {}), @@ -89,6 +105,20 @@ export const countryRouter = createTRPCRouter({ }, include: { metroCities: true }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Country", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), // ─── Metro City ───────────────────────────────────────────── @@ -101,18 +131,51 @@ export const countryRouter = createTRPCRouter({ "Country", ); - return ctx.db.metroCity.create({ + const created = await ctx.db.metroCity.create({ data: { name: input.name, countryId: input.countryId }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "MetroCity", + entityId: created.id, + entityName: created.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: created as unknown as Record, + source: "ui", + }); + + return created; }), updateCity: adminProcedure .input(z.object({ id: z.string(), data: UpdateMetroCitySchema })) .mutation(async ({ ctx, input }) => { - return ctx.db.metroCity.update({ + const existing = await findUniqueOrThrow( + ctx.db.metroCity.findUnique({ where: { id: input.id } }), + "Metro city", + ); + const before = existing as unknown as Record; + + const updated = await ctx.db.metroCity.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}) }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "MetroCity", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), deleteCity: adminProcedure @@ -132,6 +195,18 @@ export const countryRouter = createTRPCRouter({ }); } await ctx.db.metroCity.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "MetroCity", + entityId: city.id, + entityName: city.name, + action: "DELETE", + userId: ctx.dbUser?.id, + before: city as unknown as Record, + source: "ui", + }); + return { success: true }; }), }); diff --git a/packages/api/src/router/effort-rule.ts b/packages/api/src/router/effort-rule.ts index 0544bb1..d876eee 100644 --- a/packages/api/src/router/effort-rule.ts +++ b/packages/api/src/router/effort-rule.ts @@ -13,6 +13,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; +import { createAuditEntry } from "../lib/audit.js"; const ruleInclude = { rules: { orderBy: { sortOrder: "asc" as const } }, @@ -50,7 +51,7 @@ export const effortRuleRouter = createTRPCRouter({ }); } - return ctx.db.effortRuleSet.create({ + const ruleSet = await ctx.db.effortRuleSet.create({ data: { name: input.name, ...(input.description ? { description: input.description } : {}), @@ -69,13 +70,26 @@ export const effortRuleRouter = createTRPCRouter({ }, include: ruleInclude, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "EffortRuleSet", + entityId: ruleSet.id, + entityName: ruleSet.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: { name: input.name, isDefault: input.isDefault, ruleCount: input.rules.length }, + source: "ui", + }); + + return ruleSet; }), update: managerProcedure .input(UpdateEffortRuleSetSchema) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( - ctx.db.effortRuleSet.findUnique({ where: { id: input.id } }), + const before = await findUniqueOrThrow( + ctx.db.effortRuleSet.findUnique({ where: { id: input.id }, include: ruleInclude }), "Effort rule set", ); @@ -104,7 +118,7 @@ export const effortRuleRouter = createTRPCRouter({ }); } - return ctx.db.effortRuleSet.update({ + const updated = await ctx.db.effortRuleSet.update({ where: { id: input.id }, data: { ...(input.name !== undefined ? { name: input.name } : {}), @@ -113,16 +127,41 @@ export const effortRuleRouter = createTRPCRouter({ }, include: ruleInclude, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "EffortRuleSet", + entityId: input.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: { name: before.name, isDefault: before.isDefault, ruleCount: before.rules.length }, + after: { name: updated.name, isDefault: updated.isDefault, ruleCount: updated.rules.length }, + source: "ui", + }); + + return updated; }), delete: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const ruleSet = await findUniqueOrThrow( ctx.db.effortRuleSet.findUnique({ where: { id: input.id } }), "Effort rule set", ); await ctx.db.effortRuleSet.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "EffortRuleSet", + entityId: input.id, + entityName: ruleSet.name, + action: "DELETE", + userId: ctx.dbUser?.id, + source: "ui", + }); + return { id: input.id }; }), diff --git a/packages/api/src/router/entitlement.ts b/packages/api/src/router/entitlement.ts index dbed422..f3bae9a 100644 --- a/packages/api/src/router/entitlement.ts +++ b/packages/api/src/router/entitlement.ts @@ -8,6 +8,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; +import { createAuditEntry } from "../lib/audit.js"; /** Types that consume from annual leave balance */ const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER]; @@ -189,12 +190,27 @@ export const entitlementRouter = createTRPCRouter({ where: { resourceId_year: { resourceId: input.resourceId, year: input.year } }, }); if (existing) { - return ctx.db.vacationEntitlement.update({ + const updated = await ctx.db.vacationEntitlement.update({ where: { id: existing.id }, data: { entitledDays: input.entitledDays }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "VacationEntitlement", + entityId: updated.id, + entityName: `Entitlement ${input.resourceId} / ${input.year}`, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`, + }); + + return updated; } - return ctx.db.vacationEntitlement.create({ + const created = await ctx.db.vacationEntitlement.create({ data: { resourceId: input.resourceId, year: input.year, @@ -204,6 +220,20 @@ export const entitlementRouter = createTRPCRouter({ pendingDays: 0, }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "VacationEntitlement", + entityId: created.id, + entityName: `Entitlement ${input.resourceId} / ${input.year}`, + action: "CREATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + after: created as unknown as Record, + source: "ui", + summary: `Set entitlement to ${input.entitledDays} days (${input.year})`, + }); + + return created; }), /** @@ -244,6 +274,18 @@ export const entitlementRouter = createTRPCRouter({ updated++; } + void createAuditEntry({ + db: ctx.db, + entityType: "VacationEntitlement", + entityId: `bulk-${input.year}`, + entityName: `Bulk Entitlement ${input.year}`, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + after: { year: input.year, entitledDays: input.entitledDays, resourceCount: updated } as unknown as Record, + source: "ui", + summary: `Bulk set entitlement to ${input.entitledDays} days for ${updated} resources (${input.year})`, + }); + return { updated }; }), diff --git a/packages/api/src/router/experience-multiplier.ts b/packages/api/src/router/experience-multiplier.ts index 62af83c..c16c751 100644 --- a/packages/api/src/router/experience-multiplier.ts +++ b/packages/api/src/router/experience-multiplier.ts @@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; +import { createAuditEntry } from "../lib/audit.js"; const ruleInclude = { rules: { orderBy: { sortOrder: "asc" as const } }, @@ -72,7 +73,7 @@ export const experienceMultiplierRouter = createTRPCRouter({ }); } - return ctx.db.experienceMultiplierSet.create({ + const set = await ctx.db.experienceMultiplierSet.create({ data: { name: input.name, ...(input.description ? { description: input.description } : {}), @@ -93,13 +94,26 @@ export const experienceMultiplierRouter = createTRPCRouter({ }, include: ruleInclude, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ExperienceMultiplierSet", + entityId: set.id, + entityName: set.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: { name: input.name, isDefault: input.isDefault, ruleCount: input.rules.length }, + source: "ui", + }); + + return set; }), update: managerProcedure .input(UpdateExperienceMultiplierSetSchema) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( - ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } }), + const before = await findUniqueOrThrow( + ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id }, include: ruleInclude }), "Experience multiplier set", ); @@ -128,7 +142,7 @@ export const experienceMultiplierRouter = createTRPCRouter({ }); } - return ctx.db.experienceMultiplierSet.update({ + const updated = await ctx.db.experienceMultiplierSet.update({ where: { id: input.id }, data: { ...(input.name !== undefined ? { name: input.name } : {}), @@ -137,16 +151,41 @@ export const experienceMultiplierRouter = createTRPCRouter({ }, include: ruleInclude, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ExperienceMultiplierSet", + entityId: input.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: { name: before.name, isDefault: before.isDefault, ruleCount: before.rules.length }, + after: { name: updated.name, isDefault: updated.isDefault, ruleCount: updated.rules.length }, + source: "ui", + }); + + return updated; }), delete: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const set = await findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } }), "Experience multiplier set", ); await ctx.db.experienceMultiplierSet.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ExperienceMultiplierSet", + entityId: input.id, + entityName: set.name, + action: "DELETE", + userId: ctx.dbUser?.id, + source: "ui", + }); + return { id: input.id }; }), diff --git a/packages/api/src/router/index.ts b/packages/api/src/router/index.ts index 00e3efd..6564a96 100644 --- a/packages/api/src/router/index.ts +++ b/packages/api/src/router/index.ts @@ -1,6 +1,7 @@ import { createTRPCRouter } from "../trpc.js"; import { allocationRouter } from "./allocation.js"; import { assistantRouter } from "./assistant.js"; +import { auditLogRouter } from "./audit-log.js"; import { calculationRuleRouter } from "./calculation-rules.js"; import { blueprintRouter } from "./blueprint.js"; import { chargeabilityReportRouter } from "./chargeability-report.js"; @@ -36,6 +37,7 @@ import { webhookRouter } from "./webhook.js"; export const appRouter = createTRPCRouter({ assistant: assistantRouter, + auditLog: auditLogRouter, dashboard: dashboardRouter, dispo: dispoRouter, effortRule: effortRuleRouter, diff --git a/packages/api/src/router/management-level.ts b/packages/api/src/router/management-level.ts index 9bbc266..6e8eb5f 100644 --- a/packages/api/src/router/management-level.ts +++ b/packages/api/src/router/management-level.ts @@ -7,6 +7,7 @@ import { import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; export const managementLevelRouter = createTRPCRouter({ @@ -42,7 +43,7 @@ export const managementLevelRouter = createTRPCRouter({ if (existing) { throw new TRPCError({ code: "CONFLICT", message: `Group "${input.name}" already exists` }); } - return ctx.db.managementLevelGroup.create({ + const created = await ctx.db.managementLevelGroup.create({ data: { name: input.name, targetPercentage: input.targetPercentage, @@ -50,6 +51,19 @@ export const managementLevelRouter = createTRPCRouter({ }, include: { levels: true }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevelGroup", + entityId: created.id, + entityName: created.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: created as unknown as Record, + source: "ui", + }); + + return created; }), updateGroup: adminProcedure @@ -67,7 +81,9 @@ export const managementLevelRouter = createTRPCRouter({ } } - return ctx.db.managementLevelGroup.update({ + const before = existing as unknown as Record; + + const updated = await ctx.db.managementLevelGroup.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), @@ -76,6 +92,20 @@ export const managementLevelRouter = createTRPCRouter({ }, include: { levels: true }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevelGroup", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), // ─── Levels ───────────────────────────────────────────── @@ -93,9 +123,22 @@ export const managementLevelRouter = createTRPCRouter({ throw new TRPCError({ code: "CONFLICT", message: `Level "${input.name}" already exists` }); } - return ctx.db.managementLevel.create({ + const created = await ctx.db.managementLevel.create({ data: { name: input.name, groupId: input.groupId }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevel", + entityId: created.id, + entityName: created.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: created as unknown as Record, + source: "ui", + }); + + return created; }), updateLevel: adminProcedure @@ -113,13 +156,29 @@ export const managementLevelRouter = createTRPCRouter({ } } - return ctx.db.managementLevel.update({ + const before = existing as unknown as Record; + + const updated = await ctx.db.managementLevel.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), ...(input.data.groupId !== undefined ? { groupId: input.data.groupId } : {}), }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevel", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), deleteLevel: adminProcedure @@ -139,6 +198,18 @@ export const managementLevelRouter = createTRPCRouter({ }); } await ctx.db.managementLevel.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "ManagementLevel", + entityId: level.id, + entityName: level.name, + action: "DELETE", + userId: ctx.dbUser?.id, + before: level as unknown as Record, + source: "ui", + }); + return { success: true }; }), }); diff --git a/packages/api/src/router/org-unit.ts b/packages/api/src/router/org-unit.ts index 5f7d4c5..eeb7822 100644 --- a/packages/api/src/router/org-unit.ts +++ b/packages/api/src/router/org-unit.ts @@ -2,6 +2,7 @@ import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@planarchy/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; import type { OrgUnitTree } from "@planarchy/shared"; @@ -93,7 +94,7 @@ export const orgUnitRouter = createTRPCRouter({ } } - return ctx.db.orgUnit.create({ + const created = await ctx.db.orgUnit.create({ data: { name: input.name, ...(input.shortName !== undefined ? { shortName: input.shortName } : {}), @@ -102,17 +103,32 @@ export const orgUnitRouter = createTRPCRouter({ sortOrder: input.sortOrder, }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "OrgUnit", + entityId: created.id, + entityName: created.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: created as unknown as Record, + source: "ui", + }); + + return created; }), update: adminProcedure .input(z.object({ id: z.string(), data: UpdateOrgUnitSchema })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const existing = await findUniqueOrThrow( ctx.db.orgUnit.findUnique({ where: { id: input.id } }), "Org unit", ); - return ctx.db.orgUnit.update({ + const before = existing as unknown as Record; + + const updated = await ctx.db.orgUnit.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), @@ -122,14 +138,43 @@ export const orgUnitRouter = createTRPCRouter({ ...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}), }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "OrgUnit", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), deactivate: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - return ctx.db.orgUnit.update({ + const updated = await ctx.db.orgUnit.update({ where: { id: input.id }, data: { isActive: false }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "OrgUnit", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: { isActive: true }, + after: { isActive: false }, + source: "ui", + summary: "Deactivated OrgUnit", + }); + + return updated; }), }); diff --git a/packages/api/src/router/rate-card.ts b/packages/api/src/router/rate-card.ts index e352f64..3451e94 100644 --- a/packages/api/src/router/rate-card.ts +++ b/packages/api/src/router/rate-card.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; import { ROLE_BRIEF_SELECT } from "../db/selects.js"; +import { createAuditEntry } from "../lib/audit.js"; const lineSelect = { id: true, @@ -96,7 +97,7 @@ export const rateCardRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const { lines, ...cardData } = input; - return ctx.db.rateCard.create({ + const rateCard = await ctx.db.rateCard.create({ data: { name: cardData.name, currency: cardData.currency, @@ -123,17 +124,30 @@ export const rateCardRouter = createTRPCRouter({ lines: { select: lineSelect }, }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCard", + entityId: rateCard.id, + entityName: rateCard.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: { name: cardData.name, currency: cardData.currency, lineCount: lines.length }, + source: "ui", + }); + + return rateCard; }), update: managerProcedure .input(z.object({ id: z.string(), data: UpdateRateCardSchema })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const before = await findUniqueOrThrow( ctx.db.rateCard.findUnique({ where: { id: input.id } }), "Rate card", ); - return ctx.db.rateCard.update({ + const updated = await ctx.db.rateCard.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), @@ -149,15 +163,42 @@ export const rateCardRouter = createTRPCRouter({ client: { select: { id: true, name: true, code: true } }, }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCard", + entityId: input.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: before as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), deactivate: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - return ctx.db.rateCard.update({ + const deactivated = await ctx.db.rateCard.update({ where: { id: input.id }, data: { isActive: false }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCard", + entityId: input.id, + entityName: deactivated.name, + action: "DELETE", + userId: ctx.dbUser?.id, + source: "ui", + summary: "Deactivated rate card", + }); + + return deactivated; }), // ─── Line CRUD ───────────────────────────────────────────────────────────── @@ -165,12 +206,12 @@ export const rateCardRouter = createTRPCRouter({ addLine: managerProcedure .input(z.object({ rateCardId: z.string(), line: CreateRateCardLineSchema })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const rateCard = await findUniqueOrThrow( ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }), "Rate card", ); - return ctx.db.rateCardLine.create({ + const line = await ctx.db.rateCardLine.create({ data: { rateCardId: input.rateCardId, ...(input.line.roleId !== undefined ? { roleId: input.line.roleId } : {}), @@ -186,12 +227,25 @@ export const rateCardRouter = createTRPCRouter({ }, select: lineSelect, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCardLine", + entityId: line.id, + entityName: `${rateCard.name} — ${input.line.chapter ?? "line"}`, + action: "CREATE", + userId: ctx.dbUser?.id, + after: { rateCardId: input.rateCardId, costRateCents: input.line.costRateCents, billRateCents: input.line.billRateCents }, + source: "ui", + }); + + return line; }), updateLine: managerProcedure .input(z.object({ lineId: z.string(), data: UpdateRateCardLineSchema })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const before = await findUniqueOrThrow( ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }), "Rate card line", ); @@ -208,22 +262,46 @@ export const rateCardRouter = createTRPCRouter({ if (input.data.machineRateCents !== undefined) updateData.machineRateCents = input.data.machineRateCents; if (input.data.attributes !== undefined) updateData.attributes = input.data.attributes as Prisma.InputJsonValue; - return ctx.db.rateCardLine.update({ + const updated = await ctx.db.rateCardLine.update({ where: { id: input.lineId }, data: updateData, select: lineSelect, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCardLine", + entityId: input.lineId, + action: "UPDATE", + userId: ctx.dbUser?.id, + before: before as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), deleteLine: managerProcedure .input(z.object({ lineId: z.string() })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const line = await findUniqueOrThrow( ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }), "Rate card line", ); await ctx.db.rateCardLine.delete({ where: { id: input.lineId } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCardLine", + entityId: input.lineId, + action: "DELETE", + userId: ctx.dbUser?.id, + before: line as unknown as Record, + source: "ui", + }); + return { deleted: true }; }), @@ -235,12 +313,12 @@ export const rateCardRouter = createTRPCRouter({ lines: z.array(CreateRateCardLineSchema), })) .mutation(async ({ ctx, input }) => { - await findUniqueOrThrow( + const rateCard = await findUniqueOrThrow( ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }), "Rate card", ); - return ctx.db.$transaction(async (tx) => { + const result = await ctx.db.$transaction(async (tx) => { await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } }); const created = await Promise.all( @@ -266,6 +344,20 @@ export const rateCardRouter = createTRPCRouter({ return created; }); + + void createAuditEntry({ + db: ctx.db, + entityType: "RateCard", + entityId: input.rateCardId, + entityName: rateCard.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + after: { replacedLineCount: result.length }, + source: "ui", + summary: `Replaced all lines with ${result.length} new lines`, + }); + + return result; }), // ─── Rate resolution ─────────────────────────────────────────────────────── diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index da2d75a..6bd3d71 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -4,6 +4,15 @@ import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js"; import { DEFAULT_SUMMARY_PROMPT } from "./resource.js"; import { VALUE_SCORE_WEIGHTS } from "@planarchy/shared"; import { testSmtpConnection } from "../lib/email.js"; +import { createAuditEntry } from "../lib/audit.js"; + +/** Fields that must never appear in audit log values */ +const SENSITIVE_FIELDS = new Set([ + "azureOpenAiApiKey", + "smtpPassword", + "azureDalleApiKey", + "anonymizationSeed", +]); export const settingsRouter = createTRPCRouter({ getSystemSettings: adminProcedure.query(async ({ ctx }) => { @@ -151,12 +160,39 @@ export const settingsRouter = createTRPCRouter({ // Timeline if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps; + // Fetch current settings for before-snapshot + const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); + await ctx.db.systemSettings.upsert({ where: { id: "singleton" }, create: { id: "singleton", ...data }, update: data, }); + // Build sanitized snapshots — redact sensitive fields + const sanitize = (obj: Record): Record => { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = SENSITIVE_FIELDS.has(key) ? (value ? "***" : null) : value; + } + return result; + }; + + const sanitizedBefore = before ? sanitize(before as unknown as Record) : undefined; + const sanitizedAfter = sanitize(data); + + void createAuditEntry({ + db: ctx.db, + entityType: "SystemSettings", + entityId: "singleton", + entityName: "System Settings", + action: before ? "UPDATE" : "CREATE", + userId: ctx.dbUser?.id, + ...(sanitizedBefore !== undefined ? { before: sanitizedBefore } : {}), + after: sanitizedAfter, + source: "ui", + }); + return { ok: true }; }), @@ -246,8 +282,22 @@ export const settingsRouter = createTRPCRouter({ } }), - testSmtpConnection: adminProcedure.mutation(async () => { - return testSmtpConnection(); + testSmtpConnection: adminProcedure.mutation(async ({ ctx }) => { + const result = await testSmtpConnection(); + + void createAuditEntry({ + db: ctx.db, + entityType: "SystemSettings", + entityId: "singleton", + entityName: "SMTP Connection Test", + action: "UPDATE", + userId: ctx.dbUser?.id, + after: { testResult: result.ok ? "success" : "failed" }, + source: "ui", + summary: result.ok ? "SMTP connection test succeeded" : "SMTP connection test failed", + }); + + return result; }), getAiConfigured: protectedProcedure.query(async ({ ctx }) => { diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 0fc5d54..01b2330 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -13,6 +13,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; +import { createAuditEntry } from "../lib/audit.js"; export const userRouter = createTRPCRouter({ /** Lightweight user list for task assignment (ADMIN + MANAGER) */ @@ -111,6 +112,17 @@ export const userRouter = createTRPCRouter({ }); } + void createAuditEntry({ + db: ctx.db, + entityType: "User", + entityId: user.id, + entityName: `${user.name} (${user.email})`, + action: "CREATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + after: user as unknown as Record, + source: "ui", + }); + return user; }), @@ -122,11 +134,31 @@ export const userRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - return ctx.db.user.update({ + const before = await ctx.db.user.findUniqueOrThrow({ + where: { id: input.id }, + select: { id: true, name: true, email: true, systemRole: true }, + }); + + const updated = await ctx.db.user.update({ where: { id: input.id }, data: { systemRole: input.systemRole }, select: { id: true, name: true, email: true, systemRole: true }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "User", + entityId: updated.id, + entityName: `${updated.name} (${updated.email})`, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + before: before as unknown as Record, + after: updated as unknown as Record, + source: "ui", + summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`, + }); + + return updated; }), // ─── Resource Linking ────────────────────────────────────────────────── @@ -242,20 +274,61 @@ export const userRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + const before = await ctx.db.user.findUniqueOrThrow({ + where: { id: input.userId }, + select: { id: true, name: true, email: true, permissionOverrides: true }, + }); + const user = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: input.overrides ?? Prisma.DbNull }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "User", + entityId: input.userId, + entityName: `${before.name} (${before.email})`, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + before: { permissionOverrides: before.permissionOverrides } as unknown as Record, + after: { permissionOverrides: input.overrides } as unknown as Record, + source: "ui", + summary: input.overrides + ? `Set permission overrides (granted: ${input.overrides.granted?.length ?? 0}, denied: ${input.overrides.denied?.length ?? 0})` + : "Cleared permission overrides", + }); + return user; }), resetPermissions: adminProcedure .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { - return ctx.db.user.update({ + const before = await ctx.db.user.findUniqueOrThrow({ + where: { id: input.userId }, + select: { id: true, name: true, email: true, permissionOverrides: true }, + }); + + const updated = await ctx.db.user.update({ where: { id: input.userId }, data: { permissionOverrides: Prisma.DbNull }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "User", + entityId: input.userId, + entityName: `${before.name} (${before.email})`, + action: "UPDATE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + before: { permissionOverrides: before.permissionOverrides } as unknown as Record, + after: { permissionOverrides: null } as unknown as Record, + source: "ui", + summary: "Reset permission overrides to role defaults", + }); + + return updated; }), getColumnPreferences: protectedProcedure.query(async ({ ctx }) => { diff --git a/packages/api/src/router/utilization-category.ts b/packages/api/src/router/utilization-category.ts index 1158300..7f894de 100644 --- a/packages/api/src/router/utilization-category.ts +++ b/packages/api/src/router/utilization-category.ts @@ -5,6 +5,7 @@ import { import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; export const utilizationCategoryRouter = createTRPCRouter({ @@ -48,7 +49,7 @@ export const utilizationCategoryRouter = createTRPCRouter({ }); } - return ctx.db.utilizationCategory.create({ + const created = await ctx.db.utilizationCategory.create({ data: { code: input.code, name: input.name, @@ -57,6 +58,19 @@ export const utilizationCategoryRouter = createTRPCRouter({ isDefault: input.isDefault, }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "UtilizationCategory", + entityId: created.id, + entityName: created.name, + action: "CREATE", + userId: ctx.dbUser?.id, + after: created as unknown as Record, + source: "ui", + }); + + return created; }), update: adminProcedure @@ -82,7 +96,9 @@ export const utilizationCategoryRouter = createTRPCRouter({ }); } - return ctx.db.utilizationCategory.update({ + const before = existing as unknown as Record; + + const updated = await ctx.db.utilizationCategory.update({ where: { id: input.id }, data: { ...(input.data.code !== undefined ? { code: input.data.code } : {}), @@ -93,5 +109,19 @@ export const utilizationCategoryRouter = createTRPCRouter({ ...(input.data.isDefault !== undefined ? { isDefault: input.data.isDefault } : {}), }, }); + + void createAuditEntry({ + db: ctx.db, + entityType: "UtilizationCategory", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + userId: ctx.dbUser?.id, + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; }), }); diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 519cc95..e3d345f 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -11,6 +11,7 @@ import { sendEmail } from "../lib/email.js"; import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; +import { createAuditEntry } from "../lib/audit.js"; /** Types that consume from annual leave balance */ const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER]; @@ -219,6 +220,17 @@ export const vacationRouter = createTRPCRouter({ emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status }); + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: vacation.id, + entityName: `${vacation.resource?.displayName ?? "Unknown"} - ${vacation.type}`, + action: "CREATE", + userId: userRecord.id, + after: vacation as unknown as Record, + source: "ui", + }); + // Create approval tasks for managers when a non-manager submits a vacation request if (status === VacationStatus.PENDING) { const resourceName = vacation.resource?.displayName ?? "Unknown"; @@ -291,6 +303,20 @@ export const vacationRouter = createTRPCRouter({ }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: updated.id, + entityName: `Vacation ${updated.id}`, + action: "UPDATE", + ...(userRecord?.id ? { userId: userRecord.id } : {}), + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + summary: `Approved vacation (was ${existing.status})`, + }); + void dispatchWebhooks(ctx.db, "vacation.approved", { id: updated.id, resourceId: updated.resourceId, @@ -361,6 +387,19 @@ export const vacationRouter = createTRPCRouter({ }, }); + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: updated.id, + entityName: `Vacation ${updated.id}`, + action: "UPDATE", + ...(userRecord?.id ? { userId: userRecord.id } : {}), + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + summary: `Rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`, + }); + void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason); return updated; @@ -404,6 +443,18 @@ export const vacationRouter = createTRPCRouter({ emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED }); void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED); + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: v.id, + entityName: `Vacation ${v.id}`, + action: "UPDATE", + ...(userRecord?.id ? { userId: userRecord.id } : {}), + after: { status: VacationStatus.APPROVED } as unknown as Record, + source: "ui", + summary: "Batch approved vacation", + }); + // Mark approval tasks as DONE await ctx.db.notification.updateMany({ where: { @@ -461,6 +512,18 @@ export const vacationRouter = createTRPCRouter({ emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED }); void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason); + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: v.id, + entityName: `Vacation ${v.id}`, + action: "UPDATE", + ...(userRecord?.id ? { userId: userRecord.id } : {}), + after: { status: VacationStatus.REJECTED, rejectionReason: input.rejectionReason } as unknown as Record, + source: "ui", + summary: `Batch rejected vacation${input.rejectionReason ? `: ${input.rejectionReason}` : ""}`, + }); + // Mark approval tasks as DONE await ctx.db.notification.updateMany({ where: { @@ -523,6 +586,20 @@ export const vacationRouter = createTRPCRouter({ }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: updated.id, + entityName: `Vacation ${updated.id}`, + action: "UPDATE", + userId: userRecord.id, + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + summary: `Cancelled vacation (was ${existing.status})`, + }); + return updated; }), @@ -687,6 +764,18 @@ export const vacationRouter = createTRPCRouter({ } } + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: `public-holidays-${input.year}`, + entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`, + action: "CREATE", + userId: adminUser.id, + after: { created, holidays: holidays.length, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record, + source: "ui", + summary: `Batch created ${created} public holidays for ${resources.length} resources (${input.year})`, + }); + return { created, holidays: holidays.length, resources: resources.length }; }), @@ -729,6 +818,20 @@ export const vacationRouter = createTRPCRouter({ }); emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status }); + + void createAuditEntry({ + db: ctx.db, + entityType: "Vacation", + entityId: updated.id, + entityName: `Vacation ${updated.id}`, + action: "UPDATE", + userId: userRecord.id, + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + summary: `Updated vacation status to ${input.status}`, + }); + return updated; }), }); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 360afae..97bab31 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -1518,10 +1518,14 @@ model AuditLog { // changes: { before: Record, after: Record } changes Json @db.JsonB createdAt DateTime @default(now()) + source String? // "ui" | "api" | "ai" | "import" | "cron" + entityName String? // Human-readable name e.g. "Porsche Taycan Project" + summary String? // One-liner: "Changed status from DRAFT to ACTIVE" @@index([entityType, entityId]) @@index([userId]) @@index([createdAt]) + @@index([entityType, createdAt]) @@map("audit_logs") } diff --git a/plan.md b/plan.md index 02a57d7..53be991 100644 --- a/plan.md +++ b/plan.md @@ -1,125 +1,237 @@ -# Unified Skills Hub — Plan +# Activity History System — Detailed Plan ## Anforderungsanalyse -**Was:** Die zwei getrennten Skill-Seiten (`/analytics/skills` = SkillsAnalytics, `/analytics/skill-marketplace` = SkillMarketplace) zu **einer einzigen, nutzerfreundlichen Skills-Hub-Seite** zusammenfuehren. +**Ziel:** Ein lueckenloses Aenderungsprotokoll, das jede Mutation im System erfasst und ueber UI und AI Assistant abfragbar macht. Nutzer sollen fragen koennen: "Wer hat die Buchung von Person X geaendert?" oder "Was ist in den letzten Tagen bei Projekt Y passiert?" -**Problem heute:** -- **SkillsAnalytics** (496 LOC): Skill-Tabelle mit Filtern, People Finder (AND/OR Suche), XLSX Export, Skill Distribution Chart, Skill Gap Alerts -- **SkillMarketplace** (346 LOC): Skill-Suche mit Verfuegbarkeitsfilter, Skill Gap Heat Map (Supply vs Demand), Skill Distribution Chart (dupliziert!) -- **Ueberlappung:** Beide haben `ProficiencyBadge`, `PROFICIENCY_CLASSES`, `SkillDistributionChart`, aehnliche Tabellen -- **Verwirrung:** User muss zwei Seiten besuchen fuer zusammenhaengende Informationen -- **Inkonsistenz:** Analytics hat kein Dark-Theme auf manchen Elementen, Marketplace hat es +**Ist-Zustand:** +- AuditLog-Modell existiert (entityType, entityId, action, userId, changes JSONB, createdAt) +- 10 von 36 Routern loggen Aenderungen (44% Abdeckung) +- userId wird nur in ~60% der Faelle erfasst +- Kein Query-Endpoint (write-only) +- Keine UI zum Anzeigen der Historie +- AI Assistant kann keine Historie abfragen +- Inkonsistente before/after Snapshots -**Ziel:** Eine Seite `/analytics/skills` mit Tab-basiertem Layout: +**Soll-Zustand:** +- 100% Mutation-Abdeckung ueber alle Router +- Konsistente before/after Snapshots mit User-Attribution +- Query-API mit Filtern (entityType, entityId, userId, dateRange, action) +- Admin-UI: `/admin/activity-log` mit suchbarer, filterbarer Timeline +- Entity-Detail-Seiten: "History"-Tab/-Drawer auf Project/Resource/Allocation +- AI Assistant Tool: `query_change_history` fuer natuerlichsprachliche Abfragen +- Change-Source Tracking: UI vs API vs AI vs Import -``` -+-------------------------------------------------------------+ -| Skills Hub [Export] | -| 125 resources . 47 distinct skills | -+----------+----------+----------+-------------+--------------+ -| Overview | Search | Gaps | People | Distribution | -+----------+----------+----------+-------------+--------------+ -| | -| [Tab content area] | -| | -+--------------------------------------------------------------+ +--- + +## Architektur-Entscheidungen + +### 1. Audit Middleware statt manuelle Calls +**Entscheidung:** tRPC Middleware die automatisch vor/nach jeder Mutation auditiert +**Grund:** Eliminiert vergessene `auditLog.create()` Calls, garantiert 100% Abdeckung +**Umsetzung:** Middleware auf `protectedProcedure` die: +- Vor der Mutation: Entity-Snapshot speichert (before) +- Nach der Mutation: Neuen Snapshot speichert (after) +- Diff berechnet und AuditLog-Entry erstellt + +### 2. Standardisiertes Changes-Format +```typescript +interface AuditChanges { + before?: Record; // Snapshot vor der Aenderung + after?: Record; // Snapshot nach der Aenderung + diff?: Record; // Nur geaenderte Felder + metadata?: { + source: "ui" | "api" | "ai" | "import" | "cron"; // Wer hat die Aenderung ausgeloest + reason?: string; // Optionaler Kommentar + ip?: string; // Request IP (optional) + batchId?: string; // Fuer Bulk-Operationen + }; +} ``` -### Betroffene Pakete & Dateien +### 3. Schema-Erweiterungen +```prisma +model AuditLog { + // Existierende Felder behalten + id String @id @default(cuid()) + entityType String + entityId String + action AuditAction + userId String? + user User? @relation(fields: [userId], references: [id]) + changes Json @db.JsonB + createdAt DateTime @default(now()) + + // NEU: Zusaetzliche Felder + source String? // "ui" | "api" | "ai" | "import" | "cron" + entityName String? // Menschenlesbarer Name (z.B. "Porsche Taycan Project") + summary String? // Einzeiler: "Changed status from DRAFT to ACTIVE" + + @@index([entityType, entityId]) + @@index([userId]) + @@index([createdAt]) + @@index([entityType, createdAt]) // NEU: Fuer sortierte Timeline-Queries +} +``` + +--- + +## Betroffene Pakete & Dateien | Paket | Dateien | Art der Aenderung | |-------|---------|------------------| -| `apps/web` | `src/components/analytics/SkillsHub.tsx` | **create** — neue unified component | -| `apps/web` | `src/components/analytics/skills/OverviewTab.tsx` | **create** — KPI cards + distribution chart | -| `apps/web` | `src/components/analytics/skills/SearchTab.tsx` | **create** — skill search + availability (from Marketplace) | -| `apps/web` | `src/components/analytics/skills/GapsTab.tsx` | **create** — supply/demand gap analysis (from Marketplace) | -| `apps/web` | `src/components/analytics/skills/PeopleFinderTab.tsx` | **create** — AND/OR skill search (from Analytics) | -| `apps/web` | `src/components/analytics/skills/shared.tsx` | **create** — ProficiencyBadge, GapIndicator, constants | -| `apps/web` | `src/app/(app)/analytics/skills/page.tsx` | **edit** — render SkillsHub statt SkillsAnalytics | -| `apps/web` | `src/app/(app)/analytics/skill-marketplace/page.tsx` | **edit** — redirect to /analytics/skills | -| `apps/web` | `src/components/layout/AppShell.tsx` | **edit** — remove "Skill Marketplace" nav link | -| `packages/api` | `src/router/resource.ts` | **edit** — add unified getSkillsHub query | +| `packages/db` | `prisma/schema.prisma` | **edit** — AuditLog um source, entityName, summary erweitern | +| `packages/api` | `src/lib/audit.ts` | **create** — `createAuditEntry()` Helper + `auditMiddleware` | +| `packages/api` | `src/router/audit-log.ts` | **create** — Query-Router (list, getByEntity, getTimeline) | +| `packages/api` | `src/router/index.ts` | **edit** — auditLog Router registrieren | +| `packages/api` | `src/router/assistant-tools.ts` | **edit** — `query_change_history` Tool hinzufuegen | +| `packages/api` | 26 Router-Dateien | **edit** — fehlende audit Calls nachruesten | +| `apps/web` | `src/app/(app)/admin/activity-log/page.tsx` | **create** — Activity Log Seite | +| `apps/web` | `src/components/admin/ActivityLogClient.tsx` | **create** — Suchbare Timeline | +| `apps/web` | `src/components/ui/EntityHistory.tsx` | **create** — Wiederverwendbare History-Komponente | +| `apps/web` | `src/components/layout/AppShell.tsx` | **edit** — Nav-Link fuer Activity Log | -### Task-Liste +--- -- [ ] **Task 1:** Shared utilities extrahieren -> `skills/shared.tsx` - - `ProficiencyBadge`, `GapIndicator`, `PROFICIENCY_CLASSES`, `PROFICIENCY_LABELS`, `proficiencyClasses()` - - Einmal definieren, ueberall nutzen +## Task-Liste (atomare Schritte) -- [ ] **Task 2:** API: neuen `getSkillsHub` Query -> `resource.ts` - - Kombiniert alle Daten in einem Call: - - `aggregated` (from getSkillsAnalytics) - - `searchResults` (from getSkillMarketplace) - - `gapData` (from getSkillMarketplace) - - `distribution` (from both, dedupliziert) - - `totalResources`, `totalSkillEntries` - - Alte Queries behalten (AI Assistant nutzt sie) +### Phase 1: Infrastruktur (Basis) -- [ ] **Task 3:** OverviewTab bauen -> `skills/OverviewTab.tsx` - - KPI Cards: Total Resources, Total Skills, Avg Proficiency, Skill Gaps Count - - Top 10 Skills Tabelle (sortierbar) - - Skill Distribution Chart (lazy-loaded) - - Quick filters: Category, Min Count +- [ ] **Task 1:** Schema erweitern → `packages/db/prisma/schema.prisma` + - `source String?`, `entityName String?`, `summary String?` hinzufuegen + - Index `@@index([entityType, createdAt])` hinzufuegen + - `prisma db push` + `prisma generate` -- [ ] **Task 4:** SearchTab bauen -> `skills/SearchTab.tsx` - - Skill name Suche (debounced) - - Min Proficiency Filter (1-5 Buttons) - - "Available in 30 days" Toggle - - Ergebnis-Tabelle: Resource, Chapter, Skill, Proficiency, Utilization, Available From - - Links zu `/resources/[id]` +- [ ] **Task 2:** Audit Helper erstellen → `packages/api/src/lib/audit.ts` + - `createAuditEntry(db, params)` — standardisierter Audit-Entry-Creator + - Params: `{ entityType, entityId, entityName, action, userId, before?, after?, source?, summary? }` + - Automatische Diff-Berechnung wenn before + after vorhanden + - Automatische Summary-Generierung aus Diff (z.B. "Updated name, status, budgetCents") + - `computeDiff(before, after)` — gibt nur geaenderte Felder zurueck -- [ ] **Task 5:** GapsTab bauen -> `skills/GapsTab.tsx` - - Supply vs Demand Tabelle - - Supply/Demand Bar Visualisierung - - Gap Indicator (shortage/surplus/balanced) - - Sortierbar nach groesstem Gap - - Click auf Skill -> fuellt Search Tab +- [ ] **Task 3:** Query Router erstellen → `packages/api/src/router/audit-log.ts` + - `list` query (controllerProcedure): paginiert, filterbar nach entityType, entityId, userId, action, dateRange, source + - `getByEntity` query: alle Entries fuer eine Entity, chronologisch + - `getTimeline` query: globale Timeline aller Aenderungen, gruppierbar nach Tag + - `getActivitySummary` query: Zusammenfassung (counts pro entityType, pro action, pro User) fuer einen Zeitraum + - Registrieren in `router/index.ts` -- [ ] **Task 6:** PeopleFinderTab bauen -> `skills/PeopleFinderTab.tsx` - - Multi-rule Builder: Skill + Min Proficiency pro Regel - - AND/OR Operator Toggle - - Chapter Filter - - Ergebnis-Tabelle mit Match Score - - XLSX Export Button +### Phase 2: Audit-Abdeckung erweitern -- [ ] **Task 7:** SkillsHub zusammenfuegen -> `SkillsHub.tsx` - - Tab Navigation (Overview, Search, Gaps, People Finder) - - Header mit KPI Summary + Export Button - - Tab State via URL search params - - Lazy-load Tabs fuer Performance +- [ ] **Task 4:** Kritische Router nachruesteen (Parallel-fähig, 4 Agents) + - **Agent A:** `vacation.ts` (8 Mutations), `entitlement.ts` (2), `user.ts` (9) + - **Agent B:** `client.ts` (5), `org-unit.ts` (3), `country.ts` (5), `management-level.ts` (5) + - **Agent C:** `rate-card.ts` (7), `blueprint.ts` (6), `settings.ts` (3), `calculation-rules.ts` (3) + - **Agent D:** `webhook.ts` (4), `comment.ts` (3), `notification.ts` (nur create/task), `dispo.ts` (4) + - Jeder Agent: `import { createAuditEntry } from "../lib/audit.js"` verwenden + - userId immer aus `ctx.dbUser?.id` nehmen -- [ ] **Task 8:** Routing + Navigation aktualisieren - - `/analytics/skills/page.tsx` -> rendert `` - - `/analytics/skill-marketplace/page.tsx` -> redirect zu `/analytics/skills?tab=search` - - AppShell: "Skill Marketplace" entfernen, "Skills Analytics" umbenennen zu "Skills Hub" +- [ ] **Task 5:** Bestehende Audit-Calls standardisieren + - Alle 37 existierenden `auditLog.create` Calls auf `createAuditEntry()` Helper umstellen + - userId konsistent aus Context nehmen + - before/after Snapshots wo fehlend ergaenzen + - `source: "ui"` als Default setzen -- [ ] **Task 9:** Dark Theme durchgaengig - - Alle Elemente mit `dark:` Varianten - - Konsistenz mit dem Rest der App +### Phase 3: UI -### Abhaengigkeiten -- Task 1 muss zuerst (shared utilities fuer alle Tabs) -- Task 2 kann parallel zu Task 1 (API aendern) -- Tasks 3-6 koennen parallel nach Task 1 (4 Tabs, unabhaengige Dateien) -- Task 7 benoetigt Tasks 3-6 (importiert alle Tabs) -- Task 8 benoetigt Task 7 (Routing zeigt auf neue Komponente) -- Task 9 kann parallel zu Task 8 +- [ ] **Task 6:** Activity Log Admin-Seite → `ActivityLogClient.tsx` + - Globale, suchbare Timeline aller Aenderungen + - Filter: Entity-Typ (Project/Resource/Allocation/...), User, Action, Datum + - Jeder Eintrag zeigt: Zeitstempel, User (Avatar + Name), Entity (verlinkt), Action-Badge, Summary + - Expandierbares Detail: before/after Diff-View (JSON oder tabellarisch) + - Pagination (50 pro Seite) + - Sidebar Nav-Link unter Admin: "Activity Log" + +- [ ] **Task 7:** Entity History Komponente → `EntityHistory.tsx` + - Wiederverwendbar fuer Project/Resource/Allocation Detail-Seiten + - Props: `entityType: string, entityId: string` + - Chronologische Liste der Aenderungen fuer diese Entity + - Kompakte Darstellung: User, Action, Summary, Zeitstempel + - Optional: als Tab oder Drawer auf Detail-Seiten einbinden + +- [ ] **Task 8:** History-Tab auf Detail-Seiten integrieren + - `/projects/[id]` → "History" Tab mit `` + - `/resources/[id]` → "History" Tab + - Optional spaeter: Allocation Detail, Estimate Detail + +### Phase 4: AI Assistant Integration + +- [ ] **Task 9:** AI Tool erstellen → `assistant-tools.ts` + - `query_change_history` Tool: + - Input: `{ entityType?, entityId?, userId?, search?, daysBack?, limit? }` + - Ruft `auditLog.list` mit Filtern auf + - Formatiert Ergebnis menschenlesbar: + ``` + [2026-03-22 14:30] admin@planarchy.dev UPDATED Project "Porsche Taycan" + → Changed status from DRAFT to ACTIVE + → Changed budgetCents from 500000 to 750000 + ``` + - `get_entity_timeline` Tool: + - Input: `{ entityType, entityId, limit? }` + - Gibt chronologische History fuer eine Entity zurueck + - Beide Tools mit Permission `VIEW_PROJECTS` oder `VIEW_RESOURCES` je nach entityType + +--- + +## Abhaengigkeiten + +``` +Task 1 (Schema) ──► Task 2 (Helper) ──► Task 3 (Query Router) + └──► Task 4a-d (Parallel: 26 Router) + └──► Task 5 (Bestehende Calls) + Task 3 ──► Task 6 (UI: Activity Log) + ──► Task 7 (UI: Entity History) + ──► Task 9 (AI Tools) + Task 7 ──► Task 8 (Integration in Detail-Seiten) +``` + +- Tasks 4a-d koennen **parallel** ausgefuehrt werden (unterschiedliche Dateien) +- Tasks 6, 7, 9 koennen **parallel** nach Task 3 +- Task 8 benoetigt Task 7 + +--- + +## Akzeptanzkriterien -### Akzeptanzkriterien - [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors -- [ ] `/analytics/skills` zeigt die vereinte Seite mit 4 Tabs -- [ ] `/analytics/skill-marketplace` redirected zu `/analytics/skills?tab=search` -- [ ] Alle Features beider Seiten sind auf der neuen Seite verfuegbar -- [ ] Dark Theme funktioniert durchgehend -- [ ] Sidebar zeigt nur noch "Skills Hub" statt zwei Links -- [ ] XLSX Export funktioniert weiterhin -- [ ] People Finder AND/OR Suche funktioniert -- [ ] Skill Gap Heat Map mit Supply/Demand funktioniert -- [ ] Availability Filter (30 Tage) funktioniert +- [ ] `pnpm test:unit` — alle Tests gruen +- [ ] **100% Mutation-Abdeckung:** Jede Mutation in jedem Router erzeugt einen AuditLog-Entry +- [ ] **Konsistente userId:** Jeder Entry hat den ausfuehrenden User +- [ ] **before/after:** UPDATE-Actions haben immer before + after Snapshots +- [ ] **Query-API:** `trpc.auditLog.list` liefert paginierte, filterbare Ergebnisse +- [ ] **Admin UI:** `/admin/activity-log` zeigt globale Timeline mit Filtern +- [ ] **Entity History:** Project/Resource Detail-Seiten zeigen Aenderungs-Historie +- [ ] **AI Assistant:** "Wer hat die Buchung von Person X geaendert?" wird korrekt beantwortet +- [ ] **AI Assistant:** "Was ist bei Projekt Y in den letzten Tagen passiert?" liefert Ergebnis -### Risiken & offene Fragen -- **API Performance:** Ein kombinierter Query koennte langsamer sein -> Loesung: Lazy-load per Tab, Query nur wenn Tab aktiv -- **URL State:** Aktiver Tab via `?tab=search` Query Param persistiert -- **Export:** Nur aktiver Tab exportierbar -- **Backwards-Kompatibilitaet:** AI Assistant Tools nutzen alte Queries -> behalten +--- + +## Risiken & offene Fragen + +### Risiken +- **Performance:** Audit-Middleware auf jeder Mutation koennte Latenz erhoehen + → Mitigation: Audit-Writes fire-and-forget (non-blocking), oder nach Response +- **Storage:** JSONB Snapshots koennen gross werden + → Mitigation: Nur geaenderte Felder in `diff` speichern, nicht volle Snapshots +- **Migration:** 37 bestehende Calls umstellen birgt Regressions-Risiko + → Mitigation: Schrittweise, mit Tests pro Router + +### Offene Fragen +1. **Retention:** Wie lange sollen Audit-Logs aufbewahrt werden? (Vorschlag: 2 Jahre) +2. **Granularitaet:** Sollen READ-Zugriffe geloggt werden? (Vorschlag: Nein, nur Mutations) +3. **DSGVO:** Muessen Audit-Logs bei User-Loeschung anonymisiert werden? +4. **Notifications:** Sollen bestimmte Aenderungen (z.B. Projekt-Status) automatisch Notifications ausloesen? +5. **Middleware vs Manual:** Soll der Audit-Helper manuell oder als tRPC-Middleware eingebaut werden? + → Empfehlung: Manuell mit Helper-Funktion, da Middleware die Entity-Snapshots nicht automatisch kennt + +--- + +## Geschaetzter Aufwand + +| Phase | Aufwand | Parallelisierbar | +|-------|---------|-----------------| +| Phase 1: Infrastruktur | 1 Tag | Nein (sequenziell) | +| Phase 2: Audit-Abdeckung | 1 Tag | Ja (4 Agents parallel) | +| Phase 3: UI | 1 Tag | Ja (2 Agents parallel) | +| Phase 4: AI Integration | 0.5 Tag | Ja (mit Phase 3) | +| **Gesamt** | **~3.5 Tage** | |