UMBRA/frontend/src/hooks/useSharedCalendars.ts
Kyle Pope b401fd9392 Phase 6: Real-time sync, drag-drop guards, security fix, invite bug fix, UI polish
- Event polling (5s refetchInterval) so collaborators see changes without refresh
- Lock status polling in EventDetailPanel view mode — proactive lock banner
- Per-event editable flag blocks drag on read-only shared events
- Read-only permission guard in handleEventDrop/handleEventResize
- M-01 security fix: block non-owners from moving events off shared calendars (403)
- Fix invite response type (backend returns list, not wrapper object)
- Remove is_shared from CalendarCreate/CalendarUpdate input schemas
- New PermissionToggle segmented control (Eye/Pencil/Shield icons)
- CalendarMemberRow restructured into spacious two-line card layout
- CalendarForm dialog widened (sm:max-w-2xl), polished invite card with accent border
- SharedCalendarSettings dialog widened (sm:max-w-lg)
- CalendarMemberList max-height increased (max-h-48 → max-h-72)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:46:15 +08:00

197 lines
6.4 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import axios from 'axios';
import type { CalendarMemberInfo, CalendarInvite, CalendarPermission } from '@/types';
export function useSharedCalendars() {
const queryClient = useQueryClient();
const incomingInvitesQuery = useQuery({
queryKey: ['calendar-invites', 'incoming'],
queryFn: async () => {
const { data } = await api.get<CalendarInvite[]>(
'/shared-calendars/invites/incoming'
);
return data;
},
refetchOnMount: 'always' as const,
});
const fetchMembers = async (calendarId: number) => {
const { data } = await api.get<CalendarMemberInfo[]>(
`/shared-calendars/${calendarId}/members`
);
return data;
};
const useMembersQuery = (calendarId: number | null) =>
useQuery({
queryKey: ['calendar-members', calendarId],
queryFn: () => fetchMembers(calendarId!),
enabled: calendarId != null,
});
const inviteMutation = useMutation({
mutationFn: async ({
calendarId,
connectionId,
permission,
canAddOthers,
}: {
calendarId: number;
connectionId: number;
permission: CalendarPermission;
canAddOthers: boolean;
}) => {
const { data } = await api.post(`/shared-calendars/${calendarId}/invite`, {
connection_id: connectionId,
permission,
can_add_others: canAddOthers,
});
return data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
queryClient.invalidateQueries({ queryKey: ['calendars'] });
toast.success('Invite sent');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to send invite'));
},
});
const updateMemberMutation = useMutation({
mutationFn: async ({
calendarId,
memberId,
permission,
canAddOthers,
}: {
calendarId: number;
memberId: number;
permission?: CalendarPermission;
canAddOthers?: boolean;
}) => {
const body: Record<string, unknown> = {};
if (permission !== undefined) body.permission = permission;
if (canAddOthers !== undefined) body.can_add_others = canAddOthers;
const { data } = await api.put(
`/shared-calendars/${calendarId}/members/${memberId}`,
body
);
return data;
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
toast.success('Member updated');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update member'));
},
});
const removeMemberMutation = useMutation({
mutationFn: async ({
calendarId,
memberId,
}: {
calendarId: number;
memberId: number;
}) => {
await api.delete(`/shared-calendars/${calendarId}/members/${memberId}`);
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
queryClient.invalidateQueries({ queryKey: ['calendars'] });
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
toast.success('Member removed');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to remove member'));
},
});
const respondInviteMutation = useMutation({
mutationFn: async ({
inviteId,
action,
}: {
inviteId: number;
action: 'accept' | 'reject';
}) => {
const { data } = await api.put(`/shared-calendars/invites/${inviteId}/respond`, {
action,
});
return data;
},
onSuccess: (_, variables) => {
toast.dismiss(`calendar-invite-${variables.inviteId}`);
queryClient.invalidateQueries({ queryKey: ['calendar-invites'] });
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(variables.action === 'accept' ? 'Calendar added' : 'Invite declined');
},
onError: (error, variables) => {
if (axios.isAxiosError(error) && error.response?.status === 409) {
toast.dismiss(`calendar-invite-${variables.inviteId}`);
queryClient.invalidateQueries({ queryKey: ['calendar-invites'] });
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
return;
}
toast.error(getErrorMessage(error, 'Failed to respond to invite'));
},
});
const updateColorMutation = useMutation({
mutationFn: async ({
calendarId,
localColor,
}: {
calendarId: number;
localColor: string | null;
}) => {
await api.put(`/shared-calendars/${calendarId}/members/me/color`, {
local_color: localColor,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update color'));
},
});
const leaveCalendarMutation = useMutation({
mutationFn: async ({ calendarId, memberId }: { calendarId: number; memberId: number }) => {
await api.delete(`/shared-calendars/${calendarId}/members/${memberId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
toast.success('Left calendar');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to leave calendar'));
},
});
return {
incomingInvites: incomingInvitesQuery.data ?? [],
isLoadingInvites: incomingInvitesQuery.isLoading,
useMembersQuery,
invite: inviteMutation.mutateAsync,
isInviting: inviteMutation.isPending,
updateMember: updateMemberMutation.mutateAsync,
removeMember: removeMemberMutation.mutateAsync,
respondInvite: respondInviteMutation.mutateAsync,
isResponding: respondInviteMutation.isPending,
updateColor: updateColorMutation.mutateAsync,
leaveCalendar: leaveCalendarMutation.mutateAsync,
};
}