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:
parent
bef856fd15
commit
dad5c0e606
@ -763,8 +763,13 @@ 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
|
||||
@ -776,7 +781,6 @@ async def respond_to_invite(
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user