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} +
+
+ Notifications + {#if unreadCount > 0} + + {/if} +
+ + +
+ {/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} +