diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 594e5af..8e8d143 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -763,20 +763,24 @@ async def respond_to_invite( if not member: raise HTTPException(status_code=404, detail="No pending invitation found") - member.status = respond.response + # Extract response data before any mutations (ORM objects expire after commit) + resp = ProjectMemberResponse.model_validate(member) + resp.user_name = member.user.username + resp.inviter_name = member.inviter.username if member.inviter else None + if respond.response == "accepted": + member.status = "accepted" member.accepted_at = datetime.now() - # Get project owner for notification - project_result = await db.execute( - select(Project.user_id, Project.name).where(Project.id == project_id) - ) - project_row = project_result.one() - owner_id, project_name = project_row.tuple() + # Get project owner for notification + project_result = await db.execute( + select(Project.user_id, Project.name).where(Project.id == project_id) + ) + project_row = project_result.one() + owner_id, project_name = project_row.tuple() - responder_name = await _get_user_name(db, current_user.id) + responder_name = await _get_user_name(db, current_user.id) - if respond.response == "accepted": await create_notification( db, owner_id, "project_invite_accepted", f"{responder_name} joined your project", @@ -785,10 +789,11 @@ async def respond_to_invite( source_type="project_member", ) - # Extract response data before commit - resp = ProjectMemberResponse.model_validate(member) - resp.user_name = member.user.username - resp.inviter_name = member.inviter.username if member.inviter else None + resp.status = "accepted" + else: + # Rejected — delete the row to prevent accumulation (W-06) + await db.delete(member) + resp.status = "rejected" await db.commit() diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 101e957..9396200 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; -import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react'; +import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useNotifications } from '@/hooks/useNotifications'; import { useConnections } from '@/hooks/useConnections'; @@ -123,6 +123,38 @@ export default function NotificationToaster() { [], ); + const handleProjectInviteRespond = useCallback( + async (projectId: number, action: 'accepted' | 'rejected', toastId: string | number, notificationId: number) => { + const key = `proj-${projectId}`; + if (respondingRef.current.has(key)) return; + respondingRef.current.add(key); + + toast.dismiss(toastId); + const loadingId = toast.loading( + action === 'accepted' ? 'Accepting project invite\u2026' : 'Declining invite\u2026', + ); + + try { + await api.post(`/projects/memberships/${projectId}/respond`, { response: action }); + toast.dismiss(loadingId); + toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined'); + markReadRef.current([notificationId]).catch(() => {}); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + } catch (err) { + toast.dismiss(loadingId); + if (axios.isAxiosError(err) && err.response?.status === 409) { + toast.success('Already responded'); + markReadRef.current([notificationId]).catch(() => {}); + } else { + toast.error(getErrorMessage(err, 'Failed to respond to project invite')); + } + } finally { + respondingRef.current.delete(key); + } + }, + [], + ); + // Track unread count changes to force-refetch the list useEffect(() => { if (unreadCount > prevUnreadRef.current && initializedRef.current) { @@ -155,6 +187,8 @@ export default function NotificationToaster() { showCalendarInviteToast(notification); } else if (notification.type === 'event_invite' && notification.data) { showEventInviteToast(notification); + } else if (notification.type === 'project_invite' && notification.data) { + showProjectInviteToast(notification); } }); return; @@ -195,6 +229,8 @@ export default function NotificationToaster() { showCalendarInviteToast(notification); } else if (notification.type === 'event_invite' && notification.data) { showEventInviteToast(notification); + } else if (notification.type === 'project_invite' && notification.data) { + showProjectInviteToast(notification); } else { toast(notification.title || 'New Notification', { description: notification.message || undefined, @@ -203,7 +239,7 @@ export default function NotificationToaster() { }); } }); - }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]); + }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond, handleProjectInviteRespond]); const showConnectionRequestToast = (notification: AppNotification) => { const requestId = notification.source_id!; @@ -363,5 +399,48 @@ export default function NotificationToaster() { ); }; + const showProjectInviteToast = (notification: AppNotification) => { + const data = notification.data as Record | undefined; + const projectId = data?.project_id as number | undefined; + if (!projectId) return; + + const toastKey = `project-invite-${notification.id}`; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Project Invitation

+

+ {notification.message || 'You were invited to collaborate on a project'} +

+
+ + +
+
+
+
+ ), + { id: toastKey, duration: 30000 }, + ); + }; + return null; }