cargagep-v2/frontend/src/services/api/reservations.ts
Antoine Pelletier ce88b58003 wip
2026-08-24 17:23:10 +02:00

238 lines
8.5 KiB
TypeScript

/**
* 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 {
Conflict,
NewReservation,
NewReservationBike,
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<ReservationFilters> = () => ({}),
/** Left out, the query runs; the archive dialog only wants it while open */
options: { enabled?: MaybeRefOrGetter<boolean> } = {},
) {
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 }
},
})
}
export const CONFLICTS_KEY = ['reservations', 'conflicts']
/** The question a conflict check asks: a period, and the bikes wanted over it */
export type ConflictProbe = {
/** The reservation being edited, so it does not conflict with itself */
reservation?: number
start_time: string
end_time: string
bikes: NewReservationBike[]
}
/**
* Which of the bikes wanted are already held by an approved or ongoing
* reservation over the same period.
*
* Overlap is a question about the whole table, so the database answers it: the
* browser never sees the bookings it is being compared against. A read behind a
* POST, because each bike may carry a period of its own — more than a query
* string can say.
*/
export function useConflicts(
probe: MaybeRefOrGetter<ConflictProbe | null>,
options: { enabled?: MaybeRefOrGetter<boolean> } = {},
) {
const body = computed(() => toValue(probe))
return useQuery({
queryKey: computed(() => [...CONFLICTS_KEY, body.value]),
enabled: computed(() => (toValue(options.enabled) ?? true) && body.value !== null),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
queryFn: async () => {
const { data, response } = await getClient().POST('/api/reservations/conflicts', {
body: body.value!,
})
if (response.status !== HttpStatus.OK) {
throw new Error(`Unexpected status code received: ${response.status}`)
}
return (data ?? []) as Conflict[]
},
})
}
/**
* 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<Window> = () => ({})) {
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<ReservationFilters> = () => ({})) {
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 }),
})
}