feat: add admin page

This commit is contained in:
Antoine Pelletier 2026-08-24 11:09:59 +02:00
parent 1ae4ba3a58
commit 754d2ddfc4
32 changed files with 1915 additions and 487 deletions

View file

@ -29,6 +29,10 @@ spot rather than rejected: an unknown unit must never break a login.
In a debug build, `dev_users` from the configuration can be logged in through
`POST /api/login` without going through the provider — see the login page.
`/reservations` needs a session and `/admin` needs an admin. The router guards are a
convenience only: every protected route answers 401 or 403 on its own, whatever the
frontend does.
## Getting started
```bash

View file

@ -0,0 +1,27 @@
-- migrate:up
-- A reservation names either a unit we know (Whiskey group, `unit_id`) or a
-- free text the requester typed (`unit_label`). Typing a name must never create
-- a `units` row, so the two are exclusive rather than the second being a
-- fallback name for the first.
ALTER TABLE reservations ALTER COLUMN unit_id DROP NOT NULL;
ALTER TABLE reservations ADD COLUMN unit_label TEXT;
ALTER TABLE reservations ADD CONSTRAINT reservations_unit_xor CHECK (
(unit_id IS NULL) <> (unit_label IS NULL)
);
-- A free label is a name, not an empty string
ALTER TABLE reservations ADD CONSTRAINT reservations_unit_label_not_blank CHECK (
unit_label IS NULL OR btrim(unit_label) <> ''
);
-- migrate:down
ALTER TABLE reservations DROP CONSTRAINT reservations_unit_label_not_blank;
ALTER TABLE reservations DROP CONSTRAINT reservations_unit_xor;
-- The rows that only had a free label have no unit to point at any more
DELETE FROM reservations WHERE unit_id IS NULL;
ALTER TABLE reservations DROP COLUMN unit_label;
ALTER TABLE reservations ALTER COLUMN unit_id SET NOT NULL;

View file

@ -102,9 +102,12 @@ CREATE TABLE public.reservations (
telegram text NOT NULL,
description text DEFAULT ''::text NOT NULL,
status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL,
unit_id integer NOT NULL,
unit_id integer,
unit_label text,
CONSTRAINT reservations_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)),
CONSTRAINT reservations_time_order CHECK ((end_time > start_time))
CONSTRAINT reservations_time_order CHECK ((end_time > start_time)),
CONSTRAINT reservations_unit_label_not_blank CHECK (((unit_label IS NULL) OR (btrim(unit_label) <> ''::text))),
CONSTRAINT reservations_unit_xor CHECK (((unit_id IS NULL) <> (unit_label IS NULL)))
);
@ -486,4 +489,5 @@ INSERT INTO public.schema_migrations (version) VALUES
('20260823153310'),
('20260823153320'),
('20260823170000'),
('20260823210000');
('20260823210000'),
('20260823230000');

View file

@ -39,24 +39,52 @@ INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, batt
(5, '5000', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service')
ON CONFLICT DO NOTHING;
-- Reservations across the current week, one per status, so the admin page and
-- the calendar always have something to show. Times are relative to `now()`.
INSERT INTO public.reservations
(id, unit_id, start_time, end_time, requester_id, telegram, "description", status)
SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description", r.status
FROM (VALUES
(1, 'agepoly', '2026-09-01 08:00:00+02'::timestamptz, '2026-09-01 18:00:00+02'::timestamptz,
1, '@alice_martin', 'Transport du matériel pour la rentrée', 'approved'::reservation_status),
(2, 'clic', '2026-09-05 09:00:00+02'::timestamptz, '2026-09-06 17:00:00+02'::timestamptz,
3, '@chloe_favre', 'Déménagement du stock de la commission', 'requested'::reservation_status)
(1, 'agepoly',
date_trunc('day', now()) - interval '1 day' + interval '8 hours',
date_trunc('day', now()) + interval '1 day' + interval '18 hours',
1, '@alice_martin', 'Transport du matériel pour la rentrée',
'ongoing'::reservation_status),
(2, 'clic',
date_trunc('day', now()) + interval '1 day' + interval '9 hours',
date_trunc('day', now()) + interval '2 days' + interval '17 hours',
3, '@chloe_favre', 'Déménagement du stock de la commission',
'approved'::reservation_status),
(3, 'agepoly',
date_trunc('day', now()) + interval '3 days' + interval '10 hours',
date_trunc('day', now()) + interval '3 days' + interval '19 hours',
2, '@bob_dupont', 'Livraison des boissons pour la soirée',
'requested'::reservation_status),
(4, 'clic',
date_trunc('day', now()) + interval '4 days' + interval '7 hours',
date_trunc('day', now()) + interval '4 days' + interval '12 hours',
3, '@chloe_favre', 'Récupération de matériel informatique',
'requested'::reservation_status),
(5, 'agepoly',
date_trunc('day', now()) - interval '20 days' + interval '9 hours',
date_trunc('day', now()) - interval '19 days' + interval '18 hours',
1, '@alice_martin', 'Ancienne sortie, archivée',
'archived'::reservation_status),
(6, 'clic',
date_trunc('day', now()) + interval '6 days' + interval '14 hours',
date_trunc('day', now()) + interval '6 days' + interval '18 hours',
2, '@bob_dupont', 'Annulée faute de conducteur',
'cancelled'::reservation_status)
) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description", 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)
(1, 1), (1, 2), (2, 3), (3, 2), (3, 1), (4, 3), (5, 1), (6, 2)
ON CONFLICT DO NOTHING;
INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
(1, 1), (1, 2), (2, 1)
(1, 1), (1, 2), (2, 3), (3, 1), (3, 4), (4, 2), (5, 5), (6, 4)
ON CONFLICT DO NOTHING;
-- Keep the sequences in sync with the explicit ids inserted above

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { LogOut, Menu, Moon, Sun, User } from '@lucide/vue'
import { useRouter } from 'vue-router'
@ -23,14 +23,16 @@ const router = useRouter()
const flags: Record<Locale, string> = { fr: '🇫🇷', en: '🇬🇧' }
const nav = [
const { user, isLoggedIn } = useSession()
const nav = computed(() => [
{ name: 'reservations', label: 'header.reserve' },
{ name: 'calendar', label: 'header.calendar' },
]
...(user.value?.admin ? [{ name: 'admin', label: 'header.admin' }] : []),
])
const menuOpen = ref(false)
const { user, isLoggedIn } = useSession()
const logoutMutation = useLogoutMutation()
function login() {

View file

@ -9,11 +9,10 @@ import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const model = defineModel<DateValue | undefined>()
defineProps<{ id?: string; minValue?: DateValue; invalid?: boolean }>()
defineProps<{ id?: string; minValue?: DateValue; maxValue?: DateValue; invalid?: boolean }>()
const { locale } = useI18n()
// The popover is controlled so that picking a day closes it
const open = ref(false)
const intlLocale = computed(() => (locale.value === 'fr' ? 'fr-CH' : 'en-GB'))
@ -42,6 +41,7 @@ const formatter = computed(() => new DateFormatter(intlLocale.value, { dateStyle
v-model="model"
:locale="intlLocale"
:min-value="minValue"
:max-value="maxValue"
initial-focus
@update:model-value="open = false"
/>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { Input } from '@/components/ui/input'
const model = defineModel<string>({ default: '' })
defineProps<{ id?: string; invalid?: boolean }>()
function clean(value: string) {
return value
.replace(/^\s*(?:https?:\/\/)?(?:t\.me\/|telegram\.me\/)?/i, '')
.replace(/^@+/, '')
.trim()
}
</script>
<template>
<div
class="border-input focus-within:border-ring focus-within:ring-ring/50 flex h-9 w-full items-center rounded-md border shadow-xs focus-within:ring-[3px]"
:class="invalid ? 'border-destructive' : ''"
>
<span class="text-muted-foreground select-none pl-3 text-sm" aria-hidden="true">@</span>
<Input
:id="id"
:model-value="model"
class="h-8 border-0 pl-0.5 shadow-none focus-visible:ring-0"
autocomplete="off"
spellcheck="false"
:placeholder="$t('reservation.telegram-placeholder')"
@update:model-value="model = clean(String($event))"
/>
</div>
</template>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { Unit } from '@/utils/types'
export type UnitChoice =
| { kind: 'known'; id: number; name: string }
| { kind: 'free'; name: string }
const model = defineModel<UnitChoice | undefined>()
const props = defineProps<{ units: Unit[]; id?: string; invalid?: boolean }>()
const FREE = '__free__'
const selected = ref<string>(
model.value?.kind === 'known' ? String(model.value.id) : model.value ? FREE : '',
)
const freeName = ref(model.value?.kind === 'free' ? model.value.name : '')
const onlyFree = computed(() => props.units.length === 0)
const showFreeInput = computed(() => onlyFree.value || selected.value === FREE)
function emitChoice() {
if (showFreeInput.value) {
const name = freeName.value.trim()
model.value = name ? { kind: 'free', name } : undefined
return
}
const unit = props.units.find((u) => String(u.id) === selected.value)
model.value = unit ? { kind: 'known', id: unit.id, name: unit.name } : undefined
}
watch([selected, freeName], emitChoice)
watch(model, (choice) => {
if (choice === undefined && (selected.value || freeName.value)) {
selected.value = ''
freeName.value = ''
}
})
</script>
<template>
<div class="grid gap-2">
<Select v-if="!onlyFree" v-model="selected">
<SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full">
<SelectValue :placeholder="$t('reservation.association-placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="unit in units" :key="unit.id" :value="String(unit.id)">
{{ unit.name }}
</SelectItem>
<SelectItem :value="FREE">{{ $t('reservation.association-other') }}</SelectItem>
</SelectContent>
</Select>
<Input
v-if="showFreeInput"
:id="onlyFree ? id : undefined"
v-model="freeName"
:aria-label="$t('reservation.association')"
:placeholder="$t('reservation.association-placeholder')"
:aria-invalid="invalid || undefined"
/>
</div>
</template>

View file

@ -0,0 +1,236 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
const props = defineProps<{ reservations: Reservation[]; bikes: Bike[] }>()
const { locale } = useI18n()
const FIRST_HOUR = 7
const LAST_HOUR = 20
const HOUR_HEIGHT = 44
const weekStart = ref(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
}
const days = computed(() =>
Array.from({ length: 7 }, (_, i) => {
const day = new Date(weekStart.value)
day.setDate(day.getDate() + i)
return day
}),
)
const intlLocale = computed(() => (locale.value === 'fr' ? 'fr-CH' : 'en-GB'))
const dayFormatter = computed(
() =>
new Intl.DateTimeFormat(intlLocale.value, {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}),
)
const rangeFormatter = computed(
() =>
new Intl.DateTimeFormat(intlLocale.value, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}),
)
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 hours = computed(() =>
Array.from({ length: LAST_HOUR - FIRST_HOUR }, (_, i) => FIRST_HOUR + i),
)
const lanes = computed(() =>
selectedBike.value === 'all'
? props.bikes
: props.bikes.filter((bike) => String(bike.id) === selectedBike.value),
)
/** Only what actually holds a bike ends up on the planning */
const booked = computed(() =>
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'),
)
const today = new Date()
function isToday(day: Date) {
return day.toDateString() === today.toDateString()
}
type Block = { id: number; top: number; height: number; label: string }
function blocksFor(day: Date, bike: Bike): Block[] {
const dayStart = new Date(day)
dayStart.setHours(FIRST_HOUR, 0, 0, 0)
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 []
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, label: unitLabel(reservation.unit) }]
})
}
</script>
<template>
<Card>
<CardHeader class="gap-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>{{ $t('admin.calendar.title') }}</CardTitle>
<CardDescription>{{ $t('admin.calendar.intro') }}</CardDescription>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground text-sm">{{ $t('admin.calendar.bike') }}</span>
<Select v-model="selectedBike">
<SelectTrigger class="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ $t('admin.calendar.all-bikes') }}</SelectItem>
<SelectItem v-for="bike in bikes" :key="bike.id" :value="String(bike.id)">
{{ bike.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" @click="shiftWeek(-1)">
<ChevronLeft class="size-4" />
{{ $t('admin.calendar.previous') }}
</Button>
<Button variant="secondary" size="sm" @click="weekStart = startOfWeek(new Date())">
{{ $t('admin.calendar.today') }}
</Button>
<Button variant="outline" size="sm" @click="shiftWeek(1)">
{{ $t('admin.calendar.next') }}
<ChevronRight class="size-4" />
</Button>
<span class="text-muted-foreground text-sm">{{ range }}</span>
</div>
</CardHeader>
<CardContent>
<p v-if="!lanes.length" class="text-muted-foreground text-sm">
{{ $t('admin.calendar.no-bike') }}
</p>
<!-- The grid is wide: it scrolls on its own rather than the page -->
<div v-else class="overflow-x-auto">
<div class="min-w-[52rem]">
<div class="flex border-b">
<div class="text-muted-foreground w-12 shrink-0 py-2 text-xs">
{{ $t('admin.calendar.hour') }}
</div>
<div
v-for="day in days"
:key="day.toISOString()"
class="min-w-0 flex-1 border-l px-1 py-2"
:class="isToday(day) ? 'bg-primary/5' : ''"
>
<div class="truncate text-sm font-medium" :class="isToday(day) ? 'text-primary' : ''">
{{ dayFormatter.format(day) }}
</div>
<div class="text-muted-foreground flex gap-px text-[0.6rem]">
<span v-for="bike in lanes" :key="bike.id" class="flex-1 truncate text-center">
{{ bike.name }}
</span>
</div>
</div>
</div>
<div class="flex">
<!-- Hour labels -->
<div class="w-12 shrink-0">
<div
v-for="hour in hours"
:key="hour"
class="text-muted-foreground border-b text-xs"
:style="{ height: `${HOUR_HEIGHT}px` }"
>
{{ String(hour).padStart(2, '0') }}:00
</div>
</div>
<div
v-for="day in days"
:key="day.toISOString()"
class="relative min-w-0 flex-1 border-l"
:class="isToday(day) ? 'bg-primary/5' : ''"
>
<!-- Hour lines, behind the blocks -->
<div
v-for="hour in hours"
:key="hour"
class="border-b"
:style="{ height: `${HOUR_HEIGHT}px` }"
/>
<!-- One lane per bike, blocks positioned inside -->
<div class="absolute inset-0 flex gap-px px-px">
<div v-for="bike in lanes" :key="bike.id" class="relative min-w-0 flex-1">
<div
v-for="block in blocksFor(day, bike)"
:key="`${block.id}-${bike.id}`"
class="bg-primary text-primary-foreground absolute inset-x-0 overflow-hidden rounded-sm px-0.5 text-[0.6rem] leading-tight"
:style="{ top: `${block.top}px`, height: `${block.height}px` }"
:title="`#${block.id} — ${block.label}`"
>
{{ block.label }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<p class="text-muted-foreground mt-3 text-xs">{{ $t('admin.calendar.legend') }}</p>
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* The fleet, one card per bike.
*
* Three states are shown but only two are stored: `in_service` and
* `out_of_service` live in the database, while **in use** is derived from the
* reservations that are currently `ongoing`. The button therefore only ever
* toggles between the two real ones.
*/
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { useBikes, useSetBikeStatus } from '@/services/api/bikes'
import type { Bike, Reservation } from '@/utils/types'
const props = defineProps<{ reservations: Reservation[] }>()
const { t } = useI18n()
const { data: bikes, isPending, isError } = useBikes()
const setStatus = useSetBikeStatus()
/** Bikes held by a reservation that is under way right now */
const inUse = computed(() => {
const ids = new Set<number>()
for (const reservation of props.reservations) {
if (reservation.status === 'ongoing') reservation.bikes.forEach((id) => ids.add(id))
}
return ids
})
type Display = 'in_use' | 'in_service' | 'out_of_service'
function display(bike: Bike): Display {
if (bike.status === 'out_of_service') return 'out_of_service'
return inUse.value.has(bike.id) ? 'in_use' : 'in_service'
}
// One place decides the colour of a card, so the three states stay legible in
// both themes
const CARD_CLASS: Record<Display, string> = {
in_use: 'border-primary/40 bg-primary/5',
in_service: 'border-emerald-500/40 bg-emerald-500/5',
out_of_service: 'border-destructive/40 bg-destructive/5',
}
const LABEL_CLASS: Record<Display, string> = {
in_use: 'text-primary',
in_service: 'text-emerald-700 dark:text-emerald-400',
out_of_service: 'text-destructive',
}
function toggle(bike: Bike) {
const status = bike.status === 'in_service' ? 'out_of_service' : 'in_service'
setStatus.mutate({ id: bike.id, status }, { onError: () => toast.error(t('admin.bikes.error')) })
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.bikes.title') }}</CardTitle>
<CardDescription>{{ $t('admin.bikes.intro') }}</CardDescription>
</CardHeader>
<CardContent>
<div v-if="isPending" class="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Skeleton v-for="i in 5" :key="i" class="h-40 w-full" />
</div>
<p v-else-if="isError" class="text-destructive text-sm">
{{ $t('admin.bikes.load-error') }}
</p>
<p v-else-if="!bikes?.length" class="text-muted-foreground text-sm">
{{ $t('admin.bikes.empty') }}
</p>
<div v-else class="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<div
v-for="bike in bikes"
:key="bike.id"
class="flex flex-col items-center gap-3 rounded-lg border p-4 text-center"
:class="CARD_CLASS[display(bike)]"
>
<span class="text-primary text-2xl font-bold">{{ bike.name }}</span>
<span class="text-sm font-medium" :class="LABEL_CLASS[display(bike)]">
{{ $t(`admin.bikes.status.${display(bike)}`) }}
</span>
<Button
variant="outline"
size="sm"
class="mt-auto w-full"
:disabled="setStatus.isPending.value"
@click="toggle(bike)"
>
{{
bike.status === 'in_service'
? $t('admin.bikes.deactivate')
: $t('admin.bikes.activate')
}}
</Button>
</div>
</div>
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,119 @@
<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.
*/
import { computed, ref } from 'vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import ReservationCard from './ReservationCard.vue'
import type { Bike, Reservation } from '@/utils/types'
const props = defineProps<{
reservations: Reservation[]
bikes: Bike[]
isPending: boolean
isError: boolean
}>()
const showArchived = 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 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
})
}
</script>
<template>
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.reservations.title') }}</CardTitle>
<CardDescription>{{ $t('admin.reservations.intro') }}</CardDescription>
</CardHeader>
<CardContent class="grid gap-6">
<div v-if="isPending" class="grid gap-3">
<Skeleton class="h-28 w-full" />
<Skeleton 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>
<section class="grid gap-3">
<h3 class="font-medium">
{{ $t('admin.reservations.pending') }}
<span v-if="pending.length" class="text-muted-foreground">({{ pending.length }})</span>
</h3>
<p v-if="!pending.length" class="text-muted-foreground text-sm">
{{ $t('admin.reservations.pending-empty') }}
</p>
<ReservationCard
v-for="reservation in byStart(pending)"
:key="reservation.id"
:reservation="reservation"
:bikes="bikes"
/>
</section>
<section class="grid gap-3">
<h3 class="font-medium">
{{ $t('admin.reservations.active') }}
<span v-if="active.length" class="text-muted-foreground">({{ active.length }})</span>
</h3>
<p v-if="!active.length" class="text-muted-foreground text-sm">
{{ $t('admin.reservations.active-empty') }}
</p>
<ReservationCard
v-for="reservation in byStart(active)"
: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>
</template>
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,118 @@
<script setup lang="ts">
/**
* One reservation, with the transitions its current status allows. The buttons
* mirror `ReservationStatus::can_transition_to` on the backend, which re-checks
* and answers 409 on anything else.
*/
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
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'
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
const { t, locale } = useI18n()
const setStatus = useSetReservationStatus()
/** Kept in step with the state machine drawn on the backend enum */
const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
requested: ['approved', 'refused'],
approved: ['ongoing', 'cancelled'],
ongoing: ['archived', 'cancelled'],
refused: [],
cancelled: [],
archived: [],
}
const BADGE_CLASS: Record<ReservationStatus, string> = {
requested: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
approved: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
ongoing: 'bg-primary/15 text-primary',
refused: 'bg-destructive/15 text-destructive',
cancelled: 'bg-destructive/15 text-destructive',
archived: 'bg-muted text-muted-foreground',
}
const transitions = computed(() => TRANSITIONS[props.reservation.status])
const bikeNames = computed(() =>
props.reservation.bikes
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
.join(', '),
)
const formatter = computed(
() =>
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
dateStyle: 'short',
timeStyle: 'short',
}),
)
function format(iso: string) {
return formatter.value.format(new Date(iso))
}
function move(status: ReservationStatus) {
setStatus.mutate(
{ id: props.reservation.id, status },
{ onError: () => toast.error(t('admin.reservations.error')) },
)
}
</script>
<template>
<div class="rounded-lg border p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="font-semibold">#{{ reservation.id }}</span>
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
{{ $t(`admin.reservations.status.${reservation.status}`) }}
</Badge>
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span>
</div>
<div v-if="transitions.length" class="flex flex-wrap gap-2">
<Button
v-for="status in transitions"
:key="status"
size="sm"
:variant="status === 'approved' ? 'default' : 'outline'"
:disabled="setStatus.isPending.value"
@click="move(status)"
>
{{ $t(`admin.reservations.action.${status}`) }}
</Button>
</div>
</div>
<dl class="mt-3 grid gap-1 text-sm">
<div class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.period') }}</dt>
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.bikes') }}</dt>
<dd>{{ bikeNames }}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.telegram') }}</dt>
<dd class="truncate">{{ reservation.telegram }}</dd>
</div>
<div class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.people') }}</dt>
<dd class="min-w-0 break-words">
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
</dd>
</div>
<div v-if="reservation.description" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
</div>
</dl>
</div>
</template>

