From 424a32395220d50aff17723e2371e81b47f3ac45 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Fri, 22 May 2026 16:18:45 +0300 Subject: [PATCH] feat(settings): user-visible audit log + nav across settings sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit_logs was a write-only firehose: every action wrote to it but nothing ever read it. Surfaced the last 200 entries on a new /settings/activity page so workspace admins can audit who did what. - lib/appwrite/audit-queries.ts: listAuditLogs(tenantId, limit=100) scoped to the caller's tenantId via Query.equal — multi-tenant safety preserved. - /settings/activity/page.tsx: server-rendered table — time, user, action badge (create/update/delete), entity label (TR), changes summary. Resolves userIds → displayName via a single bulk lookup against TABLES.profiles. Falls back to a truncated id when a profile isn't found so the row still reads. Settings now has a horizontal tab nav too — there were six pages under /settings with no cross-links between them. Added: - settings/layout.tsx wraps every settings page with the new nav. - settings/components/settings-nav.tsx (client): pathname-active state, scrolls horizontally on mobile. Items: Çalışma Alanı, Profilim, Üyeler, Bildirimler, Görünüm, Hesap Aktivitesi. --- .../(dashboard)/settings/activity/page.tsx | 149 ++++++++++++++++++ .../settings/components/settings-nav.tsx | 43 +++++ src/app/(dashboard)/settings/layout.tsx | 16 ++ src/lib/appwrite/audit-queries.ts | 24 +++ 4 files changed, 232 insertions(+) create mode 100644 src/app/(dashboard)/settings/activity/page.tsx create mode 100644 src/app/(dashboard)/settings/components/settings-nav.tsx create mode 100644 src/app/(dashboard)/settings/layout.tsx create mode 100644 src/lib/appwrite/audit-queries.ts diff --git a/src/app/(dashboard)/settings/activity/page.tsx b/src/app/(dashboard)/settings/activity/page.tsx new file mode 100644 index 0000000..b3c8cd4 --- /dev/null +++ b/src/app/(dashboard)/settings/activity/page.tsx @@ -0,0 +1,149 @@ +import { redirect } from "next/navigation"; +import { Query } from "node-appwrite"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { listAuditLogs } from "@/lib/appwrite/audit-queries"; +import { DATABASE_ID, TABLES, type Profile } from "@/lib/appwrite/schema"; +import { createAdminClient } from "@/lib/appwrite/server"; +import { requireTenant } from "@/lib/appwrite/tenant-guard"; + +export const metadata = { + title: "DLS — Hesap Aktivitesi", +}; + +const dateFormatter = new Intl.DateTimeFormat("tr-TR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +const ENTITY_LABELS: Record = { + job: "İş", + patient: "Hasta", + prosthetic: "Ürün", + payment: "Ödeme", + clinic_pricing: "Klinik Fiyat", + job_file: "Dosya", + connection: "Bağlantı", + invite: "Davet", + tenant_settings: "Çalışma Alanı", + profile: "Profil", +}; + +const ACTION_VARIANTS = { + create: { label: "Eklendi", variant: "default" as const }, + update: { label: "Güncellendi", variant: "secondary" as const }, + delete: { label: "Silindi", variant: "destructive" as const }, +}; + +export default async function ActivityPage() { + let ctx; + try { + ctx = await requireTenant(); + } catch { + redirect("/onboarding"); + } + + const logs = await listAuditLogs(ctx.tenantId, 200); + + // Resolve userId → display name in one go so the rows read naturally. + const userIds = Array.from(new Set(logs.map((l) => l.userId))); + const userMap = new Map(); + if (userIds.length > 0) { + try { + const { tablesDB } = createAdminClient(); + const profiles = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.profiles, + queries: [Query.equal("userId", userIds), Query.limit(200)], + }); + for (const p of profiles.rows as unknown as Profile[]) { + if (p.displayName) userMap.set(p.userId, p.displayName); + } + } catch { + // best-effort; rows just show the raw id + } + } + + return ( +
+
+

Hesap Aktivitesi

+

+ Çalışma alanınızda yapılan tüm değişikliklerin kaydı. Son 200 işlem. +

+
+ + + + İşlem Kaydı + + Otomatik tutulur, silinemez. Şüpheli bir aktivite görürseniz hesabınızı + güvenli olmayan bir cihazdan çıkarmayı düşünün. + + + + {logs.length === 0 ? ( +

+ Henüz kayıtlı aktivite yok. +

+ ) : ( + + + + Zaman + Kullanıcı + İşlem + Nesne + Detay + + + + {logs.map((l) => { + const v = ACTION_VARIANTS[l.action] ?? { + label: l.action, + variant: "outline" as const, + }; + return ( + + + {dateFormatter.format(new Date(l.$createdAt))} + + + {userMap.get(l.userId) ?? ( + + {l.userId.slice(0, 8)} + + )} + + + {v.label} + + + {ENTITY_LABELS[l.entityType] ?? l.entityType} + + + {l.changes ? l.changes : } + + + ); + })} + +
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/settings/components/settings-nav.tsx b/src/app/(dashboard)/settings/components/settings-nav.tsx new file mode 100644 index 0000000..8f4088c --- /dev/null +++ b/src/app/(dashboard)/settings/components/settings-nav.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { cn } from "@/lib/utils"; + +const ITEMS: { href: string; label: string }[] = [ + { href: "/settings/workspace", label: "Çalışma Alanı" }, + { href: "/settings/account", label: "Profilim" }, + { href: "/settings/members", label: "Üyeler" }, + { href: "/settings/notifications", label: "Bildirimler" }, + { href: "/settings/appearance", label: "Görünüm" }, + { href: "/settings/activity", label: "Hesap Aktivitesi" }, +]; + +export function SettingsNav() { + const pathname = usePathname(); + return ( + + ); +} diff --git a/src/app/(dashboard)/settings/layout.tsx b/src/app/(dashboard)/settings/layout.tsx new file mode 100644 index 0000000..d753d6b --- /dev/null +++ b/src/app/(dashboard)/settings/layout.tsx @@ -0,0 +1,16 @@ +import { SettingsNav } from "./components/settings-nav"; + +export default function SettingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/src/lib/appwrite/audit-queries.ts b/src/lib/appwrite/audit-queries.ts new file mode 100644 index 0000000..1293a13 --- /dev/null +++ b/src/lib/appwrite/audit-queries.ts @@ -0,0 +1,24 @@ +import "server-only"; + +import { Query } from "node-appwrite"; + +import { DATABASE_ID, TABLES, type AuditLog } from "./schema"; +import { createAdminClient } from "./server"; +import { toPlain } from "./serialize"; + +export async function listAuditLogs( + tenantId: string, + limit = 100, +): Promise { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.auditLogs, + queries: [ + Query.equal("tenantId", tenantId), + Query.orderDesc("$createdAt"), + Query.limit(limit), + ], + }); + return toPlain(result.rows as unknown as AuditLog[]); +}