);
}
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;
+ }),
});