Compare commits
2 commits
34fbc2f3de
...
ce88b58003
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce88b58003 | ||
|
|
c6460a0a77 |
32 changed files with 2718 additions and 422 deletions
13
.env
13
.env
|
|
@ -1,2 +1,15 @@
|
||||||
# This file is used by dbmate, and by the sqlx macros at compile time.
|
# This file is used by dbmate, and by the sqlx macros at compile time.
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/cargagep?sslmode=disable
|
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=
|
||||||
|
|
|
||||||
25
db/migrations/20260824180000_reservation_bike_periods.sql
Normal file
25
db/migrations/20260824180000_reservation_bike_periods.sql
Normal 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;
|
||||||
|
|
@ -130,7 +130,10 @@ CREATE TABLE public.reservations (
|
||||||
|
|
||||||
CREATE TABLE public.reservations_bikes (
|
CREATE TABLE public.reservations_bikes (
|
||||||
reservation_id integer NOT NULL,
|
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'),
|
('20260823210000'),
|
||||||
('20260823230000'),
|
('20260823230000'),
|
||||||
('20260824120000'),
|
('20260824120000'),
|
||||||
('20260824140000');
|
('20260824140000'),
|
||||||
|
('20260824180000');
|
||||||
|
|
|
||||||
31
db/seed.sql
31
db/seed.sql
|
|
@ -81,18 +81,41 @@ FROM (VALUES
|
||||||
date_trunc('day', now()) + interval '6 days' + interval '18 hours',
|
date_trunc('day', now()) + interval '6 days' + interval '18 hours',
|
||||||
2, '@bob_dupont', 'Annulée faute de conducteur',
|
2, '@bob_dupont', 'Annulée faute de conducteur',
|
||||||
ARRAY['bob.dupont@epfl.ch'],
|
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",
|
) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description",
|
||||||
linka_emails, status)
|
linka_emails, status)
|
||||||
JOIN public.units u ON u."name" = r.unit_name
|
JOIN public.units u ON u."name" = r.unit_name
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO public.reservations_users (reservation_id, user_id) VALUES
|
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;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES
|
-- The bikes each reservation holds. The two period columns are normally NULL,
|
||||||
(1, 1), (1, 2), (2, 3), (3, 1), (3, 4), (4, 2), (5, 5), (6, 4)
|
-- 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;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- Keep the sequences in sync with the explicit ids inserted above
|
-- Keep the sequences in sync with the explicit ids inserted above
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,40 @@
|
||||||
<script setup lang="ts">
|
<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 { Clock } from '@lucide/vue'
|
||||||
|
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
|
|
||||||
const model = defineModel<string | undefined>()
|
const model = defineModel<string | undefined>()
|
||||||
defineProps<{ id?: string; invalid?: boolean }>()
|
defineProps<{ id?: string; invalid?: boolean }>()
|
||||||
|
|
||||||
const SLOT_MINUTES = 15
|
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 minutes = i * SLOT_MINUTES
|
||||||
const hh = String(Math.floor(minutes / 60)).padStart(2, '0')
|
const hh = String(Math.floor(minutes / 60)).padStart(2, '0')
|
||||||
const mm = String(minutes % 60).padStart(2, '0')
|
const mm = String(minutes % 60).padStart(2, '0')
|
||||||
return `${hh}:${mm}`
|
return `${hh}:${mm}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const slots = computed(() => (open.value ? SLOTS : []))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<SelectTrigger :id="id" :aria-invalid="invalid || undefined" class="w-full px-3">
|
||||||
<span class="flex min-w-0 items-center gap-2">
|
<span class="flex min-w-0 items-center gap-2">
|
||||||
<Clock class="size-4 shrink-0 opacity-60" />
|
<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>
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent class="max-h-64">
|
<SelectContent class="max-h-64">
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,14 @@ const setStatus = useSetBikeStatus()
|
||||||
/** Bikes held by a reservation that is under way right now */
|
/** Bikes held by a reservation that is under way right now */
|
||||||
const inUse = computed(() => {
|
const inUse = computed(() => {
|
||||||
const ids = new Set<number>()
|
const ids = new Set<number>()
|
||||||
|
const now = Date.now()
|
||||||
for (const reservation of props.reservations) {
|
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
|
return ids
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,38 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Reservations, split the way an administrator reads them: what is waiting for
|
* 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
|
* a decision, what is live or coming, and — behind a button — the archive.
|
||||||
* is over one way or another.
|
*
|
||||||
|
* 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 { computed, ref } from 'vue'
|
||||||
|
import { Archive } from '@lucide/vue'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import ReservationArchiveDialog from './ReservationArchiveDialog.vue'
|
||||||
import ReservationCard from './ReservationCard.vue'
|
import ReservationCard from './ReservationCard.vue'
|
||||||
import type { Bike, Reservation } from '@/utils/types'
|
import type { Bike, Reservation } from '@/utils/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
reservations: Reservation[]
|
pending: Reservation[]
|
||||||
|
active: Reservation[]
|
||||||
bikes: Bike[]
|
bikes: Bike[]
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
isError: boolean
|
isError: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const showArchived = ref(false)
|
const archiveOpen = ref(false)
|
||||||
|
|
||||||
const pending = computed(() => props.reservations.filter((r) => r.status === 'requested'))
|
/** Soonest first: both sections are read as "what comes next" */
|
||||||
const active = computed(() =>
|
const byStart = (list: Reservation[]) =>
|
||||||
props.reservations.filter((r) => r.status === 'approved' || r.status === 'ongoing'),
|
[...list].sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime())
|
||||||
)
|
|
||||||
// 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 */
|
const pendingSorted = computed(() => byStart(props.pending))
|
||||||
function byStart(list: Reservation[], descending = false) {
|
const activeSorted = computed(() => byStart(props.active))
|
||||||
return [...list].sort((a, b) => {
|
|
||||||
const diff = new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
|
|
||||||
return descending ? -diff : diff
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -68,7 +62,7 @@ function byStart(list: Reservation[], descending = false) {
|
||||||
{{ $t('admin.reservations.pending-empty') }}
|
{{ $t('admin.reservations.pending-empty') }}
|
||||||
</p>
|
</p>
|
||||||
<ReservationCard
|
<ReservationCard
|
||||||
v-for="reservation in byStart(pending)"
|
v-for="reservation in pendingSorted"
|
||||||
:key="reservation.id"
|
:key="reservation.id"
|
||||||
:reservation="reservation"
|
:reservation="reservation"
|
||||||
:bikes="bikes"
|
:bikes="bikes"
|
||||||
|
|
@ -84,36 +78,22 @@ function byStart(list: Reservation[], descending = false) {
|
||||||
{{ $t('admin.reservations.active-empty') }}
|
{{ $t('admin.reservations.active-empty') }}
|
||||||
</p>
|
</p>
|
||||||
<ReservationCard
|
<ReservationCard
|
||||||
v-for="reservation in byStart(active)"
|
v-for="reservation in activeSorted"
|
||||||
:key="reservation.id"
|
:key="reservation.id"
|
||||||
:reservation="reservation"
|
:reservation="reservation"
|
||||||
:bikes="bikes"
|
:bikes="bikes"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid gap-3">
|
<div>
|
||||||
<div>
|
<Button variant="outline" size="sm" @click="archiveOpen = true">
|
||||||
<Button variant="outline" size="sm" @click="showArchived = !showArchived">
|
<Archive class="size-4" />
|
||||||
{{
|
{{ $t('admin.reservations.archive.action') }}
|
||||||
showArchived
|
</Button>
|
||||||
? $t('admin.reservations.hide-archived')
|
</div>
|
||||||
: $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>
|
</template>
|
||||||
|
|
||||||
|
<ReservationArchiveDialog v-model:open="archiveOpen" :bikes="bikes" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
140
frontend/src/components/admin/ReservationArchiveDialog.vue
Normal file
140
frontend/src/components/admin/ReservationArchiveDialog.vue
Normal 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>
|
||||||
|
|
@ -6,15 +6,23 @@
|
||||||
*/
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Pencil } from '@lucide/vue'
|
import { Pencil, TriangleAlert } from '@lucide/vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||||
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue'
|
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.vue'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useSetReservationStatus } from '@/services/api/reservations'
|
import { useConflicts, useSetReservationStatus } from '@/services/api/reservations'
|
||||||
import { unitLabel, type Bike, type Reservation, type ReservationStatus } from '@/utils/types'
|
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[] }>()
|
const props = defineProps<{ reservation: Reservation; bikes: Bike[] }>()
|
||||||
|
|
||||||
|
|
@ -44,6 +52,47 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
|
||||||
|
|
||||||
const transitions = computed(() => TRANSITIONS[props.reservation.status])
|
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 */
|
/** A reservation nobody can act on any more is not worth an edit button */
|
||||||
const editable = computed(
|
const editable = computed(
|
||||||
() => !['refused', 'cancelled', 'archived'].includes(props.reservation.status),
|
() => !['refused', 'cancelled', 'archived'].includes(props.reservation.status),
|
||||||
|
|
@ -51,9 +100,18 @@ const editable = computed(
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
|
|
||||||
function move(status: ReservationStatus) {
|
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(
|
setStatus.mutate(
|
||||||
{ id: props.reservation.id, status },
|
{ 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>
|
</script>
|
||||||
|
|
@ -66,7 +124,7 @@ function move(status: ReservationStatus) {
|
||||||
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
||||||
{{ $t(`reservation.status.${reservation.status}`) }}
|
{{ $t(`reservation.status.${reservation.status}`) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span class="text-muted-foreground text-sm">{{ unitLabel(reservation.unit) }}</span>
|
<span class="text-sm font-semibold">{{ unitLabel(reservation.unit) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="transitions.length || editable" class="flex flex-wrap gap-2">
|
<div v-if="transitions.length || editable" class="flex flex-wrap gap-2">
|
||||||
|
|
@ -79,7 +137,12 @@ function move(status: ReservationStatus) {
|
||||||
:key="status"
|
:key="status"
|
||||||
size="sm"
|
size="sm"
|
||||||
:variant="status === 'approved' ? 'default' : 'outline'"
|
: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)"
|
@click="move(status)"
|
||||||
>
|
>
|
||||||
{{ $t(`admin.reservations.action.${status}`) }}
|
{{ $t(`admin.reservations.action.${status}`) }}
|
||||||
|
|
@ -87,7 +150,34 @@ function move(status: ReservationStatus) {
|
||||||
</div>
|
</div>
|
||||||
</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
|
<ReservationEditDialog
|
||||||
v-if="editable"
|
v-if="editable"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { unitLabel, type Bike, type CalendarReservation } from '@/utils/types'
|
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`
|
// Takes the narrow shape the public calendar receives; a full `Reservation`
|
||||||
// satisfies it too, so the admin page keeps passing its own list unchanged.
|
// 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 LAST_HOUR = 24
|
||||||
const HOUR_HEIGHT = 32
|
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')
|
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) {
|
function shiftWeek(weeks: number) {
|
||||||
const next = new Date(weekStart.value)
|
weekStart.value = addDays(weekStart.value, weeks * 7)
|
||||||
next.setDate(next.getDate() + weeks * 7)
|
|
||||||
weekStart.value = next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const days = computed(() =>
|
const days = computed(() =>
|
||||||
Array.from({ length: 7 }, (_, i) => {
|
Array.from({ length: 7 }, (_, i) => {
|
||||||
const day = new Date(weekStart.value)
|
return addDays(weekStart.value, i)
|
||||||
day.setDate(day.getDate() + i)
|
|
||||||
return day
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -77,11 +69,10 @@ const rangeFormatter = computed(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const range = computed(() => {
|
const range = computed(
|
||||||
const end = new Date(weekStart.value)
|
() =>
|
||||||
end.setDate(end.getDate() + 6)
|
`${rangeFormatter.value.format(weekStart.value)} – ${rangeFormatter.value.format(addDays(weekStart.value, 6))}`,
|
||||||
return `${rangeFormatter.value.format(weekStart.value)} – ${rangeFormatter.value.format(end)}`
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const hours = computed(() =>
|
const hours = computed(() =>
|
||||||
Array.from({ length: LAST_HOUR - FIRST_HOUR }, (_, i) => FIRST_HOUR + i),
|
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)
|
const dayEnd = new Date(day)
|
||||||
dayEnd.setHours(LAST_HOUR, 0, 0, 0)
|
dayEnd.setHours(LAST_HOUR, 0, 0, 0)
|
||||||
|
|
||||||
return booked.value
|
return booked.value.flatMap((reservation) => {
|
||||||
.filter((reservation) => reservation.bikes.includes(bike.id))
|
// The bike's own period, which is not always the reservation's: a
|
||||||
.flatMap((reservation) => {
|
// conflict may have been settled by handing this one over early
|
||||||
const start = new Date(reservation.start_time)
|
const held = reservation.bikes.find((held) => held.id === bike.id)
|
||||||
const end = new Date(reservation.end_time)
|
if (!held) return []
|
||||||
const from = start > dayStart ? start : dayStart
|
const start = new Date(held.start_time)
|
||||||
const to = end < dayEnd ? end : dayEnd
|
const end = new Date(held.end_time)
|
||||||
if (to <= from) return []
|
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 top = ((from.getTime() - dayStart.getTime()) / 3_600_000) * HOUR_HEIGHT
|
||||||
const height = Math.max(((to.getTime() - from.getTime()) / 3_600_000) * HOUR_HEIGHT, 6)
|
const height = Math.max(((to.getTime() - from.getTime()) / 3_600_000) * HOUR_HEIGHT, 6)
|
||||||
return [{ id: reservation.id, top, height }]
|
return [{ id: reservation.id, top, height }]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -145,7 +138,6 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>{{ $t('calendar.title') }}</CardTitle>
|
<CardTitle>{{ $t('calendar.title') }}</CardTitle>
|
||||||
<CardDescription>{{ $t('calendar.intro') }}</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -267,7 +259,9 @@ function blocksFor(day: Date, bike: Bike): Block[] {
|
||||||
{{ $t(`reservation.status.${openedReservation.status}`) }}
|
{{ $t(`reservation.status.${openedReservation.status}`) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>{{ unitLabel(openedReservation.unit) }}</DialogDescription>
|
<DialogDescription class="text-foreground font-semibold">
|
||||||
|
{{ unitLabel(openedReservation.unit) }}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<ReservationDetails :reservation="openedReservation" :bikes="bikes" />
|
<ReservationDetails :reservation="openedReservation" :bikes="bikes" />
|
||||||
</DialogScrollContent>
|
</DialogScrollContent>
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,21 @@
|
||||||
/**
|
/**
|
||||||
* The read-only body of a reservation, shared by the admin card and the two
|
* The read-only body of a reservation, shared by the admin card and the two
|
||||||
* dialogs so the three never drift apart.
|
* 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 { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import type { Bike, ReservationLike } from '@/utils/types'
|
import type { Bike, ReservationLike } from '@/utils/types'
|
||||||
|
|
||||||
type Field = 'period' | 'bikes' | 'telegram' | 'people' | 'linka' | 'reason'
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
reservation: ReservationLike
|
reservation: ReservationLike
|
||||||
|
|
@ -26,52 +33,127 @@ function shows(field: Field) {
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
const bikeNames = computed(() =>
|
/** Names when we know the bike, `#id` when the list has not loaded yet */
|
||||||
props.reservation.bikes
|
function bikeName(id: number) {
|
||||||
.map((id) => props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`)
|
return props.bikes.find((bike) => bike.id === id)?.name ?? `#${id}`
|
||||||
.join(', '),
|
}
|
||||||
|
|
||||||
|
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', {
|
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
|
||||||
dateStyle: 'short',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function format(iso: string) {
|
function format(iso: string) {
|
||||||
return formatter.value.format(new Date(iso))
|
return dateFormatter.value.format(new Date(iso))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<dl class="grid gap-1 text-sm">
|
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
|
||||||
<div v-if="shows('period')" class="flex gap-2">
|
<div v-if="shows('period')" class="min-w-0">
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.period') }}</dt>
|
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||||
<dd>{{ format(reservation.start_time) }} → {{ format(reservation.end_time) }}</dd>
|
{{ $t('reservation.details.period') }}
|
||||||
</div>
|
</dt>
|
||||||
<div v-if="shows('bikes')" class="flex gap-2">
|
<dd class="mt-0.5 flex flex-wrap items-center gap-x-2 font-medium tabular-nums">
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.bikes') }}</dt>
|
<span>{{ format(reservation.start_time) }}</span>
|
||||||
<dd>{{ bikeNames }}</dd>
|
<span class="text-muted-foreground" aria-hidden="true">→</span>
|
||||||
</div>
|
<span>{{ format(reservation.end_time) }}</span>
|
||||||
<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(', ') }}
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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>
|
<div v-if="shows('bikes') && heldBikes.length" class="min-w-0">
|
||||||
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
|
<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>
|
||||||
<div v-if="shows('reason') && reservation.description" class="flex gap-2">
|
|
||||||
<dt class="text-muted-foreground shrink-0">{{ $t('reservation.details.reason') }}</dt>
|
<div v-if="shows('telegram') && reservation.telegram" class="min-w-0">
|
||||||
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@
|
||||||
*/
|
*/
|
||||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
import { HttpStatus } from 'http-status-ts'
|
||||||
|
|
||||||
import DatePicker from '@/components/DatePicker.vue'
|
import DatePicker from '@/components/DatePicker.vue'
|
||||||
import TimePicker from '@/components/TimePicker.vue'
|
import TimePicker from '@/components/TimePicker.vue'
|
||||||
|
|
@ -30,8 +32,15 @@ import {
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useUpdateReservation } from '@/services/api/reservations'
|
import { useConflicts, useUpdateReservation } from '@/services/api/reservations'
|
||||||
import { unitLabel, type Bike, type Reservation } from '@/utils/types'
|
import {
|
||||||
|
ApiError,
|
||||||
|
unitLabel,
|
||||||
|
type Bike,
|
||||||
|
type Conflict,
|
||||||
|
type NewReservationBike,
|
||||||
|
type Reservation,
|
||||||
|
} from '@/utils/types'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{ reservation: Reservation; bikes: Bike[]; emailsOnly?: boolean }>(),
|
defineProps<{ reservation: Reservation; bikes: Bike[]; emailsOnly?: boolean }>(),
|
||||||
|
|
@ -39,7 +48,7 @@ const props = withDefaults(
|
||||||
)
|
)
|
||||||
const open = defineModel<boolean>('open', { default: false })
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const update = useUpdateReservation()
|
const update = useUpdateReservation()
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
@ -56,6 +65,15 @@ const form = reactive({
|
||||||
const errors = reactive<Record<string, string>>({})
|
const errors = reactive<Record<string, string>>({})
|
||||||
const submitted = ref(false)
|
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 {
|
function toCalendarDate(date: Date): DateValue {
|
||||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
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')}`
|
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 */
|
/** Reloads the fields from the reservation, so cancelling really cancels */
|
||||||
function load() {
|
function load() {
|
||||||
const start = new Date(props.reservation.start_time)
|
const start = new Date(props.reservation.start_time)
|
||||||
|
|
@ -75,7 +104,11 @@ function load() {
|
||||||
endDate.value = toCalendarDate(end)
|
endDate.value = toCalendarDate(end)
|
||||||
form.startTime = toSlot(start)
|
form.startTime = toSlot(start)
|
||||||
form.endTime = toSlot(end)
|
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] : ['']
|
form.emails = props.reservation.linka_emails.length ? [...props.reservation.linka_emails] : ['']
|
||||||
Object.keys(errors).forEach((key) => delete errors[key])
|
Object.keys(errors).forEach((key) => delete errors[key])
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
|
|
@ -104,16 +137,118 @@ const end = computed(() => toDate(endDate.value, form.endTime))
|
||||||
* of service — the same rule the backend applies.
|
* of service — the same rule the backend applies.
|
||||||
*/
|
*/
|
||||||
function isBlocked(bike: Bike) {
|
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) {
|
function toggleBike(bike: Bike) {
|
||||||
if (isBlocked(bike)) return
|
if (isBlocked(bike)) return
|
||||||
const index = form.bikes.indexOf(bike.id)
|
const index = form.bikes.indexOf(bike.id)
|
||||||
if (index >= 0) form.bikes.splice(index, 1)
|
if (index >= 0) {
|
||||||
else form.bikes.push(bike.id)
|
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() {
|
function addEmail() {
|
||||||
form.emails.push('')
|
form.emails.push('')
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +268,20 @@ function validate(): boolean {
|
||||||
errors.end = t('reservation.error-end-before-start')
|
errors.end = t('reservation.error-end-before-start')
|
||||||
}
|
}
|
||||||
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
|
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)
|
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
|
||||||
|
|
@ -158,7 +307,12 @@ function save() {
|
||||||
// the backend checks before accepting the change
|
// the backend checks before accepting the change
|
||||||
start_time: props.emailsOnly ? props.reservation.start_time : start.value!.toISOString(),
|
start_time: props.emailsOnly ? props.reservation.start_time : start.value!.toISOString(),
|
||||||
end_time: props.emailsOnly ? props.reservation.end_time : end.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),
|
linka_emails: form.emails.map((email) => email.trim()).filter(Boolean),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -166,7 +320,12 @@ function save() {
|
||||||
toast.success(t('reservation.edit.saved'))
|
toast.success(t('reservation.edit.saved'))
|
||||||
open.value = false
|
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>
|
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
||||||
</div>
|
</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 -->
|
<!-- Linka Go accounts -->
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="edit-email-0">{{ $t('reservation.emails') }}</Label>
|
<Label for="edit-email-0">{{ $t('reservation.emails') }}</Label>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
<script setup lang="ts">
|
<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 { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
import type { HTMLAttributes } from 'vue'
|
import type { HTMLAttributes } from 'vue'
|
||||||
import { reactiveOmit } from '@vueuse/core'
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
|
@ -22,14 +27,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay
|
<DialogOverlay class="fixed inset-0 z-50 bg-black/80" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<DialogContent
|
<DialogContent
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|
|
||||||
267
frontend/src/lib/api.d.ts
vendored
267
frontend/src/lib/api.d.ts
vendored
|
|
@ -403,24 +403,51 @@ export interface paths {
|
||||||
path?: never
|
path?: never
|
||||||
cookie?: 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: {
|
get: {
|
||||||
parameters: {
|
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
|
header?: never
|
||||||
path?: never
|
path?: never
|
||||||
cookie?: never
|
cookie?: never
|
||||||
}
|
}
|
||||||
requestBody?: never
|
requestBody?: never
|
||||||
responses: {
|
responses: {
|
||||||
|
/**
|
||||||
|
* @description One page of a listing, with the size of the whole so the caller can page
|
||||||
|
* through it without asking twice.
|
||||||
|
*/
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
}
|
}
|
||||||
content: {
|
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 */
|
/** @description Unauthenticated - a session is required */
|
||||||
401: {
|
401: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -485,7 +512,7 @@ export interface paths {
|
||||||
}
|
}
|
||||||
content?: never
|
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: {
|
409: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
|
|
@ -515,26 +542,50 @@ export interface paths {
|
||||||
cookie?: never
|
cookie?: never
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get the reservations the session user is part of
|
* 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.
|
* @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: {
|
get: {
|
||||||
parameters: {
|
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
|
header?: never
|
||||||
path?: never
|
path?: never
|
||||||
cookie?: never
|
cookie?: never
|
||||||
}
|
}
|
||||||
requestBody?: never
|
requestBody?: never
|
||||||
responses: {
|
responses: {
|
||||||
|
/**
|
||||||
|
* @description One page of a listing, with the size of the whole so the caller can page
|
||||||
|
* through it without asking twice.
|
||||||
|
*/
|
||||||
200: {
|
200: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
}
|
}
|
||||||
content: {
|
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 */
|
/** @description Unauthenticated - a session is required */
|
||||||
401: {
|
401: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -561,11 +612,14 @@ export interface paths {
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Get the approved and ongoing reservations, for the public calendar
|
* 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: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: {
|
||||||
|
from?: string | null
|
||||||
|
to?: string | null
|
||||||
|
}
|
||||||
header?: never
|
header?: never
|
||||||
path?: never
|
path?: never
|
||||||
cookie?: never
|
cookie?: never
|
||||||
|
|
@ -590,6 +644,66 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: 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}': {
|
'/api/reservations/{id}': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
|
@ -658,7 +772,7 @@ export interface paths {
|
||||||
}
|
}
|
||||||
content?: never
|
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: {
|
409: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown
|
[name: string]: unknown
|
||||||
|
|
@ -783,7 +897,8 @@ export interface components {
|
||||||
* travels to anybody, signed in or not.
|
* travels to anybody, signed in or not.
|
||||||
*/
|
*/
|
||||||
CalendarReservation: {
|
CalendarReservation: {
|
||||||
bikes: number[]
|
/** @description With their own periods: a bike handed over early is drawn as such */
|
||||||
|
bikes: components['schemas']['ReservationBike'][]
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
end_time: string
|
end_time: string
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
|
|
@ -793,6 +908,54 @@ export interface components {
|
||||||
status: components['schemas']['ReservationStatus']
|
status: components['schemas']['ReservationStatus']
|
||||||
unit: components['schemas']['ReservationUnit']
|
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: {
|
DevUser: {
|
||||||
admin: boolean
|
admin: boolean
|
||||||
email: string
|
email: string
|
||||||
|
|
@ -822,6 +985,18 @@ export interface components {
|
||||||
unit: components['schemas']['NewReservationUnit']
|
unit: components['schemas']['NewReservationUnit']
|
||||||
users: number[]
|
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. */
|
/** @description The same choice, as the client sends it: only the id travels for a known unit. */
|
||||||
NewReservationUnit:
|
NewReservationUnit:
|
||||||
| {
|
| {
|
||||||
|
|
@ -841,7 +1016,7 @@ export interface components {
|
||||||
}
|
}
|
||||||
Reservation: {
|
Reservation: {
|
||||||
description: string
|
description: string
|
||||||
bikes: number[]
|
bikes: components['schemas']['ReservationBike'][]
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
end_time: string
|
end_time: string
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
|
|
@ -861,6 +1036,26 @@ export interface components {
|
||||||
unit: components['schemas']['ReservationUnit']
|
unit: components['schemas']['ReservationUnit']
|
||||||
users: components['schemas']['UserSummary'][]
|
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
|
* @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
|
* 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.
|
* rather than trusting a client to send them unchanged.
|
||||||
*/
|
*/
|
||||||
ReservationEditForm: {
|
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 */
|
/** Format: date-time */
|
||||||
end_time: string
|
end_time: string
|
||||||
linka_emails: string[]
|
linka_emails: string[]
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
start_time: string
|
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} */
|
/** @enum {string} */
|
||||||
ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived'
|
ReservationStatus: 'requested' | 'refused' | 'approved' | 'cancelled' | 'ongoing' | 'archived'
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ reservation:
|
||||||
bikes-empty: No cargobike available for this period.
|
bikes-empty: No cargobike available for this period.
|
||||||
bikes-error: Unable to load the cargobikes.
|
bikes-error: Unable to load the cargobikes.
|
||||||
bike-out-of-service: Out of service
|
bike-out-of-service: Out of service
|
||||||
|
bike-taken: Booked
|
||||||
bike-size-large: Large cargo bikes
|
bike-size-large: Large cargo bikes
|
||||||
bike-size-small: Small cargo bikes
|
bike-size-small: Small cargo bikes
|
||||||
bike-size-empty: No bike of this size.
|
bike-size-empty: No bike of this size.
|
||||||
|
|
@ -70,8 +71,28 @@ reservation:
|
||||||
people: People
|
people: People
|
||||||
linka: Linka Go accounts
|
linka: Linka Go accounts
|
||||||
reason: Reason
|
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:
|
edit:
|
||||||
action: 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}'
|
title: 'Reservation #{id}'
|
||||||
intro: >-
|
intro: >-
|
||||||
Only the period, the cargobikes and the Linka Go accounts can be changed.
|
Only the period, the cargobikes and the Linka Go accounts can be changed.
|
||||||
|
|
@ -106,10 +127,14 @@ login:
|
||||||
dev: Development login
|
dev: Development login
|
||||||
not-allowed: Your account is not allowed to use this application.
|
not-allowed: Your account is not allowed to use this application.
|
||||||
error: The login failed.
|
error: The login failed.
|
||||||
|
pagination:
|
||||||
|
page: 'Page {page} of {pages}'
|
||||||
|
previous: Previous
|
||||||
|
next: Next
|
||||||
|
|
||||||
calendar:
|
calendar:
|
||||||
load-error: Unable to load the calendar.
|
load-error: Unable to load the calendar.
|
||||||
title: Calendar per cargobike
|
title: Calendar per cargobike
|
||||||
intro: Approved and ongoing reservations are shown in the calendar.
|
|
||||||
bike: Cargobike
|
bike: Cargobike
|
||||||
all-bikes: All
|
all-bikes: All
|
||||||
previous: Week
|
previous: Week
|
||||||
|
|
@ -144,9 +169,25 @@ admin:
|
||||||
pending-empty: No reservation request for now.
|
pending-empty: No reservation request for now.
|
||||||
active: Reservations (ongoing and upcoming)
|
active: Reservations (ongoing and upcoming)
|
||||||
active-empty: No ongoing or upcoming reservation.
|
active-empty: No ongoing or upcoming reservation.
|
||||||
show-archived: 'Show archived requests ({n})'
|
archive:
|
||||||
hide-archived: Hide archived requests
|
action: Browse the archive
|
||||||
archived-empty: No archived request.
|
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.
|
load-error: Unable to load the reservations.
|
||||||
error: The status change failed.
|
error: The status change failed.
|
||||||
action:
|
action:
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ reservation:
|
||||||
bikes-empty: Aucun cargobike disponible pour ce créneau.
|
bikes-empty: Aucun cargobike disponible pour ce créneau.
|
||||||
bikes-error: Impossible de charger les cargobikes.
|
bikes-error: Impossible de charger les cargobikes.
|
||||||
bike-out-of-service: Hors service
|
bike-out-of-service: Hors service
|
||||||
|
bike-taken: Réservé
|
||||||
bike-size-large: Grands cargos
|
bike-size-large: Grands cargos
|
||||||
bike-size-small: Petits cargos
|
bike-size-small: Petits cargos
|
||||||
bike-size-empty: Aucun vélo de cette taille.
|
bike-size-empty: Aucun vélo de cette taille.
|
||||||
|
|
@ -71,8 +72,28 @@ reservation:
|
||||||
people: Personnes
|
people: Personnes
|
||||||
linka: Comptes Linka Go
|
linka: Comptes Linka Go
|
||||||
reason: Raison
|
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:
|
edit:
|
||||||
action: Modifier
|
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}'
|
title: 'Réservation #{id}'
|
||||||
intro: >-
|
intro: >-
|
||||||
Seuls la période, les cargobikes et les comptes Linka Go peuvent être modifiés.
|
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
|
dev: Connexion de développement
|
||||||
not-allowed: Votre compte n'est pas autorisé à accéder à cette application.
|
not-allowed: Votre compte n'est pas autorisé à accéder à cette application.
|
||||||
error: La connexion a échoué.
|
error: La connexion a échoué.
|
||||||
|
pagination:
|
||||||
|
page: 'Page {page} sur {pages}'
|
||||||
|
previous: Précédent
|
||||||
|
next: Suivant
|
||||||
|
|
||||||
calendar:
|
calendar:
|
||||||
load-error: Impossible de charger le calendrier.
|
load-error: Impossible de charger le calendrier.
|
||||||
title: Calendrier par cargobike
|
title: Calendrier par cargobike
|
||||||
intro: Les réservations validées et en cours sont affichées dans le calendrier.
|
|
||||||
bike: Cargobike
|
bike: Cargobike
|
||||||
all-bikes: Tous
|
all-bikes: Tous
|
||||||
previous: Sem.
|
previous: Sem.
|
||||||
|
|
@ -145,9 +170,25 @@ admin:
|
||||||
pending-empty: Aucune demande de réservation pour le moment.
|
pending-empty: Aucune demande de réservation pour le moment.
|
||||||
active: Réservations (en cours et à venir)
|
active: Réservations (en cours et à venir)
|
||||||
active-empty: Aucune réservation en cours ou à venir.
|
active-empty: Aucune réservation en cours ou à venir.
|
||||||
show-archived: 'Voir les demandes archivées ({n})'
|
archive:
|
||||||
hide-archived: Masquer les demandes archivées
|
action: Parcourir les archives
|
||||||
archived-empty: Aucune demande archivée.
|
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.
|
load-error: Impossible de charger les réservations.
|
||||||
error: Le changement de statut a échoué.
|
error: Le changement de statut a échoué.
|
||||||
action:
|
action:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { HttpStatus } from 'http-status-ts'
|
||||||
import type { QueryClient } from '@tanstack/vue-query'
|
import type { QueryClient } from '@tanstack/vue-query'
|
||||||
|
|
||||||
import type { paths } from '@/lib/api'
|
import type { paths } from '@/lib/api'
|
||||||
import { Forbidden, Unauthorized } from '@/utils/types'
|
import { ApiError, Forbidden, Unauthorized } from '@/utils/types'
|
||||||
import { SESSION_KEY } from './keys'
|
import { SESSION_KEY } from './keys'
|
||||||
|
|
||||||
// Registered by main.ts. The interceptor below needs it to drop the session
|
// 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) {
|
} else if (response.status === HttpStatus.NOT_FOUND) {
|
||||||
throw new Error(`Not found: ${response.url}`)
|
throw new Error(`Not found: ${response.url}`)
|
||||||
}
|
}
|
||||||
throw new Error(
|
// The handlers answer with a plain string; keeping it lets a view show
|
||||||
`Unexpected error from ${response.url}: ${response.status} ${response.statusText}`,
|
// 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}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,120 @@
|
||||||
/**
|
/**
|
||||||
* Reservations. Filing a request only needs a session; reading the whole list is
|
* Reservations. Filing a request only needs a session; reading the list is an
|
||||||
* an admin action, so `useReservations` is only ever mounted on /admin.
|
* 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 { 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'
|
import { getClient } from './client'
|
||||||
|
|
||||||
export const RESERVATIONS_KEY = ['reservations']
|
export const RESERVATIONS_KEY = ['reservations']
|
||||||
export const MY_RESERVATIONS_KEY = ['reservations', 'mine']
|
export const MY_RESERVATIONS_KEY = ['reservations', 'mine']
|
||||||
export const CALENDAR_KEY = ['reservations', 'calendar']
|
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({
|
return useQuery({
|
||||||
queryKey: RESERVATIONS_KEY,
|
queryKey: computed(() => [...RESERVATIONS_KEY, 'search', params.value]),
|
||||||
|
enabled: computed(() => toValue(options.enabled) ?? true),
|
||||||
staleTime: 30 * 1000,
|
staleTime: 30 * 1000,
|
||||||
|
// Paging or typing in the search must not blank the list under the cursor
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
queryFn: async () => {
|
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) {
|
if (response.status !== HttpStatus.OK) {
|
||||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
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 }
|
return { id, status }
|
||||||
},
|
},
|
||||||
onSuccess: ({ id, status }) => {
|
// A status change moves a reservation from one filtered list to another, so
|
||||||
// Patch the cache so the card moves section immediately
|
// every page of every list is stale — patching one of them in place would
|
||||||
queryClient.setQueryData<Reservation[]>(RESERVATIONS_KEY, (reservations) =>
|
// leave the card in the section it just left.
|
||||||
reservations?.map((r) => (r.id === id ? { ...r, status } : r)),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
|
||||||
)
|
|
||||||
// "My reservations" holds its own list, and a status change moves a card
|
|
||||||
// there too
|
|
||||||
queryClient.invalidateQueries({ queryKey: MY_RESERVATIONS_KEY })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The public availability calendar: the approved and ongoing reservations,
|
* The public availability calendar: the approved and ongoing reservations over
|
||||||
* without any personal field. No session needed.
|
* 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({
|
return useQuery({
|
||||||
queryKey: CALENDAR_KEY,
|
queryKey: computed(() => [...CALENDAR_KEY, params.value]),
|
||||||
staleTime: 30 * 1000,
|
staleTime: 30 * 1000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
queryFn: async () => {
|
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) {
|
if (response.status !== HttpStatus.OK) {
|
||||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
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
|
* One page of what the session user is part of: what they filed, what they were
|
||||||
* to, and what lists their address among the Linka Go accounts — which is how a
|
* added to, and what lists their address among the Linka Go accounts — which is
|
||||||
* reservation reaches somebody who was named before they ever logged in.
|
* 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({
|
return useQuery({
|
||||||
queryKey: MY_RESERVATIONS_KEY,
|
queryKey: computed(() => [...MY_RESERVATIONS_KEY, params.value]),
|
||||||
staleTime: 30 * 1000,
|
staleTime: 30 * 1000,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
queryFn: async () => {
|
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) {
|
if (response.status !== HttpStatus.OK) {
|
||||||
throw new Error(`Unexpected status code received: ${response.status}`)
|
throw new Error(`Unexpected status code received: ${response.status}`)
|
||||||
}
|
}
|
||||||
return data ?? []
|
return data ?? { items: [], total: 0 }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Shorthands over the generated schemas, so views never import `@/lib/api` directly
|
||||||
export type Bike = components['schemas']['Bike']
|
export type Bike = components['schemas']['Bike']
|
||||||
export type BikeStatus = components['schemas']['BikeStatus']
|
export type BikeStatus = components['schemas']['BikeStatus']
|
||||||
|
|
@ -30,6 +47,9 @@ export type ReservationUnit = components['schemas']['ReservationUnit']
|
||||||
export type NewReservation = components['schemas']['NewReservation']
|
export type NewReservation = components['schemas']['NewReservation']
|
||||||
export type ReservationEdit = components['schemas']['ReservationEditForm']
|
export type ReservationEdit = components['schemas']['ReservationEditForm']
|
||||||
export type CalendarReservation = components['schemas']['CalendarReservation']
|
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,
|
* What the details block can render: the fields the public calendar carries,
|
||||||
|
|
|
||||||
19
frontend/src/utils/week.ts
Normal file
19
frontend/src/utils/week.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* Administration: the fleet, the reservations, and the week planning.
|
* 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 { RefreshCw } from '@lucide/vue'
|
||||||
|
|
||||||
import BikeCalendar from '@/components/reservation/BikeCalendar.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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { useBikes } from '@/services/api/bikes'
|
import { useBikes } from '@/services/api/bikes'
|
||||||
import { useReservations } from '@/services/api/reservations'
|
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: 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 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(() =>
|
const updatedAt = computed(() =>
|
||||||
dataUpdatedAt.value ? new Date(dataUpdatedAt.value).toLocaleTimeString() : '—',
|
active.dataUpdatedAt.value ? new Date(active.dataUpdatedAt.value).toLocaleTimeString() : '—',
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -39,7 +69,7 @@ const updatedAt = computed(() =>
|
||||||
<CardDescription>{{ $t('admin.intro') }}</CardDescription>
|
<CardDescription>{{ $t('admin.intro') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="flex flex-wrap items-center gap-3">
|
<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' : ''" />
|
<RefreshCw class="size-4" :class="isFetching ? 'animate-spin' : ''" />
|
||||||
{{ $t('admin.refresh') }}
|
{{ $t('admin.refresh') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -49,16 +79,22 @@ const updatedAt = computed(() =>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<BikeFleet class="min-w-0" :reservations="list" />
|
<BikeFleet class="min-w-0" :reservations="activeList" />
|
||||||
|
|
||||||
<ReservationAdmin
|
<ReservationAdmin
|
||||||
class="min-w-0"
|
class="min-w-0"
|
||||||
:reservations="list"
|
:pending="pendingList"
|
||||||
|
:active="activeList"
|
||||||
:bikes="fleet"
|
:bikes="fleet"
|
||||||
:is-pending="isPending"
|
:is-pending="isPending"
|
||||||
:is-error="isError"
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,22 @@
|
||||||
* endpoint that carries no personal field. Anybody can look up when the bikes
|
* endpoint that carries no personal field. Anybody can look up when the bikes
|
||||||
* are taken without being signed in.
|
* are taken without being signed in.
|
||||||
*/
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
|
import BikeCalendar from '@/components/reservation/BikeCalendar.vue'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useBikes } from '@/services/api/bikes'
|
import { useBikes } from '@/services/api/bikes'
|
||||||
import { useCalendarReservations } from '@/services/api/reservations'
|
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()
|
const { data: bikes, isPending: bikesPending } = useBikes()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -24,6 +34,11 @@ const { data: bikes, isPending: bikesPending } = useBikes()
|
||||||
{{ $t('calendar.load-error') }}
|
{{ $t('calendar.load-error') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<BikeCalendar v-else :reservations="reservations ?? []" :bikes="bikes ?? []" />
|
<BikeCalendar
|
||||||
|
v-else
|
||||||
|
v-model:week-start="weekStart"
|
||||||
|
:reservations="reservations ?? []"
|
||||||
|
:bikes="bikes ?? []"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,11 @@
|
||||||
* What can still be changed depends on the status, and the backend applies the
|
* 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
|
* 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.
|
* 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 ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||||
import ReservationEditDialog from '@/components/reservation/ReservationEditDialog.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 { useMyReservations } from '@/services/api/reservations'
|
||||||
import { unitLabel, type Reservation, type ReservationStatus } from '@/utils/types'
|
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 { data: bikes } = useBikes()
|
||||||
|
|
||||||
const BADGE_CLASS: Record<ReservationStatus, string> = {
|
const BADGE_CLASS: Record<ReservationStatus, string> = {
|
||||||
|
|
@ -32,25 +52,33 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
|
||||||
archived: 'bg-muted text-muted-foreground',
|
archived: 'bg-muted text-muted-foreground',
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECTIONS = [
|
const sections = computed(() => [
|
||||||
{ key: 'requested', statuses: ['requested'] },
|
{ key: 'requested', reservations: requested.data.value?.items ?? [] },
|
||||||
{ key: 'active', statuses: ['approved', 'ongoing'] },
|
{ key: 'active', reservations: active.data.value?.items ?? [] },
|
||||||
{ key: 'past', statuses: ['refused', 'cancelled', 'archived'] },
|
{ key: 'past', reservations: past.data.value?.items ?? [] },
|
||||||
] as const
|
])
|
||||||
|
|
||||||
const sections = computed(() =>
|
const isPending = computed(
|
||||||
SECTIONS.map((section) => ({
|
() => requested.isPending.value || active.isPending.value || past.isPending.value,
|
||||||
key: section.key,
|
|
||||||
reservations: (reservations.value ?? []).filter((r) =>
|
|
||||||
(section.statuses as readonly string[]).includes(r.status),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
|
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 */
|
/** Which reservation the edit dialog is on, by id */
|
||||||
const editing = ref<number | null>(null)
|
const editing = ref<number | null>(null)
|
||||||
const editingReservation = computed(() =>
|
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({
|
const dialogOpen = computed({
|
||||||
get: () => editing.value !== null,
|
get: () => editing.value !== null,
|
||||||
|
|
@ -85,7 +113,7 @@ function editableEmails(reservation: Reservation) {
|
||||||
{{ $t('my-reservations.load-error') }}
|
{{ $t('my-reservations.load-error') }}
|
||||||
</p>
|
</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') }}
|
{{ $t('my-reservations.empty') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -94,7 +122,7 @@ function editableEmails(reservation: Reservation) {
|
||||||
<h3 class="text-base font-semibold">
|
<h3 class="text-base font-semibold">
|
||||||
{{ $t(`my-reservations.${section.key}`) }}
|
{{ $t(`my-reservations.${section.key}`) }}
|
||||||
<span class="text-muted-foreground font-normal">
|
<span class="text-muted-foreground font-normal">
|
||||||
({{ section.reservations.length }})
|
({{ section.key === 'past' ? pastTotal : section.reservations.length }})
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|
@ -113,9 +141,7 @@ function editableEmails(reservation: Reservation) {
|
||||||
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
<Badge variant="secondary" :class="BADGE_CLASS[reservation.status]">
|
||||||
{{ $t(`reservation.status.${reservation.status}`) }}
|
{{ $t(`reservation.status.${reservation.status}`) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span class="text-muted-foreground text-sm">
|
<span class="text-sm font-semibold">{{ unitLabel(reservation.unit) }}</span>
|
||||||
{{ unitLabel(reservation.unit) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -133,7 +159,32 @@ function editableEmails(reservation: Reservation) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { Plus, TriangleAlert, X } from '@lucide/vue'
|
import { Plus, TriangleAlert, X } from '@lucide/vue'
|
||||||
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
|
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
import { HttpStatus } from 'http-status-ts'
|
||||||
|
|
||||||
import DatePicker from '@/components/DatePicker.vue'
|
import DatePicker from '@/components/DatePicker.vue'
|
||||||
import TelegramInput from '@/components/TelegramInput.vue'
|
import TelegramInput from '@/components/TelegramInput.vue'
|
||||||
|
|
@ -16,9 +17,9 @@ import { Label } from '@/components/ui/label'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { useBikes } from '@/services/api/bikes'
|
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 { useSession } from '@/services/api/auth'
|
||||||
import type { Bike, NewReservation } from '@/utils/types'
|
import { ApiError, type Bike, type NewReservation } from '@/utils/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
@ -76,8 +77,30 @@ const periodPicked = computed(() => start.value !== null && end.value !== null)
|
||||||
|
|
||||||
const { data: bikes, isPending: bikesPending, isError: bikesError } = useBikes()
|
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) {
|
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)))
|
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
|
// The message is the backend's own: "bike 3 is out of service" is worth
|
||||||
// reading, and a generic failure would hide it.
|
// 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>
|
</script>
|
||||||
|
|
@ -348,7 +376,11 @@ function submit() {
|
||||||
v-if="isUnavailable(bike)"
|
v-if="isUnavailable(bike)"
|
||||||
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase"
|
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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,15 @@
|
||||||
use aide::{
|
use aide::{
|
||||||
axum::{
|
axum::{
|
||||||
ApiRouter,
|
ApiRouter,
|
||||||
routing::{get_with, put_with},
|
routing::{get_with, post_with, put_with},
|
||||||
},
|
},
|
||||||
transform::TransformOperation,
|
transform::TransformOperation,
|
||||||
};
|
};
|
||||||
use axum::{Json, extract::Path, http::StatusCode};
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
@ -25,12 +29,10 @@ use crate::{
|
||||||
AnonAppController, AppController, ControllerError,
|
AnonAppController, AppController, ControllerError,
|
||||||
reservations::ReservationsControllerError,
|
reservations::ReservationsControllerError,
|
||||||
},
|
},
|
||||||
models::{
|
models::reservation::{
|
||||||
bike::BikeId,
|
CalendarReservation, Conflict, ConflictProbe, NewReservation, NewReservationBike,
|
||||||
reservation::{
|
Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery,
|
||||||
CalendarReservation, NewReservation, Reservation, ReservationEdit,
|
ReservationStatus,
|
||||||
ReservationStatus,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -51,6 +53,7 @@ pub fn routes() -> ApiRouter {
|
||||||
"/calendar",
|
"/calendar",
|
||||||
get_with(get_calendar_reservations, get_calendar_reservations_docs),
|
get_with(get_calendar_reservations, get_calendar_reservations_docs),
|
||||||
)
|
)
|
||||||
|
.api_route("/conflicts", post_with(find_conflicts, find_conflicts_docs))
|
||||||
.api_route(
|
.api_route(
|
||||||
"/{id}",
|
"/{id}",
|
||||||
put_with(update_reservation, update_reservation_docs),
|
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))
|
.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]
|
#[axum::debug_handler]
|
||||||
async fn get_reservations(
|
async fn get_reservations(
|
||||||
ac: AppController,
|
ac: AppController,
|
||||||
) -> Result<Json<Vec<Reservation>>, (StatusCode, String)> {
|
Query(params): Query<ReservationSearchParams>,
|
||||||
match admin(ac)?.get_reservations().await {
|
) -> Result<Json<ReservationPage>, (StatusCode, String)> {
|
||||||
Ok(reservations) => Ok(Json(reservations)),
|
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),
|
Err(err) => unexpected_error("get_reservations", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_reservations_docs(op: TransformOperation) -> TransformOperation {
|
fn get_reservations_docs(op: TransformOperation) -> TransformOperation {
|
||||||
op.tag("Reservations")
|
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)
|
.response_with::<403, (), _>(admin_desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +144,8 @@ async fn create_reservation(
|
||||||
err @ ReservationsControllerError::ReservationInvalid,
|
err @ ReservationsControllerError::ReservationInvalid,
|
||||||
)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
|
)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
|
||||||
Err(ControllerError::Reservation(
|
Err(ControllerError::Reservation(
|
||||||
err @ ReservationsControllerError::BikeOutOfService(_),
|
err @ (ReservationsControllerError::BikeOutOfService(_)
|
||||||
|
| ReservationsControllerError::BikesTaken(_)),
|
||||||
)) => Err((StatusCode::CONFLICT, err.to_string())),
|
)) => Err((StatusCode::CONFLICT, err.to_string())),
|
||||||
Err(ControllerError::Reservation(
|
Err(ControllerError::Reservation(
|
||||||
err @ ReservationsControllerError::NotAMemberOfUnit(_),
|
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::<201, Json<Reservation>, _>(desc("The reservation, as stored"))
|
||||||
.response_with::<400, (), _>(desc("The reservation is malformed"))
|
.response_with::<400, (), _>(desc("The reservation is malformed"))
|
||||||
.response_with::<403, (), _>(desc("The requester does not belong to the unit named"))
|
.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"))
|
.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
|
/// The availability calendar, open to everybody: when the bikes are taken and by
|
||||||
/// which association, with nothing personal attached.
|
/// 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]
|
#[axum::debug_handler]
|
||||||
async fn get_calendar_reservations(
|
async fn get_calendar_reservations(
|
||||||
aac: AnonAppController,
|
aac: AnonAppController,
|
||||||
|
Query(window): Query<CalendarWindow>,
|
||||||
) -> Result<Json<Vec<CalendarReservation>>, (StatusCode, String)> {
|
) -> 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)),
|
Ok(reservations) => Ok(Json(reservations)),
|
||||||
Err(err) => unexpected_error("get_calendar_reservations", err),
|
Err(err) => unexpected_error("get_calendar_reservations", err),
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +199,8 @@ fn get_calendar_reservations_docs(op: TransformOperation) -> TransformOperation
|
||||||
op.tag("Reservations")
|
op.tag("Reservations")
|
||||||
.summary("Get the approved and ongoing reservations, for the public calendar")
|
.summary("Get the approved and ongoing reservations, for the public calendar")
|
||||||
.description(
|
.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.",
|
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]
|
#[axum::debug_handler]
|
||||||
async fn get_my_reservations(
|
async fn get_my_reservations(
|
||||||
ac: AppController,
|
ac: AppController,
|
||||||
) -> Result<Json<Vec<Reservation>>, (StatusCode, String)> {
|
Query(params): Query<ReservationSearchParams>,
|
||||||
match ac.get_my_reservations().await {
|
) -> Result<Json<ReservationPage>, (StatusCode, String)> {
|
||||||
Ok(reservations) => Ok(Json(reservations)),
|
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),
|
Err(err) => unexpected_error("get_my_reservations", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation {
|
fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation {
|
||||||
op.tag("Reservations")
|
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(
|
.description(
|
||||||
"An address listed among the Linka Go accounts is enough, which is how \
|
"Takes the same filters as the listing, and answers the same page. An \
|
||||||
somebody added before they ever logged in finds the reservation waiting \
|
address listed among the Linka Go accounts is enough to be part of a \
|
||||||
for them.",
|
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
|
/// What a would-be booking asks about: a period, the bikes wanted, and the
|
||||||
/// telegram handle and the reason are shown but not editable, so they are not
|
/// reservation to leave out of the answer when one is being edited.
|
||||||
/// in the body at all: the handler reads them back from the stored reservation
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
/// rather than trusting a client to send them unchanged.
|
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)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct ReservationEditForm {
|
struct ReservationEditForm {
|
||||||
start_time: DateTime<Utc>,
|
start_time: DateTime<Utc>,
|
||||||
end_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>,
|
linka_emails: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +312,7 @@ async fn update_reservation(
|
||||||
start_time: form.start_time,
|
start_time: form.start_time,
|
||||||
end_time: form.end_time,
|
end_time: form.end_time,
|
||||||
users: current.users.iter().map(|user| user.id).collect(),
|
users: current.users.iter().map(|user| user.id).collect(),
|
||||||
telegram: current.telegram,
|
telegram: form.telegram,
|
||||||
description: current.description,
|
description: current.description,
|
||||||
bikes: form.bikes,
|
bikes: form.bikes,
|
||||||
linka_emails: form.linka_emails,
|
linka_emails: form.linka_emails,
|
||||||
|
|
@ -231,7 +353,9 @@ fn update_reservation_docs(op: TransformOperation) -> TransformOperation {
|
||||||
.response::<404, ()>()
|
.response::<404, ()>()
|
||||||
.response_with::<400, (), _>(desc("The reservation would become malformed"))
|
.response_with::<400, (), _>(desc("The reservation would become malformed"))
|
||||||
.response_with::<409, (), _>(desc(
|
.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"))
|
.response_with::<422, (), _>(desc("One of the bikes does not exist"))
|
||||||
}
|
}
|
||||||
|
|
@ -262,7 +386,8 @@ async fn set_status(
|
||||||
{
|
{
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(ControllerError::Reservation(
|
Err(ControllerError::Reservation(
|
||||||
err @ ReservationsControllerError::InvalidTransition(..),
|
err @ (ReservationsControllerError::InvalidTransition(..)
|
||||||
|
| ReservationsControllerError::BikesTaken(_)),
|
||||||
)) => Err((StatusCode::CONFLICT, err.to_string())),
|
)) => Err((StatusCode::CONFLICT, err.to_string())),
|
||||||
Err(err) => unexpected_error("set_status", err),
|
Err(err) => unexpected_error("set_status", err),
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +396,11 @@ async fn set_status(
|
||||||
fn set_status_docs(op: TransformOperation) -> TransformOperation {
|
fn set_status_docs(op: TransformOperation) -> TransformOperation {
|
||||||
op.tag("Reservations")
|
op.tag("Reservations")
|
||||||
.summary("Move a reservation through its state machine")
|
.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_with::<403, (), _>(manager_desc)
|
||||||
.response::<404, ()>()
|
.response::<404, ()>()
|
||||||
.response::<409, ()>()
|
.response::<409, ()>()
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,33 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::{
|
||||||
controller::{AnonAppController, AppController, ControllerError, ManagerAppController},
|
core::{
|
||||||
models::{
|
controller::{AnonAppController, AppController, ControllerError, ManagerAppController},
|
||||||
bike::BikeStatus,
|
models::{
|
||||||
reservation::{
|
bike::{BikeId, BikeStatus},
|
||||||
CalendarReservation, NewReservation, NewReservationUnit, Reservation, ReservationEdit,
|
reservation::{
|
||||||
ReservationId, ReservationStatus,
|
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.
|
/// Reading the reservations needs no session: the calendar is public.
|
||||||
impl AnonAppController {
|
impl AnonAppController {
|
||||||
pub async fn get_reservations(&self) -> Result<Vec<Reservation>, ControllerError> {
|
/// One page of the reservations matching `query`. Reading the whole list is
|
||||||
self.db.get_reservations().await.map_err(Into::into)
|
/// 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(
|
pub async fn get_unit_reservations(
|
||||||
|
|
@ -33,35 +44,50 @@ impl AnonAppController {
|
||||||
self.db.get_reservation(id).await.map_err(Into::into)
|
self.db.get_reservation(id).await.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the availability calendar shows: the reservations that actually hold
|
/// What the availability calendar shows: the reservations that actually
|
||||||
/// a bike, stripped of everything personal. Reading it needs no session.
|
/// 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(
|
pub async fn get_calendar_reservations(
|
||||||
&self,
|
&self,
|
||||||
|
from: Option<DateTime<Utc>>,
|
||||||
|
to: Option<DateTime<Utc>>,
|
||||||
) -> Result<Vec<CalendarReservation>, ControllerError> {
|
) -> Result<Vec<CalendarReservation>, ControllerError> {
|
||||||
Ok(self
|
let page = self
|
||||||
.db
|
.db
|
||||||
.get_reservations()
|
.search_reservations(ReservationQuery {
|
||||||
.await?
|
statuses: vec![ReservationStatus::Approved, ReservationStatus::Ongoing],
|
||||||
.into_iter()
|
from,
|
||||||
.filter(|reservation| {
|
to,
|
||||||
matches!(
|
limit: ReservationQuery::MAX_LIMIT,
|
||||||
reservation.status,
|
..Default::default()
|
||||||
ReservationStatus::Approved | ReservationStatus::Ongoing
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.map(Into::into)
|
.await?;
|
||||||
.collect())
|
Ok(page.items.into_iter().map(Into::into).collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filing a request is done in one's own name: the requester is the session
|
/// Filing a request is done in one's own name: the requester is the session
|
||||||
/// user, never something the client gets to choose.
|
/// user, never something the client gets to choose.
|
||||||
impl AppController {
|
impl AppController {
|
||||||
/// Everything the session user is part of, whichever way.
|
/// One page of what the session user is part of, whichever way. The
|
||||||
pub async fn get_my_reservations(&self) -> Result<Vec<Reservation>, ControllerError> {
|
/// 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();
|
let user = self.user();
|
||||||
self.db
|
self.db
|
||||||
.get_involved_reservations(user.id, &user.email)
|
.search_reservations(ReservationQuery {
|
||||||
|
involving: Some(Involvement {
|
||||||
|
user: user.id,
|
||||||
|
email: user.email.clone(),
|
||||||
|
}),
|
||||||
|
..query
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
@ -93,10 +119,59 @@ impl AppController {
|
||||||
return Err(ReservationsControllerError::BikeOutOfService(*id).into());
|
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)
|
.create_reservation(reservation, self.user().id)
|
||||||
.await
|
.await?;
|
||||||
.map_err(Into::into)
|
|
||||||
|
// 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
|
if current.status != ReservationStatus::Requested
|
||||||
&& (reservation.start_time != current.start_time
|
&& (reservation.start_time != current.start_time
|
||||||
|| reservation.end_time != current.end_time
|
|| reservation.end_time != current.end_time
|
||||||
|| !same_bikes(&reservation.bikes, ¤t.bikes))
|
|| reservation.telegram != current.telegram
|
||||||
|
|| !same_fleet(
|
||||||
|
&reservation.bikes,
|
||||||
|
¤t.bikes,
|
||||||
|
reservation.start_time,
|
||||||
|
reservation.end_time,
|
||||||
|
))
|
||||||
{
|
{
|
||||||
return Err(ReservationsControllerError::OnlyEmailsEditable(current.status).into());
|
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:
|
// A bike already on the reservation may well have broken down since:
|
||||||
// only a newly added one has to be in service.
|
// only a newly added one has to be in service.
|
||||||
for id in &reservation.bikes {
|
for bike in &reservation.bikes {
|
||||||
if current.bikes.contains(id) {
|
if current.bikes.iter().any(|held| held.id == bike.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
|
if self.get_bike(bike.id).await?.status == BikeStatus::OutOfService {
|
||||||
return Err(ReservationsControllerError::BikeOutOfService(*id).into());
|
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
|
/// The bikes a set of conflicts is about, once each
|
||||||
fn same_bikes(left: &[i32], right: &[i32]) -> bool {
|
fn taken(conflicts: &[Conflict]) -> Vec<BikeId> {
|
||||||
let mut left = left.to_vec();
|
let mut bikes: Vec<BikeId> = conflicts.iter().map(|conflict| conflict.bike).collect();
|
||||||
let mut right = right.to_vec();
|
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.sort_unstable();
|
||||||
left.dedup();
|
left.dedup();
|
||||||
right.sort_unstable();
|
right.sort_unstable();
|
||||||
|
|
@ -192,17 +317,46 @@ impl ManagerAppController {
|
||||||
id: ReservationId,
|
id: ReservationId,
|
||||||
status: ReservationStatus,
|
status: ReservationStatus,
|
||||||
) -> Result<(), ControllerError> {
|
) -> Result<(), ControllerError> {
|
||||||
let current = self.db.get_reservation(id).await?;
|
let reservation = self.db.get_reservation(id).await?;
|
||||||
if current.unit.scope() != self.unit {
|
if reservation.unit.scope() != self.unit {
|
||||||
return Err(ControllerError::ImmutableUnitModificationError);
|
return Err(ControllerError::ImmutableUnitModificationError);
|
||||||
}
|
}
|
||||||
let current = current.status;
|
let current = reservation.status;
|
||||||
if current == status {
|
if current == status {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if !current.can_transition_to(status) {
|
if !current.can_transition_to(status) {
|
||||||
return Err(ReservationsControllerError::InvalidTransition(current, status).into());
|
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
|
self.db
|
||||||
.set_reservation_status(id, status)
|
.set_reservation_status(id, status)
|
||||||
.await
|
.await
|
||||||
|
|
@ -233,20 +387,105 @@ pub enum ReservationsControllerError {
|
||||||
NotOnTheReservation,
|
NotOnTheReservation,
|
||||||
#[error("A reservation that is {0:?} only accepts a change of Linka Go accounts")]
|
#[error("A reservation that is {0:?} only accepts a change of Linka Go accounts")]
|
||||||
OnlyEmailsEditable(ReservationStatus),
|
OnlyEmailsEditable(ReservationStatus),
|
||||||
|
#[error("Cargobike(s) {0:?} are already booked over that period")]
|
||||||
|
BikesTaken(Vec<BikeId>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn bike_lists_compare_as_sets() {
|
fn fleets_compare_as_sets_of_bikes_and_periods() {
|
||||||
assert!(same_bikes(&[1, 2], &[2, 1]), "order carries no meaning");
|
let (start, end) = (at(12), at(20));
|
||||||
assert!(same_bikes(&[1, 1, 2], &[2, 1]), "nor do repeats");
|
let current = [held(1, 12, 20), held(2, 12, 20)];
|
||||||
assert!(same_bikes(&[], &[]));
|
|
||||||
|
|
||||||
assert!(!same_bikes(&[1, 2], &[1]), "one was removed");
|
assert!(
|
||||||
assert!(!same_bikes(&[1], &[1, 2]), "one was added");
|
same_fleet(&[wanted(2, None), wanted(1, None)], ¤t, start, end),
|
||||||
assert!(!same_bikes(&[1], &[2]));
|
"order carries no meaning"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
same_fleet(
|
||||||
|
&[wanted(1, None), wanted(1, None), wanted(2, None)],
|
||||||
|
¤t,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
),
|
||||||
|
"nor do repeats"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
same_fleet(
|
||||||
|
&[wanted(1, Some((12, 20))), wanted(2, None)],
|
||||||
|
¤t,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
),
|
||||||
|
"spelling out the reservation's own period changes nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!same_fleet(&[wanted(1, None)], ¤t, start, end),
|
||||||
|
"one was removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!same_fleet(
|
||||||
|
&[wanted(1, None), wanted(2, None), wanted(3, None)],
|
||||||
|
¤t,
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
),
|
||||||
|
"one was added"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!same_fleet(
|
||||||
|
&[wanted(1, Some((12, 15))), wanted(2, None)],
|
||||||
|
¤t,
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ impl ReservationStatus {
|
||||||
(self, next),
|
(self, next),
|
||||||
(Requested, Refused)
|
(Requested, Refused)
|
||||||
| (Requested, Approved)
|
| (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, Cancelled)
|
||||||
| (Approved, Ongoing)
|
| (Approved, Ongoing)
|
||||||
| (Ongoing, Cancelled)
|
| (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 {
|
pub fn is_final(self) -> bool {
|
||||||
use ReservationStatus::*;
|
use ReservationStatus::*;
|
||||||
matches!(self, Refused | Cancelled | Archived)
|
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.
|
/// The unit a reservation is filed for.
|
||||||
///
|
///
|
||||||
/// Either one we know — a Whiskey group, with its row — or a plain name the
|
/// 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)]
|
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||||
pub struct Reservation {
|
pub struct Reservation {
|
||||||
pub id: ReservationId,
|
pub id: ReservationId,
|
||||||
|
|
@ -114,7 +226,7 @@ pub struct Reservation {
|
||||||
pub users: Vec<UserSummary>,
|
pub users: Vec<UserSummary>,
|
||||||
pub telegram: String,
|
pub telegram: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub bikes: Vec<BikeId>,
|
pub bikes: Vec<ReservationBike>,
|
||||||
/// Linka Go accounts allowed to unlock the bikes. Plain addresses: they
|
/// Linka Go accounts allowed to unlock the bikes. Plain addresses: they
|
||||||
/// need not belong to anybody who ever logs into this app.
|
/// need not belong to anybody who ever logs into this app.
|
||||||
pub linka_emails: Vec<String>,
|
pub linka_emails: Vec<String>,
|
||||||
|
|
@ -131,7 +243,8 @@ pub struct CalendarReservation {
|
||||||
pub unit: ReservationUnit,
|
pub unit: ReservationUnit,
|
||||||
pub start_time: DateTime<Utc>,
|
pub start_time: DateTime<Utc>,
|
||||||
pub end_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,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||||
pub struct NewReservation {
|
pub struct NewReservation {
|
||||||
pub unit: NewReservationUnit,
|
pub unit: NewReservationUnit,
|
||||||
|
|
@ -169,7 +359,9 @@ pub struct ReservationEdit {
|
||||||
pub users: Vec<UserId>,
|
pub users: Vec<UserId>,
|
||||||
pub telegram: String,
|
pub telegram: String,
|
||||||
pub description: 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>,
|
pub linka_emails: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,6 +401,10 @@ impl ReservationEdit {
|
||||||
&& self.end_time > self.start_time
|
&& self.end_time > self.start_time
|
||||||
&& !self.bikes.is_empty()
|
&& !self.bikes.is_empty()
|
||||||
&& !self.users.is_empty()
|
&& !self.users.is_empty()
|
||||||
|
&& self
|
||||||
|
.bikes
|
||||||
|
.iter()
|
||||||
|
.all(|bike| bike.is_valid(self.start_time, self.end_time))
|
||||||
&& telegram_valid(&self.telegram)
|
&& telegram_valid(&self.telegram)
|
||||||
&& linka_emails_valid(&self.linka_emails)
|
&& linka_emails_valid(&self.linka_emails)
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +414,18 @@ impl ReservationEdit {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn telegram_handles_match_the_database_constraint() {
|
fn telegram_handles_match_the_database_constraint() {
|
||||||
assert!(telegram_valid("@milan_hyenne"));
|
assert!(telegram_valid("@milan_hyenne"));
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ use async_trait::async_trait;
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
models::{
|
models::{
|
||||||
reservation::{
|
reservation::{
|
||||||
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus,
|
Conflict, ConflictProbe, NewReservation, Reservation, ReservationEdit, ReservationId,
|
||||||
|
ReservationPage, ReservationQuery, ReservationStatus,
|
||||||
},
|
},
|
||||||
unit::UnitId,
|
unit::UnitId,
|
||||||
user::UserId,
|
user::UserId,
|
||||||
|
|
@ -13,22 +14,26 @@ use crate::core::{
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ReservationsRepository {
|
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(
|
async fn get_unit_reservations(
|
||||||
&self,
|
&self,
|
||||||
unit: UnitId,
|
unit: UnitId,
|
||||||
) -> Result<Vec<Reservation>, RepositoryError>;
|
) -> Result<Vec<Reservation>, RepositoryError>;
|
||||||
async fn get_reservation(&self, id: ReservationId) -> Result<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
|
/// The bikes in `probe` that somebody else already holds over part of the
|
||||||
/// to, and what lists their address among the Linka Go accounts. The email
|
/// same period, one row per overlap. Only approved and ongoing reservations
|
||||||
/// is what binds a reservation to somebody who had never logged in when it
|
/// hold a bike, and a reservation is never in conflict with itself.
|
||||||
/// was filed.
|
async fn find_conflicts(&self, probe: ConflictProbe) -> Result<Vec<Conflict>, RepositoryError>;
|
||||||
async fn get_involved_reservations(
|
|
||||||
&self,
|
|
||||||
user: UserId,
|
|
||||||
email: &str,
|
|
||||||
) -> Result<Vec<Reservation>, RepositoryError>;
|
|
||||||
|
|
||||||
/// Always stored as `Requested`: the state machine starts here. The
|
/// Always stored as `Requested`: the state machine starts here. The
|
||||||
/// requester comes from the session, not from the request body.
|
/// requester comes from the session, not from the request body.
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ use crate::{
|
||||||
core::{
|
core::{
|
||||||
models::{
|
models::{
|
||||||
reservation::{
|
reservation::{
|
||||||
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
|
Conflict, ConflictProbe, NewReservation, NewReservationBike, NewReservationUnit,
|
||||||
|
Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery,
|
||||||
ReservationStatus, ReservationUnit,
|
ReservationStatus, ReservationUnit,
|
||||||
},
|
},
|
||||||
unit::{Unit, UnitId},
|
unit::{Unit, UnitId},
|
||||||
|
|
@ -73,7 +74,7 @@ struct ReservationDB {
|
||||||
pub status: ReservationStatusDB,
|
pub status: ReservationStatusDB,
|
||||||
pub linka_emails: Vec<String>,
|
pub linka_emails: Vec<String>,
|
||||||
pub users: Value,
|
pub users: Value,
|
||||||
pub bikes: Vec<i32>,
|
pub bikes: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<ReservationDB> for Reservation {
|
impl TryFrom<ReservationDB> for Reservation {
|
||||||
|
|
@ -100,19 +101,81 @@ impl TryFrom<ReservationDB> for Reservation {
|
||||||
users: serde_json::from_value(value.users)?,
|
users: serde_json::from_value(value.users)?,
|
||||||
telegram: value.telegram,
|
telegram: value.telegram,
|
||||||
description: value.description,
|
description: value.description,
|
||||||
bikes: value.bikes,
|
bikes: serde_json::from_value(value.bikes)?,
|
||||||
linka_emails: value.linka_emails,
|
linka_emails: value.linka_emails,
|
||||||
status: value.status.into(),
|
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 {
|
impl SqlxDatabase {
|
||||||
async fn set_reservation_links(
|
async fn set_reservation_links(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
id: ReservationId,
|
id: ReservationId,
|
||||||
users: &[i32],
|
users: &[i32],
|
||||||
bikes: &[i32],
|
bikes: &[NewReservationBike],
|
||||||
) -> Result<(), RepositoryError> {
|
) -> Result<(), RepositoryError> {
|
||||||
query!(
|
query!(
|
||||||
r#"DELETE FROM reservations_users WHERE reservation_id = $1"#,
|
r#"DELETE FROM reservations_users WHERE reservation_id = $1"#,
|
||||||
|
|
@ -135,11 +198,18 @@ impl SqlxDatabase {
|
||||||
)
|
)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.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!(
|
query!(
|
||||||
r#"INSERT INTO reservations_bikes (reservation_id, bike_id)
|
r#"INSERT INTO reservations_bikes (reservation_id, bike_id, start_time, end_time)
|
||||||
SELECT $1, UNNEST($2::integer[])"#,
|
SELECT $1, * FROM UNNEST($2::integer[], $3::timestamptz[], $4::timestamptz[])"#,
|
||||||
id,
|
id,
|
||||||
bikes
|
&bike_ids,
|
||||||
|
&starts as &[Option<DateTime<Utc>>],
|
||||||
|
&ends as &[Option<DateTime<Utc>>]
|
||||||
)
|
)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -170,11 +240,37 @@ fn trim_emails(emails: &[String]) -> Vec<String> {
|
||||||
.collect()
|
.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]
|
#[async_trait]
|
||||||
impl ReservationsRepository for SqlxDatabase {
|
impl ReservationsRepository for SqlxDatabase {
|
||||||
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> {
|
async fn search_reservations(
|
||||||
Ok(query_as!(
|
&self,
|
||||||
ReservationDB,
|
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#"SELECT
|
||||||
r.id,
|
r.id,
|
||||||
r.unit_id,
|
r.unit_id,
|
||||||
|
|
@ -200,19 +296,81 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
JOIN users u ON u.id = ru.user_id
|
JOIN users u ON u.id = ru.user_id
|
||||||
WHERE ru.reservation_id = r.id
|
WHERE ru.reservation_id = r.id
|
||||||
), '[]'::json) AS "users!",
|
), '[]'::json) AS "users!",
|
||||||
ARRAY(
|
-- Each bike with the period it is actually held for: its own
|
||||||
SELECT bike_id FROM reservations_bikes
|
-- when a conflict was settled by handing it over early, the
|
||||||
WHERE reservation_id = r.id ORDER BY bike_id
|
-- reservation's otherwise
|
||||||
) AS "bikes!"
|
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
|
FROM reservations r
|
||||||
LEFT 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"#
|
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)
|
.fetch_all(&self.pool)
|
||||||
.await?
|
.await?;
|
||||||
.into_iter()
|
|
||||||
.map(TryInto::try_into)
|
// The window function only rides along on the rows; an empty page is
|
||||||
.collect::<Result<Vec<_>, _>>()?)
|
// 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(
|
async fn get_unit_reservations(
|
||||||
|
|
@ -246,10 +404,19 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
JOIN users u ON u.id = ru.user_id
|
JOIN users u ON u.id = ru.user_id
|
||||||
WHERE ru.reservation_id = r.id
|
WHERE ru.reservation_id = r.id
|
||||||
), '[]'::json) AS "users!",
|
), '[]'::json) AS "users!",
|
||||||
ARRAY(
|
-- Each bike with the period it is actually held for: its own
|
||||||
SELECT bike_id FROM reservations_bikes
|
-- when a conflict was settled by handing it over early, the
|
||||||
WHERE reservation_id = r.id ORDER BY bike_id
|
-- reservation's otherwise
|
||||||
) AS "bikes!"
|
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
|
FROM reservations r
|
||||||
LEFT JOIN units un ON un.id = r.unit_id
|
LEFT JOIN units un ON un.id = r.unit_id
|
||||||
WHERE r.unit_id = $1
|
WHERE r.unit_id = $1
|
||||||
|
|
@ -263,65 +430,54 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
.collect::<Result<Vec<_>, _>>()?)
|
.collect::<Result<Vec<_>, _>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_involved_reservations(
|
async fn find_conflicts(&self, probe: ConflictProbe) -> Result<Vec<Conflict>, RepositoryError> {
|
||||||
&self,
|
// The probe resolved: every bike with the period it would be held for
|
||||||
user: UserId,
|
let mut bikes = Vec::with_capacity(probe.bikes.len());
|
||||||
email: &str,
|
let mut starts = Vec::with_capacity(probe.bikes.len());
|
||||||
) -> Result<Vec<Reservation>, RepositoryError> {
|
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!(
|
Ok(query_as!(
|
||||||
ReservationDB,
|
ConflictDB,
|
||||||
r#"SELECT
|
r#"WITH wanted(bike_id, start_time, end_time) AS (
|
||||||
r.id,
|
SELECT * FROM UNNEST($1::integer[], $2::timestamptz[], $3::timestamptz[])
|
||||||
r.unit_id,
|
)
|
||||||
-- `?` forces the nullability sqlx cannot infer: `units.name` is
|
SELECT
|
||||||
-- NOT NULL, but the LEFT JOIN makes it null for a free label
|
w.bike_id AS "bike!",
|
||||||
un."name" AS "unit_name?",
|
r.id AS "reservation!",
|
||||||
r.unit_label,
|
COALESCE(un."name", r.unit_label, '') AS "unit!",
|
||||||
r.start_time,
|
|
||||||
r.end_time,
|
|
||||||
r.requester_id,
|
|
||||||
r.telegram,
|
|
||||||
r."description",
|
|
||||||
r.linka_emails,
|
|
||||||
r.status AS "status: ReservationStatusDB",
|
r.status AS "status: ReservationStatusDB",
|
||||||
COALESCE((
|
COALESCE(rb.start_time, r.start_time) AS "start_time!",
|
||||||
SELECT json_agg(json_build_object(
|
COALESCE(rb.end_time, r.end_time) AS "end_time!"
|
||||||
'id', u.id,
|
FROM wanted w
|
||||||
'firstname', u.firstname,
|
JOIN reservations_bikes rb ON rb.bike_id = w.bike_id
|
||||||
'name', u."name",
|
JOIN reservations r ON r.id = rb.reservation_id
|
||||||
'email', u.email
|
|
||||||
) ORDER BY u."name", u.firstname)
|
|
||||||
FROM reservations_users ru
|
|
||||||
JOIN users u ON u.id = ru.user_id
|
|
||||||
WHERE ru.reservation_id = r.id
|
|
||||||
), '[]'::json) AS "users!",
|
|
||||||
ARRAY(
|
|
||||||
SELECT bike_id FROM reservations_bikes
|
|
||||||
WHERE reservation_id = r.id ORDER BY bike_id
|
|
||||||
) AS "bikes!"
|
|
||||||
FROM reservations r
|
|
||||||
LEFT JOIN units un ON un.id = r.unit_id
|
LEFT JOIN units un ON un.id = r.unit_id
|
||||||
-- Three ways to be part of a reservation. The last one is what
|
-- Only a reservation that actually holds the bike can conflict: a
|
||||||
-- lets somebody added by address see it the first time they log in,
|
-- request holds nothing yet, and a final one holds nothing any more
|
||||||
-- without anything having to be written at login time.
|
WHERE r.status IN ('approved', 'ongoing')
|
||||||
WHERE r.requester_id = $1
|
-- A reservation never conflicts with itself
|
||||||
OR EXISTS (
|
AND ($4::integer IS NULL OR r.id <> $4)
|
||||||
SELECT 1 FROM reservations_users ru
|
-- Half-open overlap: ending exactly when the other starts is fine,
|
||||||
WHERE ru.reservation_id = r.id AND ru.user_id = $1
|
-- which is what makes handing a bike over at 15:00 a resolution
|
||||||
)
|
AND COALESCE(rb.start_time, r.start_time) < w.end_time
|
||||||
OR EXISTS (
|
AND COALESCE(rb.end_time, r.end_time) > w.start_time
|
||||||
SELECT 1 FROM unnest(r.linka_emails) AS listed
|
ORDER BY w.bike_id, "start_time!""#,
|
||||||
WHERE lower(listed) = lower($2)
|
&bikes,
|
||||||
)
|
&starts,
|
||||||
ORDER BY r.start_time DESC"#,
|
&ends,
|
||||||
user,
|
probe.reservation,
|
||||||
email
|
|
||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(TryInto::try_into)
|
.map(Into::into)
|
||||||
.collect::<Result<Vec<_>, _>>()?)
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
|
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
|
JOIN users u ON u.id = ru.user_id
|
||||||
WHERE ru.reservation_id = r.id
|
WHERE ru.reservation_id = r.id
|
||||||
), '[]'::json) AS "users!",
|
), '[]'::json) AS "users!",
|
||||||
ARRAY(
|
-- Each bike with the period it is actually held for: its own
|
||||||
SELECT bike_id FROM reservations_bikes
|
-- when a conflict was settled by handing it over early, the
|
||||||
WHERE reservation_id = r.id ORDER BY bike_id
|
-- reservation's otherwise
|
||||||
) AS "bikes!"
|
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
|
FROM reservations r
|
||||||
LEFT JOIN units un ON un.id = r.unit_id
|
LEFT JOIN units un ON un.id = r.unit_id
|
||||||
WHERE r.id = $1"#,
|
WHERE r.id = $1"#,
|
||||||
|
|
@ -401,7 +566,18 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
if !users.contains(&requester) {
|
if !users.contains(&requester) {
|
||||||
users.push(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?;
|
tx.commit().await?;
|
||||||
|
|
||||||
|
|
@ -484,3 +660,17 @@ impl ReservationsRepository for SqlxDatabase {
|
||||||
Ok(())
|
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%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
//! External services used by the core: database, and any third party api you add.
|
//! External services used by the core: database, and any third party api you add.
|
||||||
pub mod database;
|
pub mod database;
|
||||||
|
pub mod telegram;
|
||||||
|
|
|
||||||
233
src/services/telegram.rs
Normal file
233
src/services/telegram.rs
Normal 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, ¬ification.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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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("<b>Fake</b> & Co"));
|
||||||
|
// The heading is ours, and stays markup
|
||||||
|
assert!(message.contains("<b>Nouvelle demande"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,26 @@ pub struct OidcConfig {
|
||||||
pub session_lifetime: Option<i64>,
|
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.
|
/// A user that can be logged in without going through the provider.
|
||||||
/// Only usable in debug builds, see `POST /api/login`.
|
/// Only usable in debug builds, see `POST /api/login`.
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
|
|
@ -50,6 +70,9 @@ pub struct AppConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
pub postgres: PostgresConfig,
|
pub postgres: PostgresConfig,
|
||||||
pub oidc: OidcConfig,
|
pub oidc: OidcConfig,
|
||||||
|
/// Absent when the bot is not set up: notifications are then dropped
|
||||||
|
#[serde(default)]
|
||||||
|
pub telegram: Option<TelegramConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dev_users: Vec<DevUserConfig>,
|
pub dev_users: Vec<DevUserConfig>,
|
||||||
/// Directory containing the built frontend
|
/// Directory containing the built frontend
|
||||||
|
|
@ -89,11 +112,18 @@ impl AppConfig {
|
||||||
|
|
||||||
/// Loads the configuration, by decreasing priority:
|
/// Loads the configuration, by decreasing priority:
|
||||||
/// - `APP__SERVER__PORT=3000` style environment variables
|
/// - `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`
|
/// - `./config.yml`
|
||||||
/// - the file pointed by `$APP_CONFIG` (`/etc/cargagep/config.yml` by default)
|
/// - the file pointed by `$APP_CONFIG` (`/etc/cargagep/config.yml` by default)
|
||||||
pub fn get() -> &'static AppConfig {
|
pub fn get() -> &'static AppConfig {
|
||||||
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
|
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
|
||||||
CONFIG.get_or_init(|| {
|
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 =
|
let config_path =
|
||||||
std::env::var("APP_CONFIG").unwrap_or("/etc/cargagep/config.yml".to_owned());
|
std::env::var("APP_CONFIG").unwrap_or("/etc/cargagep/config.yml".to_owned());
|
||||||
let config_path = Path::new(&config_path);
|
let config_path = Path::new(&config_path);
|
||||||
|
|
@ -112,3 +142,27 @@ pub fn get() -> &'static AppConfig {
|
||||||
.expect("Invalid config format")
|
.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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue