diff --git a/components/project/activity/activity-detail-panel.tsx b/components/project/activity/activity-detail-panel.tsx new file mode 100644 index 0000000..4f972ca --- /dev/null +++ b/components/project/activity/activity-detail-panel.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { + CalendarIcon, + CompassIcon, + RefreshCw, + 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, + getActionIcon, + getEventDescription, +} from "@/lib/activity/message"; +import { guessTimezone, toFullDateTimeString } from "@/lib/utils/date"; +import { useTRPCClient } from "@/trpc/client"; + +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, isError } = useQuery({ + queryKey: ["projects", "getActivityById", +projectId!, 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" + > + {isError ? ( +
+

+ Failed to load 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: {
+														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..410bd15 100644 --- a/components/project/activity/activity-feed.tsx +++ b/components/project/activity/activity-feed.tsx @@ -1,107 +1,99 @@ "use client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { formatDistanceToNow } from "date-fns"; import { ActivityIcon } 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, + getActionIcon, +} 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) { - switch (action) { - case "created": - return { - label: "Created", - bgColor: "bg-emerald-100 dark:bg-emerald-900/50", - textColor: "text-emerald-600 dark:text-emerald-400", - }; - case "updated": - return { - label: "Updated", - bgColor: "bg-blue-100 dark:bg-blue-900/50", - textColor: "text-blue-600 dark:text-blue-400", - }; - case "deleted": - return { - label: "Deleted", - bgColor: "bg-red-100 dark:bg-red-900/50", - textColor: "text-red-600 dark:text-red-400", - }; - default: - return { - label: action, - bgColor: "bg-muted", - textColor: "text-muted-foreground", - }; - } -} - -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 +125,16 @@ export function ActivityFeed() { } return ( -
+
{activities.length ? ( <> -
- {activities.map((activityItem, index) => ( -
- - {index < activities.length - 1 && ( -
-
-
- )} -
+
+ {activities.map((activityItem) => ( + setSelectedActivityId(id)} + /> ))}
@@ -177,11 +166,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..4f864e3 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,35 @@ 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 userEmail: string | null = null; + try { + const session = await auth.api.getSession({ headers: reqHeaders }); + userEmail = session?.user?.email || null; + } catch {} + + const metadata = { + ipAddress, + userAgent, + 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..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) { @@ -117,6 +131,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/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, + }); +} 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; + }), });