- 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>
197 lines
6.4 KiB
TypeScript
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,
|
|
};
|
|
}
|