View file

@ -4,394 +4,633 @@
*/
export interface paths {
"/api/version": {
'/api/version': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
query?: never
header?: never
path?: never
cookie?: never
}
/** Get app version */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown;
};
[name: string]: unknown
}
content: {
"application/json": string;
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me": {
'application/json': string
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/me': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
query?: never
header?: never
path?: never
cookie?: never
}
/**
* Get the logged in user
* @description Answers `null` when nobody is logged in.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown;
};
[name: string]: unknown
}
content: {
"application/json": components["schemas"]["User"] | null;
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/logout": {
'application/json': components['schemas']['User'] | null
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/logout': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Log the user out */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description no content */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
[name: string]: unknown
}
content?: never
}
/** @description no content */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/whiskey/authorize": {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/whiskey/authorize': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
query?: never
header?: never
path?: never
cookie?: never
}
/** Whiskey - Get the authorization url */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown;
};
[name: string]: unknown
}
content: {
"application/json": components["schemas"]["GetAuthorizeResponse"];
};
};
'application/json': components['schemas']['GetAuthorizeResponse']
}
}
/** @description no content */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/whiskey/callback": {
[name: string]: unknown
}
content?: never
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/whiskey/callback': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Whiskey - Complete the login */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
"application/json": components["schemas"]["PostCallbackParams"];
};
};
'application/json': components['schemas']['PostCallbackParams']
}
}
responses: {
200: {
headers: {
[name: string]: unknown;
};
[name: string]: unknown
}
content: {
"application/json": components["schemas"]["User"];
};
};
'application/json': components['schemas']['User']
}
}
/** @description no content */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
[name: string]: unknown
}
content?: never
}
/** @description no content */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/login": {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/login': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/**
* Log in as a dev user
* @description Debug builds only. Takes the email of one of the `dev_users` of the configuration, creates the row if needed, and opens a session.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
"application/json": components["schemas"]["LoginDevForm"];
};
};
'application/json': components['schemas']['LoginDevForm']
}
}
responses: {
200: {
headers: {
[name: string]: unknown;
};
[name: string]: unknown
}
content: {
"application/json": components["schemas"]["User"];
};
};
'application/json': components['schemas']['User']
}
}
/** @description no content */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/login/dev-users": {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/login/dev-users': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
query?: never
header?: never
path?: never
cookie?: never
}
/**
* List the dev users
* @description Debug builds only.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown;
};
[name: string]: unknown
}
content: {
"application/json": components["schemas"]["DevUser"][];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/bikes": {
'application/json': components['schemas']['DevUser'][]
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/bikes': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
query?: never
header?: never
path?: never
cookie?: never
}
/** Get the fleet */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Bike"][];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
[name: string]: unknown
}
export type webhooks = Record<string, never>;
content: {
'application/json': components['schemas']['Bike'][]
}
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/bikes/{id}/status': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
/** Put a bike in or out of service */
put: {
parameters: {
query?: never
header?: never
path: {
id: number
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['SetStatusForm']
}
}
responses: {
/** @description no content */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be an admin */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description no content */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/reservations': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get every reservation */
get: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Reservation'][]
}
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be an admin */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/reservations/{id}/status': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
/**
* Move a reservation through its state machine
* @description Refuses a transition the state machine does not allow, with a 409.
*/
put: {
parameters: {
query?: never
header?: never
path: {
id: number
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['SetStatusForm2']
}
}
responses: {
/** @description no content */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be part of the unit, or an admin when the reservation names no known unit */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description no content */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description no content */
409: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
}
export type webhooks = Record<string, never>
export interface components {
schemas: {
Bike: {
battery?: string | null;
drivetrain?: string | null;
battery?: string | null
drivetrain?: string | null
/** Format: int32 */
id: number;
key_number?: string | null;
id: number
key_number?: string | null
/** Format: int32 */
key_quantity: number;
name: string;
status: components["schemas"]["BikeStatus"];
};
key_quantity: number
name: string
status: components['schemas']['BikeStatus']
}
/** @enum {string} */
BikeStatus: "in_service" | "out_of_service";
BikeStatus: 'in_service' | 'out_of_service'
DevUser: {
admin: boolean;
email: string;
firstname: string;
name: string;
};
admin: boolean
email: string
firstname: string
name: string
}
GetAuthorizeResponse: {
redirect_to: string;
};
redirect_to: string
}
IdPath: {
/** Format: int32 */
id: number
}
LoginDevForm: {
/** @description Email of one of the `dev_users` of the configuration */
user: string;
};
user: string
}
PostCallbackParams: {
code: string;
state: string;
};
code: string
state: string
}
Reservation: {
description: string
bikes: number[]
/** Format: date-time */
end_time: string
/** Format: int32 */
id: number
/** Format: int32 */
requester: number
/** Format: date-time */
start_time: string
status: components['schemas']['ReservationStatus']
telegram: string
/** @description The one unit the bikes are lent to */
unit: components['schemas']['ReservationUnit']
users: components['schemas']['UserSummary'][]
}
/** @enum {string} */
ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived'
/**
* @description The unit a reservation is filed for.
*
* Either one we know — a Whiskey group, with its row — or a plain name the
* requester typed. Typing a name must never create a unit, so the two are
* exclusive by construction rather than "a name that may or may not resolve".
*/
ReservationUnit:
| {
/** @constant */
kind: 'known'
unit: components['schemas']['Unit']
}
| {
/** @constant */
kind: 'free'
name: string
}
SetStatusForm: {
status: components['schemas']['BikeStatus']
}
SetStatusForm2: {
status: components['schemas']['ReservationStatus']
}
Unit: {
/** Format: int32 */
id: number;
id: number
/** @description The Whiskey group name */
name: string;
};
User: {
admin: boolean;
email: string;
external_id?: string | null;
firstname: string;
/** Format: int32 */
id: number;
name: string;
oidc_sub: string;
units: components["schemas"]["Unit"][];
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
name: string
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;
User: {
admin: boolean
email: string
external_id?: string | null
firstname: string
/** Format: int32 */
id: number
name: string
oidc_sub: string
units: components['schemas']['Unit'][]
}
/**
* @description A user as they appear inside another object (a reservation, ...): enough to
* show who they are, without dragging their units along.
*/
UserSummary: {
email: string
firstname: string
/** Format: int32 */
id: number
name: string
}
}
responses: never
parameters: never
requestBodies: never
headers: never
pathItems: never
}
export type $defs = Record<string, never>
export type operations = Record<string, never>

