From 1b888e8b6aecae0a2902fbb6b19dd32c1b9d51d8 Mon Sep 17 00:00:00 2001 From: Al Francis Date: Sun, 24 May 2026 18:04:33 -0700 Subject: [PATCH] Fix user management audit gaps --- app/admin/users/page.tsx | 176 +++++++++++++++++- .../hall-of-fame/[id]/visibility/route.ts | 2 + app/api/admin/hall-of-fame/settings/route.ts | 4 +- app/api/admin/reports/route.ts | 20 +- app/api/admin/users/[id]/activity/route.ts | 62 ++++++ app/api/admin/users/[id]/route.ts | 1 + app/api/admin/users/[id]/suspend/route.ts | 80 ++++++++ app/api/admin/users/route.ts | 25 ++- app/triage/page.tsx | 17 +- docs/MIGRATION_ORDER.md | 1 + lib/audit.ts | 23 ++- lib/db/schema.ts | 4 +- lib/services/hall-of-fame.ts | 2 + lib/validation.ts | 17 +- migrations/0011_support_user_audit_logs.sql | 73 ++++++++ 15 files changed, 471 insertions(+), 36 deletions(-) create mode 100644 app/api/admin/users/[id]/activity/route.ts create mode 100644 app/api/admin/users/[id]/suspend/route.ts create mode 100644 migrations/0011_support_user_audit_logs.sql diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index aa74681..38650f1 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -14,11 +14,23 @@ interface User { lastName: string | null; username: string | null; role: string; + banned: boolean; createdAt: number; lastSignInAt: number | null; imageUrl: string; } +interface ActivityEntry { + id: string; + reportId: string | null; + entityType: string; + entityId: string | null; + action: string; + oldValue: string | null; + newValue: string | null; + timestamp: number; +} + const ROLE_COLORS: Record = { ADMIN: "bg-red-100 text-red-700 border-red-200", TRIAGER: "bg-purple-100 text-purple-700 border-purple-200", @@ -33,6 +45,10 @@ export default function UserManagement() { const itemsPerPage = 25; const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); const [confirmDialog, setConfirmDialog] = useState<{ userId: string; newRole: string; userName: string } | null>(null); + const [suspendDialog, setSuspendDialog] = useState<{ userId: string; userName: string; suspend: boolean } | null>(null); + const [activityModal, setActivityModal] = useState<{ userId: string; userName: string } | null>(null); + const [activityLog, setActivityLog] = useState([]); + const [activityLoading, setActivityLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [sortField, setSortField] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); @@ -58,8 +74,8 @@ export default function UserManagement() { const sortedUsers = [...filteredUsers].sort((a, b) => { if (!sortField) return 0; - let aVal: any = a[sortField]; - let bVal: any = b[sortField]; + let aVal: string | number | boolean | null = a[sortField]; + let bVal: string | number | boolean | null = b[sortField]; // Special handling for display name if (sortField === 'firstName') { @@ -76,6 +92,9 @@ export default function UserManagement() { aVal = aVal.toLowerCase(); bVal = bVal.toLowerCase(); } + + if (typeof aVal === 'boolean') aVal = aVal ? 1 : 0; + if (typeof bVal === 'boolean') bVal = bVal ? 1 : 0; if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; @@ -151,6 +170,43 @@ export default function UserManagement() { } } + async function suspendUser(userId: string, suspend: boolean) { + setUpdating(userId); + try { + const res = await fetch(`/api/admin/users/${userId}/suspend`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ suspend }), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to update user'); + } + await fetchUsers(); + setToast({ message: suspend ? 'User suspended' : 'User unsuspended', type: 'success' }); + } catch (err: unknown) { + setToast({ message: err instanceof Error ? err.message : 'Failed to update user', type: 'error' }); + } finally { + setUpdating(null); + } + } + + async function openActivityModal(userId: string, userName: string) { + setActivityModal({ userId, userName }); + setActivityLog([]); + setActivityLoading(true); + try { + const res = await fetch(`/api/admin/users/${userId}/activity`); + if (!res.ok) throw new Error('Failed to fetch activity'); + const data = await res.json(); + setActivityLog(data.activity); + } catch { + setToast({ message: 'Failed to load activity log', type: 'error' }); + } finally { + setActivityLoading(false); + } + } + function getUserDisplayName(user: User): string { if (user.firstName && user.lastName) { return `${user.firstName} ${user.lastName}`; @@ -259,7 +315,7 @@ export default function UserManagement() { {paginatedUsers.map((user) => ( - +
-

{getUserDisplayName(user)}

+
+

{getUserDisplayName(user)}

+ {user.banned && ( + 🚫 Suspended + )} +
{user.username &&

@{user.username}

}
@@ -286,7 +347,8 @@ export default function UserManagement() { {user.lastSignInAt ? new Date(user.lastSignInAt).toLocaleDateString() : 'Never'} -
+
+ {/* Role actions */} {user.role === 'USER' && ( + )} + + {/* Suspend / Unsuspend (non-admin users only) */} + {user.role !== 'ADMIN' && ( + + )}
@@ -356,7 +453,7 @@ export default function UserManagement() { /> )} - {/* Confirm Dialog */} + {/* Role change confirm dialog */} {confirmDialog && ( setConfirmDialog(null)} /> )} + + {/* Suspend / Unsuspend confirm dialog */} + {suspendDialog && ( + { + suspendUser(suspendDialog.userId, suspendDialog.suspend); + setSuspendDialog(null); + }} + onCancel={() => setSuspendDialog(null)} + /> + )} + + {/* Triager activity modal */} + {activityModal && ( +
+
+
+

+ Activity — {activityModal.userName} +

+ +
+
+ {activityLoading ? ( +
+ + + + +
+ ) : activityLog.length === 0 ? ( +

No activity recorded yet.

+ ) : ( + + + + + + + + + + + {activityLog.map((entry) => ( + + + + + + + ))} + +
ActionOldNewWhen
{entry.action.replace(/_/g, ' ')}{entry.oldValue || '—'}{entry.newValue || '—'} + {new Date(entry.timestamp).toLocaleString()} +
+ )} +
+
+
+ )} ); } diff --git a/app/api/admin/hall-of-fame/[id]/visibility/route.ts b/app/api/admin/hall-of-fame/[id]/visibility/route.ts index 1cd9d25..123205f 100644 --- a/app/api/admin/hall-of-fame/[id]/visibility/route.ts +++ b/app/api/admin/hall-of-fame/[id]/visibility/route.ts @@ -63,6 +63,8 @@ export async function PATCH( await db.insert(auditLogs).values({ id: crypto.randomUUID(), reportId: entry.reportId, + entityType: 'report', + entityId: entry.reportId, actorId: userId, actorEmail: null, action: 'hall_of_fame_visibility_toggled', diff --git a/app/api/admin/hall-of-fame/settings/route.ts b/app/api/admin/hall-of-fame/settings/route.ts index 235b760..41c2528 100644 --- a/app/api/admin/hall-of-fame/settings/route.ts +++ b/app/api/admin/hall-of-fame/settings/route.ts @@ -87,7 +87,9 @@ export async function PATCH(request: NextRequest) { // Log audit entry await db.insert(auditLogs).values({ id: crypto.randomUUID(), - reportId: 'system', + reportId: null, + entityType: 'system', + entityId: 'points_config', actorId: userId, actorEmail: null, action: 'points_config_updated', diff --git a/app/api/admin/reports/route.ts b/app/api/admin/reports/route.ts index 3e1ba21..d3e6b97 100644 --- a/app/api/admin/reports/route.ts +++ b/app/api/admin/reports/route.ts @@ -17,19 +17,20 @@ export async function GET(request: NextRequest) { // Parse + validate query params const sp = request.nextUrl.searchParams; const parsed = PaginationSchema.safeParse({ - page: sp.get('page'), - per_page: sp.get('per_page'), - status: sp.get('status') ?? undefined, - severity: sp.get('severity') ?? undefined, - target: sp.get('target') ?? undefined, - q: sp.get('q') ?? undefined, - assignedTo: sp.get('assigned_to') ?? undefined, - unassigned: sp.get('unassigned') ?? undefined, + page: sp.get('page'), + per_page: sp.get('per_page'), + status: sp.get('status') ?? undefined, + severity: sp.get('severity') ?? undefined, + target: sp.get('target') ?? undefined, + q: sp.get('q') ?? undefined, + assignedTo: sp.get('assigned_to') ?? undefined, + unassigned: sp.get('unassigned') ?? undefined, + clerkUserId: sp.get('clerk_user_id') ?? undefined, }); if (!parsed.success) { return NextResponse.json({ error: 'Invalid query params' }, { status: 400 }); } - const { page = 1, per_page = 20, status, severity, target, q, assignedTo, unassigned } = parsed.data; + const { page = 1, per_page = 20, status, severity, target, q, assignedTo, unassigned, clerkUserId } = parsed.data; const db = getDb(getCfEnv().DB); @@ -40,6 +41,7 @@ export async function GET(request: NextRequest) { if (target) conditions.push(eq(reports.target, target)); if (assignedTo) conditions.push(eq(reports.assignedTo, assignedTo)); if (unassigned === 'true') conditions.push(isNull(reports.assignedTo)); + if (clerkUserId) conditions.push(eq(reports.clerkUserId, clerkUserId)); if (q) { // Sanitize user input to prevent SQL injection via LIKE wildcards const sanitized = q.replace(/[%_\\]/g, '\\$&'); diff --git a/app/api/admin/users/[id]/activity/route.ts b/app/api/admin/users/[id]/activity/route.ts new file mode 100644 index 0000000..10f1eba --- /dev/null +++ b/app/api/admin/users/[id]/activity/route.ts @@ -0,0 +1,62 @@ +/** + * GET /api/admin/users/[id]/activity — Triager/admin action history + * + * Returns audit log entries where actorId = [id], ordered by timestamp desc. + * Query params: page (default 1), per_page (default 20, max 50) + */ +import { NextRequest, NextResponse } from 'next/server'; +import { desc, eq } from 'drizzle-orm'; +import { requireRole } from '@/lib/auth'; +import { getDb, getCfEnv } from '@/lib/db'; +import { auditLogs } from '@/lib/db/schema'; +import { z } from 'zod'; + +const QuerySchema = z.object({ + page: z.coerce.number().int().positive().optional().default(1), + per_page: z.coerce.number().int().positive().max(50).optional().default(20), +}); + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + await requireRole('ADMIN'); + const { id: actorId } = await context.params; + + const sp = request.nextUrl.searchParams; + const parsed = QuerySchema.safeParse({ + page: sp.get('page'), + per_page: sp.get('per_page'), + }); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid query params' }, { status: 400 }); + } + + const { page, per_page } = parsed.data; + const db = getDb(getCfEnv().DB); + + const rows = await db + .select({ + id: auditLogs.id, + reportId: auditLogs.reportId, + entityType: auditLogs.entityType, + entityId: auditLogs.entityId, + action: auditLogs.action, + oldValue: auditLogs.oldValue, + newValue: auditLogs.newValue, + timestamp: auditLogs.timestamp, + }) + .from(auditLogs) + .where(eq(auditLogs.actorId, actorId)) + .orderBy(desc(auditLogs.timestamp)) + .limit(per_page) + .offset((page - 1) * per_page); + + return NextResponse.json({ activity: rows, page, per_page }); + } catch (err) { + if (err instanceof Response) return err; + console.error('[GET /api/admin/users/[id]/activity]', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts index 89de87f..8baf22b 100644 --- a/app/api/admin/users/[id]/route.ts +++ b/app/api/admin/users/[id]/route.ts @@ -21,6 +21,7 @@ export async function GET( id: user.id, name: getDisplayName(user), email: user.emailAddresses[0]?.emailAddress, + banned: user.banned, }); } catch (err) { console.error('[GET /api/admin/users/[id]]', err); diff --git a/app/api/admin/users/[id]/suspend/route.ts b/app/api/admin/users/[id]/suspend/route.ts new file mode 100644 index 0000000..97a02ed --- /dev/null +++ b/app/api/admin/users/[id]/suspend/route.ts @@ -0,0 +1,80 @@ +/** + * POST /api/admin/users/[id]/suspend — Suspend or unsuspend a user via Clerk ban/unban + * + * Body: { suspend: boolean } + */ +import { NextRequest, NextResponse } from 'next/server'; +import { clerkClient } from '@clerk/nextjs/server'; +import { requireRole, getSessionEmail } from '@/lib/auth'; +import { getDb, getCfEnv } from '@/lib/db'; +import { logAudit } from '@/lib/audit'; +import { z } from 'zod'; + +const SuspendSchema = z.object({ + suspend: z.boolean(), +}); + +export async function POST( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { userId: adminId } = await requireRole('ADMIN'); + const { id: targetUserId } = await context.params; + + let body: unknown; + try { body = await request.json(); } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const parsed = SuspendSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, + { status: 422 } + ); + } + + const { suspend } = parsed.data; + + const client = await clerkClient(); + const targetUser = await client.users.getUser(targetUserId); + const targetRole = (targetUser.publicMetadata as { role?: string })?.role || 'USER'; + + if (targetUserId === adminId) { + return NextResponse.json({ error: 'You cannot suspend your own account' }, { status: 400 }); + } + + if (targetRole === 'ADMIN') { + return NextResponse.json({ error: 'Admin users cannot be suspended' }, { status: 403 }); + } + + if (suspend) { + await client.users.banUser(targetUserId); + } else { + await client.users.unbanUser(targetUserId); + } + + const adminEmail = await getSessionEmail(); + const db = getDb(getCfEnv().DB); + await logAudit({ + db, + entityType: 'user', + entityId: targetUserId, + actorId: adminId, + actorEmail: adminEmail, + action: suspend ? 'user_suspended' : 'user_unsuspended', + oldValue: targetUser.banned ? 'suspended' : 'active', + newValue: suspend ? 'suspended' : 'active', + }); + + return NextResponse.json({ + success: true, + message: suspend ? 'User suspended' : 'User unsuspended', + }); + } catch (err) { + if (err instanceof Response) return err; + console.error('[POST /api/admin/users/[id]/suspend]', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 3ab7a62..1886736 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -4,7 +4,9 @@ */ import { NextRequest, NextResponse } from 'next/server'; import { clerkClient } from '@clerk/nextjs/server'; -import { requireRole } from '@/lib/auth'; +import { requireRole, getSessionEmail } from '@/lib/auth'; +import { getDb, getCfEnv } from '@/lib/db'; +import { logAudit } from '@/lib/audit'; import { z } from 'zod'; // GET - List all users @@ -25,6 +27,7 @@ export async function GET(_request: NextRequest) { lastName: user.lastName, username: user.username, role: (user.publicMetadata as { role?: string })?.role || 'USER', + banned: user.banned, createdAt: user.createdAt, lastSignInAt: user.lastSignInAt, imageUrl: user.imageUrl, @@ -71,14 +74,28 @@ export async function PATCH(request: NextRequest) { const { userId, role } = parsed.data; - // Update user role in Clerk + // Fetch old role before updating const client = await clerkClient(); + const targetUser = await client.users.getUser(userId); + const oldRole = (targetUser.publicMetadata as { role?: string })?.role || 'USER'; + await client.users.updateUser(userId, { publicMetadata: { role }, }); - // Log the role change (optional - could add to audit_logs) - console.log(`[ADMIN] User ${userId} role changed to ${role} by ${adminId}`); + // Write audit log for the role change + const adminEmail = await getSessionEmail(); + const db = getDb(getCfEnv().DB); + await logAudit({ + db, + entityType: 'user', + entityId: userId, + actorId: adminId, + actorEmail: adminEmail, + action: 'role_changed', + oldValue: oldRole, + newValue: role, + }); return NextResponse.json({ success: true, diff --git a/app/triage/page.tsx b/app/triage/page.tsx index 91dba30..a3d9b6b 100644 --- a/app/triage/page.tsx +++ b/app/triage/page.tsx @@ -67,8 +67,12 @@ export default function AdminDashboard() { const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [assignmentFilter, setAssignmentFilter] = useState<"all" | "mine" | "unassigned">("all"); const [currentUserEmail, setCurrentUserEmail] = useState(null); + const [researcherFilter, setResearcherFilter] = useState(null); + const [hasReadUrlFilters, setHasReadUrlFilters] = useState(false); const fetchReports = useCallback(async () => { + if (!hasReadUrlFilters) return; + setLoading(true); const params = new URLSearchParams({ page: String(page), per_page: "20" }); if (filterStatus !== "all") params.set("status", filterStatus); @@ -76,6 +80,7 @@ export default function AdminDashboard() { if (search) params.set("q", search); if (assignmentFilter === "mine" && currentUserEmail) params.set("assigned_to", currentUserEmail); if (assignmentFilter === "unassigned") params.set("unassigned", "true"); + if (researcherFilter) params.set("clerk_user_id", researcherFilter); try { const res = await fetch(`/api/admin/reports?${params}`); @@ -89,14 +94,14 @@ export default function AdminDashboard() { } finally { setLoading(false); } - }, [page, filterStatus, filterSeverity, search, assignmentFilter, currentUserEmail]); + }, [page, filterStatus, filterSeverity, search, assignmentFilter, currentUserEmail, researcherFilter, hasReadUrlFilters]); // Client-side sorting of current page results const sortedReports = [...reports].sort((a, b) => { if (!sortField) return 0; - let aVal: any = a[sortField]; - let bVal: any = b[sortField]; + let aVal: string | number | null = a[sortField]; + let bVal: string | number | null = b[sortField]; // Handle null values if (aVal === null || aVal === undefined) return 1; @@ -139,6 +144,12 @@ export default function AdminDashboard() { }, []); useEffect(() => { fetchReports(); }, [fetchReports]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + setResearcherFilter(params.get("clerk_user_id")); + setHasReadUrlFilters(true); + }, []); useEffect(() => { // Get current user email from localStorage (set after first self-assignment) diff --git a/docs/MIGRATION_ORDER.md b/docs/MIGRATION_ORDER.md index fda6bb4..62dfd6f 100644 --- a/docs/MIGRATION_ORDER.md +++ b/docs/MIGRATION_ORDER.md @@ -11,6 +11,7 @@ Apply migrations in the order below for a fresh database. The filenames are not 7. `migrations/0008_add_title_disclosed.sql` 8. `migrations/0009_add_internal_flags.sql` 9. `migrations/0010_convert_assigned_to_user_ids.sql` +10. `migrations/0011_support_user_audit_logs.sql` Run data maintenance scripts only after the schema migrations they depend on: diff --git a/lib/audit.ts b/lib/audit.ts index 1449c1b..7818c29 100644 --- a/lib/audit.ts +++ b/lib/audit.ts @@ -16,14 +16,25 @@ export type AuditAction = | 'assigned' | 'poc_uploaded' | 'report_viewed' - | 'report_decrypted'; + | 'report_decrypted' + | 'role_changed' + | 'user_suspended' + | 'user_unsuspended'; // Internal actions that should only be visible to triagers/admins -const INTERNAL_ACTIONS: AuditAction[] = ['assigned', 'severity_changed']; +const INTERNAL_ACTIONS: AuditAction[] = [ + 'assigned', + 'severity_changed', + 'role_changed', + 'user_suspended', + 'user_unsuspended', +]; interface LogAuditParams { db: DbClient; - reportId: string; + reportId?: string; + entityType?: 'report' | 'user' | 'system'; + entityId?: string; actorId: string; actorEmail?: string; action: AuditAction; @@ -44,10 +55,14 @@ export async function logAudit(params: LogAuditParams): Promise { // Determine if this action should be internal-only const isInternal = INTERNAL_ACTIONS.includes(params.action) ? 1 : 0; + const entityType = params.entityType ?? (params.reportId ? 'report' : 'system'); + const entityId = params.entityId ?? params.reportId ?? entityType; await params.db.insert(auditLogs).values({ id: crypto.randomUUID(), - reportId: params.reportId, + reportId: entityType === 'report' ? params.reportId : null, + entityType, + entityId, actorId: params.actorId, actorEmail: params.actorEmail, action: params.action, diff --git a/lib/db/schema.ts b/lib/db/schema.ts index fbef6df..3bb9de8 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -28,7 +28,9 @@ export const reports = sqliteTable('reports', { // ── Audit Logs ──────────────────────────────────────────────────────────────── export const auditLogs = sqliteTable('audit_logs', { id: text('id').primaryKey(), - reportId: text('report_id').notNull(), + reportId: text('report_id'), + entityType: text('entity_type').notNull().default('report'), + entityId: text('entity_id'), actorId: text('actor_id').notNull(), actorEmail: text('actor_email'), action: text('action').notNull(), diff --git a/lib/services/hall-of-fame.ts b/lib/services/hall-of-fame.ts index 553bf3c..b3a676b 100644 --- a/lib/services/hall-of-fame.ts +++ b/lib/services/hall-of-fame.ts @@ -195,6 +195,8 @@ export async function awardPoints( await db.insert(auditLogs).values({ id: crypto.randomUUID(), reportId: report.id, + entityType: 'report', + entityId: report.id, actorId, actorEmail: null, action: 'points_awarded', diff --git a/lib/validation.ts b/lib/validation.ts index 49820a8..9bbc1be 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -92,14 +92,15 @@ export type PresignRequestInput = z.infer; // ── Pagination Schema ───────────────────────────────────────────────────────── export const PaginationSchema = z.object({ - page: z.coerce.number().int().positive().optional(), - per_page: z.coerce.number().int().positive().max(100).optional(), - status: z.enum(VALID_STATUSES).optional(), - severity: z.enum(SEVERITIES).optional(), - target: z.string().max(255).optional(), - q: z.string().max(255).optional(), - assignedTo: z.string().email().optional(), - unassigned: z.string().optional(), + page: z.coerce.number().int().positive().optional(), + per_page: z.coerce.number().int().positive().max(100).optional(), + status: z.enum(VALID_STATUSES).optional(), + severity: z.enum(SEVERITIES).optional(), + target: z.string().max(255).optional(), + q: z.string().max(255).optional(), + assignedTo: z.string().email().optional(), + unassigned: z.string().optional(), + clerkUserId: z.string().max(255).optional(), }); export type PaginationInput = z.infer; diff --git a/migrations/0011_support_user_audit_logs.sql b/migrations/0011_support_user_audit_logs.sql new file mode 100644 index 0000000..1de7974 --- /dev/null +++ b/migrations/0011_support_user_audit_logs.sql @@ -0,0 +1,73 @@ +-- Migration: Support non-report audit log entities +-- +-- User-management actions need to write to audit_logs, but report_id is a +-- foreign key to reports.id. This keeps report audit behavior intact while +-- allowing user/system audit entries with report_id = NULL. + +PRAGMA foreign_keys = off; + +CREATE TABLE audit_logs_new ( + id TEXT PRIMARY KEY, + report_id TEXT, + entity_type TEXT NOT NULL DEFAULT 'report', + entity_id TEXT, + actor_id TEXT NOT NULL, + actor_email TEXT, + action TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + ip_hash TEXT, + is_internal INTEGER NOT NULL DEFAULT 0, + timestamp INTEGER NOT NULL, + FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE +); + +INSERT INTO audit_logs_new ( + id, + report_id, + entity_type, + entity_id, + actor_id, + actor_email, + action, + old_value, + new_value, + ip_hash, + is_internal, + timestamp +) +SELECT + id, + CASE + WHEN report_id = 'system' THEN NULL + WHEN report_id LIKE 'user_%' THEN NULL + ELSE report_id + END AS report_id, + CASE + WHEN report_id = 'system' THEN 'system' + WHEN report_id LIKE 'user_%' THEN 'user' + ELSE 'report' + END AS entity_type, + CASE + WHEN report_id IS NULL THEN NULL + ELSE report_id + END AS entity_id, + actor_id, + actor_email, + action, + old_value, + new_value, + ip_hash, + is_internal, + timestamp +FROM audit_logs; + +DROP TABLE audit_logs; +ALTER TABLE audit_logs_new RENAME TO audit_logs; + +CREATE INDEX IF NOT EXISTS idx_audit_report_id ON audit_logs(report_id); +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_logs(timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_audit_actor_id ON audit_logs(actor_id); +CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_logs(entity_type, entity_id); + +PRAGMA foreign_keys = on;