use-event-rsvp.ts3.8 KBView on GitHub
import { useCallback } from 'react';
import { toast } from 'sonner';

import { toRsvpStatus, type CalendarEvent, type RsvpStatus } from '../types/calendar-types';
import { useUpdateCalendarEvent } from './use-calendar';

/**
 * Answering an invitation — the whole write, in one place.
 *
 * Lifted out of `EventDetailsPopover.handleRsvpChange` (:1850). RSVP looks like a
 * one-field update and is not: Google replaces the ENTIRE attendee array on write, so
 * every other guest's `responseStatus` has to be re-sent verbatim or the write silently
 * resets everyone else to `needsAction`. That rule is why this is a shared hook and not
 * a mutation call each caller writes for itself.
 *
 * The optimism stays with the caller, via `onEventChange`: the hook does not know where
 * the caller keeps its copy of the event. It is invoked up to three times per RSVP —
 * once with the optimistic event, once with the server's answer, or once with the
 * ORIGINAL event if the write failed (the rollback). A caller that only wants the happy
 * path can pass nothing and let the mutation's own cache invalidation refresh the view.
 */
export interface UseEventRsvpOptions {
  /** Optimistic apply, server confirm, and rollback all arrive here. */
  onEventChange?: (event: CalendarEvent) => void;
}

export interface UseEventRsvp {
  /** Resolves `true` when the RSVP reached Google, `false` when it was rolled back. */
  setRsvp: (event: CalendarEvent, status: RsvpStatus) => Promise<boolean>;
  isPending: boolean;
}

export function useEventRsvp({ onEventChange }: UseEventRsvpOptions = {}): UseEventRsvp {
  const updateEventMutation = useUpdateCalendarEvent();

  const setRsvp = useCallback(
    async (event: CalendarEvent, newStatus: RsvpStatus): Promise<boolean> => {
      if (!event.id) return false;

      const attendees = event.attendees ?? [];

      // The full array, ours rewritten and everyone else's preserved. `toRsvpStatus`
      // narrows Google's open-string field to the four values the update accepts —
      // an unknown value would be rejected for the whole request, not just that row.
      const updatedAttendees = attendees.map((a) => {
        if (a.self === true) {
          return {
            email: a.email || '',
            displayName: a.displayName || undefined,
            responseStatus: newStatus,
          };
        }
        return {
          email: a.email || '',
          displayName: a.displayName || undefined,
          responseStatus: toRsvpStatus(a.responseStatus),
        };
      });

      // Keep the pre-write event for the rollback path.
      const previousEvent = event;

      const optimisticEvent: CalendarEvent = {
        ...event,
        attendees: updatedAttendees.map((a) => ({
          ...attendees.find((orig) => orig.email === a.email),
          email: a.email,
          displayName: a.displayName,
          responseStatus: a.responseStatus,
        })),
      };
      onEventChange?.(optimisticEvent);

      try {
        const updatedEvent = await updateEventMutation.mutateAsync({
          eventId: event.id,
          sendUpdates: 'all',
          requestBody: {
            attendees: updatedAttendees.map((a) => ({
              email: a.email,
              displayName: a.displayName,
              responseStatus: a.responseStatus,
            })),
          },
        });
        if (updatedEvent) {
          onEventChange?.({
            ...event,
            attendees: updatedEvent.attendees as CalendarEvent['attendees'],
          });
        }
        return true;
      } catch (error) {
        onEventChange?.(previousEvent);
        toast.error('Failed to update RSVP');
        console.error(error);
        return false;
      }
    },
    [onEventChange, updateEventMutation],
  );

  return { setRsvp, isPending: updateEventMutation.isPending };
}