View file

@ -2,6 +2,7 @@ locale: en
app:
title: Cargobikes
header:
admin: Admin
logout: Log out
logout-error: Unable to log out.
reserve: Book
@ -36,6 +37,10 @@ reservation:
remove-email: Remove this address
submit: Send the request
reset: Reset
association-placeholder: Pick or type the association
association-other: Other association…
telegram-placeholder: username
error-too-far: A reservation can be made at most 1 month in advance.
error-required: This field is required.
error-datetime-required: Pick a date and a time.
error-end-before-start: The end must be after the start.
@ -57,3 +62,61 @@ login:
calendar:
title: Calendar
todo: This page is not built yet.
admin:
title: Reservation administration
intro: Approve, refuse and follow the reservations, and see the planning of each cargobike.
refresh: Refresh
updated-at: 'Last loaded: {time}'
bikes:
title: Fleet management
intro: Deactivate a bike so that it can no longer be picked in the form.
activate: Activate
deactivate: Deactivate
empty: No cargobike in the fleet.
load-error: Unable to load the cargobikes.
error: The status change failed.
status:
in_service: In service
out_of_service: Out of service
in_use: In use
reservations:
title: Reservations
intro: Requests are waiting for a decision; approved reservations show up below.
pending: Pending requests
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.
period: Period
bikes: Cargobike(s)
telegram: Telegram
people: People
reason: Reason
load-error: Unable to load the reservations.
error: The status change failed.
status:
requested: Pending
refused: Refused
approved: Approved
cancelled: Cancelled
ongoing: Ongoing
archived: Archived
action:
approved: Approve
refused: Refuse
cancelled: Cancel
ongoing: Start
archived: Archive
calendar:
title: Calendar per cargobike
intro: Approved and ongoing reservations are shown in the calendar.
bike: Cargobike
all-bikes: All
previous: Week
next: Week
today: Today
hour: H
no-bike: No cargobike to show.
legend: One bar per booked cargobike, split by day.

