From e27740b090cdd09cb202247c6122cafcc163a02b Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 28 Feb 2026 17:16:27 +1100 Subject: [PATCH 1/2] Improve audit logs --- .../activity/activity-detail-panel.tsx | 270 ++++++++++++++++++ components/project/activity/activity-feed.tsx | 174 ++++++----- drizzle/schema.ts | 2 + drizzle/types.ts | 2 +- lib/activity/index.ts | 27 ++ lib/activity/message.ts | 56 ++++ trpc/routers/projects.ts | 39 +++ 7 files changed, 490 insertions(+), 80 deletions(-) create mode 100644 components/project/activity/activity-detail-panel.tsx diff --git a/components/project/activity/activity-detail-panel.tsx b/components/project/activity/activity-detail-panel.tsx new file mode 100644 index 0000000..097ae85 --- /dev/null +++ b/components/project/activity/activity-detail-panel.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { + CalendarIcon, + CompassIcon, + Plus, + RefreshCw, + Trash2, + UserIcon, + X, +} from "lucide-react"; +import { useParams } from "next/navigation"; +import { Panel } from "@/components/core/panel"; +import { UserAvatar } from "@/components/core/user-avatar"; +import { SpinnerWithSpacing } from "@/components/core/loaders"; +import { + formatEventTypeLabel, + getEventDescription, +} from "@/lib/activity/message"; +import { guessTimezone } from "@/lib/utils/date"; +import { useTRPCClient } from "@/trpc/client"; + +function getActionIcon(action: string) { + switch (action) { + case "created": + return { icon: Plus, color: "text-emerald-500", bg: "bg-emerald-500/10" }; + case "updated": + return { + icon: RefreshCw, + color: "text-amber-500", + bg: "bg-amber-500/10", + }; + case "deleted": + return { icon: Trash2, color: "text-red-500", bg: "bg-red-500/10" }; + default: + return { + icon: RefreshCw, + color: "text-muted-foreground", + bg: "bg-muted", + }; + } +} + +function formatLocalTime(date: Date, timeZone: string): string { + return date.toLocaleString("en-US", { + timeZone, + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + }); +} + +function formatUTCTime(date: Date): string { + return date.toLocaleString("en-US", { + timeZone: "UTC", + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + }); +} + +function SectionLabel({ + icon: Icon, + label, +}: { icon: React.ElementType; label: string }) { + return ( +
+
+ +
+

{label}

+
+ ); +} + +function DataRow({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +export function ActivityDetailPanel({ + activityId, + onClose, +}: { + activityId: number; + onClose: () => void; +}) { + const { projectId } = useParams(); + const trpcClient = useTRPCClient(); + + const { data: item, isPending } = useQuery({ + queryKey: ["projects", "getActivityById", activityId], + queryFn: () => + trpcClient.projects.getActivityById.query({ + id: activityId, + projectId: +projectId!, + }), + }); + + const actionConfig = item ? getActionIcon(item.action) : null; + const ActionIcon = actionConfig?.icon ?? RefreshCw; + + return ( + { + if (!open) onClose(); + }} + title="Activity Details" + > + {isPending || !item ? ( + + ) : ( + <> +
+
+
+ +
+
+

+ {formatEventTypeLabel(item.type, item.action)} +

+

+ {getEventDescription(item.type, item.action)} +

+
+
+ +
+ +
+
+ +
+ +
+ +
+ + + +
+
+ +
+ +
+
+ +
+

+ {item.actor.firstName || "Unknown"} +

+

+ {item.actor.email} +

+
+
+ {item.metadata ? ( +
+
+											{JSON.stringify(
+												{
+													user: {
+														id: item.actor.id,
+														email: item.actor.email,
+													},
+													session: {
+														id: (item.metadata as Record)
+															.sessionId,
+														ipAddress: (
+															item.metadata as Record
+														).ipAddress,
+														userAgent: (
+															item.metadata as Record
+														).userAgent,
+													},
+												},
+												null,
+												2,
+											)}
+										
+
+ ) : null} +
+
+ +
+ +
+ {item.action === "updated" && item.oldValue ? ( + <> +
+

+ Previous value +

+
+												{JSON.stringify(item.oldValue, null, 2)}
+											
+
+
+

+ New value +

+
+												{JSON.stringify(item.newValue, null, 2)}
+											
+
+ + ) : ( +
+
+											{JSON.stringify(
+												item.newValue || item.oldValue,
+												null,
+												2,
+											)}
+										
+
+ )} +
+
+
+ + )} +
+ ); +} diff --git a/components/project/activity/activity-feed.tsx b/components/project/activity/activity-feed.tsx index 890580e..f943b25 100644 --- a/components/project/activity/activity-feed.tsx +++ b/components/project/activity/activity-feed.tsx @@ -1,107 +1,119 @@ "use client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { formatDistanceToNow } from "date-fns"; -import { ActivityIcon } from "lucide-react"; +import { ActivityIcon, Plus, RefreshCw, Trash2 } from "lucide-react"; import { useParams } from "next/navigation"; +import { parseAsInteger, useQueryState } from "nuqs"; import { useMemo } from "react"; import Markdown from "react-markdown"; import { Spinner, SpinnerWithSpacing } from "@/components/core/loaders"; +import { UserAvatar } from "@/components/core/user-avatar"; import { Button } from "@/components/ui/button"; import type { ActivityWithActor } from "@/drizzle/types"; -import { generateObjectDiffMessage } from "@/lib/activity/message"; +import { + formatEventTypeLabel, + generateObjectDiffMessage, +} from "@/lib/activity/message"; import { guessTimezone, toDateTimeString } from "@/lib/utils/date"; import { useTRPCClient } from "@/trpc/client"; +import { ActivityDetailPanel } from "./activity-detail-panel"; const ACTIVITIES_LIMIT = 25; -function getActionConfig(action: string) { +function getActionIcon(action: string) { switch (action) { case "created": - return { - label: "Created", - bgColor: "bg-emerald-100 dark:bg-emerald-900/50", - textColor: "text-emerald-600 dark:text-emerald-400", - }; + return { icon: Plus, color: "text-emerald-500", bg: "bg-emerald-500/10" }; case "updated": return { - label: "Updated", - bgColor: "bg-blue-100 dark:bg-blue-900/50", - textColor: "text-blue-600 dark:text-blue-400", + icon: RefreshCw, + color: "text-amber-500", + bg: "bg-amber-500/10", }; case "deleted": - return { - label: "Deleted", - bgColor: "bg-red-100 dark:bg-red-900/50", - textColor: "text-red-600 dark:text-red-400", - }; + return { icon: Trash2, color: "text-red-500", bg: "bg-red-500/10" }; default: return { - label: action, - bgColor: "bg-muted", - textColor: "text-muted-foreground", + icon: ActivityIcon, + color: "text-muted-foreground", + bg: "bg-muted", }; } } -export function ActivityItem({ item }: { item: ActivityWithActor }) { - const actionConfig = getActionConfig(item.action); +export function ActivityItem({ + item, + onSelect, +}: { item: ActivityWithActor; onSelect: (id: number) => void }) { + const actionConfig = getActionIcon(item.action); + const ActionIcon = actionConfig.icon; return ( -
-
-
- - {item.actor.firstName} - - - {actionConfig.label} - - - {formatDistanceToNow(new Date(item.createdAt), { - addSuffix: true, - })} - + ); } export function ActivityFeed() { const { projectId } = useParams(); const trpcClient = useTRPCClient(); + const [selectedActivityId, setSelectedActivityId] = useQueryState( + "activity", + parseAsInteger, + ); const { data: activitiesData, @@ -133,19 +145,16 @@ export function ActivityFeed() { } return ( -
+
{activities.length ? ( <> -
- {activities.map((activityItem, index) => ( -
- - {index < activities.length - 1 && ( -
-
-
- )} -
+
+ {activities.map((activityItem) => ( + setSelectedActivityId(id)} + /> ))}
@@ -177,11 +186,18 @@ export function ActivityFeed() { No activity yet

- Activity will appear here as you and your team make changes to this - project. + Activity will appear here as you and your team make changes to + this project.

)} + + {selectedActivityId && ( + setSelectedActivityId(null)} + /> + )}
); } diff --git a/drizzle/schema.ts b/drizzle/schema.ts index bdbf8ca..69430b4 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -226,10 +226,12 @@ export const commentRelations = relations(comment, ({ one }) => ({ export const activity = pgTable("activity", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + eventId: text("eventId"), action: text("action").notNull(), type: text("type").notNull(), oldValue: jsonb("oldValue"), newValue: jsonb("newValue"), + metadata: jsonb("metadata"), target: text("target"), projectId: integer("projectId") .notNull() diff --git a/drizzle/types.ts b/drizzle/types.ts index be46678..2ce2fc6 100644 --- a/drizzle/types.ts +++ b/drizzle/types.ts @@ -74,7 +74,7 @@ export type EventWithCreator = CalendarEvent & { }; export type ActivityWithActor = Activity & { - actor: Pick; + actor: Pick; }; export type NotificationWithUser = Notification & { diff --git a/lib/activity/index.ts b/lib/activity/index.ts index 0ce502e..398b65a 100644 --- a/lib/activity/index.ts +++ b/lib/activity/index.ts @@ -1,4 +1,6 @@ +import { headers } from "next/headers"; import { activity } from "@/drizzle/schema"; +import { auth } from "@/lib/auth"; import { database } from "../utils/useDatabase"; import { getOwner } from "../utils/useOwner"; @@ -31,13 +33,38 @@ export async function logActivity({ }) { const db = await database(); const { userId } = await getOwner(); + + const reqHeaders = await headers(); + const ipAddress = + reqHeaders.get("x-forwarded-for")?.split(",")[0]?.trim() || + reqHeaders.get("x-real-ip") || + null; + const userAgent = reqHeaders.get("user-agent") || null; + + let sessionId: string | null = null; + let userEmail: string | null = null; + try { + const session = await auth.api.getSession({ headers: reqHeaders }); + sessionId = session?.session?.id || null; + userEmail = session?.user?.email || null; + } catch {} + + const metadata = { + ipAddress, + userAgent, + sessionId, + userEmail, + }; + await db .insert(activity) .values({ + eventId: crypto.randomUUID(), action, type, oldValue, newValue, + metadata, target, projectId, userId, diff --git a/lib/activity/message.ts b/lib/activity/message.ts index 976341c..6bb166d 100644 --- a/lib/activity/message.ts +++ b/lib/activity/message.ts @@ -117,6 +117,62 @@ function generateContextualMessage( } } +const typeLabels: Record = { + tasklist: "TaskList", + task: "Task", + project: "Project", + blob: "File", + event: "Event", + comment: "Comment", + post: "Post", +}; + +export function formatEventTypeLabel(type: string, action: string): string { + return `${typeLabels[type] || type}.${action}`; +} + +export function getEventDescription(type: string, action: string): string { + const descriptions: Record> = { + task: { + created: "A new task was added to the project.", + updated: "An existing task was modified.", + deleted: "A task was removed from the project.", + }, + tasklist: { + created: "A new task list was created.", + updated: "A task list was modified.", + deleted: "A task list was removed.", + }, + project: { + created: "A new project was created.", + updated: "Project settings or details were modified.", + deleted: "A project was removed.", + }, + blob: { + created: "A file was uploaded.", + updated: "A file was modified.", + deleted: "A file was removed.", + }, + event: { + created: "A new calendar event was created.", + updated: "A calendar event was modified.", + deleted: "A calendar event was removed.", + }, + comment: { + created: "A comment was added.", + updated: "A comment was edited.", + deleted: "A comment was removed.", + }, + post: { + created: "A new post was published.", + updated: "A post was edited.", + deleted: "A post was removed.", + }, + }; + + return descriptions[type]?.[action] || `${typeLabels[type] || type} was ${action}.`; +} + export function generateObjectDiffMessage(item: ActivityWithActor) { // biome-ignore lint/suspicious/noExplicitAny: type casting for activity values const newValue = item.newValue as any; diff --git a/trpc/routers/projects.ts b/trpc/routers/projects.ts index bef43bf..55a6363 100644 --- a/trpc/routers/projects.ts +++ b/trpc/routers/projects.ts @@ -452,6 +452,7 @@ export const projectsRouter = createTRPCRouter({ id: true, firstName: true, image: true, + email: true, }, }, }, @@ -464,4 +465,42 @@ export const projectsRouter = createTRPCRouter({ return activities; }), + getActivityById: protectedProcedure + .input(z.object({ id: z.number(), projectId: z.number() })) + .query(async ({ ctx, input }) => { + const hasAccess = await canViewProject(ctx, input.projectId); + if (!hasAccess) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project access denied", + }); + } + + const activityItem = await ctx.db.query.activity.findFirst({ + with: { + actor: { + columns: { + id: true, + firstName: true, + lastName: true, + image: true, + email: true, + }, + }, + }, + where: and( + eq(activity.id, input.id), + eq(activity.projectId, input.projectId), + ), + }); + + if (!activityItem) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Activity not found", + }); + } + + return activityItem; + }), }); From 0734056238fd872d887bc08068ef14daaf0dbb49 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 1 Mar 2026 09:41:24 +1100 Subject: [PATCH 2/2] Address CR --- .../activity/activity-detail-panel.tsx | 70 ++++--------------- components/project/activity/activity-feed.tsx | 24 +------ lib/activity/index.ts | 3 - lib/activity/message.ts | 14 ++++ lib/utils/date.ts | 13 ++++ 5 files changed, 42 insertions(+), 82 deletions(-) diff --git a/components/project/activity/activity-detail-panel.tsx b/components/project/activity/activity-detail-panel.tsx index 097ae85..4f972ca 100644 --- a/components/project/activity/activity-detail-panel.tsx +++ b/components/project/activity/activity-detail-panel.tsx @@ -4,9 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { CalendarIcon, CompassIcon, - Plus, RefreshCw, - Trash2, UserIcon, X, } from "lucide-react"; @@ -16,58 +14,12 @@ import { UserAvatar } from "@/components/core/user-avatar"; import { SpinnerWithSpacing } from "@/components/core/loaders"; import { formatEventTypeLabel, + getActionIcon, getEventDescription, } from "@/lib/activity/message"; -import { guessTimezone } from "@/lib/utils/date"; +import { guessTimezone, toFullDateTimeString } from "@/lib/utils/date"; import { useTRPCClient } from "@/trpc/client"; -function getActionIcon(action: string) { - switch (action) { - case "created": - return { icon: Plus, color: "text-emerald-500", bg: "bg-emerald-500/10" }; - case "updated": - return { - icon: RefreshCw, - color: "text-amber-500", - bg: "bg-amber-500/10", - }; - case "deleted": - return { icon: Trash2, color: "text-red-500", bg: "bg-red-500/10" }; - default: - return { - icon: RefreshCw, - color: "text-muted-foreground", - bg: "bg-muted", - }; - } -} - -function formatLocalTime(date: Date, timeZone: string): string { - return date.toLocaleString("en-US", { - timeZone, - day: "numeric", - month: "long", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); -} - -function formatUTCTime(date: Date): string { - return date.toLocaleString("en-US", { - timeZone: "UTC", - day: "numeric", - month: "long", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); -} - function SectionLabel({ icon: Icon, label, @@ -103,8 +55,8 @@ export function ActivityDetailPanel({ const { projectId } = useParams(); const trpcClient = useTRPCClient(); - const { data: item, isPending } = useQuery({ - queryKey: ["projects", "getActivityById", activityId], + const { data: item, isPending, isError } = useQuery({ + queryKey: ["projects", "getActivityById", +projectId!, activityId], queryFn: () => trpcClient.projects.getActivityById.query({ id: activityId, @@ -123,7 +75,13 @@ export function ActivityDetailPanel({ }} title="Activity Details" > - {isPending || !item ? ( + {isError ? ( +
+

+ Failed to load activity details. +

+
+ ) : isPending || !item ? ( ) : ( <> @@ -165,14 +123,14 @@ export function ActivityDetailPanel({
) - .sessionId, ipAddress: ( item.metadata as Record ).ipAddress, diff --git a/components/project/activity/activity-feed.tsx b/components/project/activity/activity-feed.tsx index f943b25..410bd15 100644 --- a/components/project/activity/activity-feed.tsx +++ b/components/project/activity/activity-feed.tsx @@ -1,7 +1,7 @@ "use client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { ActivityIcon, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { ActivityIcon } from "lucide-react"; import { useParams } from "next/navigation"; import { parseAsInteger, useQueryState } from "nuqs"; import { useMemo } from "react"; @@ -13,6 +13,7 @@ import type { ActivityWithActor } from "@/drizzle/types"; import { formatEventTypeLabel, generateObjectDiffMessage, + getActionIcon, } from "@/lib/activity/message"; import { guessTimezone, toDateTimeString } from "@/lib/utils/date"; import { useTRPCClient } from "@/trpc/client"; @@ -20,27 +21,6 @@ import { ActivityDetailPanel } from "./activity-detail-panel"; const ACTIVITIES_LIMIT = 25; -function getActionIcon(action: string) { - switch (action) { - case "created": - return { icon: Plus, color: "text-emerald-500", bg: "bg-emerald-500/10" }; - case "updated": - return { - icon: RefreshCw, - color: "text-amber-500", - bg: "bg-amber-500/10", - }; - case "deleted": - return { icon: Trash2, color: "text-red-500", bg: "bg-red-500/10" }; - default: - return { - icon: ActivityIcon, - color: "text-muted-foreground", - bg: "bg-muted", - }; - } -} - export function ActivityItem({ item, onSelect, diff --git a/lib/activity/index.ts b/lib/activity/index.ts index 398b65a..4f864e3 100644 --- a/lib/activity/index.ts +++ b/lib/activity/index.ts @@ -41,18 +41,15 @@ export async function logActivity({ null; const userAgent = reqHeaders.get("user-agent") || null; - let sessionId: string | null = null; let userEmail: string | null = null; try { const session = await auth.api.getSession({ headers: reqHeaders }); - sessionId = session?.session?.id || null; userEmail = session?.user?.email || null; } catch {} const metadata = { ipAddress, userAgent, - sessionId, userEmail, }; diff --git a/lib/activity/message.ts b/lib/activity/message.ts index 6bb166d..22b92f7 100644 --- a/lib/activity/message.ts +++ b/lib/activity/message.ts @@ -1,6 +1,20 @@ +import { ActivityIcon, Plus, RefreshCw, Trash2 } from "lucide-react"; import type { ActivityWithActor } from "@/drizzle/types"; import { guessTimezone, toDateStringWithDay } from "../utils/date"; +export function getActionIcon(action: string) { + switch (action) { + case "created": + return { icon: Plus, color: "text-emerald-500", bg: "bg-emerald-500/10" }; + case "updated": + return { icon: RefreshCw, color: "text-amber-500", bg: "bg-amber-500/10" }; + case "deleted": + return { icon: Trash2, color: "text-red-500", bg: "bg-red-500/10" }; + default: + return { icon: ActivityIcon, color: "text-muted-foreground", bg: "bg-muted" }; + } +} + // biome-ignore lint/suspicious/noExplicitAny: flexible date parameter handling function toDateString(date: any) { if (!date) { diff --git a/lib/utils/date.ts b/lib/utils/date.ts index b05217e..1ded1fe 100644 --- a/lib/utils/date.ts +++ b/lib/utils/date.ts @@ -88,3 +88,16 @@ export function toStartOfHour(date: Date) { export function toMs(durationInMinutes: number) { return durationInMinutes * 60 * 1000; } + +export function toFullDateTimeString(date: Date, timeZone: string) { + return date.toLocaleString("en-US", { + timeZone, + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + }); +}