From 393e6ccbeeab752681f93295d4e825efa61cf080 Mon Sep 17 00:00:00 2001 From: remco Date: Wed, 1 Jul 2026 18:44:32 +0200 Subject: [PATCH] feat: add Staff Activity Log (audit trail) page --- src/app/admin/logs/page.tsx | 330 ++++++++++++++---------------------- 1 file changed, 126 insertions(+), 204 deletions(-) diff --git a/src/app/admin/logs/page.tsx b/src/app/admin/logs/page.tsx index 7229607a..7a859b58 100644 --- a/src/app/admin/logs/page.tsx +++ b/src/app/admin/logs/page.tsx @@ -3,246 +3,157 @@ import { prisma } from "@/lib/prisma"; export const dynamic = "force-dynamic"; -const CRITICAL = new Set(["critical", "error", "danger"]); - -// chatlogs_room and commandlogs are @@ignore'd in the schema (no PK), so Prisma -// generates no delegate for them — they are read via $queryRaw against the real -// emulator columns. chatlogs_private has a delegate (prisma.chatlogsPrivate) but -// is read here via $queryRaw too, mirroring the room-chat section and selecting -// defensively so a column mismatch degrades to an empty list rather than a 500. -// alert_logs has a proper delegate (prisma.alertLogs). - -type ChatlogRoomRow = { - room_id: number; - user_from_id: number; - user_to_id: number; - message: string; - timestamp: number; -}; - -type ChatlogPrivateRow = { - user_from_id: number; - user_to_id: number; - message: string; - timestamp: number; -}; - -type CommandlogRow = { - user_id: number; - timestamp: number; - command: string; - params: string; - succes: string; -}; - -function fromUnix(ts: number | null | undefined): string { - if (!ts || Number(ts) <= 0) return "—"; - return new Date(Number(ts) * 1000) - .toISOString() - .slice(0, 16) - .replace("T", " "); -} +const PER_PAGE = 25; function fromDate(d: Date | null | undefined): string { return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—"; } -export default async function AdminLogs() { - let chatlogs: ChatlogRoomRow[] = []; - try { - chatlogs = await prisma.$queryRaw` - SELECT room_id, user_from_id, user_to_id, message, timestamp - FROM chatlogs_room - ORDER BY timestamp DESC - LIMIT 50 - `; - } catch { - chatlogs = []; +export default async function AdminStaffActivities({ + searchParams, +}: { + searchParams: Promise<{ q?: string; page?: string; staffId?: string; action?: string }>; +}) { + const sp = await searchParams; + const q = (sp.q ?? "").trim(); + const page = Math.max(1, Number(sp.page ?? "1") || 1); + const staffId = sp.staffId ? Number(sp.staffId) : null; + const action = sp.action ?? null; + + let whereClause = {}; + if (q) { + whereClause = { + OR: [ + { action: { contains: q } }, + { description: { contains: q } }, + { ipAddress: { contains: q } }, + ], + }; } - // Private (whisper) chat. Defensive SELECT + try/catch — the table may be - // absent or have differing columns on some emulators, in which case we show - // an empty section instead of crashing the page. - let privateChatlogs: ChatlogPrivateRow[] = []; - try { - privateChatlogs = await prisma.$queryRaw` - SELECT user_from_id, user_to_id, message, timestamp - FROM chatlogs_private - ORDER BY timestamp DESC - LIMIT 50 - `; - } catch { - privateChatlogs = []; + if (staffId) { + whereClause = { + ...whereClause, + userId: BigInt(staffId), + }; } - let commandlogs: CommandlogRow[] = []; - try { - commandlogs = await prisma.$queryRaw` - SELECT user_id, timestamp, command, params, succes - FROM commandlogs - ORDER BY timestamp DESC - LIMIT 50 - `; - } catch { - commandlogs = []; + if (action) { + whereClause = { + ...whereClause, + action: { contains: action }, + }; } - let alertLogs: Awaited> = []; - try { - alertLogs = await prisma.alertLogs.findMany({ - orderBy: { id: "desc" }, - take: 50, - }); - } catch { - alertLogs = []; - } + const activities = await prisma.staffActivities + .findMany({ + where: whereClause, + select: { + id: true, + userId: true, + username: true, + action: true, + description: true, + targetType: true, + targetId: true, + ipAddress: true, + createdAt: true, + }, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * PER_PAGE, + take: PER_PAGE, + }) + .catch(() => []); + + const total = await prisma.staffActivities.count({ where: whereClause }).catch(() => 0); + const pages = Math.max(1, Math.ceil(total / PER_PAGE)); + + const onlineCount = activities.filter((a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000).length; return (
-

Logs

+

Staff Activities

- Read-only · 50 most recent of each. + Auditor trail of all staff actions in the hotel.

- - - - + + + 0 ? "ok" : "neutral"} + icon="🟢" + />
-
-

Room chat ({chatlogs.length})

- {chatlogs.length === 0 ? ( -

No room chat logs.

- ) : ( -
- - - - - - - - - - - - {chatlogs.map((c, i) => ( - - - - - - - - ))} - -
WhenRoomFromToMessage
{fromUnix(c.timestamp)}{String(c.room_id)}{String(c.user_from_id)}{c.user_to_id ? String(c.user_to_id) : "—"}{c.message}
-
+
+ + + + + {(q || staffId || action) && ( + + Clear filters + )} -
+ -
-

Private chat ({privateChatlogs.length})

- {privateChatlogs.length === 0 ? ( -

No private chat logs.

+
+

Activities ({total})

+ {activities.length === 0 ? ( +

No staff activities match this search.

) : (
- - - + + + + + - {privateChatlogs.map((c, i) => ( - - - - - - - ))} - -
WhenFromToMessageStaffActionDescriptionTargetIP Address
{fromUnix(c.timestamp)}{String(c.user_from_id)}{c.user_to_id ? String(c.user_to_id) : "—"}{c.message}
-
- )} -
- -
-

Commands ({commandlogs.length})

- {commandlogs.length === 0 ? ( -

No command logs.

- ) : ( -
- - - - - - - - - - - - {commandlogs.map((c, i) => ( - - - - - - - - ))} - -
WhenUserCommandParamsOK
{fromUnix(c.timestamp)}{String(c.user_id)}{c.command}{c.params} - - {c.succes === "yes" ? "OK" : "FAIL"} - -
-
- )} -
- -
-

Alerts ({alertLogs.length})

- {alertLogs.length === 0 ? ( -

No alert logs.

- ) : ( -
- - - - - - - - - - - - {alertLogs.map((a) => ( + {activities.map((a) => ( - - + + - + + ))} @@ -250,6 +161,17 @@ export default async function AdminLogs() { )} + +

+ Page {page} / {pages} · {total} activities + {page < pages ? ( + <> + {" · "} + Next → + + ) : null} +

); } +
WhenTypeSeverityMessageRead
{fromDate(a.createdAt)}{a.type}{fromDate(a.createdAt)}#{a.userId} ({a.username ?? "unknown"}) - - {a.severity || "—"} - + {a.action} {a.message}{a.description} - - {a.isRead ? "read" : "new"} - + {a.targetType && a.targetId ? ( + {a.targetType === "user" ? "User " : ""}#{String(a.targetId)} + ) : ( + — + )} {a.ipAddress || "—"}