View file

@ -2,6 +2,7 @@ locale: fr
app:
title: Cargobikes
header:
admin: Admin
logout: Se déconnecter
logout-error: Impossible de se déconnecter.
reserve: Réserver
@ -37,6 +38,10 @@ reservation:
remove-email: Retirer cette adresse
submit: Envoyer la demande
reset: Réinitialiser
association-placeholder: Choisissez ou saisissez l'association
association-other: Autre association…
telegram-placeholder: username
error-too-far: Une réservation se fait au maximum 1 mois à l'avance.
error-required: Ce champ est obligatoire.
error-datetime-required: Choisissez une date et une heure.
error-end-before-start: La fin doit être après le début.
@ -58,3 +63,61 @@ login:
calendar:
title: Calendrier
todo: Cette page n'est pas encore construite.
admin:
title: Administration des réservations
intro: Validez, refusez et suivez les réservations, et visualisez les plannings par cargobike.
refresh: Rafraîchir
updated-at: 'Dernier chargement : {time}'
bikes:
title: Gestion des vélos
intro: Désactivez un vélo pour qu'il ne soit plus sélectionnable dans le formulaire.
activate: Activer
deactivate: Désactiver
empty: Aucun cargobike dans la flotte.
load-error: Impossible de charger les cargobikes.
error: Le changement de statut a échoué.
status:
in_service: En service
out_of_service: Hors service
in_use: En usage
reservations:
title: Réservations
intro: Les demandes attendent une décision ; les réservations validées apparaissent ensuite.
pending: Demandes en attente
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.
period: Période
bikes: Cargobike(s)
telegram: Telegram
people: Personnes
reason: Raison
load-error: Impossible de charger les réservations.
error: Le changement de statut a échoué.
status:
requested: En attente
refused: Refusée
approved: Validée
cancelled: Annulée
ongoing: En cours
archived: Archivée
action:
approved: Valider
refused: Refuser
cancelled: Annuler
ongoing: Démarrer
archived: Archiver
calendar:
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.
next: Sem.
today: Aujourd'hui
hour: H
no-bike: Aucun cargobike à afficher.
legend: Une barre par cargobike réservé, découpée par jour.

