/** * Reservations. Filing a request only needs a session; reading the list is an * admin action, so `useReservations` is only ever mounted on /admin. * * Nothing here ever asks for "all the reservations": the status filter, the text * search and the paging are query parameters the database applies. The table * only grows, and an admin looking at three pending requests must not pay for * the archive behind them. */ import { computed, toValue, type MaybeRefOrGetter } from 'vue' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { HttpStatus } from 'http-status-ts' import type { NewReservation, Reservation, ReservationEdit, ReservationStatus } from '@/utils/types' import { getClient } from './client' export const RESERVATIONS_KEY = ['reservations'] export const MY_RESERVATIONS_KEY = ['reservations', 'mine'] export const CALENDAR_KEY = ['reservations', 'calendar'] /** A window of time, as the two calendar-shaped endpoints take it */ export type Window = { from?: Date; to?: Date } export type ReservationFilters = Window & { /** Empty or absent means any status */ status?: ReservationStatus[] /** Free text, matched by the database across the whole reservation */ q?: string limit?: number offset?: number } /** The filters in the shape the query string takes: absent means "no filter" */ function toParams(filters: ReservationFilters) { return { status: filters.status?.length ? filters.status.join(',') : undefined, q: filters.q?.trim() || undefined, from: filters.from?.toISOString(), to: filters.to?.toISOString(), limit: filters.limit, offset: filters.offset, } } /** * One page of the reservations matching `filters`, with the size of the whole * match so the caller can page through it. */ export function useReservations( filters: MaybeRefOrGetter = () => ({}), /** Left out, the query runs; the archive dialog only wants it while open */ options: { enabled?: MaybeRefOrGetter } = {}, ) { const params = computed(() => toParams(toValue(filters))) return useQuery({ queryKey: computed(() => [...RESERVATIONS_KEY, 'search', params.value]), enabled: computed(() => toValue(options.enabled) ?? true), staleTime: 30 * 1000, // Paging or typing in the search must not blank the list under the cursor placeholderData: keepPreviousData, queryFn: async () => { const { data, response } = await getClient().GET('/api/reservations', { params: { query: params.value }, }) if (response.status !== HttpStatus.OK) { throw new Error(`Unexpected status code received: ${response.status}`) } return data ?? { items: [], total: 0 } }, }) } /** * The backend re-checks the state machine and answers 409 on a transition it * does not allow, so the buttons only have to offer the plausible ones. */ export function useSetReservationStatus() { const queryClient = useQueryClient() return useMutation({ retry: 0, mutationFn: async ({ id, status }: { id: number; status: ReservationStatus }) => { await getClient().PUT('/api/reservations/{id}/status', { params: { path: { id } }, body: { status }, }) return { id, status } }, // A status change moves a reservation from one filtered list to another, so // every page of every list is stale — patching one of them in place would // leave the card in the section it just left. onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }), }) } /** * The public availability calendar: the approved and ongoing reservations over * the week being drawn, without any personal field. No session needed. */ export function useCalendarReservations(window: MaybeRefOrGetter = () => ({})) { const params = computed(() => { const { from, to } = toValue(window) return { from: from?.toISOString(), to: to?.toISOString() } }) return useQuery({ queryKey: computed(() => [...CALENDAR_KEY, params.value]), staleTime: 30 * 1000, placeholderData: keepPreviousData, queryFn: async () => { const { data, response } = await getClient().GET('/api/reservations/calendar', { params: { query: params.value }, }) if (response.status !== HttpStatus.OK) { throw new Error(`Unexpected status code received: ${response.status}`) } return data ?? [] }, }) } /** * One page of what the session user is part of: what they filed, what they were * added to, and what lists their address among the Linka Go accounts — which is * how a reservation reaches somebody who was named before they ever logged in. * * Same filters as the admin listing; who "mine" means is the session's business, * not a parameter. */ export function useMyReservations(filters: MaybeRefOrGetter = () => ({})) { const params = computed(() => toParams(toValue(filters))) return useQuery({ queryKey: computed(() => [...MY_RESERVATIONS_KEY, params.value]), staleTime: 30 * 1000, placeholderData: keepPreviousData, queryFn: async () => { const { data, response } = await getClient().GET('/api/reservations/mine', { params: { query: params.value }, }) if (response.status !== HttpStatus.OK) { throw new Error(`Unexpected status code received: ${response.status}`) } return data ?? { items: [], total: 0 } }, }) } /** * Files a request. The backend fills in the requester and the `requested` * status, so neither is part of the body. */ export function useCreateReservation() { const queryClient = useQueryClient() return useMutation({ retry: 0, mutationFn: async (reservation: NewReservation) => { const { data, response, error } = await getClient().POST('/api/reservations', { body: reservation, }) if (response.status !== HttpStatus.CREATED) { // The handler answers with a plain string, which is what to show throw new Error(typeof error === 'string' ? error : `Unexpected status: ${response.status}`) } return data as Reservation }, // Both lists are separate queries: let them refetch rather than guessing // where the new reservation belongs in their ordering. onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }), }) } /** * Edits the period, the bikes and the Linka Go accounts. Everything else is * left as stored: the backend reads it back rather than taking it from here. */ export function useUpdateReservation() { const queryClient = useQueryClient() return useMutation({ retry: 0, mutationFn: async ({ id, ...edit }: ReservationEdit & { id: number }) => { const { response, error } = await getClient().PUT('/api/reservations/{id}', { params: { path: { id } }, body: edit, }) if (response.status !== HttpStatus.OK) { throw new Error(typeof error === 'string' ? error : `Unexpected status: ${response.status}`) } }, onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }), }) }