From e5f2c64e146549146142d13165e92c66d63d420f Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:26:15 -0500 Subject: [PATCH] Phase 6: Activity log and notifications Add activity logging to all write API routes, notification system with real-time delivery via Socket.IO, @mention parsing in comments, activity feed sidebar on board page, per-card activity in card modal, and notification bell with unread badge in the navbar. Co-Authored-By: Claude Opus 4.6 --- server.js | 5 + src/lib/components/ActivityFeed.svelte | 155 ++++++++++++++++++ src/lib/components/CardModal.svelte | 7 + src/lib/components/NotificationBell.svelte | 151 +++++++++++++++++ src/lib/server/activity.ts | 27 +++ src/lib/server/notifications.ts | 35 ++++ src/lib/server/realtime.ts | 10 ++ src/lib/utils/mentions.ts | 10 ++ src/routes/+layout.svelte | 2 + .../api/attachments/[attachmentId]/+server.ts | 10 ++ src/routes/api/boards/[boardId]/+server.ts | 20 ++- .../api/boards/[boardId]/activity/+server.ts | 34 ++++ .../api/boards/[boardId]/columns/+server.ts | 13 +- .../[boardId]/columns/[columnId]/+server.ts | 26 ++- .../columns/[columnId]/cards/+server.ts | 14 +- .../[columnId]/cards/[cardId]/+server.ts | 57 ++++++- .../cards/[cardId]/activity/+server.ts | 35 ++++ .../cards/[cardId]/assignees/+server.ts | 37 ++++- .../cards/[cardId]/attachments/+server.ts | 14 +- .../cards/[cardId]/checklists/+server.ts | 14 +- .../checklists/[checklistId]/+server.ts | 10 ++ .../checklists/[checklistId]/items/+server.ts | 13 +- .../[checklistId]/items/[itemId]/+server.ts | 15 +- .../cards/[cardId]/comments/+server.ts | 40 ++++- .../[cardId]/comments/[commentId]/+server.ts | 9 + .../cards/[cardId]/labels/+server.ts | 26 ++- .../api/boards/[boardId]/members/+server.ts | 26 ++- .../[boardId]/members/[memberId]/+server.ts | 27 ++- src/routes/api/notifications/+server.ts | 37 +++++ .../notifications/[notificationId]/+server.ts | 25 +++ .../api/notifications/read-all/+server.ts | 17 ++ src/routes/boards/[boardId]/+page.svelte | 32 ++++ 32 files changed, 930 insertions(+), 23 deletions(-) create mode 100644 src/lib/components/ActivityFeed.svelte create mode 100644 src/lib/components/NotificationBell.svelte create mode 100644 src/lib/server/activity.ts create mode 100644 src/lib/server/notifications.ts create mode 100644 src/lib/utils/mentions.ts create mode 100644 src/routes/api/boards/[boardId]/activity/+server.ts create mode 100644 src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/activity/+server.ts create mode 100644 src/routes/api/notifications/+server.ts create mode 100644 src/routes/api/notifications/[notificationId]/+server.ts create mode 100644 src/routes/api/notifications/read-all/+server.ts diff --git a/server.js b/server.js index 29a27e5..8493e67 100644 --- a/server.js +++ b/server.js @@ -75,6 +75,11 @@ io.on('connection', (socket) => { const user = socket.data.user; console.log(`Socket connected: ${socket.id} (user: ${user?.name ?? 'anon'})`); + // Join user-specific room for notifications + if (user) { + socket.join('user:' + user.id); + } + // Track which boards this socket has joined (for cleanup on disconnect) const joinedBoards = new Set(); diff --git a/src/lib/components/ActivityFeed.svelte b/src/lib/components/ActivityFeed.svelte new file mode 100644 index 0000000..6ccb4ab --- /dev/null +++ b/src/lib/components/ActivityFeed.svelte @@ -0,0 +1,155 @@ + + +
+ {#if loading} +
+
+
+
+
+ {:else if activities.length === 0} +

No activity yet

+ {:else} + {#each activities as activity (activity.id)} +
+
+ {activity.user.name[0].toUpperCase()} +
+
+

+ {activity.user.name} + {' '}{formatAction(activity)} +

+

{timeAgo(activity.createdAt)}

+
+
+ {/each} + + {#if nextCursor} + + {/if} + {/if} +
diff --git a/src/lib/components/CardModal.svelte b/src/lib/components/CardModal.svelte index d5e7939..c30bc7f 100644 --- a/src/lib/components/CardModal.svelte +++ b/src/lib/components/CardModal.svelte @@ -7,6 +7,7 @@ import ChecklistSection from './ChecklistSection.svelte'; import CommentList from './CommentList.svelte'; import AttachmentList from './AttachmentList.svelte'; + import ActivityFeed from './ActivityFeed.svelte'; type Props = { card: any; @@ -209,6 +210,12 @@ {currentUserId} {canEdit} /> + + +
+

Activity

+ +
{/if} diff --git a/src/lib/components/NotificationBell.svelte b/src/lib/components/NotificationBell.svelte new file mode 100644 index 0000000..e9db29d --- /dev/null +++ b/src/lib/components/NotificationBell.svelte @@ -0,0 +1,151 @@ + + +
+ + + {#if open} + + {/if} +
diff --git a/src/lib/server/activity.ts b/src/lib/server/activity.ts new file mode 100644 index 0000000..c407882 --- /dev/null +++ b/src/lib/server/activity.ts @@ -0,0 +1,27 @@ +import type { Prisma } from '@prisma/client'; + +type TxClient = Prisma.TransactionClient; + +interface LogActivityOptions { + boardId: string; + userId: string; + action: string; + cardId?: string | null; + metadata?: Record; +} + +/** + * Log an activity entry inside an existing transaction. + * Call this within `withTenant(...)` so it shares the same RLS context. + */ +export async function logActivity(tx: TxClient, opts: LogActivityOptions) { + return tx.activity.create({ + data: { + boardId: opts.boardId, + userId: opts.userId, + action: opts.action, + cardId: opts.cardId ?? undefined, + metadata: opts.metadata ?? undefined + } + }); +} diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts new file mode 100644 index 0000000..29f5821 --- /dev/null +++ b/src/lib/server/notifications.ts @@ -0,0 +1,35 @@ +import type { Prisma } from '@prisma/client'; +import { notifyUser } from './realtime.js'; + +type TxClient = Prisma.TransactionClient; + +interface NotifyOptions { + userId: string; + type: string; + title: string; + body?: string; + link?: string; +} + +/** + * Create a notification and push it to the user via Socket.IO. + * Call within `withTenant(...)` so it shares the same RLS context. + */ +export async function notify(tx: TxClient, opts: NotifyOptions) { + const notification = await tx.notification.create({ + data: { + userId: opts.userId, + type: opts.type, + title: opts.title, + body: opts.body, + link: opts.link + } + }); + + // Push to user in real-time (fire-and-forget, runs outside the tx) + queueMicrotask(() => { + notifyUser(opts.userId, 'notification:new', notification); + }); + + return notification; +} diff --git a/src/lib/server/realtime.ts b/src/lib/server/realtime.ts index 2c96972..d2d460f 100644 --- a/src/lib/server/realtime.ts +++ b/src/lib/server/realtime.ts @@ -12,3 +12,13 @@ export function broadcast(boardId: string, event: string, data: Record` room joined on connection. + */ +export function notifyUser(userId: string, event: string, data: Record = {}) { + const io = globalThis.__socketIO; + if (!io) return; + io.to(`user:${userId}`).emit(event, data); +} diff --git a/src/lib/utils/mentions.ts b/src/lib/utils/mentions.ts new file mode 100644 index 0000000..f3aafce --- /dev/null +++ b/src/lib/utils/mentions.ts @@ -0,0 +1,10 @@ +/** + * Extract unique @username mentions from text. + * Matches `@word` where word is alphanumeric, dot, dash, or underscore. + */ +export function parseMentions(text: string): string[] { + const matches = text.match(/@([\w.\-]+)/g); + if (!matches) return []; + const names = matches.map((m) => m.slice(1)); + return [...new Set(names)]; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a9091af..b04479d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import type { Snippet } from 'svelte'; import { onMount } from 'svelte'; import { connectSocket, disconnectSocket } from '$lib/stores/socket.js'; + import NotificationBell from '$lib/components/NotificationBell.svelte'; let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props(); @@ -39,6 +40,7 @@ {/if} {data.user.name} + + +
+ + + {#if showActivity} +
+
+

Activity

+ +
+ +
+ {/if} +