View file

@ -1,19 +1,55 @@
import { createRouter, createWebHistory } from 'vue-router'
import { ensureSession } from '@/services/api/auth'
import LoginView from '@/views/LoginView.vue'
import ReservationView from '@/views/ReservationView.vue'
import CalendarView from '@/views/CalendarView.vue'
import AdminView from '@/views/AdminView.vue'
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
declare module 'vue-router' {
interface RouteMeta {
/** The route needs a session */
requiresAuth?: boolean
/** ... and the session must belong to an admin */
requiresAdmin?: boolean
}
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ name: 'login', path: '/', component: LoginView },
{ name: 'reservations', path: '/reservations', component: ReservationView },
{
name: 'reservations',
path: '/reservations',
component: ReservationView,
meta: { requiresAuth: true },
},
{ name: 'calendar', path: '/calendar', component: CalendarView },
{
name: 'admin',
path: '/admin',
component: AdminView,
meta: { requiresAuth: true, requiresAdmin: true },
},
// Registered as the OIDC redirect uri, see `server.base_url` in config.yml
{ name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView },
],
})
/**
* Keeps anonymous visitors off the pages that need a session. This is a
* convenience, not the security boundary: every protected route answers 401 or
* 403 on its own, whatever the frontend does.
*/
router.beforeEach(async (to) => {
if (!to.meta.requiresAuth) return true
const user = await ensureSession().catch(() => null)
if (!user) return { name: 'login' }
if (to.meta.requiresAdmin && !user.admin) return { name: 'reservations' }
return true
})
export default router

View file

@ -10,7 +10,7 @@ import { HttpStatus } from 'http-status-ts'
import { computed } from 'vue'
import type { User } from '@/utils/types'
import { getClient } from './client'
import { getClient, getQueryClient } from './client'
import { SESSION_KEY } from './keys'
export { SESSION_KEY }
@ -19,18 +19,30 @@ export { SESSION_KEY }
* `/api/me` is a probe, not a protected route: it answers 200 with `null` when
* nobody is logged in, so a page load never looks like an error.
*/
export function useSession() {
const query = useQuery({
queryKey: SESSION_KEY,
staleTime: Infinity,
retry: false,
queryFn: async () => {
async function fetchSession() {
const { data, response } = await getClient().GET('/api/me')
if (response.status !== HttpStatus.OK) {
throw new Error(`Unexpected status code received: ${response.status}`)
}
return data ?? null
},
}
/**
* The session as the router guards see it. Reads through the same cache the
* components use, so a navigation costs no extra call.
*/
export async function ensureSession() {
const queryClient = getQueryClient()
if (!queryClient) return fetchSession()
return queryClient.ensureQueryData({ queryKey: SESSION_KEY, queryFn: fetchSession })
}
export function useSession() {
const query = useQuery({
queryKey: SESSION_KEY,
staleTime: Infinity,
retry: false,
queryFn: fetchSession,
})
return {

View file

@ -2,14 +2,17 @@
* The fleet. One file per domain area, exposing vue-query hooks: views never
* call `fetch` themselves.
*/
import { useQuery } from '@tanstack/vue-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts'
import type { Bike, BikeStatus } from '@/utils/types'
import { getClient } from './client'
export const BIKES_KEY = ['bikes']
export function useBikes() {
return useQuery({
queryKey: ['bikes'],
queryKey: BIKES_KEY,
staleTime: 60 * 1000,
queryFn: async () => {
const { data, response } = await getClient().GET('/api/bikes')
@ -20,3 +23,23 @@ export function useBikes() {
},
})
}
/** Takes a bike in or out of the fleet. Admin only, the backend enforces it. */
export function useSetBikeStatus() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async ({ id, status }: { id: number; status: BikeStatus }) => {
await getClient().PUT('/api/bikes/{id}/status', {
params: { path: { id } },
body: { status },
})
return { id, status }
},
onSuccess: ({ id, status }) => {
queryClient.setQueryData<Bike[]>(BIKES_KEY, (bikes) =>
bikes?.map((bike) => (bike.id === id ? { ...bike, status } : bike)),
)
},
})
}

View file

@ -14,6 +14,10 @@ export function setQueryClient(client: QueryClient) {
queryClient = client
}
export function getQueryClient() {
return queryClient
}
// Types come from src/lib/api.d.ts, generated from the backend: `npm run openapi`
const client = createClient<paths>({
credentials: 'same-origin',

View file

@ -0,0 +1,49 @@
/**
* Reservations, from the administration side. Reading the whole list is an
* admin action, so these hooks are only ever mounted on /admin.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts'
import type { Reservation, ReservationStatus } from '@/utils/types'
import { getClient } from './client'
export const RESERVATIONS_KEY = ['reservations']
export function useReservations() {
return useQuery({
queryKey: RESERVATIONS_KEY,
staleTime: 30 * 1000,
queryFn: async () => {
const { data, response } = await getClient().GET('/api/reservations')
if (response.status !== HttpStatus.OK) {
throw new Error(`Unexpected status code received: ${response.status}`)
}
return data ?? []
},
})
}
/**
* The backend re-checks the state machine and answers 409 on a transition it
* does not allow, so the buttons only have to offer the plausible ones.
*/
export function useSetReservationStatus() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async ({ id, status }: { id: number; status: ReservationStatus }) => {
await getClient().PUT('/api/reservations/{id}/status', {
params: { path: { id } },
body: { status },
})
return { id, status }
},
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)),
)
},
})
}

