diff --git a/apps/web/src/app/(app)/dashboard/boards/[boardId]/board-client.tsx b/apps/web/src/app/(app)/dashboard/boards/[boardId]/board-client.tsx index 364515c..3bbbc04 100644 --- a/apps/web/src/app/(app)/dashboard/boards/[boardId]/board-client.tsx +++ b/apps/web/src/app/(app)/dashboard/boards/[boardId]/board-client.tsx @@ -39,7 +39,7 @@ import { } from '@taskly/ui'; import { useWorkspaceUI } from '@/components/workspace/workspace-ui-provider'; import { UserAvatar } from '@/components/user/user-avatar'; -import { MoreHorizontal, Plus, CheckSquare, Paperclip, MessageSquare, GripVertical, Calendar, ScrollText, Clock, Paintbrush } from 'lucide-react'; +import { MoreHorizontal, Plus, CheckSquare, Paperclip, MessageSquare, GripVertical, Calendar, ScrollText, Clock, Paintbrush, Bell } from 'lucide-react'; import TicketDialogV2 from '@/components/ticket/ticket-dialog-v2'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -174,6 +174,7 @@ function SortableBoardTicket({ formatUserSecondary: _formatUserSecondary, initialsForUser: _initialsForUser, userById, + notifCount, }: { t: TicketLike; labelMap: Map; @@ -182,6 +183,7 @@ function SortableBoardTicket({ formatUserSecondary: (userId: string) => string; initialsForUser: (userId: string) => string; userById: Map; + notifCount: number; }) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: dndTicketId(t.id), @@ -285,6 +287,12 @@ function SortableBoardTicket({ 0 )} + {notifCount > 0 && ( +
+ + {notifCount} +
+ )} {/* Assignees */} {t.assigneeIds && t.assigneeIds.length > 0 && (
@@ -339,6 +347,7 @@ function SortableBoardColumn({ formatUserSecondary, initialsForUser, userById, + ticketNotifCounts, }: { boardId: string; col: ColumnLike; @@ -370,6 +379,7 @@ function SortableBoardColumn({ formatUserSecondary: (userId: string) => string; initialsForUser: (userId: string) => string; userById: Map; + ticketNotifCounts: Map; }) { const { t } = useTranslation(); const { setNodeRef: setDropRef } = useDroppable({ @@ -486,6 +496,7 @@ function SortableBoardColumn({ formatUserSecondary={formatUserSecondary} initialsForUser={initialsForUser} userById={userById} + notifCount={ticketNotifCounts.get(ticket.id) ?? 0} /> ))} @@ -534,6 +545,35 @@ export default function BoardClient({ boardId }: { boardId: string }) { { enabled: boardActivityOpen }, ); + // Fetch unread notifications to display per-ticket badge counts + const unreadNotifsQuery = api.notifications.list.useQuery( + { limit: 50, unreadOnly: true }, + { refetchInterval: 15_000 }, + ); + + const { ticketNotifCounts, ticketNotifIds } = React.useMemo(() => { + const counts = new Map(); + const ids = new Map(); + const items = unreadNotifsQuery.data?.items ?? []; + for (const n of items) { + const nBoardId = typeof n.data?.boardId === 'string' ? n.data.boardId : null; + const nTicketId = typeof n.data?.ticketId === 'string' ? n.data.ticketId : null; + if (nBoardId !== boardId || !nTicketId) continue; + counts.set(nTicketId, (counts.get(nTicketId) ?? 0) + 1); + const arr = ids.get(nTicketId) ?? []; + arr.push(n.id); + ids.set(nTicketId, arr); + } + return { ticketNotifCounts: counts, ticketNotifIds: ids }; + }, [unreadNotifsQuery.data, boardId]); + + const markRead = api.notifications.markRead.useMutation({ + onSuccess: async () => { + await utils.notifications.list.invalidate(); + await utils.notifications.unreadCount.invalidate(); + }, + }); + const [backgroundPickerOpen, setBackgroundPickerOpen] = React.useState(false); const [backgroundTab, setBackgroundTab] = React.useState<'color' | 'gradient' | 'image'>('color'); const [imageSearch, setImageSearch] = React.useState(''); @@ -1068,6 +1108,10 @@ export default function BoardClient({ boardId }: { boardId: string }) { onClick={() => { setBoardActivityOpen(false); setOpenedTicketId(it.ticketId!); + const ids = ticketNotifIds.get(it.ticketId!); + if (ids?.length) { + for (const id of ids) markRead.mutate({ id }); + } }} > {t('board.open_ticket')} @@ -1185,11 +1229,19 @@ export default function BoardClient({ boardId }: { boardId: string }) { setNewCardDescByColumn((s) => ({ ...s, [col.id]: '' })); }} labelMap={labelMap} - onOpenTicket={(ticketId) => setOpenedTicketId(ticketId)} + onOpenTicket={(ticketId) => { + setOpenedTicketId(ticketId); + // Mark related notifications as read + const ids = ticketNotifIds.get(ticketId); + if (ids?.length) { + for (const id of ids) markRead.mutate({ id }); + } + }} formatUserPrimary={formatUserPrimary} formatUserSecondary={formatUserSecondary} initialsForUser={initialsForUser} userById={usersById} + ticketNotifCounts={ticketNotifCounts} /> ); })} diff --git a/apps/web/src/app/(app)/dashboard/notifications/notifications-client.tsx b/apps/web/src/app/(app)/dashboard/notifications/notifications-client.tsx index 281d10f..c2eeba5 100644 --- a/apps/web/src/app/(app)/dashboard/notifications/notifications-client.tsx +++ b/apps/web/src/app/(app)/dashboard/notifications/notifications-client.tsx @@ -24,10 +24,11 @@ export default function NotificationsClient() { { limit: PAGE_SIZE }, { getNextPageParam: (lastPage) => lastPage.nextCursor, + refetchInterval: 10_000, }, ); - const unreadCountQuery = api.notifications.unreadCount.useQuery(); + const unreadCountQuery = api.notifications.unreadCount.useQuery(undefined, { refetchInterval: 10_000 }); const markRead = api.notifications.markRead.useMutation({ onSuccess: async () => { diff --git a/apps/web/src/components/layout/navbar.tsx b/apps/web/src/components/layout/navbar.tsx index 174b9e4..2730d2c 100644 --- a/apps/web/src/components/layout/navbar.tsx +++ b/apps/web/src/components/layout/navbar.tsx @@ -141,8 +141,8 @@ export function Navbar() { }, ) - const notificationsQuery = api.notifications.list.useQuery({ limit: 6 }) - const unreadCountQuery = api.notifications.unreadCount.useQuery() + const notificationsQuery = api.notifications.list.useQuery({ limit: 6 }, { refetchInterval: 10_000 }) + const unreadCountQuery = api.notifications.unreadCount.useQuery(undefined, { refetchInterval: 10_000 }) const markRead = api.notifications.markRead.useMutation({ onSuccess: async () => { await utils.notifications.list.invalidate() @@ -159,6 +159,37 @@ export function Navbar() { const notifications = notificationsQuery.data?.items ?? [] const unreadCount = unreadCountQuery.data?.count ?? 0 + // Track known notification IDs to detect new ones arriving via polling + const knownNotifIdsRef = React.useRef | null>(null) + + React.useEffect(() => { + if (!notificationsQuery.data) return + + const currentIds = new Set(notifications.map((n) => n.id)) + + // First load: just seed the ref, don't toast + if (knownNotifIdsRef.current === null) { + knownNotifIdsRef.current = currentIds + return + } + + // Find notifications that weren't in the previous set + const newNotifs = notifications.filter((n) => !knownNotifIdsRef.current!.has(n.id) && !n.readAt) + knownNotifIdsRef.current = currentIds + + for (const n of newNotifs) { + toast({ + title: ( + + + {n.title} + + ) as unknown as string, + description: n.body ?? undefined, + }) + } + }, [notificationsQuery.data]) // eslint-disable-line react-hooks/exhaustive-deps + const notificationHref = (n: (typeof notifications)[number]) => { const data = n.data ?? {} const boardId = typeof data.boardId === "string" ? data.boardId : null diff --git a/packages/database/src/workspaces/workspaces.store.ts b/packages/database/src/workspaces/workspaces.store.ts index 4e3e3d9..fdaaa63 100644 --- a/packages/database/src/workspaces/workspaces.store.ts +++ b/packages/database/src/workspaces/workspaces.store.ts @@ -38,6 +38,15 @@ export class WorkspacesStore { return this.workspaceRef(workspaceId).collection('invitations'); } + private getProjectIdForConsole(): string { + return ( + process.env.FIREBASE_PROJECT_ID ?? + process.env.GCLOUD_PROJECT ?? + process.env.GCP_PROJECT ?? + '' + ); + } + async createWorkspace( input: WorkspaceCreateInput, ): Promise { @@ -284,11 +293,29 @@ export class WorkspacesStore { async acceptInvitationByToken(token: string, userId: string): Promise { // Find invitation doc reference via collectionGroup, then tx update + upsert member - const snap = await this.db - .collectionGroup('invitations') - .where('token', '==', token) - .limit(1) - .get(); + let snap: FirebaseFirestore.QuerySnapshot; + try { + snap = await this.db + .collectionGroup('invitations') + .where('token', '==', token) + .limit(1) + .get(); + } catch (e) { + const err = e as { code?: number | string; message?: string }; + const code = typeof err?.code === 'string' ? Number(err.code) : err?.code; + if (code === 9) { + const projectId = this.getProjectIdForConsole(); + console.error( + `[Firestore] Missing collection group index for 'invitations'. Create it here:\n` + + `https://console.firebase.google.com/v1/r/project/${projectId}/firestore/indexes?create_exemption=ClBwcm9qZWN0cy8ke3Byb2plY3RJZH0vZGF0YWJhc2VzLyhkZWZhdWx0KS9jb2xsZWN0aW9uR3JvdXBzL2ludml0YXRpb25zL2ZpZWxkcy90b2tlbhACGggKBHRva2Vu\n\n` + + `Or manually: Go to Firebase Console > Firestore > Indexes > Collection Group > Add index:\n` + + ` Collection group: invitations\n` + + ` Field: token (Ascending)\n` + + ` Query scope: Collection group`, + ); + } + throw e; + } if (snap.empty) throw new Error('Invitation not found'); const invDoc = snap.docs[0]!; const workspaceRef = invDoc.ref.parent.parent; @@ -300,7 +327,10 @@ export class WorkspacesStore { const now = nowIso(); await this.db.runTransaction(async (tx) => { + // All reads MUST happen before any write in a Firestore transaction const invSnap = await tx.get(invRef); + const memSnap = await tx.get(memberRef); + const inv = invSnap.data() as InvitationDoc; if (!inv) throw new Error('Invitation not found'); if (inv.cancelledAt) throw new Error('Invitation cancelled'); @@ -310,7 +340,6 @@ export class WorkspacesStore { tx.update(invRef, { acceptedAt: now, acceptedBy: userId } satisfies Partial); - const memSnap = await tx.get(memberRef); if (memSnap.exists) { tx.update(memberRef, { role: inv.role, updatedAt: now } satisfies Partial); } else { @@ -324,11 +353,29 @@ export class WorkspacesStore { } async declineInvitationByToken(token: string, userId: string): Promise { - const snap = await this.db - .collectionGroup('invitations') - .where('token', '==', token) - .limit(1) - .get(); + let snap: FirebaseFirestore.QuerySnapshot; + try { + snap = await this.db + .collectionGroup('invitations') + .where('token', '==', token) + .limit(1) + .get(); + } catch (e) { + const err = e as { code?: number | string; message?: string }; + const code = typeof err?.code === 'string' ? Number(err.code) : err?.code; + if (code === 9) { + const projectId = this.getProjectIdForConsole(); + console.error( + `[Firestore] Missing collection group index for 'invitations'. Create it here:\n` + + `https://console.firebase.google.com/v1/r/project/${projectId}/firestore/indexes?create_exemption=ClBwcm9qZWN0cy8ke3Byb2plY3RJZH0vZGF0YWJhc2VzLyhkZWZhdWx0KS9jb2xsZWN0aW9uR3JvdXBzL2ludml0YXRpb25zL2ZpZWxkcy90b2tlbhACGggKBHRva2Vu\n\n` + + `Or manually: Go to Firebase Console > Firestore > Indexes > Collection Group > Add index:\n` + + ` Collection group: invitations\n` + + ` Field: token (Ascending)\n` + + ` Query scope: Collection group`, + ); + } + throw e; + } if (snap.empty) throw new Error('Invitation not found'); const invDoc = snap.docs[0]!; const workspaceRef = invDoc.ref.parent.parent;