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:
|
if not member:
|
||||||
raise HTTPException(status_code=404, detail="No pending invitation found")
|
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":
|
if respond.response == "accepted":
|
||||||
|
member.status = "accepted"
|
||||||
member.accepted_at = datetime.now()
|
member.accepted_at = datetime.now()
|
||||||
|
|
||||||
# Get project owner for notification
|
# Get project owner for notification
|
||||||
@ -776,7 +781,6 @@ async def respond_to_invite(
|
|||||||
|
|
||||||
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(
|
await create_notification(
|
||||||
db, owner_id, "project_invite_accepted",
|
db, owner_id, "project_invite_accepted",
|
||||||
f"{responder_name} joined your project",
|
f"{responder_name} joined your project",
|
||||||
@ -785,10 +789,11 @@ async def respond_to_invite(
|
|||||||
source_type="project_member",
|
source_type="project_member",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract response data before commit
|
resp.status = "accepted"
|
||||||
resp = ProjectMemberResponse.model_validate(member)
|
else:
|
||||||
resp.user_name = member.user.username
|
# Rejected — delete the row to prevent accumulation (W-06)
|
||||||
resp.inviter_name = member.inviter.username if member.inviter else None
|
await db.delete(member)
|
||||||
|
resp.status = "rejected"
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
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
|
// Track unread count changes to force-refetch the list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||||
@ -155,6 +187,8 @@ export default function NotificationToaster() {
|
|||||||
showCalendarInviteToast(notification);
|
showCalendarInviteToast(notification);
|
||||||
} else if (notification.type === 'event_invite' && notification.data) {
|
} else if (notification.type === 'event_invite' && notification.data) {
|
||||||
showEventInviteToast(notification);
|
showEventInviteToast(notification);
|
||||||
|
} else if (notification.type === 'project_invite' && notification.data) {
|
||||||
|
showProjectInviteToast(notification);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@ -195,6 +229,8 @@ export default function NotificationToaster() {
|
|||||||
showCalendarInviteToast(notification);
|
showCalendarInviteToast(notification);
|
||||||
} else if (notification.type === 'event_invite' && notification.data) {
|
} else if (notification.type === 'event_invite' && notification.data) {
|
||||||
showEventInviteToast(notification);
|
showEventInviteToast(notification);
|
||||||
|
} else if (notification.type === 'project_invite' && notification.data) {
|
||||||
|
showProjectInviteToast(notification);
|
||||||
} else {
|
} else {
|
||||||
toast(notification.title || 'New Notification', {
|
toast(notification.title || 'New Notification', {
|
||||||
description: notification.message || undefined,
|
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 showConnectionRequestToast = (notification: AppNotification) => {
|
||||||
const requestId = notification.source_id!;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user