View file

@ -23,3 +23,16 @@ export type Bike = components['schemas']['Bike']
export type BikeStatus = components['schemas']['BikeStatus']
export type Unit = components['schemas']['Unit']
export type User = components['schemas']['User']
export type Reservation = components['schemas']['Reservation']
export type ReservationStatus = components['schemas']['ReservationStatus']
export type UserSummary = components['schemas']['UserSummary']
export type ReservationUnit = components['schemas']['ReservationUnit']
/**
* What to display for a reservation's unit: the name of the unit it points at,
* or the name the requester typed. One place, so no view has to know which of
* the two shapes it is holding.
*/
export function unitLabel(unit: ReservationUnit): string {
return unit.kind === 'known' ? unit.unit.name : unit.name
}

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
/**
* Administration: the fleet, the reservations, and the week planning.
* The three sections share one fetch of the reservations.
*/
import { computed } from 'vue'
import { RefreshCw } from '@lucide/vue'
import BikeCalendar from '@/components/admin/BikeCalendar.vue'
import BikeFleet from '@/components/admin/BikeFleet.vue'
import ReservationAdmin from '@/components/admin/ReservationAdmin.vue'
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'
const { data: bikes } = useBikes()
const {
data: reservations,
isPending,
isError,
isFetching,
refetch,
dataUpdatedAt,
} = useReservations()
const list = computed(() => reservations.value ?? [])
const fleet = computed(() => bikes.value ?? [])
const updatedAt = computed(() =>
dataUpdatedAt.value ? new Date(dataUpdatedAt.value).toLocaleTimeString() : '—',
)
</script>
<template>
<div class="mx-auto grid w-full max-w-6xl gap-6">
<Card>
<CardHeader>
<CardTitle class="text-xl">{{ $t('admin.title') }}</CardTitle>
<CardDescription>{{ $t('admin.intro') }}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap items-center gap-3">
<Button size="sm" :disabled="isFetching" @click="refetch()">
<RefreshCw class="size-4" :class="isFetching ? 'animate-spin' : ''" />
{{ $t('admin.refresh') }}
</Button>
<span class="text-muted-foreground text-sm">
{{ $t('admin.updated-at', { time: updatedAt }) }}
</span>
</CardContent>
</Card>
<BikeFleet class="min-w-0" :reservations="list" />
<ReservationAdmin
class="min-w-0"
:reservations="list"
:bikes="fleet"
:is-pending="isPending"
:is-error="isError"
/>
<BikeCalendar class="min-w-0" :reservations="list" :bikes="fleet" />
</div>
</template>

View file

