Compare commits

...

2 commits

Author SHA1 Message Date
Antoine Pelletier
ce88b58003 wip 2026-08-24 17:23:10 +02:00
Antoine Pelletier
c6460a0a77 wip 2026-08-24 15:36:23 +02:00
32 changed files with 2718 additions and 422 deletions

13
.env
View file

@ -1,2 +1,15 @@
# This file is used by dbmate, and by the sqlx macros at compile time.
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cargagep?sslmode=disable
# Read by the backend too: anything named APP__* here reaches the configuration
# exactly like an exported variable would, and a real environment variable still
# wins over this file. This is where the secrets live in development.
#
# The bot that posts to the cargobikes group. Leave both empty and nothing is
# sent — which is what a machine without the bot wants.
# * BOT_TOKEN comes from @BotFather
# * CHAT_ID is the group's id: add the bot to the group, post a message, then
# read it from https://api.telegram.org/bot<token>/getUpdates (a group id is
# negative, e.g. -1001234567890)
APP__TELEGRAM__BOT_TOKEN=
APP__TELEGRAM__CHAT_ID=

View file

@ -0,0 +1,25 @@
-- migrate:up
-- A bike is normally held for its reservation's own period. These two columns
-- override it for one bike, which is how a conflict is resolved without moving
-- the whole booking: the reservation keeps its hours, and only the bike both
-- bookings want changes hands earlier.
--
-- NULL means "the reservation's period", so every existing row keeps behaving
-- exactly as before.
ALTER TABLE reservations_bikes
ADD COLUMN start_time timestamptz,
ADD COLUMN end_time timestamptz;
-- Both or neither, and the right way round
ALTER TABLE reservations_bikes ADD CONSTRAINT reservations_bikes_period CHECK (
(start_time IS NULL AND end_time IS NULL)
OR (start_time IS NOT NULL AND end_time IS NOT NULL AND start_time < end_time));
-- Conflicts are looked up by bike — "who else holds this one, and when" — which
-- `reservations_bikes_bike_id_idx` already serves: it was created with the
-- table, and the primary key, starting with reservation_id, could not.
-- migrate:down
ALTER TABLE reservations_bikes DROP CONSTRAINT reservations_bikes_period;
ALTER TABLE reservations_bikes DROP COLUMN start_time, DROP COLUMN end_time;

View file

@ -130,7 +130,10 @@ CREATE TABLE public.reservations (
CREATE TABLE public.reservations_bikes (
reservation_id integer NOT NULL,
bike_id integer NOT NULL
bike_id integer NOT NULL,
start_time timestamp with time zone,
end_time timestamp with time zone,
CONSTRAINT reservations_bikes_period CHECK ((((start_time IS NULL) AND (end_time IS NULL)) OR ((start_time IS NOT NULL) AND (end_time IS NOT NULL) AND (start_time < end_time))))
);
@ -505,4 +508,5 @@ INSERT INTO public.schema_migrations (version) VALUES
('20260823210000'),
('20260823230000'),
('20260824120000'),
('20260824140000');
('20260824140000'),
('20260824180000');

View file

@ -81,18 +81,41 @@ FROM (VALUES
date_trunc('day', now()) + interval '6 days' + interval '18 hours',
2, '@bob_dupont', 'Annulée faute de conducteur',
ARRAY['bob.dupont@epfl.ch'],
'cancelled'::reservation_status)
'cancelled'::reservation_status),
-- Wants the 5000 while reservation 2 still holds it, so the admin page has
-- a conflict to warn about without anybody having to build one by hand
(7, 'S4S',
date_trunc('day', now()) + interval '1 day' + interval '14 hours',
date_trunc('day', now()) + interval '2 days' + interval '10 hours',
1, '@alice_martin', 'Stand de la journée portes ouvertes',
ARRAY['alice.martin@epfl.ch'],
'requested'::reservation_status)
) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description",
linka_emails, status)
JOIN public.units u ON u."name" = r.unit_name
ON CONFLICT DO NOTHING;
INSERT INTO public.reservations_users (reservation_id, user_id) VALUES
(1, 1), (1, 2), (2, 3), (3, 2), (3, 1), (4, 3), (5, 1), (6, 2)
(1, 1), (1, 2), (2, 3), (3, 2), (3, 1), (4, 3), (5, 1), (6, 2), (7, 1)
ON CONFLICT DO NOTHING;
INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
(1, 1), (1, 2), (2, 3), (3, 1), (3, 4), (4, 2), (5, 5), (6, 4)
-- The bikes each reservation holds. The two period columns are normally NULL,
-- which means "the reservation's own period"; they are only filled when a bike
-- is handed over early to settle a conflict, as reservation 1 does with the
-- 2000 — that one is given back at noon today rather than tomorrow evening.
INSERT INTO public.reservations_bikes (reservation_id, bike_id, start_time, end_time) VALUES
(1, 1, NULL::timestamptz, NULL::timestamptz),
(1, 2, date_trunc('day', now()) - interval '1 day' + interval '8 hours',
date_trunc('day', now()) + interval '12 hours'),
(2, 3, NULL, NULL),
-- Held by reservation 2, and wanted by reservation 7 at the same time
(2, 5, NULL, NULL),
(3, 1, NULL, NULL),
(3, 4, NULL, NULL),
(4, 2, NULL, NULL),
(5, 5, NULL, NULL),
(6, 4, NULL, NULL),
(7, 5, NULL, NULL)
ON CONFLICT DO NOTHING;
-- Keep the sequences in sync with the explicit ids inserted above

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

@ -26,8 +26,14 @@ const setStatus = useSetBikeStatus()
/** Bikes held by a reservation that is under way right now */
const inUse = computed(() => {
const ids = new Set<number>()
const now = Date.now()
for (const reservation of props.reservations) {
if (reservation.status === 'ongoing') reservation.bikes.forEach((id) => ids.add(id))
if (reservation.status !== 'ongoing') continue
// A bike handed over early is no longer in use, even though the
// reservation it belonged to is still running
for (const bike of reservation.bikes) {
if (new Date(bike.end_time).getTime() > now) ids.add(bike.id)
}
}
return ids
})

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

