Fix QA findings: project invite toast with action buttons, rejected row cleanup

W-04: Add showProjectInviteToast with Accept/Decline buttons in
NotificationToaster, matching the connection/calendar/event invite
toast pattern. Wired into both initial-load and new-notification flows.

W-06: Delete rejected ProjectMember rows on rejection instead of
accumulating them with status='rejected'. Prevents indefinite growth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-17 03:25:17 +08:00
parent bef856fd15
commit dad5c0e606
2 changed files with 99 additions and 15 deletions

View File

@ -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()

View File

@ -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<string, unknown> | undefined;
const projectId = data?.project_id as number | undefined;
if (!projectId) return;
const toastKey = `project-invite-${notification.id}`;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
<FolderKanban className="h-4 w-4 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Project Invitation</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'You were invited to collaborate on a project'}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleProjectInviteRespond(projectId, 'accepted', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleProjectInviteRespond(projectId, 'rejected', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Decline
</button>
</div>
</div>
</div>
</div>
),
{ id: toastKey, duration: 30000 },
);
};
return null;
}