@ -6,6 +6,8 @@ import { getLocalTimeZone, today, type DateValue } from '@internationalized/date
import { toast } from 'vue-sonner'
import DatePicker from '@/components/DatePicker.vue'
import TelegramInput from '@/components/TelegramInput.vue'
import UnitPicker, { type UnitChoice } from '@/components/UnitPicker.vue'
import TimePicker from '@/components/TimePicker.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@ -14,6 +16,7 @@ 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 { useSession } from '@/services/api/auth'
import type { Bike } from '@/utils/types'
const { t } = useI18n()
@ -23,7 +26,7 @@ const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
type Form = {
association: string
association: UnitChoice | undefined
reason: string
startTime: string | undefined
endTime: string | undefined
@ -34,7 +37,7 @@ type Form = {
function emptyForm(): Form {
return {
association: '',
association: undefined,
reason: '',
startTime: undefined,
endTime: undefined,
@ -52,6 +55,11 @@ const errors = reactive<Record<string, string>>({})
const submitted = ref(false)
const minDate = today(getLocalTimeZone())
/** Bookings open one month ahead, no further */
const maxDate = minDate.add({ months: 1 })
const { user } = useSession()
const units = computed(() => user.value?.units ?? [])
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
if (!date || !time) return null
@ -97,15 +105,27 @@ function removeEmail(index: number) {
function validate(): boolean {
Object.keys(errors).forEach((key) => delete errors[key])
if (!form.association.trim()) errors.association = t('reservation.error-required')
if (!form.association) errors.association = t('reservation.error-required')
if (!form.reason.trim()) errors.reason = t('reservation.error-required')
if (!start.value) errors.start = t('reservation.error-datetime-required')
if (!end.value) errors.end = t('reservation.error-datetime-required')
// Strictly after: a reservation of zero length is not one
if (start.value && end.value && end.value <= start.value) {
errors.end = t('reservation.error-end-before-start')
}
// The pickers already refuse these dates; re-checked in case the model was
// filled another way
if (startDate.value && startDate.value.compare(maxDate) > 0) {
errors.start = t('reservation.error-too-far')
}
if (endDate.value && endDate.value.compare(maxDate) > 0) {
errors.end = t('reservation.error-too-far')
}
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
if (!TELEGRAM_RE.test(form.telegram)) errors.telegram = t('reservation.error-telegram')
if (!form.telegram) errors.telegram = t('reservation.error-required')
else if (!TELEGRAM_RE.test(`@${form.telegram}`)) {
errors.telegram = t('reservation.error-telegram')
}
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
if (emails.length === 0) errors.emails = t('reservation.error-required')
@ -160,11 +180,11 @@ function submit() {
<!-- Association -->
<div class="grid gap-2">
<Label for="association">{{ $t('reservation.association') }}</Label>
<Input
<UnitPicker
id="association"
v-model.trim="form.association"
:placeholder="$t('reservation.association')"
:aria-invalid="!!errors.association || undefined"
v-model="form.association"
:units="units"
:invalid="!!errors.association"
/>
<p v-if="errors.association" class="text-destructive text-xs">
{{ errors.association }}
@ -192,6 +212,7 @@ function submit() {
id="start-date"
v-model="startDate"
:min-value="minDate"
:max-value="maxDate"
:invalid="!!errors.start"
/>
<TimePicker id="start-time" v-model="form.startTime" :invalid="!!errors.start" />
@ -207,6 +228,7 @@ function submit() {
id="end-date"
v-model="endDate"
:min-value="startDate ?? minDate"
:max-value="maxDate"
:invalid="!!errors.end"
/>
<TimePicker id="end-time" v-model="form.endTime" :invalid="!!errors.end" />
@ -273,13 +295,7 @@ function submit() {
<!-- Telegram -->
<div class="grid gap-2">
<Label for="telegram">{{ $t('reservation.telegram') }}</Label>
<Input
id="telegram"
v-model.trim="form.telegram"
placeholder="@username"
autocomplete="off"
:aria-invalid="!!errors.telegram || undefined"
/>
<TelegramInput id="telegram" v-model="form.telegram" :invalid="!!errors.telegram" />
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
</div>

View file

@ -87,7 +87,7 @@ async fn logout(
}
fn logout_docs(op: TransformOperation) -> TransformOperation {
op.tag("Auth").summary("Log the user out").response::<401, ()>()
op.tag("Auth").summary("Log the user out")
}
#[derive(Debug, JsonSchema, Serialize)]

View file

@ -13,10 +13,10 @@ use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
api::helpers::{admin, admin_desc, unexpected_error},
api::helpers::{IdPath, admin, admin_desc, unexpected_error},
core::{
controller::{AnonAppController, AppController},
models::bike::{Bike, BikeId, BikeStatus},
models::bike::{Bike, BikeStatus},
},
};
@ -36,7 +36,7 @@ struct SetStatusForm {
#[axum::debug_handler]
async fn set_status(
ac: AppController,
Path(id): Path<BikeId>,
Path(IdPath { id }): Path<IdPath>,
Json(SetStatusForm { status }): Json<SetStatusForm>,
) -> Result<(), (StatusCode, String)> {
match admin(ac)?.set_bike_status(id, status).await {

View file

@ -1,4 +1,6 @@
use aide::transform::TransformResponse;
use schemars::JsonSchema;
use serde::Deserialize;
use axum::http::StatusCode;
use tracing::error;
@ -7,11 +9,9 @@ use crate::core::{
models::unit::UnitId,
};
/// Narrows a session down to "member of this unit", or 403.
/// `manager(ac, unit)?` in a handler is the whole authorization check.
pub fn manager(
ac: AppController,
unit: UnitId,
unit: Option<UnitId>,
) -> Result<ManagerAppController, (StatusCode, String)> {
ac.try_into_manager(unit).map_err(|_| {
(
@ -22,7 +22,7 @@ pub fn manager(
}
pub fn manager_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
op.description("Forbidden - the user must be part of the unit")
op.description("Forbidden - the user must be part of the unit, or an admin when the reservation names no known unit")
}
/// Narrows a session down to "admin", or 403
@ -39,7 +39,6 @@ pub fn admin_desc<T>(op: TransformResponse<'_, T>) -> TransformResponse<'_, T> {
op.description("Forbidden - the user must be an admin")
}
/// Last resort branch of a handler `match`: logs the error and answers 500
pub fn unexpected_error<T>(fn_name: &str, err: ControllerError) -> Result<T, (StatusCode, String)> {
error!("[HANDLER] {fn_name}: Unexpected error: {err:?}");
Err((
@ -54,3 +53,8 @@ pub fn desc<T>(
) -> impl FnOnce(TransformResponse<'_, T>) -> TransformResponse<'_, T> {
|op| op.description(description)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct IdPath {
pub id: i32,
}

View file

@ -43,8 +43,6 @@ pub fn get_router(aac: AnonAppController) -> Router {
let config = utils::config::get();
// Sessions live in memory: everybody is logged out when the backend
// restarts. Swap the store for a persistent one if that becomes a problem.
let session_layer = SessionManagerLayer::new(MemoryStore::default())
// Over plain http in development the cookie cannot be `Secure`
.with_secure(config.get_base_url().starts_with("https://"))
@ -64,7 +62,6 @@ pub fn get_router(aac: AnonAppController) -> Router {
|op| op.tag("misc").summary("Get app version"),
),
)
// `auth` carries its own `/api/...` paths, so it is merged, not nested
.merge(auth::routes())
.nest_api_service("/api/bikes", bikes::routes())
.nest_api_service("/api/reservations", reservations::routes())
@ -91,8 +88,6 @@ where
}
}
/// Lets a handler take an `AppController`, which requires a session: asking for
/// it *is* the authentication check.
impl<S> FromRequestParts<S> for AppController
where
S: Send + Sync,
@ -118,8 +113,6 @@ where
}
}
// The controllers are not part of the request/response bodies, but asking for
// an `AppController` documents the 401 and the cookie requirement.
impl OperationOutput for AnonAppController {
type Inner = Self;
}

View file

@ -6,7 +6,10 @@
//! controller down.
use aide::{
axum::{ApiRouter, routing::get_with},
axum::{
ApiRouter,
routing::{get_with, put_with},
},
transform::TransformOperation,
};
use axum::{
@ -18,20 +21,17 @@ use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
api::helpers::{admin, admin_desc, manager, manager_desc, unexpected_error},
api::helpers::{IdPath, admin, admin_desc, manager, manager_desc, unexpected_error},
core::{
controller::{AppController, ControllerError, reservations::ReservationsControllerError},
models::reservation::{Reservation, ReservationId, ReservationStatus},
models::reservation::{Reservation, ReservationStatus},
},
};
pub fn routes() -> ApiRouter {
ApiRouter::new()
.api_route("/", get_with(get_reservations, get_reservations_docs))
.api_route(
"/{id}/status",
aide::axum::routing::put_with(set_status, set_status_docs),
)
.api_route("/{id}/status", put_with(set_status, set_status_docs))
}
#[axum::debug_handler]
@ -58,7 +58,7 @@ struct SetStatusForm {
#[axum::debug_handler]
async fn set_status(
ac: AppController,
Path(id): Path<ReservationId>,
Path(IdPath { id }): Path<IdPath>,
Json(SetStatusForm { status }): Json<SetStatusForm>,
) -> Result<(), (StatusCode, String)> {
// The unit is not in the body: it is the reservation's own
@ -70,7 +70,7 @@ async fn set_status(
Err(err) => return unexpected_error("set_status", err),
};
match manager(ac, reservation.unit.id)?
match manager(ac, reservation.unit.scope())?
.set_reservation_status(id, status)
.await
{

View file

@ -71,13 +71,21 @@ impl AppController {
&self.user
}
/// An admin manages every unit: refusing them here would only produce
/// surprising 403s on routes they are otherwise allowed to use.
pub fn try_into_manager(self, unit: UnitId) -> Result<ManagerAppController, ControllerError> {
if self.user.admin || self.user.units.iter().any(|u| u.id == unit) {
pub fn try_into_manager(
self,
unit: Option<UnitId>,
) -> Result<ManagerAppController, ControllerError> {
let allowed = match unit {
Some(unit) => self.user.admin || self.user.units.iter().any(|u| u.id == unit),
None => self.user.admin,
};
if allowed {
Ok(ManagerAppController { inner: self, unit })
} else {
Err(ControllerError::ManagerAuthorizationError(unit))
match unit {
Some(unit) => Err(ControllerError::ManagerAuthorizationError(unit)),
None => Err(ControllerError::AdminAuthorizationError),
}
}
}
@ -90,11 +98,10 @@ impl AppController {
}
}
/// A member of `unit`, acting for that unit
#[derive(Clone)]
pub struct ManagerAppController {
inner: AppController,
pub(crate) unit: UnitId,
pub(crate) unit: Option<UnitId>,
}
impl Deref for ManagerAppController {
@ -116,7 +123,7 @@ impl Deref for AdminAppController {
}
impl AdminAppController {
pub fn into_manager(self, unit: UnitId) -> ManagerAppController {
pub fn into_manager(self, unit: Option<UnitId>) -> ManagerAppController {
ManagerAppController {
inner: self.inner,
unit,

View file

@ -67,7 +67,7 @@ impl ManagerAppController {
}
let current = self.db.get_reservation(reservation.id).await?;
if current.unit.id != self.unit {
if current.unit.scope() != self.unit {
return Err(ControllerError::ImmutableUnitModificationError);
}
if current.status.is_final() {
@ -86,7 +86,7 @@ impl ManagerAppController {
status: ReservationStatus,
) -> Result<(), ControllerError> {
let current = self.db.get_reservation(id).await?;
if current.unit.id != self.unit {
if current.unit.scope() != self.unit {
return Err(ControllerError::ImmutableUnitModificationError);
}
let current = current.status;
@ -103,7 +103,7 @@ impl ManagerAppController {
}
pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> {
if self.db.get_reservation(id).await?.unit.id != self.unit {
if self.db.get_reservation(id).await?.unit.scope() != self.unit {
return Err(ControllerError::ImmutableUnitModificationError);
}
self.db.delete_reservation(id).await.map_err(Into::into)

View file

@ -43,10 +43,63 @@ impl ReservationStatus {
}
}
/// The unit a reservation is filed for.
///
/// Either one we know — a Whiskey group, with its row — or a plain name the
/// requester typed. Typing a name must never create a unit, so the two are
/// exclusive by construction rather than "a name that may or may not resolve".
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ReservationUnit {
/// Picked from the units the requester belongs to
Known { unit: Unit },
/// Typed by hand, stored on the reservation and nowhere else
Free { name: String },
}
impl ReservationUnit {
/// What to show: the unit's name, or the typed one
pub fn label(&self) -> &str {
match self {
ReservationUnit::Known { unit } => &unit.name,
ReservationUnit::Free { name } => name,
}
}
/// The scope somebody must manage to act on this reservation. `None` for a
/// typed name: nobody is the manager of a unit we do not know, so only an
/// admin qualifies.
pub fn scope(&self) -> Option<UnitId> {
match self {
ReservationUnit::Known { unit } => Some(unit.id),
ReservationUnit::Free { .. } => None,
}
}
}
/// The same choice, as the client sends it: only the id travels for a known unit.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NewReservationUnit {
Known { id: UnitId },
Free { name: String },
}
impl NewReservationUnit {
pub fn is_valid(&self) -> bool {
match self {
NewReservationUnit::Known { .. } => true,
NewReservationUnit::Free { name } => !name.trim().is_empty(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Reservation {
pub id: ReservationId,
pub unit: Unit,
/// The one unit the bikes are lent to
pub unit: ReservationUnit,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub requester: UserId,
@ -59,7 +112,7 @@ pub struct Reservation {
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct NewReservation {
pub unit: UnitId,
pub unit: NewReservationUnit,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub users: Vec<UserId>,
@ -71,7 +124,7 @@ pub struct NewReservation {
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct ReservationEdit {
pub id: ReservationId,
pub unit: UnitId,
pub unit: NewReservationUnit,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub users: Vec<UserId>,
@ -82,12 +135,15 @@ pub struct ReservationEdit {
impl NewReservation {
pub fn is_valid(&self) -> bool {
self.end_time > self.start_time && !self.bikes.is_empty()
self.unit.is_valid() && self.end_time > self.start_time && !self.bikes.is_empty()
}
}
impl ReservationEdit {
pub fn is_valid(&self) -> bool {
self.end_time > self.start_time && !self.bikes.is_empty() && !self.users.is_empty()
self.unit.is_valid()
&& self.end_time > self.start_time
&& !self.bikes.is_empty()
&& !self.users.is_empty()
}
}

View file

@ -12,7 +12,8 @@ use crate::{
core::{
models::{
reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
ReservationStatus, ReservationUnit,
},
unit::{Unit, UnitId},
user::UserId,
@ -61,8 +62,9 @@ impl From<ReservationStatus> for ReservationStatusDB {
struct ReservationDB {
pub id: i32,
pub unit_id: i32,
pub unit_name: String,
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,
@ -79,9 +81,17 @@ impl TryFrom<ReservationDB> for Reservation {
fn try_from(value: ReservationDB) -> Result<Self, Self::Error> {
Ok(Reservation {
id: value.id,
unit: Unit {
id: value.unit_id,
name: value.unit_name,
unit: match (value.unit_id, value.unit_name, value.unit_label) {
(Some(id), Some(name), None) => ReservationUnit::Known {
unit: Unit { id, name },
},
(None, None, Some(name)) => ReservationUnit::Free { name },
// The `reservations_unit_xor` check makes this unreachable
other => {
return Err(RepositoryError::TypeConversion(format!(
"reservation with an inconsistent unit: {other:?}"
)));
}
},
start_time: value.start_time,
end_time: value.end_time,
@ -136,6 +146,15 @@ impl SqlxDatabase {
}
}
/// The two exclusive columns behind `NewReservationUnit`: exactly one is `Some`,
/// which is what the `reservations_unit_xor` check enforces.
fn split_unit(unit: &NewReservationUnit) -> (Option<i32>, Option<String>) {
match unit {
NewReservationUnit::Known { id } => (Some(*id), None),
NewReservationUnit::Free { name } => (None, Some(name.trim().to_owned())),
}
}
#[async_trait]
impl ReservationsRepository for SqlxDatabase {
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> {
@ -144,7 +163,10 @@ impl ReservationsRepository for SqlxDatabase {
r#"SELECT
r.id,
r.unit_id,
un."name" AS unit_name,
-- `?` 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,
@ -167,7 +189,7 @@ impl ReservationsRepository for SqlxDatabase {
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
JOIN units un ON un.id = r.unit_id
LEFT JOIN units un ON un.id = r.unit_id
ORDER BY r.start_time DESC"#
)
.fetch_all(&self.pool)
@ -186,7 +208,10 @@ impl ReservationsRepository for SqlxDatabase {
r#"SELECT
r.id,
r.unit_id,
un."name" AS unit_name,
-- `?` 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,
@ -209,7 +234,7 @@ impl ReservationsRepository for SqlxDatabase {
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
JOIN units un ON un.id = r.unit_id
LEFT JOIN units un ON un.id = r.unit_id
WHERE r.unit_id = $1
ORDER BY r.start_time DESC"#,
unit
@ -227,7 +252,10 @@ impl ReservationsRepository for SqlxDatabase {
r#"SELECT
r.id,
r.unit_id,
un."name" AS unit_name,
-- `?` 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,
@ -250,7 +278,7 @@ impl ReservationsRepository for SqlxDatabase {
WHERE reservation_id = r.id ORDER BY bike_id
) AS "bikes!"
FROM reservations r
JOIN units un ON un.id = r.unit_id
LEFT JOIN units un ON un.id = r.unit_id
WHERE r.id = $1"#,
id
)
@ -268,11 +296,14 @@ impl ReservationsRepository for SqlxDatabase {
// No status here: the column defaults to 'requested', the start of the
// state machine.
let (unit_id, unit_label) = split_unit(&reservation.unit);
let id = query!(
r#"INSERT INTO reservations (unit_id, start_time, end_time, requester_id, telegram, "description")
VALUES ($1, $2, $3, $4, $5, $6)
r#"INSERT INTO reservations
(unit_id, unit_label, start_time, end_time, requester_id, telegram, "description")
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id"#,
reservation.unit,
unit_id,
unit_label,
reservation.start_time,
reservation.end_time,
requester,
@ -304,12 +335,15 @@ impl ReservationsRepository for SqlxDatabase {
) -> Result<(), RepositoryError> {
let mut tx = self.pool.begin().await?;
let (unit_id, unit_label) = split_unit(&reservation.unit);
let result = query!(
r#"UPDATE reservations
SET unit_id = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6
SET unit_id = $2, unit_label = $3, start_time = $4, end_time = $5,
telegram = $6, "description" = $7
WHERE id = $1"#,
reservation.id,
reservation.unit,
unit_id,
unit_label,
reservation.start_time,
reservation.end_time,
reservation.telegram,