Files
MealQuest/web/src/settings/calendarApi.ts
T
2026-10-08 03:13:21 -04:00

166 lines
6.7 KiB
TypeScript

import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { ApiError, useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
export const GOOGLE_PATH = '/api/v1/integrations/google'
// GOOGLE_CONNECT_PATH starts connecting: the browser opens it as a page,
// and the server sends it on to Google's consent screen and, once Google
// answers, back to /settings/calendar.
export const GOOGLE_CONNECT_PATH = `${GOOGLE_PATH}/connect`
export const GOOGLE_CALENDARS_PATH = `${GOOGLE_PATH}/calendars`
export const GOOGLE_CALENDAR_PATH = `${GOOGLE_PATH}/calendar`
// GOOGLE_RECONNECT_REQUIRED is the API error code of a call Google refused
// the stored grant for: only connecting again helps.
export const GOOGLE_RECONNECT_REQUIRED = 'google_reconnect_required'
export const GOOGLE_STATUSES = ['not_connected', 'connected', 'reconnect_required'] as const
// GoogleStatus is reconnect_required once Google has refused the stored
// grant: the connection and its chosen calendar are kept, but nothing can be
// pushed until the household connects again.
export type GoogleStatus = (typeof GOOGLE_STATUSES)[number]
// GoogleConnection is GET /api/v1/integrations/google: the connection's
// status, the calendar chosen to push dinners to, when it was connected and
// why it needs a reconnect, each null when there is none.
export type GoogleConnection = {
status: GoogleStatus
calendar_id: string | null
calendar_name: string | null
connected_at: string | null
last_sync_error: string | null
}
// GoogleCalendar is a calendar MealQuest can push dinners to: one the
// connected account can write to.
export type GoogleCalendar = { id: string; summary: string; primary: boolean }
export const GOOGLE_KEY = ['integrations', 'google'] as const
const GOOGLE_CALENDARS_KEY = ['integrations', 'google', 'calendars'] as const
function isStringOrNull(value: unknown): value is string | null {
return value === null || typeof value === 'string'
}
function parseConnection(where: string, body: unknown): GoogleConnection {
if (
!isRecord(body) ||
!GOOGLE_STATUSES.includes(body.status as GoogleStatus) ||
!isStringOrNull(body.calendar_id) ||
!isStringOrNull(body.calendar_name) ||
!isStringOrNull(body.connected_at) ||
!isStringOrNull(body.last_sync_error)
) {
throw new Error(`${where}: response is not a Google Calendar connection`)
}
return {
status: body.status as GoogleStatus,
calendar_id: body.calendar_id,
calendar_name: body.calendar_name,
connected_at: body.connected_at,
last_sync_error: body.last_sync_error,
}
}
// reconnectRequired says whether error is Google refusing the stored grant.
export function reconnectRequired(error: Error): boolean {
return error instanceof ApiError && error.code === GOOGLE_RECONNECT_REQUIRED
}
// noteGoogleFailure reads the connection again after a call Google refused
// the grant for: the server now reports it as reconnect_required.
export function noteGoogleFailure(queryClient: QueryClient, error: Error) {
if (reconnectRequired(error)) void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true })
}
// useGoogleConnection is the Google Calendar connection.
export function useGoogleConnection() {
const { apiFetch } = useApi()
return useQuery({
queryKey: GOOGLE_KEY,
queryFn: async () => parseConnection(`GET ${GOOGLE_PATH}`, await apiFetch<unknown>(GOOGLE_PATH)),
})
}
// useGoogleCalendars lists the connected account's calendars MealQuest can
// push to, in Google's order. It asks Google, so it is read only while
// connected.
export function useGoogleCalendars(enabled: boolean) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useQuery({
queryKey: GOOGLE_CALENDARS_KEY,
enabled,
queryFn: async (): Promise<GoogleCalendar[]> => {
let body: unknown
try {
body = await apiFetch<unknown>(GOOGLE_CALENDARS_PATH)
} catch (error) {
noteGoogleFailure(queryClient, error as Error)
throw error
}
if (
!Array.isArray(body) ||
!body.every((c) => isRecord(c) && typeof c.id === 'string' && typeof c.summary === 'string' && typeof c.primary === 'boolean')
) {
throw new Error(`GET ${GOOGLE_CALENDARS_PATH}: response is not a calendar list`)
}
return body.map((c: Record<string, unknown>) => ({ id: c.id as string, summary: c.summary as string, primary: c.primary as boolean }))
},
})
}
// useChooseGoogleCalendar chooses the calendar dinners are pushed to.
// Choosing another calendar than the one pushed to makes every pushed plan
// changed (the next push writes the whole week to the new calendar), so the
// plans are read again.
export function useChooseGoogleCalendar() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (calendarId: string) => {
const where = `PUT ${GOOGLE_CALENDAR_PATH}`
const body = await apiFetch<unknown>(GOOGLE_CALENDAR_PATH, { method: 'PUT', body: { calendar_id: calendarId } })
if (!isRecord(body) || body.calendar_id !== calendarId || typeof body.calendar_name !== 'string') {
throw new Error(`${where}: response is not the calendar chosen`)
}
return { calendar_id: calendarId, calendar_name: body.calendar_name }
},
onSuccess: (chosen) => {
queryClient.setQueryData<GoogleConnection>(GOOGLE_KEY, (connection) => connection && { ...connection, ...chosen })
void queryClient.invalidateQueries({ queryKey: ['plans'] })
},
onError: (error) => noteGoogleFailure(queryClient, error),
})
}
// useDisconnectGoogle revokes MealQuest's access at Google and forgets the
// connection and every pushed event, making each pushed plan changed: the
// connection and the plans are read again.
export function useDisconnectGoogle() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => apiFetch<void>(GOOGLE_PATH, { method: 'DELETE' }),
onSuccess: () => {
queryClient.removeQueries({ queryKey: GOOGLE_CALENDARS_KEY })
void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true })
void queryClient.invalidateQueries({ queryKey: ['plans'] })
},
})
}
// calendarStatus is the settings list's line for the connection: the
// handoff's "Connected · Dinners calendar".
export function calendarStatus(connection: GoogleConnection): string {
switch (connection.status) {
case 'not_connected':
return 'Not connected'
case 'reconnect_required':
return 'Reconnect needed'
case 'connected':
return connection.calendar_name === null ? 'Connected · no calendar chosen' : `Connected · ${connection.calendar_name}`
}
}