This commit is contained in:
Antoine Pelletier 2026-08-24 15:36:23 +02:00
parent 34fbc2f3de
commit c6460a0a77
20 changed files with 1099 additions and 316 deletions

View file

@ -1,32 +1,40 @@
<script setup lang="ts">
/**
* A quarter-hour picker.
*
* The 96 slots are only put in the tree while the list is open: reka-ui creates
* a component per `SelectItem` as soon as they are rendered, even though it
* shows none of them until then. Two pickers' worth of that is what made the
* edit dialog take a third of a second to appear.
*/
import { computed, ref } from 'vue'
import { Clock } from '@lucide/vue'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
const model = defineModel<string | undefined>()
defineProps<{ id?: string; invalid?: boolean }>()
const SLOT_MINUTES = 15
const slots = Array.from({ length: (24 * 60) / SLOT_MINUTES }, (_, i) => {
const SLOTS = Array.from({ length: (24 * 60) / SLOT_MINUTES }, (_, i) => {
const minutes = i * SLOT_MINUTES
const hh = String(Math.floor(minutes / 60)).padStart(2, '0')
const mm = String(minutes % 60).padStart(2, '0')
return `${hh}:${mm}`
})
const open = ref(false)
const slots = computed(() => (open.value ? SLOTS : []))
</script>
<template>
<Select v-model="model">
<Select v-model="model" v-model:open="open">
<SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full px-3">
<span class="flex min-w-0 items-center gap-2">
<Clock class="size-4 shrink-0 opacity-60" />
<SelectValue :placeholder="$t('reservation.pick-time')" />
<!-- The label is the value itself, so it needs no mounted item to read
it from — which is what lets the list stay empty while closed -->
<span class="truncate">{{ model || $t('reservation.pick-time') }}</span>
</span>
</SelectTrigger>
<SelectContent class="max-h-64">

View file

@ -1,44 +1,38 @@
<script setup lang="ts">
/**
* Reservations, split the way an administrator reads them: what is waiting for
* a decision, what is live or coming, and — behind a toggle — everything that
* is over one way or another.
* a decision, what is live or coming, and — behind a button — the archive.
*
* The two sections are two queries, each asking the backend for its own
* statuses; the archive is a third one, paged, inside its dialog. Nothing here
* ever holds the whole table.
*/
import { computed, ref } from 'vue'
import { Archive } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import ReservationArchiveDialog from './ReservationArchiveDialog.vue'
import ReservationCard from './ReservationCard.vue'
import type { Bike, Reservation } from '@/utils/types'
const props = defineProps<{
reservations: Reservation[]
pending: Reservation[]
active: Reservation[]
bikes: Bike[]
isPending: boolean
isError: boolean
}>()
const showArchived = ref(false)
const archiveOpen = ref(false)
const pending = computed(() => props.reservations.filter((r) => r.status === 'requested'))
const active = computed(() =>
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'),
)
// The three final states of the machine, grouped: nothing more will happen to them
const archived = computed(() =>
props.reservations.filter(
(r) => r.status === 'refused' || r.status === 'cancelled' || r.status === 'archived',
),
)
/** Soonest first: both sections are read as "what comes next" */
const byStart = (list: Reservation[]) =>
[...list].sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime())
/** Soonest first for what is coming, most recent first for the history */
function byStart(list: Reservation[], descending = false) {
return [...list].sort((a, b) => {
const diff = new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
return descending ? -diff : diff
})
}
const pendingSorted = computed(() => byStart(props.pending))
const activeSorted = computed(() => byStart(props.active))
</script>
<template>
@ -68,7 +62,7 @@ function byStart(list: Reservation[], descending = false) {
{{ $t('admin.reservations.pending-empty') }}
</p>
<ReservationCard
v-for="reservation in byStart(pending)"
v-for="reservation in pendingSorted"
:key="reservation.id"
:reservation="reservation"
:bikes="bikes"
@ -84,36 +78,22 @@ function byStart(list: Reservation[], descending = false) {
{{ $t('admin.reservations.active-empty') }}
</p>
<ReservationCard
v-for="reservation in byStart(active)"
v-for="reservation in activeSorted"
:key="reservation.id"
:reservation="reservation"
:bikes="bikes"
/>
</section>
<section class="grid gap-3">
<div>
<Button variant="outline" size="sm" @click="showArchived = !showArchived">
{{
showArchived
? $t('admin.reservations.hide-archived')
: $t('admin.reservations.show-archived', { n: archived.length })
}}
</Button>
</div>
<template v-if="showArchived">
<p v-if="!archived.length" class="text-muted-foreground text-sm">
{{ $t('admin.reservations.archived-empty') }}
</p>
<ReservationCard
v-for="reservation in byStart(archived, true)"
:key="reservation.id"
:reservation="reservation"
:bikes="bikes"
/>
</template>
</section>
<div>
<Button variant="outline" size="sm" @click="archiveOpen = true">
<Archive class="size-4" />
{{ $t('admin.reservations.archive.action') }}
</Button>
</div>
</template>
<ReservationArchiveDialog v-model:open="archiveOpen" :bikes="bikes" />
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,140 @@
<script setup lang="ts">
/**
* The archive: everything a reservation can end up as — refused, cancelled or
* archived — browsed one page at a time.
*
* The search and the paging are query parameters, not a filter over a list this
* component holds: the archive is the part of the table that only ever grows,
* and it is the one place where downloading all of it to hide most of it would
* hurt first.
*/
import { computed, ref, watch } from 'vue'
import { refDebounced } from '@vueuse/core'
import { Search } from '@lucide/vue'
import ReservationCard from './ReservationCard.vue'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { useReservations } from '@/services/api/reservations'
import type { Bike, ReservationStatus } from '@/utils/types'
defineProps<{ bikes: Bike[] }>()
const open = defineModel<boolean>('open', { default: false })
const PAGE_SIZE = 10
/** The three final states of the machine: nothing more will happen to these */
const FINAL: ReservationStatus[] = ['refused', 'cancelled', 'archived']
const search = ref('')
// The backend answers every keystroke otherwise; a third of a second of quiet
// is what turns typing into one query.
const debouncedSearch = refDebounced(search, 300)
const status = ref<'all' | ReservationStatus>('all')
const page = ref(0)
const filters = computed(() => ({
status: status.value === 'all' ? FINAL : [status.value],
q: debouncedSearch.value,
limit: PAGE_SIZE,
offset: page.value * PAGE_SIZE,
}))
// Closed, the archive costs nothing: the query only runs while it is on screen
const { data, isPending, isError, isFetching } = useReservations(filters, { enabled: open })
const items = computed(() => data.value?.items ?? [])
const total = computed(() => data.value?.total ?? 0)
const pages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
// Narrowing the search while on page 4 would otherwise land past the end
watch([debouncedSearch, status], () => (page.value = 0))
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="flex max-h-[85vh] flex-col gap-4 sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{{ $t('admin.reservations.archive.title') }}</DialogTitle>
<DialogDescription>{{ $t('admin.reservations.archive.intro') }}</DialogDescription>
</DialogHeader>
<div class="flex flex-wrap items-center gap-2">
<div class="relative min-w-56 flex-1">
<Search class="text-muted-foreground absolute top-2.5 left-2.5 size-4" />
<Input
v-model="search"
class="pl-8"
type="search"
:placeholder="$t('admin.reservations.archive.search-placeholder')"
:aria-label="$t('admin.reservations.archive.search')"
/>
</div>
<Select v-model="status">
<SelectTrigger class="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ $t('admin.reservations.archive.all-status') }}</SelectItem>
<SelectItem v-for="value in FINAL" :key="value" :value="value">
{{ $t(`reservation.status.${value}`) }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="isPending" class="grid gap-3 overflow-y-auto">
<Skeleton v-for="i in 3" :key="i" class="h-28 w-full" />
</div>
<p v-else-if="isError" class="text-destructive text-sm">
{{ $t('admin.reservations.load-error') }}
</p>
<template v-else>
<p class="text-muted-foreground text-sm" :class="isFetching ? 'opacity-60' : ''">
{{ $t('admin.reservations.archive.count', total) }}
</p>
<p v-if="!items.length" class="text-muted-foreground text-sm">
{{ $t('admin.reservations.archive.empty') }}
</p>
<div v-else class="-mr-2 grid min-h-0 gap-3 overflow-y-auto pr-2">
<ReservationCard
v-for="reservation in items"
:key="reservation.id"
:reservation="reservation"
:bikes="bikes"
/>
</div>
<div v-if="pages > 1" class="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" :disabled="page === 0" @click="page -= 1">
{{ $t('pagination.previous') }}
</Button>
<span class="text-muted-foreground text-sm">
{{ $t('pagination.page', { page: page + 1, pages }) }}
</span>
<Button variant="outline" size="sm" :disabled="page + 1 >= pages" @click="page += 1">
{{ $t('pagination.next') }}
</Button>
</div>
</template>
</DialogContent>
</Dialog>
</template>

View file

@ -66,7 +66,7 @@ function move(status: ReservationStatus) {
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
{{ $t(`reservation.status.${reservation.status}`) }}
</Badge>
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span>
<span class="text-sm font-semibold">{{ unitLabel(reservation.unit) }}</span>
</div>
<div v-if="transitions.length || editable" class="flex flex-wrap gap-2">
@ -87,7 +87,7 @@ function move(status: ReservationStatus) {
</div>
</div>
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes" />
<ReservationDetails class="mt-3 border-t pt-3" :reservation="reservation" :bikes="bikes" />
<ReservationEditDialog
v-if="editable"

View file

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
@ -22,6 +22,7 @@ import {
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { unitLabel, type Bike, type CalendarReservation } from '@/utils/types'
import { addDays, startOfWeek } from '@/utils/week'
// Takes the narrow shape the public calendar receives; a full `Reservation`
// satisfies it too, so the admin page keeps passing its own list unchanged.
@ -35,27 +36,18 @@ const FIRST_HOUR = 0
const LAST_HOUR = 24
const HOUR_HEIGHT = 32
const weekStart = ref(startOfWeek(new Date()))
// Bound by the parent, which fetches exactly the week being drawn; left alone
// the calendar simply keeps the week to itself.
const weekStart = defineModel<Date>('weekStart', { default: () => startOfWeek(new Date()) })
const selectedBike = ref<string>('all')
function startOfWeek(date: Date) {
const start = new Date(date)
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
return start
}
function shiftWeek(weeks: number) {
const next = new Date(weekStart.value)
next.setDate(next.getDate() + weeks * 7)
weekStart.value = next
weekStart.value = addDays(weekStart.value, weeks * 7)
}
const days = computed(() =>
Array.from({ length: 7 }, (_, i) => {
const day = new Date(weekStart.value)
day.setDate(day.getDate() + i)
return day
return addDays(weekStart.value, i)
}),
)
@ -77,11 +69,10 @@ const rangeFormatter = computed(
}),
)
const range = computed(() => {
const end = new Date(weekStart.value)
end.setDate(end.getDate() + 6)
return `${rangeFormatter.value.format(weekStart.value)} – ${rangeFormatter.value.format(end)}`
})
const range = computed(
() =>
`${rangeFormatter.value.format(weekStart.value)} – ${rangeFormatter.value.format(addDays(weekStart.value, 6))}`,
)
const hours = computed(() =>
Array.from({ length: LAST_HOUR - FIRST_HOUR }, (_, i) => FIRST_HOUR + i),
@ -145,7 +136,6 @@ function blocksFor(day: Date, bike: Bike): Block[] {
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>{{ $t('calendar.title') }}</CardTitle>
<CardDescription>{{ $t('calendar.intro') }}</CardDescription>
</div>
<div class="flex items-center gap-2">
@ -267,7 +257,9 @@ function blocksFor(day: Date, bike: Bike): Block[] {
{{ $t(`reservation.status.${openedReservation.status}`) }}
</Badge>
</DialogTitle>
<DialogDescription>{{ unitLabel(openedReservation.unit) }}</DialogDescription>
<DialogDescription class="text-foreground font-semibold">
{{ unitLabel(openedReservation.unit) }}
</DialogDescription>
</DialogHeader>
<ReservationDetails :reservation="openedReservation" :bikes="bikes" />
</DialogScrollContent>

View file

@ -2,14 +2,21 @@
/**
* The read-only body of a reservation, shared by the admin card and the two
* dialogs so the three never drift apart.
*
* Each field is a stacked block — caption above, value below — and the blocks
* flow in two even columns: side-by-side label/value pairs wrapped badly on a
* phone, and a single column made the cards far taller than they needed to be.
*/
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Bike, ReservationLike } from '@/utils/types'
type Field = 'period' | 'bikes' | 'telegram' | 'people' | 'linka' | 'reason'
/** Past this many Linka Go accounts the list is folded behind a button */
const LINKA_SHOWN = 5
const props = withDefaults(
defineProps<{
reservation: ReservationLike
@ -26,52 +33,107 @@ function shows(field: Field) {
const { locale } = useI18n()
/** Names when we know the bike, `#id` when the list has not loaded yet */
const bikeNames = computed(() =>
props.reservation.bikes
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
.join(', '),
props.reservation.bikes.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`),
)
const formatter = computed(
// The e-mail address is already on the reservation as a Linka Go account or in
// the admin's own directory: the name is what tells the people apart here.
const peopleNames = computed(() =>
(props.reservation.users ?? []).map((user) => `${user.firstname} ${user.name}`),
)
const linkaEmails = computed(() => props.reservation.linka_emails ?? [])
const linkaExpanded = ref(false)
const linkaShown = computed(() =>
linkaExpanded.value ? linkaEmails.value : linkaEmails.value.slice(0, LINKA_SHOWN),
)
const linkaHidden = computed(() => Math.max(0, linkaEmails.value.length - LINKA_SHOWN))
const dateFormatter = computed(
() =>
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
dateStyle: 'short',
dateStyle: 'medium',
timeStyle: 'short',
}),
)
function format(iso: string) {
return formatter.value.format(new Date(iso))
return dateFormatter.value.format(new Date(iso))
}
</script>
<template>
<dl class="grid gap-1 text-sm">
<div v-if="shows('period')" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.period') }}</dt>
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd>
</div>
<div v-if="shows('bikes')" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.bikes') }}</dt>
<dd>{{ bikeNames }}</dd>
</div>
<div v-if="shows('telegram') && reservation.telegram" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.telegram') }}</dt>
<dd class="truncate">{{ reservation.telegram }}</dd>
</div>
<div v-if="shows('people') && reservation.users?.length" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.people') }}</dt>
<dd class="min-w-0 break-words">
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
<div v-if="shows('period')" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.period') }}
</dt>
<dd class="mt-0.5 flex flex-wrap items-center gap-x-2 font-medium tabular-nums">
<span>{{ format(reservation.start_time) }}</span>
<span class="text-muted-foreground" aria-hidden="true">→</span>
<span>{{ format(reservation.end_time) }}</span>
</dd>
</div>
<div v-if="shows('linka') && reservation.linka_emails?.length" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.linka') }}</dt>
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
<div v-if="shows('bikes') && bikeNames.length" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.bikes') }}
</dt>
<dd class="mt-0.5 flex flex-wrap gap-1">
<span
v-for="name in bikeNames"
:key="name"
class="bg-muted rounded-md px-2 py-0.5 font-medium tabular-nums"
>
{{ name }}
</span>
</dd>
</div>
<div v-if="shows('reason') && reservation.description" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.reason') }}</dt>
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
<div v-if="shows('telegram') && reservation.telegram" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.telegram') }}
</dt>
<dd class="mt-0.5 font-medium break-all">{{ reservation.telegram }}</dd>
</div>
<div v-if="shows('people') && peopleNames.length" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.people') }}
</dt>
<dd class="mt-0.5 break-words">{{ peopleNames.join(', ') }}</dd>
</div>
<div v-if="shows('linka') && linkaEmails.length" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.linka') }}
</dt>
<dd class="mt-0.5">
<ul class="grid gap-0.5">
<li v-for="email in linkaShown" :key="email" class="break-all">{{ email }}</li>
</ul>
<button
v-if="linkaHidden"
type="button"
class="text-primary mt-1 cursor-pointer text-xs font-medium hover:underline"
@click="linkaExpanded = !linkaExpanded"
>
{{
linkaExpanded
? $t('reservation.details.show-less')
: $t('reservation.details.show-more', { n: linkaHidden })
}}
</button>
</dd>
</div>
<div v-if="shows('reason') && reservation.description" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.reason') }}
</dt>
<dd class="mt-0.5 break-words whitespace-pre-line">{{ reservation.description }}</dd>
</div>
</dl>
</template>

View file

@ -1,4 +1,9 @@
<script setup lang="ts">
/**
* The shadcn dialog, with the enter and exit animations taken out on purpose:
* a dialog is a reply to a click, and 200ms of zoom and fade in front of it
* reads as the app being slow. The overlay appears with it, in one paint.
*/
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
@ -22,14 +27,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80" />
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
props.class,
)
"

View file

@ -403,24 +403,51 @@ export interface paths {
path?: never
cookie?: never
}
/** Get every reservation */
/**
* Get a page of the reservations, filtered
* @description The filtering, the text search and the paging all happen in the database: no caller ever receives the whole table.
*/
get: {
parameters: {
query?: never
query?: {
/** @description Keeps only what overlaps the window */
from?: string | null
limit?: number | null
offset?: number | null
/**
* @description Free text: the unit, the telegram handle, the people, the Linka Go
* addresses, the reason, or the number of a reservation
*/
q?: string | null
/** @description Comma-separated statuses (`requested,approved`); left out means any */
status?: string | null
to?: string | null
}
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/**
* @description One page of a listing, with the size of the whole so the caller can page
* through it without asking twice.
*/
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Reservation'][]
'application/json': components['schemas']['ReservationPage']
}
}
/** @description A status in the filter is not a known one */
400: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
@ -515,26 +542,50 @@ export interface paths {
cookie?: never
}
/**
* Get the reservations the session user is part of
* @description An address listed among the Linka Go accounts is enough, which is how somebody added before they ever logged in finds the reservation waiting for them.
* Get a page of the reservations the session user is part of
* @description Takes the same filters as the listing, and answers the same page. An address listed among the Linka Go accounts is enough to be part of a reservation, which is how somebody added before they ever logged in finds the one waiting for them.
*/
get: {
parameters: {
query?: never
query?: {
/** @description Keeps only what overlaps the window */
from?: string | null
limit?: number | null
offset?: number | null
/**
* @description Free text: the unit, the telegram handle, the people, the Linka Go
* addresses, the reason, or the number of a reservation
*/
q?: string | null
/** @description Comma-separated statuses (`requested,approved`); left out means any */
status?: string | null
to?: string | null
}
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/**
* @description One page of a listing, with the size of the whole so the caller can page
* through it without asking twice.
*/
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Reservation'][]
'application/json': components['schemas']['ReservationPage']
}
}
/** @description A status in the filter is not a known one */
400: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
@ -561,11 +612,14 @@ export interface paths {
}
/**
* Get the approved and ongoing reservations, for the public calendar
* @description No session needed, and no personal field travels: the telegram handle, the people, the Linka Go addresses and the reason are left out.
* @description Give `from` and `to` to read one week rather than the whole table. No session needed, and no personal field travels: the telegram handle, the people, the Linka Go addresses and the reason are left out.
*/
get: {
parameters: {
query?: never
query?: {
from?: string | null
to?: string | null
}
header?: never
path?: never
cookie?: never
@ -793,6 +847,17 @@ export interface components {
status: components['schemas']['ReservationStatus']
unit: components['schemas']['ReservationUnit']
}
/**
* @description The availability calendar, open to everybody: when the bikes are taken and by
* which association, with nothing personal attached.
* The window a calendar draws, so only that window is read.
*/
CalendarWindow: {
/** Format: date-time */
from?: string | null
/** Format: date-time */
to?: string | null
}
DevUser: {
admin: boolean
email: string
@ -875,6 +940,46 @@ export interface components {
/** Format: date-time */
start_time: string
}
/**
* @description One page of a listing, with the size of the whole so the caller can page
* through it without asking twice.
*/
ReservationPage: {
items: components['schemas']['Reservation'][]
/**
* Format: int64
* @description How many reservations match the query, ignoring `limit` and `offset`
*/
total: number
}
/**
* @description The listing filters, the way a query string carries them.
*
* Everything is optional and everything narrows, so the same route serves the
* three sections of the admin page and the archive browser. The alternative —
* handing the whole table to the browser and filtering it there — stops
* working the day the archive is a few thousand rows long.
*/
ReservationSearchParams: {
/**
* Format: date-time
* @description Keeps only what overlaps the window
*/
from?: string | null
/** Format: int64 */
limit?: number | null
/** Format: int64 */
offset?: number | null
/**
* @description Free text: the unit, the telegram handle, the people, the Linka Go
* addresses, the reason, or the number of a reservation
*/
q?: string | null
/** @description Comma-separated statuses (`requested,approved`); left out means any */
status?: string | null
/** Format: date-time */
to?: string | null
}
/** @enum {string} */
ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived'
/**

View file

@ -70,6 +70,8 @@ reservation:
people: People
linka: Linka Go accounts
reason: Reason
show-more: 'Show {n} more'
show-less: Show less
edit:
action: Edit
title: 'Reservation #{id}'
@ -106,10 +108,14 @@ login:
dev: Development login
not-allowed: Your account is not allowed to use this application.
error: The login failed.
pagination:
page: 'Page {page} of {pages}'
previous: Previous
next: Next
calendar:
load-error: Unable to load the calendar.
title: Calendar per cargobike
intro: Approved and ongoing reservations are shown in the calendar.
bike: Cargobike
all-bikes: All
previous: Week
@ -144,9 +150,18 @@ admin:
pending-empty: No reservation request for now.
active: Reservations (ongoing and upcoming)
active-empty: No ongoing or upcoming reservation.
show-archived: 'Show archived requests ({n})'
hide-archived: Hide archived requests
archived-empty: No archived request.
archive:
action: Browse the archive
title: Archived reservations
intro: >-
The refused, cancelled and archived reservations. The search covers the
association, the telegram handle, the people, the Linka Go accounts, the
reason and the reservation number.
search: Search the archive
search-placeholder: 'Association, person, e-mail, #number…'
all-status: Any status
count: No result | 1 reservation | {count} reservations
empty: No reservation matches this search.
load-error: Unable to load the reservations.
error: The status change failed.
action:

View file

@ -71,6 +71,8 @@ reservation:
people: Personnes
linka: Comptes Linka Go
reason: Raison
show-more: 'Afficher {n} de plus'
show-less: Réduire
edit:
action: Modifier
title: 'Réservation #{id}'
@ -107,10 +109,14 @@ login:
dev: Connexion de développement
not-allowed: Votre compte n'est pas autorisé à accéder à cette application.
error: La connexion a échoué.
pagination:
page: 'Page {page} sur {pages}'
previous: Précédent
next: Suivant
calendar:
load-error: Impossible de charger le calendrier.
title: Calendrier par cargobike
intro: Les réservations validées et en cours sont affichées dans le calendrier.
bike: Cargobike
all-bikes: Tous
previous: Sem.
@ -145,9 +151,18 @@ admin:
pending-empty: Aucune demande de réservation pour le moment.
active: Réservations (en cours et à venir)
active-empty: Aucune réservation en cours ou à venir.
show-archived: 'Voir les demandes archivées ({n})'
hide-archived: Masquer les demandes archivées
archived-empty: Aucune demande archivée.
archive:
action: Parcourir les archives
title: Réservations archivées
intro: >-
Les réservations refusées, annulées et archivées. La recherche porte sur
l'association, le Telegram, les personnes, les comptes Linka Go, la raison
et le numéro de réservation.
search: Rechercher dans les archives
search-placeholder: 'Association, personne, e-mail, #numéro…'
all-status: Tous les statuts
count: Aucun résultat | 1 réservation | {count} réservations
empty: Aucune réservation ne correspond à cette recherche.
load-error: Impossible de charger les réservations.
error: Le changement de statut a échoué.
action:

View file

@ -1,8 +1,14 @@
/**
* Reservations. Filing a request only needs a session; reading the whole list is
* an admin action, so `useReservations` is only ever mounted on /admin.
* 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 { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
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'
@ -12,16 +18,54 @@ export const RESERVATIONS_KEY = ['reservations']
export const MY_RESERVATIONS_KEY = ['reservations', 'mine']
export const CALENDAR_KEY = ['reservations', 'calendar']
export function useReservations() {
/** 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: RESERVATIONS_KEY,
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')
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 ?? []
return data ?? { items: [], total: 0 }
},
})
}
@ -41,28 +85,30 @@ export function useSetReservationStatus() {
})
return { id, status }
},
onSuccess: ({ id, status }) => {
// Patch the cache so the card moves section immediately
queryClient.setQueryData<Reservation[]>(RESERVATIONS_KEY, (reservations) =>
reservations?.map((r) => (r.id === id ? { ...r, status } : r)),
)
// "My reservations" holds its own list, and a status change moves a card
// there too
queryClient.invalidateQueries({ queryKey: MY_RESERVATIONS_KEY })
},
// 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,
* without any personal field. No session needed.
* The public availability calendar: the approved and ongoing reservations over
* the week being drawn, without any personal field. No session needed.
*/
export function useCalendarReservations() {
export function useCalendarReservations(window: MaybeRefOrGetter<Window> = () => ({})) {
const params = computed(() => {
const { from, to } = toValue(window)
return { from: from?.toISOString(), to: to?.toISOString() }
})
return useQuery({
queryKey: CALENDAR_KEY,
queryKey: computed(() => [...CALENDAR_KEY, params.value]),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
queryFn: async () => {
const { data, response } = await getClient().GET('/api/reservations/calendar')
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}`)
}
@ -72,20 +118,27 @@ export function useCalendarReservations() {
}
/**
* Everything 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.
* 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() {
export function useMyReservations(filters: MaybeRefOrGetter<ReservationFilters> = () => ({})) {
const params = computed(() => toParams(toValue(filters)))
return useQuery({
queryKey: MY_RESERVATIONS_KEY,
queryKey: computed(() => [...MY_RESERVATIONS_KEY, params.value]),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
queryFn: async () => {
const { data, response } = await getClient().GET('/api/reservations/mine')
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 ?? []
return data ?? { items: [], total: 0 }
},
})
}

View file

@ -0,0 +1,19 @@
/**
* The week a calendar draws, shared by the component and by whoever fetches the
* reservations for it: the fetch is narrowed to the window on the backend, so
* both sides have to agree on where a week starts.
*/
/** Monday, at midnight local time */
export function startOfWeek(date: Date): Date {
const start = new Date(date)
start.setHours(0, 0, 0, 0)
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
return start
}
export function addDays(date: Date, days: number): Date {
const next = new Date(date)
next.setDate(next.getDate() + days)
return next
}

View file

@ -1,9 +1,12 @@
<script setup lang="ts">
/**
* Administration: the fleet, the reservations, and the week planning.
* The three sections share one fetch of the reservations.
*
* Three narrow queries rather than one wide one: the pending requests, what is
* live or coming, and the week the calendar is showing. Each asks the backend
* for its own slice, so none of them grows with the archive.
*/
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { RefreshCw } from '@lucide/vue'
import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
@ -13,21 +16,48 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useBikes } from '@/services/api/bikes'
import { useReservations } from '@/services/api/reservations'
import { addDays, startOfWeek } from '@/utils/week'
/** What is worth showing at once; past that, the archive dialog is the way in */
const SECTION_LIMIT = 200
const { data: bikes } = useBikes()
const {
data: reservations,
isPending,
isError,
isFetching,
refetch,
dataUpdatedAt,
} = useReservations()
const list = computed(() => reservations.value ?? [])
const pending = useReservations(() => ({ status: ['requested'], limit: SECTION_LIMIT }))
const active = useReservations(() => ({
status: ['approved', 'ongoing'],
limit: SECTION_LIMIT,
}))
// The calendar draws one week, so one week is what is fetched for it — and the
// bound `weekStart` is what moves the window when the admin changes week.
const weekStart = ref(startOfWeek(new Date()))
const week = useReservations(() => ({
status: ['approved', 'ongoing'],
from: weekStart.value,
to: addDays(weekStart.value, 7),
limit: SECTION_LIMIT,
}))
const fleet = computed(() => bikes.value ?? [])
const pendingList = computed(() => pending.data.value?.items ?? [])
const activeList = computed(() => active.data.value?.items ?? [])
const weekList = computed(() => week.data.value?.items ?? [])
const isPending = computed(() => pending.isPending.value || active.isPending.value)
const isError = computed(() => pending.isError.value || active.isError.value)
const isFetching = computed(
() => pending.isFetching.value || active.isFetching.value || week.isFetching.value,
)
function refresh() {
pending.refetch()
active.refetch()
week.refetch()
}
const updatedAt = computed(() =>
dataUpdatedAt.value ? new Date(dataUpdatedAt.value).toLocaleTimeString() : '—',
active.dataUpdatedAt.value ? new Date(active.dataUpdatedAt.value).toLocaleTimeString() : '—',
)
</script>
@ -39,7 +69,7 @@ const updatedAt = computed(() =>
<CardDescription>{{ $t('admin.intro') }}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap items-center gap-3">
<Button size="sm" :disabled="isFetching" @click="refetch()">
<Button size="sm" :disabled="isFetching" @click="refresh()">
<RefreshCw class="size-4" :class="isFetching ? 'animate-spin' : ''" />
{{ $t('admin.refresh') }}
</Button>
@ -49,16 +79,22 @@ const updatedAt = computed(() =>
</CardContent>
</Card>
<BikeFleet class="min-w-0" :reservations="list" />
<BikeFleet class="min-w-0" :reservations="activeList" />
<ReservationAdmin
class="min-w-0"
:reservations="list"
:pending="pendingList"
:active="activeList"
:bikes="fleet"
:is-pending="isPending"
:is-error="isError"
/>
<BikeCalendar class="min-w-0" :reservations="list" :bikes="fleet" />
<BikeCalendar
v-model:week-start="weekStart"
class="min-w-0"
:reservations="weekList"
:bikes="fleet"
/>
</div>
</template>

View file

@ -4,12 +4,22 @@
* endpoint that carries no personal field. Anybody can look up when the bikes
* are taken without being signed in.
*/
import { ref } from 'vue'
import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
import { Skeleton } from '@/components/ui/skeleton'
import { useBikes } from '@/services/api/bikes'
import { useCalendarReservations } from '@/services/api/reservations'
import { addDays, startOfWeek } from '@/utils/week'
const { data: reservations, isPending, isError } = useCalendarReservations()
// The week on screen is the week fetched: changing week refetches that one
// rather than shipping every reservation to the browser.
const weekStart = ref(startOfWeek(new Date()))
const {
data: reservations,
isPending,
isError,
} = useCalendarReservations(() => ({ from: weekStart.value, to: addDays(weekStart.value, 7) }))
const { data: bikes, isPending: bikesPending } = useBikes()
</script>
@ -24,6 +34,11 @@ const { data: bikes, isPending: bikesPending } = useBikes()
{{ $t('calendar.load-error') }}
</p>
<BikeCalendar v-else :reservations="reservations ?? []" :bikes="bikes ?? []" />
<BikeCalendar
v-else
v-model:week-start="weekStart"
:reservations="reservations ?? []"
:bikes="bikes ?? []"
/>
</div>
</template>

View file

@ -6,8 +6,11 @@
* What can still be changed depends on the status, and the backend applies the
* same rule: a request is fully editable, an approved or ongoing reservation
* only accepts a change of Linka Go accounts, and a final one is read-only.
*
* Three queries rather than one: the two live sections are short by nature, and
* the history — the part that keeps growing — is paged by the backend.
*/
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue'
@ -20,7 +23,24 @@ import { useBikes } from '@/services/api/bikes'
import { useMyReservations } from '@/services/api/reservations'
import { unitLabel, type Reservation, type ReservationStatus } from '@/utils/types'
const { data: reservations, isPending, isError } = useMyReservations()
/** How many past reservations one page of the history holds */
const PAGE_SIZE = 5
/** What is worth showing at once in the two live sections */
const SECTION_LIMIT = 50
const requested = useMyReservations(() => ({ status: ['requested'], limit: SECTION_LIMIT }))
const active = useMyReservations(() => ({
status: ['approved', 'ongoing'],
limit: SECTION_LIMIT,
}))
const pastPage = ref(0)
const past = useMyReservations(() => ({
status: ['refused', 'cancelled', 'archived'],
limit: PAGE_SIZE,
offset: pastPage.value * PAGE_SIZE,
}))
const { data: bikes } = useBikes()
const BADGE_CLASS: Record<ReservationStatus, string> = {
@ -32,25 +52,33 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
archived: 'bg-muted text-muted-foreground',
}
const SECTIONS = [
{ key: 'requested', statuses: ['requested'] },
{ key: 'active', statuses: ['approved', 'ongoing'] },
{ key: 'past', statuses: ['refused', 'cancelled', 'archived'] },
] as const
const sections = computed(() => [
{ key: 'requested', reservations: requested.data.value?.items ?? [] },
{ key: 'active', reservations: active.data.value?.items ?? [] },
{ key: 'past', reservations: past.data.value?.items ?? [] },
])
const sections = computed(() =>
SECTIONS.map((section) => ({
key: section.key,
reservations: (reservations.value ?? []).filter((r) =>
(section.statuses as readonly string[]).includes(r.status),
),
})),
const isPending = computed(
() => requested.isPending.value || active.isPending.value || past.isPending.value,
)
const isError = computed(
() => requested.isError.value || active.isError.value || past.isError.value,
)
const empty = computed(
() => !pastPage.value && sections.value.every((section) => !section.reservations.length),
)
const pastTotal = computed(() => past.data.value?.total ?? 0)
const pastPages = computed(() => Math.max(1, Math.ceil(pastTotal.value / PAGE_SIZE)))
// A page that no longer exists — the last one emptied by a status change, say
watch(pastPages, (pages) => {
if (pastPage.value >= pages) pastPage.value = pages - 1
})
/** Which reservation the edit dialog is on, by id */
const editing = ref<number | null>(null)
const editingReservation = computed(() =>
(reservations.value ?? []).find((r) => r.id === editing.value),
sections.value.flatMap((section) => section.reservations).find((r) => r.id === editing.value),
)
const dialogOpen = computed({
get: () => editing.value !== null,
@ -85,7 +113,7 @@ function editableEmails(reservation: Reservation) {
{{ $t('my-reservations.load-error') }}
</p>
<p v-else-if="!reservations?.length" class="text-muted-foreground text-sm">
<p v-else-if="empty" class="text-muted-foreground text-sm">
{{ $t('my-reservations.empty') }}
</p>
@ -94,7 +122,7 @@ function editableEmails(reservation: Reservation) {
<h3 class="text-base font-semibold">
{{ $t(`my-reservations.${section.key}`) }}
<span class="text-muted-foreground font-normal">
({{ section.reservations.length }})
({{ section.key === 'past' ? pastTotal : section.reservations.length }})
</span>
</h3>
@ -113,9 +141,7 @@ function editableEmails(reservation: Reservation) {
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
{{ $t(`reservation.status.${reservation.status}`) }}
</Badge>
<span class="text-muted-foreground text-sm">
{{ unitLabel(reservation.unit) }}
</span>
<span class="text-sm font-semibold">{{ unitLabel(reservation.unit) }}</span>
</div>
<Button
@ -133,7 +159,32 @@ function editableEmails(reservation: Reservation) {
</Button>
</div>
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes ?? []" />
<ReservationDetails
class="mt-3 border-t pt-3"
:reservation="reservation"
:bikes="bikes ?? []"
/>
</div>
<!-- The history is the one section that keeps growing -->
<div
v-if="section.key === 'past' && pastPages > 1"
class="flex items-center justify-between gap-2"
>
<Button variant="outline" size="sm" :disabled="pastPage === 0" @click="pastPage -= 1">
{{ $t('pagination.previous') }}
</Button>
<span class="text-muted-foreground text-sm">
{{ $t('pagination.page', { page: pastPage + 1, pages: pastPages }) }}
</span>
<Button
variant="outline"
size="sm"
:disabled="pastPage + 1 >= pastPages"
@click="pastPage += 1"
>
{{ $t('pagination.next') }}
</Button>
</div>
</section>
</template>

View file

@ -13,7 +13,11 @@ use aide::{
},
transform::TransformOperation,
};
use axum::{Json, extract::Path, http::StatusCode};
use axum::{
Json,
extract::{Path, Query},
http::StatusCode,
};
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::Deserialize;
@ -28,8 +32,8 @@ use crate::{
models::{
bike::BikeId,
reservation::{
CalendarReservation, NewReservation, Reservation, ReservationEdit,
ReservationStatus,
CalendarReservation, NewReservation, Reservation, ReservationEdit, ReservationPage,
ReservationQuery, ReservationStatus,
},
},
},
@ -58,19 +62,73 @@ pub fn routes() -> ApiRouter {
.api_route("/{id}/status", put_with(set_status, set_status_docs))
}
/// The listing filters, the way a query string carries them.
///
/// Everything is optional and everything narrows, so the same route serves the
/// three sections of the admin page and the archive browser. The alternative —
/// handing the whole table to the browser and filtering it there — stops
/// working the day the archive is a few thousand rows long.
#[derive(Debug, Deserialize, JsonSchema)]
struct ReservationSearchParams {
/// Comma-separated statuses (`requested,approved`); left out means any
status: Option<String>,
/// Free text: the unit, the telegram handle, the people, the Linka Go
/// addresses, the reason, or the number of a reservation
q: Option<String>,
/// Keeps only what overlaps the window
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
limit: Option<i64>,
offset: Option<i64>,
}
impl TryFrom<ReservationSearchParams> for ReservationQuery {
type Error = String;
fn try_from(params: ReservationSearchParams) -> Result<Self, Self::Error> {
let statuses = params
.status
.as_deref()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|status| !status.is_empty())
.map(|status| status.parse::<ReservationStatus>())
.collect::<Result<Vec<_>, _>>()
.map_err(|err| err.to_string())?;
Ok(ReservationQuery {
statuses,
// Set by the handler that needs it, from the session
involving: None,
search: params.q,
from: params.from,
to: params.to,
limit: params.limit.unwrap_or(ReservationQuery::DEFAULT_LIMIT),
offset: params.offset.unwrap_or(0),
})
}
}
#[axum::debug_handler]
async fn get_reservations(
ac: AppController,
) -> Result<Json<Vec<Reservation>>, (StatusCode, String)> {
match admin(ac)?.get_reservations().await {
Ok(reservations) => Ok(Json(reservations)),
Query(params): Query<ReservationSearchParams>,
) -> Result<Json<ReservationPage>, (StatusCode, String)> {
let query = ReservationQuery::try_from(params).map_err(|err| (StatusCode::BAD_REQUEST, err))?;
match admin(ac)?.search_reservations(query).await {
Ok(page) => Ok(Json(page)),
Err(err) => unexpected_error("get_reservations", err),
}
}
fn get_reservations_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("Get every reservation")
.summary("Get a page of the reservations, filtered")
.description(
"The filtering, the text search and the paging all happen in the database: no caller ever receives the whole table.",
)
.response_with::<400, (), _>(desc("A status in the filter is not a known one"))
.response_with::<403, (), _>(admin_desc)
}
@ -117,11 +175,19 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
/// The availability calendar, open to everybody: when the bikes are taken and by
/// which association, with nothing personal attached.
/// The window a calendar draws, so only that window is read.
#[derive(Debug, Deserialize, JsonSchema)]
struct CalendarWindow {
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
}
#[axum::debug_handler]
async fn get_calendar_reservations(
aac: AnonAppController,
Query(window): Query<CalendarWindow>,
) -> Result<Json<Vec<CalendarReservation>>, (StatusCode, String)> {
match aac.get_calendar_reservations().await {
match aac.get_calendar_reservations(window.from, window.to).await {
Ok(reservations) => Ok(Json(reservations)),
Err(err) => unexpected_error("get_calendar_reservations", err),
}
@ -131,7 +197,8 @@ fn get_calendar_reservations_docs(op: TransformOperation) -> TransformOperation
op.tag("Reservations")
.summary("Get the approved and ongoing reservations, for the public calendar")
.description(
"No session needed, and no personal field travels: the telegram handle, \
"Give `from` and `to` to read one week rather than the whole table. No session \
needed, and no personal field travels: the telegram handle, \
the people, the Linka Go addresses and the reason are left out.",
)
}
@ -141,21 +208,25 @@ fn get_calendar_reservations_docs(op: TransformOperation) -> TransformOperation
#[axum::debug_handler]
async fn get_my_reservations(
ac: AppController,
) -> Result<Json<Vec<Reservation>>, (StatusCode, String)> {
match ac.get_my_reservations().await {
Ok(reservations) => Ok(Json(reservations)),
Query(params): Query<ReservationSearchParams>,
) -> Result<Json<ReservationPage>, (StatusCode, String)> {
let query = ReservationQuery::try_from(params).map_err(|err| (StatusCode::BAD_REQUEST, err))?;
match ac.get_my_reservations(query).await {
Ok(page) => Ok(Json(page)),
Err(err) => unexpected_error("get_my_reservations", err),
}
}
fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("Get the reservations the session user is part of")
.summary("Get a page of the reservations the session user is part of")
.description(
"An address listed among the Linka Go accounts is enough, which is how \
somebody added before they ever logged in finds the reservation waiting \
for them.",
"Takes the same filters as the listing, and answers the same page. An \
address listed among the Linka Go accounts is enough to be part of a \
reservation, which is how somebody added before they ever logged in \
finds the one waiting for them.",
)
.response_with::<400, (), _>(desc("A status in the filter is not a known one"))
}
/// Only what the admin page lets somebody change. The unit, the requester, the

View file

@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use thiserror::Error;
use crate::core::{
@ -5,8 +6,8 @@ use crate::core::{
models::{
bike::BikeStatus,
reservation::{
CalendarReservation, NewReservation, NewReservationUnit, Reservation, ReservationEdit,
ReservationId, ReservationStatus,
CalendarReservation, Involvement, NewReservation, NewReservationUnit, Reservation,
ReservationEdit, ReservationId, ReservationPage, ReservationQuery, ReservationStatus,
},
unit::UnitId,
},
@ -15,8 +16,13 @@ use crate::core::{
/// Reading the reservations needs no session: the calendar is public.
impl AnonAppController {
pub async fn get_reservations(&self) -> Result<Vec<Reservation>, ControllerError> {
self.db.get_reservations().await.map_err(Into::into)
/// One page of the reservations matching `query`. Reading the whole list is
/// an admin action, which the API layer is what enforces.
pub async fn search_reservations(
&self,
query: ReservationQuery,
) -> Result<ReservationPage, ControllerError> {
self.db.search_reservations(query).await.map_err(Into::into)
}
pub async fn get_unit_reservations(
@ -33,35 +39,50 @@ impl AnonAppController {
self.db.get_reservation(id).await.map_err(Into::into)
}
/// What the availability calendar shows: the reservations that actually hold
/// a bike, stripped of everything personal. Reading it needs no session.
/// What the availability calendar shows: the reservations that actually
/// hold a bike over the window asked for, stripped of everything personal.
/// Reading it needs no session.
///
/// The window is what keeps this cheap: a calendar draws one week, so one
/// week is what the database returns.
pub async fn get_calendar_reservations(
&self,
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
) -> Result<Vec<CalendarReservation>, ControllerError> {
Ok(self
let page = self
.db
.get_reservations()
.await?
.into_iter()
.filter(|reservation| {
matches!(
reservation.status,
ReservationStatus::Approved | ReservationStatus::Ongoing
)
.search_reservations(ReservationQuery {
statuses: vec![ReservationStatus::Approved, ReservationStatus::Ongoing],
from,
to,
limit: ReservationQuery::MAX_LIMIT,
..Default::default()
})
.map(Into::into)
.collect())
.await?;
Ok(page.items.into_iter().map(Into::into).collect())
}
}
/// Filing a request is done in one's own name: the requester is the session
/// user, never something the client gets to choose.
impl AppController {
/// Everything the session user is part of, whichever way.
pub async fn get_my_reservations(&self) -> Result<Vec<Reservation>, ControllerError> {
/// One page of what the session user is part of, whichever way. The
/// involvement is taken from the session, never from the query: this is the
/// route on which somebody could otherwise read another person's list.
pub async fn get_my_reservations(
&self,
query: ReservationQuery,
) -> Result<ReservationPage, ControllerError> {
let user = self.user();
self.db
.get_involved_reservations(user.id, &user.email)
.search_reservations(ReservationQuery {
involving: Some(Involvement {
user: user.id,
email: user.email.clone(),
}),
..query
})
.await
.map_err(Into::into)
}

View file

@ -37,12 +37,48 @@ impl ReservationStatus {
)
}
/// The spelling shared by the JSON, the Postgres enum and the query
/// parameters — one name, so no mapping table has to be kept in step.
pub fn as_str(self) -> &'static str {
use ReservationStatus::*;
match self {
Requested => "requested",
Refused => "refused",
Approved => "approved",
Cancelled => "cancelled",
Ongoing => "ongoing",
Archived => "archived",
}
}
pub fn is_final(self) -> bool {
use ReservationStatus::*;
matches!(self, Refused | Cancelled | Archived)
}
}
impl std::str::FromStr for ReservationStatus {
type Err = UnknownStatus;
fn from_str(value: &str) -> Result<Self, Self::Err> {
use ReservationStatus::*;
match value {
"requested" => Ok(Requested),
"refused" => Ok(Refused),
"approved" => Ok(Approved),
"cancelled" => Ok(Cancelled),
"ongoing" => Ok(Ongoing),
"archived" => Ok(Archived),
other => Err(UnknownStatus(other.to_owned())),
}
}
}
/// A status spelling that is not one of the six
#[derive(Debug, thiserror::Error)]
#[error("unknown reservation status: {0}")]
pub struct UnknownStatus(pub String);
/// The unit a reservation is filed for.
///
/// Either one we know — a Whiskey group, with its row — or a plain name the
@ -148,6 +184,83 @@ impl From<Reservation> for CalendarReservation {
}
}
/// The three ways of being part of a reservation: having filed it, being named
/// on it, or having one's address among the Linka Go accounts.
///
/// The address is what binds a reservation to somebody who had never logged in
/// when it was filed — nothing is written at login time, the match is made when
/// the list is read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Involvement {
pub user: UserId,
pub email: String,
}
/// What a listing asks the database for.
///
/// Everything here narrows: an empty `statuses` means any status, `search` left
/// out means no text filter. It exists so that a page showing a handful of rows
/// never receives the whole table — the archive alone will outgrow that — and so
/// that the narrowing happens once, in the index, instead of in every browser.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReservationQuery {
pub statuses: Vec<ReservationStatus>,
/// Keeps only the reservations one person is part of. `None` is "anybody's",
/// which is what the admin listing asks for.
pub involving: Option<Involvement>,
/// Free text, matched against the unit, the telegram handle, the people on
/// the reservation, the Linka Go addresses, the reason and the id
pub search: Option<String>,
/// Keeps only the reservations overlapping the window
pub from: Option<DateTime<Utc>>,
pub to: Option<DateTime<Utc>>,
pub limit: i64,
pub offset: i64,
}
impl ReservationQuery {
pub const DEFAULT_LIMIT: i64 = 20;
/// A page nobody can read past. It caps what one request may cost, whatever
/// the client asks for.
pub const MAX_LIMIT: i64 = 200;
/// The same query with the parts a client controls brought back into range:
/// a limit outside `1..=MAX_LIMIT` is clamped, a negative offset is zero,
/// and a blank search is no search at all.
pub fn sanitised(mut self) -> Self {
self.limit = self.limit.clamp(1, Self::MAX_LIMIT);
self.offset = self.offset.max(0);
self.search = self
.search
.map(|search| search.trim().to_owned())
.filter(|search| !search.is_empty());
self
}
}
impl Default for ReservationQuery {
fn default() -> Self {
ReservationQuery {
statuses: Vec::new(),
involving: None,
search: None,
from: None,
to: None,
limit: Self::DEFAULT_LIMIT,
offset: 0,
}
}
}
/// One page of a listing, with the size of the whole so the caller can page
/// through it without asking twice.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct ReservationPage {
pub items: Vec<Reservation>,
/// How many reservations match the query, ignoring `limit` and `offset`
pub total: i64,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct NewReservation {
pub unit: NewReservationUnit,
@ -218,6 +331,18 @@ impl ReservationEdit {
mod tests {
use super::*;
#[test]
fn every_status_survives_a_round_trip_through_its_name() {
use ReservationStatus::*;
for status in [Requested, Refused, Approved, Cancelled, Ongoing, Archived] {
assert_eq!(
status.as_str().parse::<ReservationStatus>().unwrap(),
status
);
}
assert!("started".parse::<ReservationStatus>().is_err());
}
#[test]
fn telegram_handles_match_the_database_constraint() {
assert!(telegram_valid("@milan_hyenne"));

View file

@ -3,7 +3,8 @@ use async_trait::async_trait;
use crate::core::{
models::{
reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationPage,
ReservationQuery, ReservationStatus,
},
unit::UnitId,
user::UserId,
@ -13,23 +14,22 @@ use crate::core::{
#[async_trait]
pub trait ReservationsRepository {
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError>;
/// One page of the reservations matching `query`, most recent first, with
/// the size of the whole match.
///
/// There is deliberately no "give me every reservation": the table only
/// grows, and the narrowing belongs here, where the indexes are, rather
/// than in a browser that would have to download the archive to hide it.
async fn search_reservations(
&self,
query: ReservationQuery,
) -> Result<ReservationPage, RepositoryError>;
async fn get_unit_reservations(
&self,
unit: UnitId,
) -> Result<Vec<Reservation>, RepositoryError>;
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>;
/// Everything the user is part of: what they filed, what they were added
/// to, and what lists their address among the Linka Go accounts. The email
/// is what binds a reservation to somebody who had never logged in when it
/// was filed.
async fn get_involved_reservations(
&self,
user: UserId,
email: &str,
) -> Result<Vec<Reservation>, RepositoryError>;
/// Always stored as `Requested`: the state machine starts here. The
/// requester comes from the session, not from the request body.
async fn create_reservation(

View file

@ -13,7 +13,7 @@ use crate::{
models::{
reservation::{
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
ReservationStatus, ReservationUnit,
ReservationPage, ReservationQuery, ReservationStatus, ReservationUnit,
},
unit::{Unit, UnitId},
user::UserId,
@ -107,6 +107,46 @@ impl TryFrom<ReservationDB> for Reservation {
}
}
/// The page query carries one extra column — how many rows the filter matches
/// in all, from a window function — so it needs its own row type. Everything
/// else is the shared one, which the conversion below hands back.
struct ReservationPageRowDB {
pub id: i32,
pub unit_id: Option<i32>,
pub unit_name: Option<String>,
pub unit_label: Option<String>,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester_id: i32,
pub telegram: String,
pub description: String,
pub status: ReservationStatusDB,
pub linka_emails: Vec<String>,
pub users: Value,
pub bikes: Vec<i32>,
pub total: i64,
}
impl From<ReservationPageRowDB> for ReservationDB {
fn from(value: ReservationPageRowDB) -> Self {
ReservationDB {
id: value.id,
unit_id: value.unit_id,
unit_name: value.unit_name,
unit_label: value.unit_label,
start_time: value.start_time,
end_time: value.end_time,
requester_id: value.requester_id,
telegram: value.telegram,
description: value.description,
status: value.status,
linka_emails: value.linka_emails,
users: value.users,
bikes: value.bikes,
}
}
}
impl SqlxDatabase {
async fn set_reservation_links(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
@ -170,11 +210,37 @@ fn trim_emails(emails: &[String]) -> Vec<String> {
.collect()
}
/// Wraps the search text for `ILIKE`, escaping what the pattern language would
/// otherwise read as a wildcard — an address contains `_` often enough that
/// leaving it alone would quietly widen the search.
fn like_pattern(search: &str) -> String {
let escaped = search
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
format!("%{escaped}%")
}
#[async_trait]
impl ReservationsRepository for SqlxDatabase {
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> {
Ok(query_as!(
ReservationDB,
async fn search_reservations(
&self,
query: ReservationQuery,
) -> Result<ReservationPage, RepositoryError> {
let query = query.sanitised();
// Every filter is "the parameter is NULL, or it matches": one prepared
// statement serves every combination the admin page can ask for.
let statuses = (!query.statuses.is_empty()).then(|| {
query
.statuses
.iter()
.map(|status| status.as_str().to_owned())
.collect::<Vec<_>>()
});
let pattern = query.search.as_deref().map(like_pattern);
let rows = query_as!(
ReservationPageRowDB,
r#"SELECT
r.id,
r.unit_id,
@ -203,16 +269,69 @@ impl ReservationsRepository for SqlxDatabase {
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
) AS "bikes!",
-- The size of the whole match, alongside the page itself: the
-- caller can show "42 results" without a second round trip
COUNT(*) OVER () AS "total!"
FROM reservations r
LEFT JOIN units un ON un.id = r.unit_id
ORDER BY r.start_time DESC"#
WHERE ($1::text[] IS NULL OR r.status::text = ANY($1))
-- The three ways of being part of a reservation. The last one is
-- what lets somebody added by address see it the first time they
-- log in, without anything having to be written at login time.
AND ($8::integer IS NULL
OR r.requester_id = $8
OR EXISTS (
SELECT 1 FROM reservations_users ru
WHERE ru.reservation_id = r.id AND ru.user_id = $8
)
OR EXISTS (
SELECT 1 FROM unnest(r.linka_emails) AS listed
WHERE lower(listed) = lower($9)
))
-- Overlap with the window, not containment: a reservation running
-- across the displayed week belongs to it
AND ($2::timestamptz IS NULL OR r.end_time > $2)
AND ($3::timestamptz IS NULL OR r.start_time < $3)
AND ($4::text IS NULL
OR COALESCE(un."name", r.unit_label, '') ILIKE $4
OR r.telegram ILIKE $4
OR r."description" ILIKE $4
OR array_to_string(r.linka_emails, ' ') ILIKE $4
-- Typing the number of a reservation is how an admin looks
-- one up from a mail or a chat
OR r.id::text = $5
OR EXISTS (
SELECT 1 FROM reservations_users ru
JOIN users u ON u.id = ru.user_id
WHERE ru.reservation_id = r.id
AND u.firstname || ' ' || u."name" || ' ' || u.email ILIKE $4
))
ORDER BY r.start_time DESC, r.id DESC
LIMIT $6 OFFSET $7"#,
statuses.as_deref(),
query.from,
query.to,
pattern,
query.search,
query.limit,
query.offset,
query.involving.as_ref().map(|who| who.user),
query.involving.as_ref().map(|who| who.email.as_str()),
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()?)
.await?;
// The window function only rides along on the rows; an empty page is
// an empty match, or a page past the end of one.
let total = rows.first().map_or(0, |row| row.total);
Ok(ReservationPage {
items: rows
.into_iter()
.map(|row| ReservationDB::from(row).try_into())
.collect::<Result<Vec<_>, _>>()?,
total,
})
}
async fn get_unit_reservations(
@ -263,67 +382,6 @@ impl ReservationsRepository for SqlxDatabase {
.collect::<Result<Vec<_>, _>>()?)
}
async fn get_involved_reservations(
&self,
user: UserId,
email: &str,
) -> Result<Vec<Reservation>, RepositoryError> {
Ok(query_as!(
ReservationDB,
r#"SELECT
r.id,
r.unit_id,
-- `?` forces the nullability sqlx cannot infer: `units.name` is
-- NOT NULL, but the LEFT JOIN makes it null for a free label
un."name" AS "unit_name?",
r.unit_label,
r.start_time,
r.end_time,
r.requester_id,
r.telegram,
r."description",
r.linka_emails,
r.status AS "status: ReservationStatusDB",
COALESCE((
SELECT json_agg(json_build_object(
'id', u.id,
'firstname', u.firstname,
'name', u."name",
'email', u.email
) ORDER BY u."name", u.firstname)
FROM reservations_users ru
JOIN users u ON u.id = ru.user_id
WHERE ru.reservation_id = r.id
), '[]'::json) AS "users!",
ARRAY(
SELECT bike_id FROM reservations_bikes
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
LEFT JOIN units un ON un.id = r.unit_id
-- Three ways to be part of a reservation. The last one is what
-- lets somebody added by address see it the first time they log in,
-- without anything having to be written at login time.
WHERE r.requester_id = $1
OR EXISTS (
SELECT 1 FROM reservations_users ru
WHERE ru.reservation_id = r.id AND ru.user_id = $1
)
OR EXISTS (
SELECT 1 FROM unnest(r.linka_emails) AS listed
WHERE lower(listed) = lower($2)
)
ORDER BY r.start_time DESC"#,
user,
email
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()?)
}
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
Ok(query_as!(
ReservationDB,
@ -484,3 +542,17 @@ impl ReservationsRepository for SqlxDatabase {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn like_patterns_escape_the_wildcards() {
assert_eq!(like_pattern("milan"), "%milan%");
// Underscores are ordinary in an address; they must not match anything
assert_eq!(like_pattern("a_b"), "%a\\_b%");
assert_eq!(like_pattern("100%"), "%100\\%%");
assert_eq!(like_pattern("a\\b"), "%a\\\\b%");
}
}