@ -6,15 +6,23 @@
*/
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Pencil } from '@lucide/vue'
import { Pencil, TriangleAlert } from '@lucide/vue'
import { toast } from 'vue-sonner'
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useSetReservationStatus } from '@/services/api/reservations'
import { unitLabel, type Bike, type Reservation, type ReservationStatus } from '@/utils/types'
import { useConflicts, useSetReservationStatus } from '@/services/api/reservations'
import { HttpStatus } from 'http-status-ts'
import {
ApiError,
unitLabel,
type Bike,
type Reservation,
type ReservationStatus,
} from '@/utils/types'
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
@ -44,6 +52,47 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
const transitions = computed(() => TRANSITIONS[props.reservation.status])
/**
* What approving this reservation would clash with.
*
* Only asked while approving is on the table: an already approved one has
* taken its bikes, and a final one has given them back. The backend refuses
* the transition too — this is what says why, before the click.
*/
const canApprove = computed(() => transitions.value.includes('approved'))
const probe = computed(() =>
canApprove.value
? {
reservation: props.reservation.id,
start_time: props.reservation.start_time,
end_time: props.reservation.end_time,
bikes: props.reservation.bikes.map((held) => ({
id: held.id,
start_time: held.start_time,
end_time: held.end_time,
})),
}
: null,
)
const { data: conflicts } = useConflicts(probe, { enabled: canApprove })
const blocking = computed(() =>
(conflicts.value ?? []).map((conflict) => ({
...conflict,
name: props.bikes.find((bike) => bike.id === conflict.bike)?.name ?? `#${conflict.bike}`,
})),
)
const { locale } = useI18n()
const formatter = computed(
() =>
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
dateStyle: 'medium',
timeStyle: 'short',
}),
)
const format = (iso: string) => formatter.value.format(new Date(iso))
/** A reservation nobody can act on any more is not worth an edit button */
const editable = computed(
() => !['refused', 'cancelled', 'archived'].includes(props.reservation.status),
@ -51,9 +100,18 @@ const editable = computed(
const editing = ref(false)
function move(status: ReservationStatus) {
// The button is disabled while a conflict stands, but the list it was drawn
// from may be a few seconds old: the backend has the last word.
setStatus.mutate(
{ id: props.reservation.id, status },
{ onError: () => toast.error(t('admin.reservations.error')) },
{
onError: (error) =>
toast.error(
error instanceof ApiError && error.status === HttpStatus.CONFLICT
? t('admin.reservations.conflict.error')
: t('admin.reservations.error'),
),
},
)
}
</script>
@ -66,7 +124,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">
@ -79,7 +137,12 @@ function move(status: ReservationStatus) {
:key="status"
size="sm"
:variant="status === 'approved' ? 'default' : 'outline'"
:disabled="setStatus.isPending.value"
:disabled="setStatus.isPending.value || (status === 'approved' && blocking.length > 0)"
:title="
status === 'approved' && blocking.length
? $t('admin.reservations.conflict.title')
: undefined
"
@click="move(status)"
>
{{ $t(`admin.reservations.action.${status}`) }}
@ -87,7 +150,34 @@ function move(status: ReservationStatus) {
</div>
</div>
<ReservationDetails class="mt-3" :reservation="reservation" :bikes="bikes" />
<!-- Why the reservation cannot be approved, named rather than merely refused -->
<div
v-if="blocking.length"
class="border-destructive/40 bg-destructive/5 mt-3 grid gap-1 rounded-md border p-3"
>
<p class="text-destructive flex items-center gap-2 text-sm font-medium">
<TriangleAlert class="size-4 shrink-0" />
{{ $t('admin.reservations.conflict.title') }}
</p>
<p
v-for="conflict in blocking"
:key="`${conflict.bike}-${conflict.reservation}`"
class="text-sm"
>
{{
$t('admin.reservations.conflict.line', {
bike: conflict.name,
id: conflict.reservation,
unit: conflict.unit,
from: format(conflict.start_time),
to: format(conflict.end_time),
})
}}
</p>
<p class="text-muted-foreground text-xs">{{ $t('admin.reservations.conflict.hint') }}</p>
</div>
<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),
@ -123,19 +114,21 @@ function blocksFor(day: Date, bike: Bike): Block[] {
const dayEnd = new Date(day)
dayEnd.setHours(LAST_HOUR, 0, 0, 0)
return booked.value
.filter((reservation) => reservation.bikes.includes(bike.id))
.flatMap((reservation) => {
const start = new Date(reservation.start_time)
const end = new Date(reservation.end_time)
const from = start > dayStart ? start : dayStart
const to = end < dayEnd ? end : dayEnd
if (to <= from) return []
return booked.value.flatMap((reservation) => {
// The bike's own period, which is not always the reservation's: a
// conflict may have been settled by handing this one over early
const held = reservation.bikes.find((held) => held.id === bike.id)
if (!held) return []
const start = new Date(held.start_time)
const end = new Date(held.end_time)
const from = start > dayStart ? start : dayStart
const to = end < dayEnd ? end : dayEnd
if (to <= from) return []
const top = ((from.getTime() - dayStart.getTime()) / 3_600_000) * HOUR_HEIGHT
const height = Math.max(((to.getTime() - from.getTime()) / 3_600_000) * HOUR_HEIGHT, 6)
return [{ id: reservation.id, top, height }]
})
const top = ((from.getTime() - dayStart.getTime()) / 3_600_000) * HOUR_HEIGHT
const height = Math.max(((to.getTime() - from.getTime()) / 3_600_000) * HOUR_HEIGHT, 6)
return [{ id: reservation.id, top, height }]
})
}
</script>
@ -145,7 +138,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 +259,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,127 @@ function shows(field: Field) {
const { locale } = useI18n()
const bikeNames = computed(() =>
props.reservation.bikes
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
.join(', '),
/** Names when we know the bike, `#id` when the list has not loaded yet */
function bikeName(id: number) {
return props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`
}
const heldBikes = computed(() =>
props.reservation.bikes.map((held) => ({ ...held, name: bikeName(held.id) })),
)
const formatter = computed(
// A bike whose period is not the reservation's gets a line of its own: it is
// the whole point of the override, and hiding it would make the reservation
// look like it still holds the bike to the end.
const rescheduled = computed(() => heldBikes.value.filter((held) => held.custom))
// 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') && heldBikes.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="held in heldBikes"
:key="held.id"
class="rounded-md px-2 py-0.5 font-medium tabular-nums"
:class="held.custom ? 'bg-amber-500/15 text-amber-700 dark:text-amber-400' : 'bg-muted'"
:title="held.custom ? $t('reservation.details.own-period') : undefined"
>
{{ held.name }}
</span>
</dd>
<dd v-if="rescheduled.length" class="mt-1 grid gap-0.5">
<span
v-for="held in rescheduled"
:key="held.id"
class="text-muted-foreground text-xs tabular-nums"
>
{{ held.name }} · {{ format(held.start_time) }} → {{ format(held.end_time) }}
</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

@ -11,9 +11,11 @@
*/
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus, X } from '@lucide/vue'
import { refDebounced } from '@vueuse/core'
import { CalendarClock, Plus, TriangleAlert, Undo2, X } from '@lucide/vue'
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'
import { toast } from 'vue-sonner'
import { HttpStatus } from 'http-status-ts'
import DatePicker from '@/components/DatePicker.vue'
import TimePicker from '@/components/TimePicker.vue'
@ -30,8 +32,15 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useUpdateReservation } from '@/services/api/reservations'
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
import { useConflicts, useUpdateReservation } from '@/services/api/reservations'
import {
ApiError,
unitLabel,
type Bike,
type Conflict,
type NewReservationBike,
type Reservation,
} from '@/utils/types'
const props = withDefaults(
defineProps<{ reservation: Reservation; bikes: Bike[]; emailsOnly?: boolean }>(),
@ -39,7 +48,7 @@ const props = withDefaults(
)
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
const { t, locale } = useI18n()
const update = useUpdateReservation()
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@ -56,6 +65,15 @@ const form = reactive({
const errors = reactive<Record<string, string>>({})
const submitted = ref(false)
/**
* A period one bike is held for, when it is not the reservation's own. This is
* how a conflict is settled: the booking keeps its hours and the disputed bike
* changes hands earlier. A bike absent from here simply follows the
* reservation.
*/
type Period = { startDate: DateValue; startTime: string; endDate: DateValue; endTime: string }
const overrides = reactive<Record<number, Period>>({})
function toCalendarDate(date: Date): DateValue {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}
@ -67,6 +85,17 @@ function toSlot(date: Date): string {
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`
}
function toPeriod(from: string, to: string): Period {
const start = new Date(from)
const end = new Date(to)
return {
startDate: toCalendarDate(start),
startTime: toSlot(start),
endDate: toCalendarDate(end),
endTime: toSlot(end),
}
}
/** Reloads the fields from the reservation, so cancelling really cancels */
function load() {
const start = new Date(props.reservation.start_time)
@ -75,7 +104,11 @@ function load() {
endDate.value = toCalendarDate(end)
form.startTime = toSlot(start)
form.endTime = toSlot(end)
form.bikes = [...props.reservation.bikes]
form.bikes = props.reservation.bikes.map((held) => held.id)
Object.keys(overrides).forEach((key) => delete overrides[Number(key)])
for (const held of props.reservation.bikes) {
if (held.custom) overrides[held.id] = toPeriod(held.start_time, held.end_time)
}
form.emails = props.reservation.linka_emails.length ? [...props.reservation.linka_emails] : ['']
Object.keys(errors).forEach((key) => delete errors[key])
submitted.value = false
@ -104,16 +137,118 @@ const end = computed(() => toDate(endDate.value, form.endTime))
* of service — the same rule the backend applies.
*/
function isBlocked(bike: Bike) {
return bike.status === 'out_of_service' && !props.reservation.bikes.includes(bike.id)
return (
bike.status === 'out_of_service' && !props.reservation.bikes.some((held) => held.id === bike.id)
)
}
function toggleBike(bike: Bike) {
if (isBlocked(bike)) return
const index = form.bikes.indexOf(bike.id)
if (index >= 0) form.bikes.splice(index, 1)
else form.bikes.push(bike.id)
if (index >= 0) {
form.bikes.splice(index, 1)
delete overrides[bike.id]
} else form.bikes.push(bike.id)
}
function bikeName(id: number) {
return props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`
}
/** The period a bike would be held for once saved, reservation's or its own */
function heldPeriod(id: number): [Date, Date] | null {
const own = overrides[id]
const from = own ? toDate(own.startDate, own.startTime) : start.value
const to = own ? toDate(own.endDate, own.endTime) : end.value
return from && to ? [from, to] : null
}
function useOwnPeriod(id: number) {
if (!start.value || !end.value) return
overrides[id] = toPeriod(start.value.toISOString(), end.value.toISOString())
}
function followReservation(id: number) {
delete overrides[id]
}
/** The fleet in the shape the backend takes: no period means "the booking's" */
const editedBikes = computed<NewReservationBike[]>(() =>
form.bikes.map((id) => {
const period = heldPeriod(id)
if (!overrides[id] || !period) return { id }
return { id, start_time: period[0].toISOString(), end_time: period[1].toISOString() }
}),
)
/**
* What the reservation would clash with once saved. Answered by the backend
* against the whole table, and only a warning: whoever manages the reservation
* is allowed to double-book knowingly, and is the only one who can sort it out.
*/
const probe = computed(() => {
// Emails-only mode changes neither the period nor the fleet: nothing to check
if (props.emailsOnly || !start.value || !end.value || !form.bikes.length) return null
if (end.value <= start.value) return null
return {
reservation: props.reservation.id,
start_time: start.value.toISOString(),
end_time: end.value.toISOString(),
bikes: editedBikes.value,
}
})
// Every picker change would otherwise be its own request
const debouncedProbe = refDebounced(probe, 300)
const { data: conflicts } = useConflicts(debouncedProbe, { enabled: open })
/** One block per bike, since the same one can be held by two reservations */
const conflictsByBike = computed(() => {
const grouped = new Map<number, Conflict[]>()
for (const conflict of conflicts.value ?? []) {
const list = grouped.get(conflict.bike)
if (list) list.push(conflict)
else grouped.set(conflict.bike, [conflict])
}
return [...grouped].map(([id, list]) => ({ id, name: bikeName(id), conflicts: list }))
})
/**
* The one-click way out, when there is one: hand the bike back before the other
* booking takes it, or take it once that one is done. It only ever shortens our
* own hold, so it is always safe — and it is exactly what settles the case
* where two bookings want the same bike on either side of an hour.
*/
function resolution(id: number, conflict: Conflict): { at: Date; kind: 'end' | 'start' } | null {
const period = heldPeriod(id)
if (!period) return null
const [from, to] = period
const otherFrom = new Date(conflict.start_time)
const otherTo = new Date(conflict.end_time)
if (otherFrom > from) return { at: otherFrom, kind: 'end' }
if (otherTo < to) return { at: otherTo, kind: 'start' }
// The other booking covers ours whole: only moving the reservation, or the
// other one, can settle this
return null
}
function applyResolution(id: number, conflict: Conflict) {
const fix = resolution(id, conflict)
const period = heldPeriod(id)
if (!fix || !period) return
const [from, to] = period
const next = fix.kind === 'end' ? [from, fix.at] : [fix.at, to]
overrides[id] = toPeriod(next[0].toISOString(), next[1].toISOString())
}
const timeFormatter = computed(
() =>
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
dateStyle: 'medium',
timeStyle: 'short',
}),
)
const formatMoment = (value: Date | string) => timeFormatter.value.format(new Date(value))
function addEmail() {
form.emails.push('')
}
@ -133,6 +268,20 @@ function validate(): boolean {
errors.end = t('reservation.error-end-before-start')
}
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
// The backend refuses these too; saying so here saves a round trip and
// names the bike rather than the whole reservation
for (const id of form.bikes) {
const own = overrides[id]
if (!own) continue
const from = toDate(own.startDate, own.startTime)
const to = toDate(own.endDate, own.endTime)
if (!from || !to || to <= from) {
errors.periods = t('reservation.edit.error-period', { bike: bikeName(id) })
} else if (start.value && end.value && (from < start.value || to > end.value)) {
errors.periods = t('reservation.edit.error-period-outside', { bike: bikeName(id) })
}
}
}
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
@ -158,7 +307,12 @@ function save() {
// the backend checks before accepting the change
start_time: props.emailsOnly ? props.reservation.start_time : start.value!.toISOString(),
end_time: props.emailsOnly ? props.reservation.end_time : end.value!.toISOString(),
bikes: props.emailsOnly ? [...props.reservation.bikes] : [...form.bikes],
bikes: props.emailsOnly
? props.reservation.bikes.map((held) => ({
id: held.id,
...(held.custom ? { start_time: held.start_time, end_time: held.end_time } : {}),
}))
: editedBikes.value,
linka_emails: form.emails.map((email) => email.trim()).filter(Boolean),
},
{
@ -166,7 +320,12 @@ function save() {
toast.success(t('reservation.edit.saved'))
open.value = false
},
onError: (error) => toast.error(error.message || t('reservation.edit.error')),
onError: (error) =>
toast.error(
error instanceof ApiError && error.status === HttpStatus.CONFLICT
? t('reservation.conflicts.refused')
: error.message || t('reservation.edit.error'),
),
},
)
}
@ -254,6 +413,111 @@ function save() {
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
</div>
<!-- One period per bike, which is how a conflict gets settled without
moving the whole booking -->
<div v-if="!emailsOnly && form.bikes.length" class="grid gap-2">
<span class="text-sm font-medium">{{ $t('reservation.edit.periods') }}</span>
<div v-for="id in form.bikes" :key="id" class="grid gap-2 rounded-md border p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="flex items-center gap-2 text-sm font-medium tabular-nums">
{{ bikeName(id) }}
<span v-if="!overrides[id]" class="text-muted-foreground font-normal">
{{ $t('reservation.edit.follows') }}
</span>
</span>
<Button
v-if="!overrides[id]"
type="button"
variant="ghost"
size="sm"
@click="useOwnPeriod(id)"
>
<CalendarClock class="size-4" />
{{ $t('reservation.edit.own-period') }}
</Button>
<Button v-else type="button" variant="ghost" size="sm" @click="followReservation(id)">
<Undo2 class="size-4" />
{{ $t('reservation.edit.follow-again') }}
</Button>
</div>
<div v-if="overrides[id]" class="grid gap-2">
<span class="text-muted-foreground text-xs">
{{ $t('reservation.edit.period-from') }}
</span>
<div class="grid gap-2 sm:grid-cols-2">
<DatePicker
v-model="overrides[id].startDate"
:min-value="startDate"
:max-value="endDate"
/>
<TimePicker v-model="overrides[id].startTime" />
</div>
<span class="text-muted-foreground text-xs">
{{ $t('reservation.edit.period-to') }}
</span>
<div class="grid gap-2 sm:grid-cols-2">
<DatePicker
v-model="overrides[id].endDate"
:min-value="overrides[id].startDate"
:max-value="endDate"
/>
<TimePicker v-model="overrides[id].endTime" />
</div>
</div>
</div>
<p v-if="errors.periods" class="text-destructive text-xs">{{ errors.periods }}</p>
</div>
<!-- A warning, not a wall: whoever manages the reservation is allowed to
double-book on purpose, and is the only one who can settle it -->
<div
v-if="conflictsByBike.length"
class="grid gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3"
>
<p class="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
<TriangleAlert class="size-4 shrink-0" />
{{ $t('reservation.conflicts.title') }}
</p>
<p class="text-muted-foreground text-xs">{{ $t('reservation.conflicts.intro') }}</p>
<div v-for="group in conflictsByBike" :key="group.id" class="grid gap-1">
<div v-for="conflict in group.conflicts" :key="conflict.reservation" class="text-sm">
<p>
<span class="font-medium tabular-nums">{{ group.name }}</span>
—
{{
$t('reservation.conflicts.held', {
unit: conflict.unit,
id: conflict.reservation,
from: formatMoment(conflict.start_time),
to: formatMoment(conflict.end_time),
})
}}
</p>
<Button
v-if="resolution(group.id, conflict)"
type="button"
variant="outline"
size="sm"
class="mt-1"
@click="applyResolution(group.id, conflict)"
>
{{
$t(
resolution(group.id, conflict)!.kind === 'end'
? 'reservation.conflicts.give-back'
: 'reservation.conflicts.take-later',
{
bike: group.name,
at: formatMoment(resolution(group.id, conflict)!.at),
},
)
}}
</Button>
</div>
</div>
</div>
<!-- Linka Go accounts -->
<div class="grid gap-2">
<Label for="edit-email-0">{{ $t('reservation.emails') }}</Label>

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: {
@ -485,7 +512,7 @@ export interface paths {
}
content?: never
}
/** @description One of the bikes is out of service */
/** @description One of the bikes is out of service, or is already held over that period */
409: {
headers: {
[name: string]: unknown
@ -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
@ -590,6 +644,66 @@ export interface paths {
patch?: never
trace?: never
}
'/api/reservations/conflicts': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/**
* Which of the bikes wanted are already held over the same period
* @description Only approved and ongoing reservations hold a bike, so only they appear here. The booking form uses it to grey out what is taken, and the admin page to warn before double-booking on purpose.
*/
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/**
* @description What a would-be booking asks about: a period, the bikes wanted, and the
* reservation to leave out of the answer when one is being edited.
*/
requestBody: {
content: {
'application/json': components['schemas']['ConflictProbeForm']
}
}
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Conflict'][]
}
}
/** @description The period is empty or the wrong way round */
400: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/reservations/{id}': {
parameters: {
query?: never
@ -658,7 +772,7 @@ export interface paths {
}
content?: never
}
/** @description A newly added bike is out of service, or the reservation is final */
/** @description A newly added bike is out of service or already held over that period (managers may double-book on purpose, so this only reaches anybody else), or the reservation is final */
409: {
headers: {
[name: string]: unknown
@ -783,7 +897,8 @@ export interface components {
* travels to anybody, signed in or not.
*/
CalendarReservation: {
bikes: number[]
/** @description With their own periods: a bike handed over early is drawn as such */
bikes: components['schemas']['ReservationBike'][]
/** Format: date-time */
end_time: string
/** Format: int32 */
@ -793,6 +908,54 @@ 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
}
/**
* @description A bike the probe wants that somebody else already holds over part of the
* same period. Only the reservations that actually hold a bike — approved and
* ongoing — can produce one.
*/
Conflict: {
/** Format: int32 */
bike: number
/** Format: date-time */
end_time: string
/** Format: int32 */
reservation: number
/**
* Format: date-time
* @description When the bike is held, which is what a resolution has to step around
*/
start_time: string
status: components['schemas']['ReservationStatus']
/** @description Who holds it, to be named in the warning */
unit: string
}
/**
* @description What a would-be booking asks about: a period, the bikes wanted, and the
* reservation to leave out of the answer when one is being edited.
*/
ConflictProbeForm: {
bikes: components['schemas']['NewReservationBike'][]
/** Format: date-time */
end_time: string
/**
* Format: int32
* @description The reservation being edited, so it does not conflict with itself
*/
reservation?: number | null
/** Format: date-time */
start_time: string
}
DevUser: {
admin: boolean
email: string
@ -822,6 +985,18 @@ export interface components {
unit: components['schemas']['NewReservationUnit']
users: number[]
}
/**
* @description A bike as an edit carries it. `None` means "the reservation's period", which
* is what a bike nobody had to arbitrate over keeps.
*/
NewReservationBike: {
/** Format: date-time */
end_time?: string | null
/** Format: int32 */
id: number
/** Format: date-time */
start_time?: string | null
}
/** @description The same choice, as the client sends it: only the id travels for a known unit. */
NewReservationUnit:
| {
@ -841,7 +1016,7 @@ export interface components {
}
Reservation: {
description: string
bikes: number[]
bikes: components['schemas']['ReservationBike'][]
/** Format: date-time */
end_time: string
/** Format: int32 */
@ -861,6 +1036,26 @@ export interface components {
unit: components['schemas']['ReservationUnit']
users: components['schemas']['UserSummary'][]
}
/**
* @description One bike on a reservation, with the period it is actually held for.
*
* Normally the reservation's own period. An override is how a conflict is
* settled without moving the whole booking: the reservation keeps its hours
* and only the disputed bike changes hands earlier.
*/
ReservationBike: {
/** @description Whether that period is the bike's own rather than the reservation's */
custom: boolean
/** Format: date-time */
end_time: string
/** Format: int32 */
id: number
/**
* Format: date-time
* @description Already resolved: the bike's own period, or the reservation's
*/
start_time: string
}
/**
* @description Only what the admin page lets somebody change. The unit, the requester, the
* telegram handle and the reason are shown but not editable, so they are not
@ -868,13 +1063,57 @@ export interface components {
* rather than trusting a client to send them unchanged.
*/
ReservationEditForm: {
bikes: number[]
/**
* @description Each with its own period when a conflict was settled by handing it over
* early, and without one when it simply follows the reservation
*/
bikes: components['schemas']['NewReservationBike'][]
/** Format: date-time */
end_time: string
linka_emails: string[]
/** 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

@ -31,6 +31,7 @@ reservation:
bikes-empty: No cargobike available for this period.
bikes-error: Unable to load the cargobikes.
bike-out-of-service: Out of service
bike-taken: Booked
bike-size-large: Large cargo bikes
bike-size-small: Small cargo bikes
bike-size-empty: No bike of this size.
@ -70,8 +71,28 @@ reservation:
people: People
linka: Linka Go accounts
reason: Reason
show-more: 'Show {n} more'
show-less: Show less
own-period: This cargobike's own period
conflicts:
title: Cargobike already booked
intro: >-
These cargobikes are already held by another reservation over the same period.
You can save anyway — the call is yours.
held: 'held by {unit} (#{id}) from {from} to {to}'
give-back: 'Give the {bike} back on {at}'
take-later: 'Take the {bike} from {at}'
refused: One of the cargobikes is already booked over that period.
edit:
action: Edit
periods: Period per cargobike
follows: follows the reservation
own-period: Different period
follow-again: Follow the reservation
period-from: Taken from
period-to: Given back on
error-period: 'The period for the {bike} is empty or the wrong way round.'
error-period-outside: "The period for the {bike} must stay inside the reservation's."
title: 'Reservation #{id}'
intro: >-
Only the period, the cargobikes and the Linka Go accounts can be changed.
@ -106,10 +127,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 +169,25 @@ 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.
conflict:
title: 'Cannot approve: cargobike already booked'
line: 'The {bike} is already held by {unit} (#{id}), from {from} to {to}.'
hint: >-
Change that cargobike's period on either reservation, or drop it from this
one, then approve.
error: 'Approval refused: a cargobike is already booked over that period.'
load-error: Unable to load the reservations.
error: The status change failed.
action:

View file

@ -32,6 +32,7 @@ reservation:
bikes-empty: Aucun cargobike disponible pour ce créneau.
bikes-error: Impossible de charger les cargobikes.
bike-out-of-service: Hors service
bike-taken: Réservé
bike-size-large: Grands cargos
bike-size-small: Petits cargos
bike-size-empty: Aucun vélo de cette taille.
@ -71,8 +72,28 @@ reservation:
people: Personnes
linka: Comptes Linka Go
reason: Raison
show-more: 'Afficher {n} de plus'
show-less: Réduire
own-period: Période propre à ce cargobike
conflicts:
title: Cargobike déjà réservé
intro: >-
Ces cargobikes sont déjà tenus par une autre réservation sur la même plage.
Vous pouvez enregistrer quand même : à vous de trancher.
held: 'pris par {unit} (#{id}) du {from} au {to}'
give-back: 'Rendre le {bike} le {at}'
take-later: 'Prendre le {bike} à partir du {at}'
refused: Un des cargobikes est déjà réservé sur cette plage horaire.
edit:
action: Modifier
periods: Périodes par cargobike
follows: suit la réservation
own-period: Période différente
follow-again: Suivre la réservation
period-from: Pris à partir de
period-to: Rendu le
error-period: "La période du {bike} est vide ou à l'envers."
error-period-outside: 'La période du {bike} doit rester dans celle de la réservation.'
title: 'Réservation #{id}'
intro: >-
Seuls la période, les cargobikes et les comptes Linka Go peuvent être modifiés.
@ -107,10 +128,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 +170,25 @@ 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.
conflict:
title: 'Validation impossible : cargobike déjà réservé'
line: 'Le {bike} est déjà pris par {unit} (#{id}), du {from} au {to}.'
hint: >-
Modifiez la période de ce cargobike sur l'une des deux réservations, ou
retirez-le de celle-ci, puis validez.
error: 'Validation refusée : un cargobike est déjà réservé sur cette plage.'
load-error: Impossible de charger les réservations.
error: Le changement de statut a échoué.
action:

View file

@ -3,7 +3,7 @@ import { HttpStatus } from 'http-status-ts'
import type { QueryClient } from '@tanstack/vue-query'
import type { paths } from '@/lib/api'
import { Forbidden, Unauthorized } from '@/utils/types'
import { ApiError, Forbidden, Unauthorized } from '@/utils/types'
import { SESSION_KEY } from './keys'
// Registered by main.ts. The interceptor below needs it to drop the session
@ -41,8 +41,17 @@ client.use({
} else if (response.status === HttpStatus.NOT_FOUND) {
throw new Error(`Not found: ${response.url}`)
}
throw new Error(
`Unexpected error from ${response.url}: ${response.status} ${response.statusText}`,
// The handlers answer with a plain string; keeping it lets a view show
// what actually went wrong instead of a generic failure. Cloned, so
// nothing downstream finds the body already read.
const detail = await response
.clone()
.text()
.catch(() => '')
throw new ApiError(
response.status,
detail.trim() ||
`Unexpected error from ${response.url}: ${response.status} ${response.statusText}`,
)
}
},

View file

@ -1,27 +1,120 @@
/**
* 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'
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']
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 }
},
})
}
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[]
},
})
}
@ -41,28 +134,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 +167,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

@ -18,6 +18,23 @@ export class Forbidden extends Error {
}
}
/**
* Any other refusal, with the status and whatever the handler wrote. The
* handlers answer with a plain string, and some of them — a bike taken in the
* meantime, a period the backend rejects — are worth showing rather than
* flattening into "unexpected error".
*/
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message)
this.name = 'ApiError'
Object.setPrototypeOf(this, ApiError.prototype)
}
}
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
export type Bike = components['schemas']['Bike']
export type BikeStatus = components['schemas']['BikeStatus']
@ -30,6 +47,9 @@ export type ReservationUnit = components['schemas']['ReservationUnit']
export type NewReservation = components['schemas']['NewReservation']
export type ReservationEdit = components['schemas']['ReservationEditForm']
export type CalendarReservation = components['schemas']['CalendarReservation']
export type ReservationBike = components['schemas']['ReservationBike']
export type NewReservationBike = components['schemas']['NewReservationBike']
export type Conflict = components['schemas']['Conflict']
/**
* What the details block can render: the fields the public calendar carries,

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

@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { Plus, TriangleAlert, X } from '@lucide/vue'
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
import { toast } from 'vue-sonner'
import { HttpStatus } from 'http-status-ts'
import DatePicker from '@/components/DatePicker.vue'
import TelegramInput from '@/components/TelegramInput.vue'
@ -16,9 +17,9 @@ import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { useBikes } from '@/services/api/bikes'
import { useCreateReservation } from '@/services/api/reservations'
import { useConflicts, useCreateReservation } from '@/services/api/reservations'
import { useSession } from '@/services/api/auth'
import type { Bike, NewReservation } from '@/utils/types'
import { ApiError, type Bike, type NewReservation } from '@/utils/types'
const { t } = useI18n()
@ -76,8 +77,30 @@ const periodPicked = computed(() => start.value !== null && end.value !== null)
const { data: bikes, isPending: bikesPending, isError: bikesError } = useBikes()
/**
* Which bikes an approved or ongoing reservation already holds over the period
* asked for. The overlap is worked out by the backend against the whole table:
* the browser is told "these are taken", not handed everybody's bookings to
* work it out itself.
*/
const probe = computed(() => {
if (!start.value || !end.value || end.value <= start.value) return null
return {
start_time: start.value.toISOString(),
end_time: end.value.toISOString(),
bikes: (bikes.value ?? []).map((bike) => ({ id: bike.id })),
}
})
const { data: conflicts } = useConflicts(probe)
const taken = computed(() => new Set((conflicts.value ?? []).map((conflict) => conflict.bike)))
function isTaken(bike: Bike) {
return taken.value.has(bike.id)
}
/** Out of service or already booked: either way it cannot be picked */
function isUnavailable(bike: Bike) {
return bike.status === 'out_of_service'
return bike.status === 'out_of_service' || isTaken(bike)
}
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
@ -192,7 +215,12 @@ function submit() {
},
// The message is the backend's own: "bike 3 is out of service" is worth
// reading, and a generic failure would hide it.
onError: (error) => toast.error(error.message || t('reservation.submit-error')),
onError: (error) =>
toast.error(
error instanceof ApiError && error.status === HttpStatus.CONFLICT
? t('reservation.conflicts.refused')
: error.message || t('reservation.submit-error'),
),
})
}
</script>
@ -348,7 +376,11 @@ function submit() {
v-if="isUnavailable(bike)"
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase"
>
{{ $t('reservation.bike-out-of-service') }}
{{
bike.status === 'out_of_service'
? $t('reservation.bike-out-of-service')
: $t('reservation.bike-taken')
}}
</span>
</button>
</div>

View file

@ -9,11 +9,15 @@
use aide::{
axum::{
ApiRouter,
routing::{get_with, put_with},
routing::{get_with, post_with, put_with},
},
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;
@ -25,12 +29,10 @@ use crate::{
AnonAppController, AppController, ControllerError,
reservations::ReservationsControllerError,
},
models::{
bike::BikeId,
reservation::{
CalendarReservation, NewReservation, Reservation, ReservationEdit,
ReservationStatus,
},
models::reservation::{
CalendarReservation, Conflict, ConflictProbe, NewReservation, NewReservationBike,
Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery,
ReservationStatus,
},
},
};
@ -51,6 +53,7 @@ pub fn routes() -> ApiRouter {
"/calendar",
get_with(get_calendar_reservations, get_calendar_reservations_docs),
)
.api_route("/conflicts", post_with(find_conflicts, find_conflicts_docs))
.api_route(
"/{id}",
put_with(update_reservation, update_reservation_docs),
@ -58,19 +61,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)
}
@ -87,7 +144,8 @@ async fn create_reservation(
err @ ReservationsControllerError::ReservationInvalid,
)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
Err(ControllerError::Reservation(
err @ ReservationsControllerError::BikeOutOfService(_),
err @ (ReservationsControllerError::BikeOutOfService(_)
| ReservationsControllerError::BikesTaken(_)),
)) => Err((StatusCode::CONFLICT, err.to_string())),
Err(ControllerError::Reservation(
err @ ReservationsControllerError::NotAMemberOfUnit(_),
@ -111,17 +169,27 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
.response_with::<201, Json<Reservation>, _>(desc("The reservation, as stored"))
.response_with::<400, (), _>(desc("The reservation is malformed"))
.response_with::<403, (), _>(desc("The requester does not belong to the unit named"))
.response_with::<409, (), _>(desc("One of the bikes is out of service"))
.response_with::<409, (), _>(desc(
"One of the bikes is out of service, or is already held over that period",
))
.response_with::<422, (), _>(desc("The unit or one of the bikes does not exist"))
}
/// 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 +199,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,32 +210,85 @@ 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
/// telegram handle and the reason are shown but not editable, so they are not
/// in the body at all: the handler reads them back from the stored reservation
/// rather than trusting a client to send them unchanged.
/// What a would-be booking asks about: a period, the bikes wanted, and the
/// reservation to leave out of the answer when one is being edited.
#[derive(Debug, Deserialize, JsonSchema)]
struct ConflictProbeForm {
/// The reservation being edited, so it does not conflict with itself
reservation: Option<ReservationId>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
bikes: Vec<NewReservationBike>,
}
/// A read, not a write, but the question does not fit in a query string: each
/// bike may carry a period of its own.
#[axum::debug_handler]
async fn find_conflicts(
ac: AppController,
Json(form): Json<ConflictProbeForm>,
) -> Result<Json<Vec<Conflict>>, (StatusCode, String)> {
if form.end_time <= form.start_time {
return Err((StatusCode::BAD_REQUEST, "Empty period".to_owned()));
}
let probe = ConflictProbe {
reservation: form.reservation,
start_time: form.start_time,
end_time: form.end_time,
bikes: form.bikes,
};
match ac.find_conflicts(probe).await {
Ok(conflicts) => Ok(Json(conflicts)),
Err(err) => unexpected_error("find_conflicts", err),
}
}
fn find_conflicts_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("Which of the bikes wanted are already held over the same period")
.description(
"Only approved and ongoing reservations hold a bike, so only they appear \
here. The booking form uses it to grey out what is taken, and the admin \
page to warn before double-booking on purpose.",
)
.response_with::<400, (), _>(desc("The period is empty or the wrong way round"))
}
/// Only what the admin page lets somebody change. The unit, the requester and
/// the reason are shown but not editable, so they are not in the body at all:
/// the handler reads them back from the stored reservation rather than trusting
/// a client to send them unchanged.
#[derive(Debug, Deserialize, JsonSchema)]
struct ReservationEditForm {
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
bikes: Vec<BikeId>,
/// Whoever picks the bikes up may change, and with them the handle to
/// reach on the day
telegram: String,
/// Each with its own period when a conflict was settled by handing it over
/// early, and without one when it simply follows the reservation
bikes: Vec<NewReservationBike>,
linka_emails: Vec<String>,
}
@ -190,7 +312,7 @@ async fn update_reservation(
start_time: form.start_time,
end_time: form.end_time,
users: current.users.iter().map(|user| user.id).collect(),
telegram: current.telegram,
telegram: form.telegram,
description: current.description,
bikes: form.bikes,
linka_emails: form.linka_emails,
@ -231,7 +353,9 @@ fn update_reservation_docs(op: TransformOperation) -> TransformOperation {
.response::<404, ()>()
.response_with::<400, (), _>(desc("The reservation would become malformed"))
.response_with::<409, (), _>(desc(
"A newly added bike is out of service, or the reservation is final",
"A newly added bike is out of service or already held over that period \
(managers may double-book on purpose, so this only reaches anybody else), \
or the reservation is final",
))
.response_with::<422, (), _>(desc("One of the bikes does not exist"))
}
@ -262,7 +386,8 @@ async fn set_status(
{
Ok(()) => Ok(()),
Err(ControllerError::Reservation(
err @ ReservationsControllerError::InvalidTransition(..),
err @ (ReservationsControllerError::InvalidTransition(..)
| ReservationsControllerError::BikesTaken(_)),
)) => Err((StatusCode::CONFLICT, err.to_string())),
Err(err) => unexpected_error("set_status", err),
}
@ -271,7 +396,11 @@ async fn set_status(
fn set_status_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("Move a reservation through its state machine")
.description("Refuses a transition the state machine does not allow, with a 409.")
.description(
"Refuses a transition the state machine does not allow, with a 409 — and, \
with the same status, approving a reservation whose bikes an approved \
one already holds over the same period.",
)
.response_with::<403, (), _>(manager_desc)
.response::<404, ()>()
.response::<409, ()>()

View file

@ -1,22 +1,33 @@
use chrono::{DateTime, Utc};
use thiserror::Error;
use crate::core::{
controller::{AnonAppController, AppController, ControllerError, ManagerAppController},
models::{
bike::BikeStatus,
reservation::{
CalendarReservation, NewReservation, NewReservationUnit, Reservation, ReservationEdit,
ReservationId, ReservationStatus,
use crate::{
core::{
controller::{AnonAppController, AppController, ControllerError, ManagerAppController},
models::{
bike::{BikeId, BikeStatus},
reservation::{
CalendarReservation, Conflict, ConflictProbe, Involvement, NewReservation,
NewReservationBike, NewReservationUnit, Reservation, ReservationBike,
ReservationEdit, ReservationId, ReservationPage, ReservationQuery,
ReservationStatus,
},
unit::UnitId,
},
unit::UnitId,
repositories::RepositoryError,
},
repositories::RepositoryError,
services::telegram::{self, Notification},
};
/// 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 +44,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)
}
@ -93,10 +119,59 @@ impl AppController {
return Err(ReservationsControllerError::BikeOutOfService(*id).into());
}
}
self.db
// A bike held by an approved or ongoing reservation is not on offer.
// An admin is left free to double-book knowingly; the form they use
// warns them, and only they can sort the conflict out afterwards.
if !self.user().admin {
let conflicts = self
.db
.find_conflicts(ConflictProbe {
reservation: None,
start_time: reservation.start_time,
end_time: reservation.end_time,
bikes: reservation
.bikes
.iter()
.map(|&id| NewReservationBike {
id,
start_time: None,
end_time: None,
})
.collect(),
})
.await?;
if !conflicts.is_empty() {
return Err(ReservationsControllerError::BikesTaken(taken(&conflicts)).into());
}
}
let created = self
.db
.create_reservation(reservation, self.user().id)
.await
.map_err(Into::into)
.await?;
// The group is told, in the background: a reservation is filed whether
// or not Telegram answered.
let mut names = Vec::with_capacity(created.bikes.len());
for held in &created.bikes {
names.push(match self.get_bike(held.id).await {
Ok(bike) => bike.name,
Err(_) => format!("#{}", held.id),
});
}
telegram::notify(Notification::reservation_requested(&created, names));
Ok(created)
}
/// Which of the bikes wanted are already held over the same period, and by
/// whom. Answering this needs a session but nothing more: it is what the
/// booking form greys out and what the admin page warns about.
pub async fn find_conflicts(
&self,
probe: ConflictProbe,
) -> Result<Vec<Conflict>, ControllerError> {
self.db.find_conflicts(probe).await.map_err(Into::into)
}
}
@ -138,7 +213,13 @@ impl AppController {
if current.status != ReservationStatus::Requested
&& (reservation.start_time != current.start_time
|| reservation.end_time != current.end_time
|| !same_bikes(&reservation.bikes, &current.bikes))
|| reservation.telegram != current.telegram
|| !same_fleet(
&reservation.bikes,
&current.bikes,
reservation.start_time,
reservation.end_time,
))
{
return Err(ReservationsControllerError::OnlyEmailsEditable(current.status).into());
}
@ -146,12 +227,31 @@ impl AppController {
// A bike already on the reservation may well have broken down since:
// only a newly added one has to be in service.
for id in &reservation.bikes {
if current.bikes.contains(id) {
for bike in &reservation.bikes {
if current.bikes.iter().any(|held| held.id == bike.id) {
continue;
}
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
return Err(ReservationsControllerError::BikeOutOfService(*id).into());
if self.get_bike(bike.id).await?.status == BikeStatus::OutOfService {
return Err(ReservationsControllerError::BikeOutOfService(bike.id).into());
}
}
// Whoever manages the reservation may double-book on purpose — the
// admin page warns them and lets them decide. Anybody else may not:
// otherwise the rule the booking form applies would be one page refresh
// away from being bypassed.
if !manages {
let conflicts = self
.db
.find_conflicts(ConflictProbe {
reservation: Some(reservation.id),
start_time: reservation.start_time,
end_time: reservation.end_time,
bikes: reservation.bikes.clone(),
})
.await?;
if !conflicts.is_empty() {
return Err(ReservationsControllerError::BikesTaken(taken(&conflicts)).into());
}
}
@ -174,10 +274,35 @@ impl AppController {
}
}
/// Order carries no meaning here, so the two lists are compared as sets
fn same_bikes(left: &[i32], right: &[i32]) -> bool {
let mut left = left.to_vec();
let mut right = right.to_vec();
/// The bikes a set of conflicts is about, once each
fn taken(conflicts: &[Conflict]) -> Vec<BikeId> {
let mut bikes: Vec<BikeId> = conflicts.iter().map(|conflict| conflict.bike).collect();
bikes.sort_unstable();
bikes.dedup();
bikes
}
/// Whether an edit leaves the fleet exactly as it is — the same bikes, each
/// held over the same period. Order carries no meaning, so both are compared as
/// sets, and the edit's periods are resolved first: leaving one out means "the
/// reservation's own", which is what a bike nobody arbitrated over already has.
fn same_fleet(
edit: &[NewReservationBike],
current: &[ReservationBike],
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> bool {
let mut left: Vec<_> = edit
.iter()
.map(|bike| {
let (from, to) = bike.period(start, end);
(bike.id, from, to)
})
.collect();
let mut right: Vec<_> = current
.iter()
.map(|bike| (bike.id, bike.start_time, bike.end_time))
.collect();
left.sort_unstable();
left.dedup();
right.sort_unstable();
@ -192,17 +317,46 @@ impl ManagerAppController {
id: ReservationId,
status: ReservationStatus,
) -> Result<(), ControllerError> {
let current = self.db.get_reservation(id).await?;
if current.unit.scope() != self.unit {
let reservation = self.db.get_reservation(id).await?;
if reservation.unit.scope() != self.unit {
return Err(ControllerError::ImmutableUnitModificationError);
}
let current = current.status;
let current = reservation.status;
if current == status {
return Ok(());
}
if !current.can_transition_to(status) {
return Err(ReservationsControllerError::InvalidTransition(current, status).into());
}
// Approving is the moment a reservation really takes its bikes, so it is
// the moment a double booking stops being a warning and becomes a
// refusal: two approved reservations over one bike means somebody turns
// up to an empty rack. Only this transition is guarded — a reservation
// already approved is allowed to start, whatever was overridden earlier.
if status == ReservationStatus::Approved {
let conflicts = self
.db
.find_conflicts(ConflictProbe {
reservation: Some(id),
start_time: reservation.start_time,
end_time: reservation.end_time,
bikes: reservation
.bikes
.iter()
.map(|held| NewReservationBike {
id: held.id,
start_time: Some(held.start_time),
end_time: Some(held.end_time),
})
.collect(),
})
.await?;
if !conflicts.is_empty() {
return Err(ReservationsControllerError::BikesTaken(taken(&conflicts)).into());
}
}
self.db
.set_reservation_status(id, status)
.await
@ -233,20 +387,105 @@ pub enum ReservationsControllerError {
NotOnTheReservation,
#[error("A reservation that is {0:?} only accepts a change of Linka Go accounts")]
OnlyEmailsEditable(ReservationStatus),
#[error("Cargobike(s) {0:?} are already booked over that period")]
BikesTaken(Vec<BikeId>),
}
#[cfg(test)]
mod tests {
use super::same_bikes;
use chrono::{TimeZone, Utc};
use super::{same_fleet, taken};
use crate::core::models::reservation::{
Conflict, NewReservationBike, ReservationBike, ReservationStatus,
};
fn at(hour: u32) -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 8, 25, hour, 0, 0).unwrap()
}
/// A bike on the edit, following the reservation unless told otherwise
fn wanted(id: i32, period: Option<(u32, u32)>) -> NewReservationBike {
NewReservationBike {
id,
start_time: period.map(|(from, _)| at(from)),
end_time: period.map(|(_, to)| at(to)),
}
}
fn held(id: i32, from: u32, to: u32) -> ReservationBike {
ReservationBike {
id,
start_time: at(from),
end_time: at(to),
custom: false,
}
}
#[test]
fn bike_lists_compare_as_sets() {
assert!(same_bikes(&[1, 2], &[2, 1]), "order carries no meaning");
assert!(same_bikes(&[1, 1, 2], &[2, 1]), "nor do repeats");
assert!(same_bikes(&[], &[]));
fn fleets_compare_as_sets_of_bikes_and_periods() {
let (start, end) = (at(12), at(20));
let current = [held(1, 12, 20), held(2, 12, 20)];
assert!(!same_bikes(&[1, 2], &[1]), "one was removed");
assert!(!same_bikes(&[1], &[1, 2]), "one was added");
assert!(!same_bikes(&[1], &[2]));
assert!(
same_fleet(&[wanted(2, None), wanted(1, None)], &current, start, end),
"order carries no meaning"
);
assert!(
same_fleet(
&[wanted(1, None), wanted(1, None), wanted(2, None)],
&current,
start,
end
),
"nor do repeats"
);
assert!(
same_fleet(
&[wanted(1, Some((12, 20))), wanted(2, None)],
&current,
start,
end
),
"spelling out the reservation's own period changes nothing"
);
assert!(
!same_fleet(&[wanted(1, None)], &current, start, end),
"one was removed"
);
assert!(
!same_fleet(
&[wanted(1, None), wanted(2, None), wanted(3, None)],
&current,
start,
end
),
"one was added"
);
assert!(
!same_fleet(
&[wanted(1, Some((12, 15))), wanted(2, None)],
&current,
start,
end
),
"one is handed over early"
);
}
#[test]
fn conflicting_bikes_are_named_once() {
let conflict = |bike| Conflict {
bike,
reservation: 1,
unit: "PolyNite".to_owned(),
status: ReservationStatus::Approved,
start_time: at(12),
end_time: at(20),
};
// The same bike can be held by two reservations at once
assert_eq!(taken(&[conflict(2), conflict(1), conflict(2)]), vec![1, 2]);
assert!(taken(&[]).is_empty());
}
}

View file

@ -30,6 +30,9 @@ impl ReservationStatus {
(self, next),
(Requested, Refused)
| (Requested, Approved)
// Back to the queue: an admin who approved too quickly, or who
// needs the bikes freed while the booking is discussed
| (Approved, Requested)
| (Approved, Cancelled)
| (Approved, Ongoing)
| (Ongoing, Cancelled)
@ -37,12 +40,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
@ -103,6 +142,79 @@ impl NewReservationUnit {
}
}
/// One bike on a reservation, with the period it is actually held for.
///
/// Normally the reservation's own period. An override is how a conflict is
/// settled without moving the whole booking: the reservation keeps its hours
/// and only the disputed bike changes hands earlier.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct ReservationBike {
pub id: BikeId,
/// Already resolved: the bike's own period, or the reservation's
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
/// Whether that period is the bike's own rather than the reservation's
pub custom: bool,
}
/// A bike as an edit carries it. `None` means "the reservation's period", which
/// is what a bike nobody had to arbitrate over keeps.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct NewReservationBike {
pub id: BikeId,
pub start_time: Option<DateTime<Utc>>,
pub end_time: Option<DateTime<Utc>>,
}
impl NewReservationBike {
/// The period this bike is held for, given the reservation's own
pub fn period(
&self,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> (DateTime<Utc>, DateTime<Utc>) {
(
self.start_time.unwrap_or(start),
self.end_time.unwrap_or(end),
)
}
/// Both bounds or neither, the right way round, and inside the
/// reservation's own period: a bike cannot be held when nothing is booked.
fn is_valid(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> bool {
match (self.start_time, self.end_time) {
(None, None) => true,
(Some(from), Some(to)) => from < to && from >= start && to <= end,
_ => false,
}
}
}
/// A bike wanted over a period, as a conflict check asks about it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictProbe {
/// The reservation being edited, left out of its own check
pub reservation: Option<ReservationId>,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub bikes: Vec<NewReservationBike>,
}
/// A bike the probe wants that somebody else already holds over part of the
/// same period. Only the reservations that actually hold a bike — approved and
/// ongoing — can produce one.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Conflict {
pub bike: BikeId,
pub reservation: ReservationId,
/// Who holds it, to be named in the warning
pub unit: String,
pub status: ReservationStatus,
/// When the bike is held, which is what a resolution has to step around
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Reservation {
pub id: ReservationId,
@ -114,7 +226,7 @@ pub struct Reservation {
pub users: Vec<UserSummary>,
pub telegram: String,
pub description: String,
pub bikes: Vec<BikeId>,
pub bikes: Vec<ReservationBike>,
/// Linka Go accounts allowed to unlock the bikes. Plain addresses: they
/// need not belong to anybody who ever logs into this app.
pub linka_emails: Vec<String>,
@ -131,7 +243,8 @@ pub struct CalendarReservation {
pub unit: ReservationUnit,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub bikes: Vec<BikeId>,
/// With their own periods: a bike handed over early is drawn as such
pub bikes: Vec<ReservationBike>,
pub status: ReservationStatus,
}
@ -148,6 +261,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,
@ -169,7 +359,9 @@ pub struct ReservationEdit {
pub users: Vec<UserId>,
pub telegram: String,
pub description: String,
pub bikes: Vec<BikeId>,
/// Each with the period it is held for, or nothing to follow the
/// reservation's own
pub bikes: Vec<NewReservationBike>,
pub linka_emails: Vec<String>,
}
@ -209,6 +401,10 @@ impl ReservationEdit {
&& self.end_time > self.start_time
&& !self.bikes.is_empty()
&& !self.users.is_empty()
&& self
.bikes
.iter()
.all(|bike| bike.is_valid(self.start_time, self.end_time))
&& telegram_valid(&self.telegram)
&& linka_emails_valid(&self.linka_emails)
}
@ -218,6 +414,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,
Conflict, ConflictProbe, NewReservation, Reservation, ReservationEdit, ReservationId,
ReservationPage, ReservationQuery, ReservationStatus,
},
unit::UnitId,
user::UserId,
@ -13,22 +14,26 @@ 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>;
/// The bikes in `probe` that somebody else already holds over part of the
/// same period, one row per overlap. Only approved and ongoing reservations
/// hold a bike, and a reservation is never in conflict with itself.
async fn find_conflicts(&self, probe: ConflictProbe) -> Result<Vec<Conflict>, RepositoryError>;
/// Always stored as `Requested`: the state machine starts here. The
/// requester comes from the session, not from the request body.

View file

@ -12,7 +12,8 @@ use crate::{
core::{
models::{
reservation::{
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
Conflict, ConflictProbe, NewReservation, NewReservationBike, NewReservationUnit,
Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery,
ReservationStatus, ReservationUnit,
},
unit::{Unit, UnitId},
@ -73,7 +74,7 @@ struct ReservationDB {
pub status: ReservationStatusDB,
pub linka_emails: Vec<String>,
pub users: Value,
pub bikes: Vec<i32>,
pub bikes: Value,
}
impl TryFrom<ReservationDB> for Reservation {
@ -100,19 +101,81 @@ impl TryFrom<ReservationDB> for Reservation {
users: serde_json::from_value(value.users)?,
telegram: value.telegram,
description: value.description,
bikes: value.bikes,
bikes: serde_json::from_value(value.bikes)?,
linka_emails: value.linka_emails,
status: value.status.into(),
})
}
}
struct ConflictDB {
pub bike: i32,
pub reservation: i32,
pub unit: String,
pub status: ReservationStatusDB,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
}
impl From<ConflictDB> for Conflict {
fn from(value: ConflictDB) -> Self {
Conflict {
bike: value.bike,
reservation: value.reservation,
unit: value.unit,
status: value.status.into(),
start_time: value.start_time,
end_time: value.end_time,
}
}
}
/// 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: Value,
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>,
id: ReservationId,
users: &[i32],
bikes: &[i32],
bikes: &[NewReservationBike],
) -> Result<(), RepositoryError> {
query!(
r#"DELETE FROM reservations_users WHERE reservation_id = $1"#,
@ -135,11 +198,18 @@ impl SqlxDatabase {
)
.execute(&mut **tx)
.await?;
// Three parallel arrays rather than a row constructor: it keeps the
// insert to one statement, and NULL still means "the reservation's period"
let bike_ids: Vec<i32> = bikes.iter().map(|bike| bike.id).collect();
let starts: Vec<Option<DateTime<Utc>>> = bikes.iter().map(|bike| bike.start_time).collect();
let ends: Vec<Option<DateTime<Utc>>> = bikes.iter().map(|bike| bike.end_time).collect();
query!(
r#"INSERT INTO reservations_bikes (reservation_id, bike_id)
SELECT $1, UNNEST($2::integer[])"#,
r#"INSERT INTO reservations_bikes (reservation_id, bike_id, start_time, end_time)
SELECT $1, * FROM UNNEST($2::integer[], $3::timestamptz[], $4::timestamptz[])"#,
id,
bikes
&bike_ids,
&starts as &[Option<DateTime<Utc>>],
&ends as &[Option<DateTime<Utc>>]
)
.execute(&mut **tx)
.await?;
@ -170,11 +240,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,
@ -200,19 +296,81 @@ impl ReservationsRepository for SqlxDatabase {
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!"
-- Each bike with the period it is actually held for: its own
-- when a conflict was settled by handing it over early, the
-- reservation's otherwise
COALESCE((
SELECT json_agg(json_build_object(
'id', rb.bike_id,
'start_time', COALESCE(rb.start_time, r.start_time),
'end_time', COALESCE(rb.end_time, r.end_time),
'custom', rb.start_time IS NOT NULL
) ORDER BY rb.bike_id)
FROM reservations_bikes rb
WHERE rb.reservation_id = r.id
), '[]'::json) 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(
@ -246,10 +404,19 @@ impl ReservationsRepository for SqlxDatabase {
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!"
-- Each bike with the period it is actually held for: its own
-- when a conflict was settled by handing it over early, the
-- reservation's otherwise
COALESCE((
SELECT json_agg(json_build_object(
'id', rb.bike_id,
'start_time', COALESCE(rb.start_time, r.start_time),
'end_time', COALESCE(rb.end_time, r.end_time),
'custom', rb.start_time IS NOT NULL
) ORDER BY rb.bike_id)
FROM reservations_bikes rb
WHERE rb.reservation_id = r.id
), '[]'::json) AS "bikes!"
FROM reservations r
LEFT JOIN units un ON un.id = r.unit_id
WHERE r.unit_id = $1
@ -263,65 +430,54 @@ impl ReservationsRepository for SqlxDatabase {
.collect::<Result<Vec<_>, _>>()?)
}
async fn get_involved_reservations(
&self,
user: UserId,
email: &str,
) -> Result<Vec<Reservation>, RepositoryError> {
async fn find_conflicts(&self, probe: ConflictProbe) -> Result<Vec<Conflict>, RepositoryError> {
// The probe resolved: every bike with the period it would be held for
let mut bikes = Vec::with_capacity(probe.bikes.len());
let mut starts = Vec::with_capacity(probe.bikes.len());
let mut ends = Vec::with_capacity(probe.bikes.len());
for bike in &probe.bikes {
let (from, to) = bike.period(probe.start_time, probe.end_time);
bikes.push(bike.id);
starts.push(from);
ends.push(to);
}
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,
ConflictDB,
r#"WITH wanted(bike_id, start_time, end_time) AS (
SELECT * FROM UNNEST($1::integer[], $2::timestamptz[], $3::timestamptz[])
)
SELECT
w.bike_id AS "bike!",
r.id AS "reservation!",
COALESCE(un."name", r.unit_label, '') AS "unit!",
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
COALESCE(rb.start_time, r.start_time) AS "start_time!",
COALESCE(rb.end_time, r.end_time) AS "end_time!"
FROM wanted w
JOIN reservations_bikes rb ON rb.bike_id = w.bike_id
JOIN reservations r ON r.id = rb.reservation_id
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
-- Only a reservation that actually holds the bike can conflict: a
-- request holds nothing yet, and a final one holds nothing any more
WHERE r.status IN ('approved', 'ongoing')
-- A reservation never conflicts with itself
AND ($4::integer IS NULL OR r.id <> $4)
-- Half-open overlap: ending exactly when the other starts is fine,
-- which is what makes handing a bike over at 15:00 a resolution
AND COALESCE(rb.start_time, r.start_time) < w.end_time
AND COALESCE(rb.end_time, r.end_time) > w.start_time
ORDER BY w.bike_id, "start_time!""#,
&bikes,
&starts,
&ends,
probe.reservation,
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()?)
.map(Into::into)
.collect())
}
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
@ -352,10 +508,19 @@ impl ReservationsRepository for SqlxDatabase {
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!"
-- Each bike with the period it is actually held for: its own
-- when a conflict was settled by handing it over early, the
-- reservation's otherwise
COALESCE((
SELECT json_agg(json_build_object(
'id', rb.bike_id,
'start_time', COALESCE(rb.start_time, r.start_time),
'end_time', COALESCE(rb.end_time, r.end_time),
'custom', rb.start_time IS NOT NULL
) ORDER BY rb.bike_id)
FROM reservations_bikes rb
WHERE rb.reservation_id = r.id
), '[]'::json) AS "bikes!"
FROM reservations r
LEFT JOIN units un ON un.id = r.unit_id
WHERE r.id = $1"#,
@ -401,7 +566,18 @@ impl ReservationsRepository for SqlxDatabase {
if !users.contains(&requester) {
users.push(requester);
}
Self::set_reservation_links(&mut tx, id, &users, &reservation.bikes).await?;
// A new reservation holds every bike for its own period; only an edit
// can hand one over early.
let bikes: Vec<NewReservationBike> = reservation
.bikes
.iter()
.map(|&id| NewReservationBike {
id,
start_time: None,
end_time: None,
})
.collect();
Self::set_reservation_links(&mut tx, id, &users, &bikes).await?;
tx.commit().await?;
@ -484,3 +660,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%");
}
}

View file

@ -1,2 +1,3 @@
//! External services used by the core: database, and any third party api you add.
pub mod database;
pub mod telegram;

233
src/services/telegram.rs Normal file
View file

@ -0,0 +1,233 @@
//! Telegram notifications to the group that runs the cargobikes.
//!
//! The point of this module is that adding a message is a two-line change:
//! a variant on [`Notification`] and an arm in [`Notification::render`].
//! Nothing else in the app has to know that Telegram exists, nor how a message
//! is worded — a caller says *what happened*, not *what to write*.
//!
//! Sending never blocks the request that triggered it and never fails it: a
//! reservation is filed whether or not the group was told about it. When the
//! bot is not configured, [`notify`] is a no-op, which is what a development
//! machine and the test suite want.
use std::sync::OnceLock;
use chrono::{DateTime, Utc};
use openidconnect::reqwest;
use serde::Serialize;
use tracing::{debug, error, warn};
use crate::{core::models::reservation::Reservation, utils::config};
/// Something worth telling the group about.
///
/// Each variant carries what the message needs, already resolved: the renderer
/// reads no database and can therefore never fail nor be slow.
#[derive(Debug, Clone)]
pub enum Notification {
/// A reservation request has just been filed
ReservationRequested {
id: i32,
unit: String,
requester: String,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
bikes: Vec<String>,
description: String,
},
}
impl Notification {
/// The message body, in Telegram's HTML parse mode. Only `&`, `<` and `>`
/// need escaping there, which [`escape`] does for every value that comes
/// from a user.
fn render(&self) -> String {
match self {
Notification::ReservationRequested {
id,
unit,
requester,
start_time,
end_time,
bikes,
description,
} => {
let bikes = if bikes.is_empty() {
"—".to_owned()
} else {
escape(&bikes.join(", "))
};
format!(
"🚲 <b>Nouvelle demande de réservation #{id}</b>\n\
Association : {unit}\n\
Demandée par : {requester}\n\
Période : {start} → {end}\n\
Cargobike(s) : {bikes}\n\
Raison : {description}",
unit = escape(unit),
requester = escape(requester),
start = format_moment(*start_time),
end = format_moment(*end_time),
description = escape(description),
)
}
}
}
}
impl Notification {
/// The notification a freshly filed request produces, given the reservation
/// as stored and the names of the bikes it holds.
pub fn reservation_requested(reservation: &Reservation, bikes: Vec<String>) -> Self {
Notification::ReservationRequested {
id: reservation.id,
unit: reservation.unit.label().to_owned(),
requester: reservation
.users
.iter()
.find(|user| user.id == reservation.requester)
.map_or_else(
|| format!("#{}", reservation.requester),
|user| format!("{} {}", user.firstname, user.name),
),
start_time: reservation.start_time,
end_time: reservation.end_time,
bikes,
description: reservation.description.clone(),
}
}
}
/// Sends `notification` to the configured group, in the background.
///
/// Returns immediately. A failure is logged and goes no further: the caller's
/// own work has already succeeded, and undoing it because Telegram is down
/// would be worse than a missing message.
pub fn notify(notification: Notification) {
let Some(telegram) = config::get().telegram.as_ref() else {
debug!("[TELEGRAM] not configured, dropping {notification:?}");
return;
};
let token = telegram.bot_token.clone();
let chat_id = telegram.chat_id.clone();
let api_url = telegram.get_api_url().to_owned();
tokio::spawn(async move {
if let Err(err) = send(&api_url, &token, &chat_id, &notification.render()).await {
error!("[TELEGRAM] could not send the notification: {err}");
}
});
}
#[derive(Serialize)]
struct SendMessage<'a> {
chat_id: &'a str,
text: &'a str,
parse_mode: &'a str,
/// Link previews turn a reservation into a wall of nothing
disable_web_page_preview: bool,
}
async fn send(api_url: &str, token: &str, chat_id: &str, text: &str) -> Result<(), String> {
// Serialised by hand: the http client is the one `openidconnect` brings, and
// it is built without its `json` feature.
let body = serde_json::to_string(&SendMessage {
chat_id,
text,
parse_mode: "HTML",
disable_web_page_preview: true,
})
.map_err(|err| err.to_string())?;
let response = http_client()
.post(format!("{api_url}/bot{token}/sendMessage"))
.header("content-type", "application/json")
.body(body)
.send()
.await
.map_err(|err| err.to_string())?;
if !response.status().is_success() {
// Telegram explains itself in the body, and that is the only way to
// tell "wrong token" from "the bot is not in that group"
let status = response.status();
let body = response.text().await.unwrap_or_default();
warn!("[TELEGRAM] {status}: {body}");
return Err(format!("{status}: {body}"));
}
Ok(())
}
fn http_client() -> &'static reqwest::Client {
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
HTTP_CLIENT.get_or_init(|| {
reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("Unable to build the telegram http client")
})
}
/// The three characters Telegram's HTML mode reads as markup
fn escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Swiss local time, which is the only one the group cares about
fn format_moment(moment: DateTime<Utc>) -> String {
moment
.with_timezone(&chrono::FixedOffset::east_opt(2 * 3600).expect("valid offset"))
.format("%d.%m.%Y %H:%M")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn moment(day: u32, hour: u32) -> DateTime<Utc> {
use chrono::TimeZone;
Utc.with_ymd_and_hms(2026, 8, day, hour, 0, 0).unwrap()
}
#[test]
fn a_request_reads_as_a_message() {
let message = Notification::ReservationRequested {
id: 12,
unit: "PolyNite".to_owned(),
requester: "Milan Hyenne".to_owned(),
start_time: moment(25, 10),
end_time: moment(26, 16),
bikes: vec!["1000".to_owned(), "2000".to_owned()],
description: "Transport du matériel".to_owned(),
}
.render();
assert!(message.contains("#12"));
assert!(message.contains("PolyNite"));
assert!(message.contains("1000, 2000"));
// Rendered in local time: 10:00 UTC is noon in Lausanne
assert!(message.contains("25.08.2026 12:00"), "{message}");
}
#[test]
fn markup_in_a_user_value_is_escaped() {
let message = Notification::ReservationRequested {
id: 1,
unit: "<b>Fake</b> & Co".to_owned(),
requester: "A".to_owned(),
start_time: moment(25, 10),
end_time: moment(25, 12),
bikes: vec![],
description: String::new(),
}
.render();
assert!(message.contains("&lt;b&gt;Fake&lt;/b&gt; &amp; Co"));
// The heading is ours, and stays markup
assert!(message.contains("<b>Nouvelle demande"));
}
}

View file

@ -31,6 +31,26 @@ pub struct OidcConfig {
pub session_lifetime: Option<i64>,
}
/// The bot that posts to the group. Absent, nothing is sent — which is what a
/// development machine wants. Both values are secrets: keep them in `.env` or
/// in the environment, not in a committed file.
#[derive(Deserialize, Clone)]
pub struct TelegramConfig {
pub bot_token: String,
pub chat_id: String,
/// Where the bot api lives. Only ever set to something else to point the
/// sender at a stub, or at a proxy in a network that cannot reach Telegram.
pub api_url: Option<String>,
}
impl TelegramConfig {
pub fn get_api_url(&self) -> &str {
self.api_url
.as_deref()
.unwrap_or("https://api.telegram.org")
}
}
/// A user that can be logged in without going through the provider.
/// Only usable in debug builds, see `POST /api/login`.
#[derive(Deserialize, Clone)]
@ -50,6 +70,9 @@ pub struct AppConfig {
pub server: ServerConfig,
pub postgres: PostgresConfig,
pub oidc: OidcConfig,
/// Absent when the bot is not set up: notifications are then dropped
#[serde(default)]
pub telegram: Option<TelegramConfig>,
#[serde(default)]
pub dev_users: Vec<DevUserConfig>,
/// Directory containing the built frontend
@ -89,11 +112,18 @@ impl AppConfig {
/// Loads the configuration, by decreasing priority:
/// - `APP__SERVER__PORT=3000` style environment variables
/// - the same variables read from `./.env`, which is where the secrets live in
/// development (a real environment variable still wins over the file)
/// - `./config.yml`
/// - the file pointed by `$APP_CONFIG` (`/etc/cargagep/config.yml` by default)
pub fn get() -> &'static AppConfig {
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
CONFIG.get_or_init(|| {
// `.env` is read into the environment first, so `APP__*` written there
// behaves exactly like a variable exported by hand. Anything already in
// the environment is left alone.
load_dotenv();
let config_path =
std::env::var("APP_CONFIG").unwrap_or("/etc/cargagep/config.yml".to_owned());
let config_path = Path::new(&config_path);
@ -112,3 +142,27 @@ pub fn get() -> &'static AppConfig {
.expect("Invalid config format")
})
}
/// A minimal `.env` reader: `KEY=value` per line, `#` comments, optional
/// surrounding quotes. Deliberately not a dependency — the file is ours, and
/// dbmate already reads it with the same rules.
fn load_dotenv() {
let Ok(contents) = std::fs::read_to_string(".env") else {
return;
};
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim().trim_start_matches("export ").trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
// An exported variable wins: that is how a deployment overrides the file
if std::env::var_os(key).is_none() {
unsafe { std::env::set_var(key, value) };
}
}
}