Compare commits

...

5 commits

Author SHA1 Message Date
Antoine Pelletier
6c8715fee3 wip 2026-08-25 12:29:30 +02:00
Antoine Pelletier
1619a34992 wip 2026-08-25 12:13:43 +02:00
Antoine Pelletier
2d6ef8de6c feat: add linka 2026-08-25 12:10:53 +02:00
Antoine Pelletier
7c38a1e606 wip 2026-08-25 11:09:50 +02:00
Antoine Pelletier
51df9e75ad wip 2026-08-25 10:38:05 +02:00
70 changed files with 5540 additions and 860 deletions

15
.env
View file

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

2
.gitignore vendored
View file

@ -3,3 +3,5 @@ config.yml
config.yaml config.yaml
/LEGACY /LEGACY
summary.ai summary.ai
.env
.env.example

View file

@ -143,7 +143,8 @@ change every time a domain model does.
`query!`/`query_as!` check the sql against a **real database at compile time**, so the `query!`/`query_as!` check the sql against a **real database at compile time**, so the
development database must be up and migrated for `cargo build` to work. `DATABASE_URL` is development database must be up and migrated for `cargo build` to work. `DATABASE_URL` is
read from `.env`. read from `.env`, which is **gitignored**: copy `.env.example` to `.env` and fill
in the secrets there — never commit them.
To build without a database (CI, docker image), commit the offline data: To build without a database (CI, docker image), commit the offline data:
@ -152,6 +153,38 @@ cargo install sqlx-cli
cargo sqlx prepare # writes .sqlx/, commit it cargo sqlx prepare # writes .sqlx/, commit it
``` ```
### Linka Go
The locks are Linka Go's, and so is the list of who may open them. `services/linka` is the
whole of what this app knows about the platform: the access token and its refresh, then one
file per family of calls (`locks`, `rentals`, `whitelist`). The wording of what comes back
is translated into the app's own terms (`core/models/linka.rs`) before anything else sees
it — no serial number or lock id leaves that module.
A ticker runs one pass a minute (`sync_linka`):
1. **the fleet is read** — lock state, battery, and who has a bike out. *In use* means a
ride under way on that very bike, not a reservation that covers it;
2. **service state follows the platform**: a bike out of service there is out of service
here. The other way round is pushed as it happens, and a lock that refuses the change
leaves the app unchanged (the api answers 502, and says so);
3. **the access list is reconciled**: everybody entitled by a live reservation is on it,
nobody else. Riders are let in 30 minutes before their booking and taken off 30 minutes
after it. Approving, editing or cancelling reconciles straight away, without waiting for
the tick.
The platform offers no way to read that list back, which is why `linka_whitelist` records
what has actually been asked of it: the difference between "should be allowed" and "has
been allowed" is what gets called, so a failed call is retried at the next tick and an
edited reservation never leaves somebody behind.
A bike taken out with nothing entitling its rider to it raises an alert — on the admin page
and in the Telegram group, once per episode.
**On a development machine, set `LINKA_DRY_RUN=1`.** The fleet is still read, but nothing is
written: without it, the made-up addresses of `db/seed.sql` would be granted access to the
real bikes.
### Migrations ### Migrations
```bash ```bash

View file

@ -0,0 +1,48 @@
# Copy to config.yml (gitignored) and adapt. Every value can also be given as an
# environment variable, e.g. APP__SERVER__PORT=3000 — and `.env` is read the same
# way, which is where the secrets belong in development.
server:
address: 0.0.0.0
port: 3000
# Public origin the browser reaches the app on; the OIDC redirect uri is built
# from it. In development this is the vite dev server, not the backend.
base_url: http://localhost:5000
postgres:
host: localhost
port: 5432
user: postgres
password: postgres
name: cargagep
oidc:
client_id: cargagep
client_secret: change-me
issuer_url: https://hydra.agepoly.ch
# Minutes
session_lifetime: 60
# The bot that posts to the cargobikes group. Leave the whole section out and
# nothing is sent, which is what a machine without the bot wants. Both values
# are secrets: give them through `.env` (APP__TELEGRAM__BOT_TOKEN,
# APP__TELEGRAM__CHAT_ID) rather than committing them here.
# telegram:
# bot_token: "123456:ABC-DEF..."
# chat_id: "-1001234567890"
# # Only to point the sender somewhere else than the real bot api
# api_url: https://api.telegram.org
# Linka Go — the locks, the rides and the access list — is configured through plain
# `LINKA_*` variables in `.env` rather than here: they are the names the platform
# itself documents. See `.env.example`, and set LINKA_DRY_RUN=1 in development.
# Logged in without the provider, debug builds only (POST /api/login)
dev_users:
- firstname: Milan
name: Hyenne
email: milan.hyenne@epfl.ch
units:
- agepoly
admin: true
frontend_dir: frontend/dist

View file

@ -0,0 +1,16 @@
-- migrate:up
-- What the app has actually put on the Linka Go restriction list.
--
-- The platform offers no way to read that list back, so the only honest record
-- of it is the one kept here: each tick compares the addresses that *should* be
-- allowed right now against this table, and calls the api for the difference.
-- A row is written only once the call has succeeded, so a failed call is simply
-- retried at the next tick.
CREATE TABLE linka_whitelist (
email text PRIMARY KEY,
added_at timestamptz NOT NULL DEFAULT now()
);
-- migrate:down
DROP TABLE linka_whitelist;

View file

@ -90,6 +90,16 @@ CREATE SEQUENCE public.bikes_id_seq
ALTER SEQUENCE public.bikes_id_seq OWNED BY public.bikes.id; ALTER SEQUENCE public.bikes_id_seq OWNED BY public.bikes.id;
--
-- Name: linka_whitelist; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.linka_whitelist (
email text NOT NULL,
added_at timestamp with time zone DEFAULT now() NOT NULL
);
-- --
-- Name: oidc_states; Type: TABLE; Schema: public; Owner: - -- Name: oidc_states; Type: TABLE; Schema: public; Owner: -
-- --
@ -287,6 +297,14 @@ ALTER TABLE ONLY public.bikes
ADD CONSTRAINT bikes_pkey PRIMARY KEY (id); ADD CONSTRAINT bikes_pkey PRIMARY KEY (id);
--
-- Name: linka_whitelist linka_whitelist_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.linka_whitelist
ADD CONSTRAINT linka_whitelist_pkey PRIMARY KEY (email);
-- --
-- Name: oidc_states oidc_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- Name: oidc_states oidc_states_pkey; Type: CONSTRAINT; Schema: public; Owner: -
-- --
@ -509,4 +527,5 @@ INSERT INTO public.schema_migrations (version) VALUES
('20260823230000'), ('20260823230000'),
('20260824120000'), ('20260824120000'),
('20260824140000'), ('20260824140000'),
('20260824180000'); ('20260824180000'),
('20260825120000');

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ClipboardList, LogOut, Menu, Moon, Sun, User } from '@lucide/vue' import { ClipboardList, LogOut, Menu, Moon, ShieldCheck, Sun, User } from '@lucide/vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@ -138,6 +138,12 @@ function signOut() {
{{ $t('header.my-reservations') }} {{ $t('header.my-reservations') }}
</RouterLink> </RouterLink>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem v-if="user?.admin" as-child>
<RouterLink :to="{ name: 'admins' }">
<ShieldCheck class="size-4" />
{{ $t('header.admins') }}
</RouterLink>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem @click="signOut()"> <DropdownMenuItem @click="signOut()">
<LogOut class="size-4" /> <LogOut class="size-4" />
@ -186,6 +192,18 @@ function signOut() {
{{ $t('header.my-reservations') }} {{ $t('header.my-reservations') }}
</RouterLink> </RouterLink>
</Button> </Button>
<Button
v-if="user?.admin"
variant="ghost"
class="justify-start"
as-child
@click="menuOpen = false"
>
<RouterLink :to="{ name: 'admins' }">
<ShieldCheck class="size-4" />
{{ $t('header.admins') }}
</RouterLink>
</Button>
<Button variant="outline" class="mt-1 justify-start" @click="signOut()"> <Button variant="outline" class="mt-1 justify-start" @click="signOut()">
<LogOut class="size-4" /> <LogOut class="size-4" />
{{ $t('header.logout') }} {{ $t('header.logout') }}

View file

@ -28,23 +28,40 @@ const freeName = ref(model.value?.kind === 'free' ? model.value.name : '')
const onlyFree = computed(() => props.units.length === 0) const onlyFree = computed(() => props.units.length === 0)
const showFreeInput = computed(() => onlyFree.value || selected.value === FREE) const showFreeInput = computed(() => onlyFree.value || selected.value === FREE)
function emitChoice() { /** The choice the two controls above currently stand for */
const local = computed<UnitChoice | undefined>(() => {
if (showFreeInput.value) { if (showFreeInput.value) {
const name = freeName.value.trim() const name = freeName.value.trim()
model.value = name ? { kind: 'free', name } : undefined // An empty box is an unfinished choice, not another association: the field
return // reads as unfilled, while the box itself stays open to type in.
return name ? { kind: 'free', name } : undefined
} }
const unit = props.units.find((u) => String(u.id) === selected.value) const unit = props.units.find((u) => String(u.id) === selected.value)
model.value = unit ? { kind: 'known', id: unit.id, name: unit.name } : undefined return unit ? { kind: 'known', id: unit.id, name: unit.name } : undefined
})
function sameChoice(left: UnitChoice | undefined, right: UnitChoice | undefined) {
if (!left || !right) return left === right
if (left.kind === 'known') return right.kind === 'known' && left.id === right.id
return right.kind === 'free' && left.name === right.name
} }
watch([selected, freeName], emitChoice) watch(local, (choice) => {
model.value = choice
})
/**
* Follows the model when it says something other than what the controls say —
* a form being reset, mostly.
*
* Comparing the two, rather than watching for any change, is what keeps an
* emptied text box open: clearing it does report "nothing chosen", and that
* report must not be read back as somebody else cancelling the choice.
*/
watch(model, (choice) => { watch(model, (choice) => {
if (choice === undefined && (selected.value || freeName.value)) { if (sameChoice(choice, local.value)) return
selected.value = '' selected.value = choice?.kind === 'known' ? String(choice.id) : choice ? FREE : ''
freeName.value = '' freeName.value = choice?.kind === 'free' ? choice.name : ''
}
}) })
</script> </script>

View file

@ -2,47 +2,46 @@
/** /**
* The fleet, one card per bike. * The fleet, one card per bike.
* *
* Three states are shown but only two are stored: `in_service` and * Two states are stored — `in_service` and `out_of_service` — and everything
* `out_of_service` live in the database, while **in use** is derived from the * else comes from Linka Go: whether the lock is open, and whether somebody has
* reservations that are currently `ongoing`. The button therefore only ever * the bike out right now. **In use** means a ride under way on that very bike,
* toggles between the two real ones. * not a reservation that happens to cover it: a booked bike still in the rack
* is not in use, and a bike taken without a booking is.
*
* The button only ever toggles the two real states, and the platform has the
* last word on it — a lock that refuses the change leaves the card as it was.
*/ */
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Lock, LockOpen, TriangleAlert } from '@lucide/vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { HttpStatus } from 'http-status-ts'
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 { useBikes, useSetBikeStatus } from '@/services/api/bikes' import { useBikes, useFleetLive, useSetBikeStatus } from '@/services/api/bikes'
import type { Bike, Reservation } from '@/utils/types' import { ApiError, type Bike, type BikeLive } from '@/utils/types'
const props = defineProps<{ reservations: Reservation[] }>() const { t, locale } = useI18n()
const { t } = useI18n()
const { data: bikes, isPending, isError } = useBikes() const { data: bikes, isPending, isError } = useBikes()
const { data: live } = useFleetLive()
const setStatus = useSetBikeStatus() const setStatus = useSetBikeStatus()
/** Bikes held by a reservation that is under way right now */ /** What the platform says about each bike, by id */
const inUse = computed(() => { const platform = computed(() => {
const ids = new Set<number>() const byBike = new Map<number, BikeLive>()
const now = Date.now() for (const bike of live.value?.bikes ?? []) byBike.set(bike.bike, bike)
for (const reservation of props.reservations) { return byBike
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
}) })
const alerts = computed(() => live.value?.alerts ?? [])
type Display = 'in_use' | 'in_service' | 'out_of_service' type Display = 'in_use' | 'in_service' | 'out_of_service'
function display(bike: Bike): Display { function display(bike: Bike): Display {
if (bike.status === 'out_of_service') return 'out_of_service' if (bike.status === 'out_of_service') return 'out_of_service'
return inUse.value.has(bike.id) ? 'in_use' : 'in_service' return platform.value.get(bike.id)?.rider ? 'in_use' : 'in_service'
} }
// One place decides the colour of a card, so the three states stay legible in // One place decides the colour of a card, so the three states stay legible in
@ -58,9 +57,32 @@ const LABEL_CLASS: Record<Display, string> = {
out_of_service: 'text-destructive', out_of_service: 'text-destructive',
} }
const formatter = computed(
() =>
new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-CH' : 'en-GB', {
dateStyle: 'short',
timeStyle: 'short',
}),
)
function since(moment: string | null | undefined) {
return moment ? formatter.value.format(new Date(moment)) : '—'
}
function toggle(bike: Bike) { function toggle(bike: Bike) {
const status = bike.status === 'in_service' ? 'out_of_service' : 'in_service' const status = bike.status === 'in_service' ? 'out_of_service' : 'in_service'
setStatus.mutate({ id: bike.id, status }, { onError: () => toast.error(t('admin.bikes.error')) }) setStatus.mutate(
{ id: bike.id, status },
{
onError: (error) =>
toast.error(
// The platform would not take it, so nothing changed here either
error instanceof ApiError && error.status === HttpStatus.BAD_GATEWAY
? t('admin.bikes.platform-error')
: t('admin.bikes.error'),
),
},
)
} }
</script> </script>
@ -71,7 +93,38 @@ function toggle(bike: Bike) {
<CardDescription>{{ $t('admin.bikes.intro') }}</CardDescription> <CardDescription>{{ $t('admin.bikes.intro') }}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent class="grid gap-4">
<!-- A bike out with nothing entitling its rider to it. The group is told
at the same time; this is the same alert, where it can be acted on. -->
<div
v-if="alerts.length"
class="border-destructive/40 bg-destructive/5 grid gap-2 rounded-lg border p-4"
>
<p class="text-destructive flex items-center gap-2 text-sm font-medium">
<TriangleAlert class="size-4 shrink-0" />
{{ $t('admin.bikes.alert.title') }}
</p>
<p v-for="alert in alerts" :key="`${alert.bike}-${alert.rider.email}`" class="text-sm">
{{
alert.reservation
? $t('admin.bikes.alert.outside', {
bike: alert.bike_name,
rider: alert.rider.name,
email: alert.rider.email,
id: alert.reservation,
allowed: alert.allowed.join(', ') || '—',
since: since(alert.rider.since),
})
: $t('admin.bikes.alert.without', {
bike: alert.bike_name,
rider: alert.rider.name,
email: alert.rider.email,
since: since(alert.rider.since),
})
}}
</p>
</div>
<div v-if="isPending" class="grid gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div v-if="isPending" class="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Skeleton v-for="i in 5" :key="i" class="h-40 w-full" /> <Skeleton v-for="i in 5" :key="i" class="h-40 w-full" />
</div> </div>
@ -88,13 +141,35 @@ function toggle(bike: Bike) {
<div <div
v-for="bike in bikes" v-for="bike in bikes"
:key="bike.id" :key="bike.id"
class="flex flex-col items-center gap-3 rounded-lg border p-4 text-center" class="flex flex-col items-center gap-2 rounded-lg border p-4 text-center"
:class="CARD_CLASS[display(bike)]" :class="CARD_CLASS[display(bike)]"
> >
<span class="text-primary text-2xl font-bold">{{ bike.name }}</span> <span class="text-primary text-2xl font-bold">{{ bike.name }}</span>
<span class="text-sm font-medium" :class="LABEL_CLASS[display(bike)]"> <span class="text-sm font-medium" :class="LABEL_CLASS[display(bike)]">
{{ $t(`admin.bikes.status.${display(bike)}`) }} {{ $t(`admin.bikes.status.${display(bike)}`) }}
</span> </span>
<!-- What the lock itself reports. Nothing is shown for a bike the
platform says nothing about, rather than a made-up "locked". -->
<span
v-if="platform.get(bike.id)"
class="text-muted-foreground flex items-center gap-1 text-xs"
:title="
platform.get(bike.id)?.rider
? $t('admin.bikes.ridden-by', {
rider: platform.get(bike.id)!.rider!.name,
since: since(platform.get(bike.id)!.rider!.since),
})
: undefined
"
>
<component
:is="platform.get(bike.id)!.lock_state === 'unlocked' ? LockOpen : Lock"
class="size-3.5 shrink-0"
/>
{{ $t(`admin.bikes.lock.${platform.get(bike.id)!.lock_state}`) }}
</span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View file

@ -8,12 +8,13 @@
* ever holds the whole table. * ever holds the whole table.
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { Archive } from '@lucide/vue' import { Archive, Plus } 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 ReservationArchiveDialog from './ReservationArchiveDialog.vue'
import ReservationCreateDialog from './ReservationCreateDialog.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'
@ -26,6 +27,7 @@ const props = defineProps<{
}>() }>()
const archiveOpen = ref(false) const archiveOpen = ref(false)
const createOpen = ref(false)
/** Soonest first: both sections are read as "what comes next" */ /** Soonest first: both sections are read as "what comes next" */
const byStart = (list: Reservation[]) => const byStart = (list: Reservation[]) =>
@ -37,9 +39,15 @@ const activeSorted = computed(() => byStart(props.active))
<template> <template>
<Card> <Card>
<CardHeader> <CardHeader class="flex-row items-start justify-between gap-4">
<div class="grid gap-y-1.5">
<CardTitle>{{ $t('admin.reservations.title') }}</CardTitle> <CardTitle>{{ $t('admin.reservations.title') }}</CardTitle>
<CardDescription>{{ $t('admin.reservations.intro') }}</CardDescription> <CardDescription>{{ $t('admin.reservations.intro') }}</CardDescription>
</div>
<Button size="sm" class="shrink-0" @click="createOpen = true">
<Plus class="size-4" />
{{ $t('admin.reservations.create.action') }}
</Button>
</CardHeader> </CardHeader>
<CardContent class="grid gap-6"> <CardContent class="grid gap-6">
@ -85,7 +93,7 @@ const activeSorted = computed(() => byStart(props.active))
/> />
</section> </section>
<div> <div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" @click="archiveOpen = true"> <Button variant="outline" size="sm" @click="archiveOpen = true">
<Archive class="size-4" /> <Archive class="size-4" />
{{ $t('admin.reservations.archive.action') }} {{ $t('admin.reservations.archive.action') }}
@ -94,6 +102,7 @@ const activeSorted = computed(() => byStart(props.active))
</template> </template>
<ReservationArchiveDialog v-model:open="archiveOpen" :bikes="bikes" /> <ReservationArchiveDialog v-model:open="archiveOpen" :bikes="bikes" />
<ReservationCreateDialog v-model:open="createOpen" />
</CardContent> </CardContent>
</Card> </Card>
</template> </template>

View file

@ -11,7 +11,19 @@ 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useConflicts, useSetReservationStatus } from '@/services/api/reservations' import { useConflicts, useSetReservationStatus } from '@/services/api/reservations'
import { HttpStatus } from 'http-status-ts' import { HttpStatus } from 'http-status-ts'
@ -34,7 +46,8 @@ const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
requested: ['approved', 'refused'], requested: ['approved', 'refused'],
// No "start" here on purpose: a reservation becomes `ongoing` on its own, not // No "start" here on purpose: a reservation becomes `ongoing` on its own, not
// by an admin pressing a button. The backend still allows the transition. // by an admin pressing a button. The backend still allows the transition.
approved: ['cancelled'], // "requested" puts it back in the queue, which also frees its bikes.
approved: ['requested', 'cancelled'],
ongoing: ['archived', 'cancelled'], ongoing: ['archived', 'cancelled'],
refused: [], refused: [],
cancelled: [], cancelled: [],
@ -50,6 +63,19 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
archived: 'bg-muted text-muted-foreground', archived: 'bg-muted text-muted-foreground',
} }
/**
* The rail down the left edge: a status is seen before it is read, which is
* what an administrator scanning the queue actually does.
*/
const RAIL_CLASS: Record<ReservationStatus, string> = {
requested: 'border-l-amber-500',
approved: 'border-l-emerald-500',
ongoing: 'border-l-primary',
refused: 'border-l-destructive',
cancelled: 'border-l-destructive',
archived: 'border-l-muted-foreground/40',
}
const transitions = computed(() => TRANSITIONS[props.reservation.status]) const transitions = computed(() => TRANSITIONS[props.reservation.status])
/** /**
@ -99,11 +125,29 @@ const editable = computed(
) )
const editing = ref(false) const editing = ref(false)
function move(status: ReservationStatus) { /**
* Cancelling is the one transition somebody cannot take back, and the one the
* people on the reservation hear about by mail: it asks first, and takes a
* reason to put in that mail.
*/
const cancelling = ref(false)
const cancelReason = ref('')
function askToCancel() {
cancelReason.value = ''
cancelling.value = true
}
function confirmCancel() {
cancelling.value = false
move('cancelled', cancelReason.value.trim() || undefined)
}
function move(status: ReservationStatus, reason?: string) {
// The button is disabled while a conflict stands, but the list it was drawn // 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. // 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, reason },
{ {
onError: (error) => onError: (error) =>
toast.error( toast.error(
@ -117,7 +161,10 @@ function move(status: ReservationStatus) {
</script> </script>
<template> <template>
<div class="rounded-lg border p-4"> <div
class="bg-card rounded-lg border border-l-4 p-4 shadow-sm transition-shadow hover:shadow-md"
:class="RAIL_CLASS[reservation.status]"
>
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="font-semibold">#{{ reservation.id }}</span> <span class="font-semibold">#{{ reservation.id }}</span>
@ -143,7 +190,7 @@ function move(status: ReservationStatus) {
? $t('admin.reservations.conflict.title') ? $t('admin.reservations.conflict.title')
: undefined : undefined
" "
@click="move(status)" @click="status === 'cancelled' ? askToCancel() : move(status)"
> >
{{ $t(`admin.reservations.action.${status}`) }} {{ $t(`admin.reservations.action.${status}`) }}
</Button> </Button>
@ -179,6 +226,39 @@ function move(status: ReservationStatus) {
<ReservationDetails class="mt-3 border-t pt-3" :reservation="reservation" :bikes="bikes" /> <ReservationDetails class="mt-3 border-t pt-3" :reservation="reservation" :bikes="bikes" />
<AlertDialog v-model:open="cancelling">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{{ $t('admin.reservations.cancel.title', { id: reservation.id }) }}
</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('admin.reservations.cancel.intro') }}
</AlertDialogDescription>
</AlertDialogHeader>
<div class="grid gap-2">
<Label for="cancel-reason">{{ $t('admin.reservations.cancel.reason') }}</Label>
<Textarea
id="cancel-reason"
v-model="cancelReason"
rows="2"
:placeholder="$t('admin.reservations.cancel.reason-placeholder')"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('admin.reservations.cancel.back') }}</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive hover:bg-destructive/90 text-white"
@click="confirmCancel()"
>
{{ $t('admin.reservations.cancel.confirm') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ReservationEditDialog <ReservationEditDialog
v-if="editable" v-if="editable"
v-model:open="editing" v-model:open="editing"

View file

@ -0,0 +1,63 @@
<script setup lang="ts">
/**
* Filing a reservation by hand, from the admin page — for a walk-in, or to
* record one agreed elsewhere.
*
* Same form as the booking page, plus the person it is for. That person is
* named by address: an unknown one is created on the spot, and the first time
* they log in they land on that same profile with this reservation already on
* it, rather than on a second account.
*/
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { HttpStatus } from 'http-status-ts'
import ReservationForm, {
type ReservationPayload,
} from '@/components/reservation/ReservationForm.vue'
import {
Dialog,
DialogDescription,
DialogHeader,
DialogScrollContent,
DialogTitle,
} from '@/components/ui/dialog'
import { useCreateReservationFor } from '@/services/api/reservations'
import { ApiError } from '@/utils/types'
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
const create = useCreateReservationFor()
const form = ref<InstanceType<typeof ReservationForm> | null>(null)
function submit(payload: ReservationPayload) {
create.mutate(payload, {
onSuccess: () => {
toast.success(t('admin.reservations.create.done'))
form.value?.reset()
open.value = false
},
onError: (error) =>
toast.error(
error instanceof ApiError && error.status === HttpStatus.CONFLICT
? t('reservation.conflicts.refused')
: error.message || t('reservation.submit-error'),
),
})
}
</script>
<template>
<Dialog v-model:open="open">
<DialogScrollContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ $t('admin.reservations.create.title') }}</DialogTitle>
<DialogDescription>{{ $t('admin.reservations.create.intro') }}</DialogDescription>
</DialogHeader>
<ReservationForm ref="form" admin :pending="create.isPending.value" @submit="submit" />
</DialogScrollContent>
</Dialog>
</template>

View file

@ -74,8 +74,11 @@ function format(iso: string) {
</script> </script>
<template> <template>
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2"> <dl class="text-sm">
<div v-if="shows('period')" class="min-w-0"> <!-- Column flow, not a grid: the blocks have very different heights, and a
grid would align their rows and leave the short side blank. -->
<div class="gap-x-8 sm:columns-2">
<div v-if="shows('period')" class="mb-3 break-inside-avoid">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.period') }} {{ $t('reservation.details.period') }}
</dt> </dt>
@ -86,7 +89,7 @@ function format(iso: string) {
</dd> </dd>
</div> </div>
<div v-if="shows('bikes') && heldBikes.length" class="min-w-0"> <div v-if="shows('bikes') && heldBikes.length" class="mb-3 break-inside-avoid">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.bikes') }} {{ $t('reservation.details.bikes') }}
</dt> </dt>
@ -112,21 +115,21 @@ function format(iso: string) {
</dd> </dd>
</div> </div>
<div v-if="shows('telegram') && reservation.telegram" class="min-w-0"> <div v-if="shows('telegram') && reservation.telegram" class="mb-3 break-inside-avoid">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.telegram') }} {{ $t('reservation.details.telegram') }}
</dt> </dt>
<dd class="mt-0.5 font-medium break-all">{{ reservation.telegram }}</dd> <dd class="mt-0.5 font-medium break-all">{{ reservation.telegram }}</dd>
</div> </div>
<div v-if="shows('people') && peopleNames.length" class="min-w-0"> <div v-if="shows('people') && peopleNames.length" class="mb-3 break-inside-avoid">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.people') }} {{ $t('reservation.details.people') }}
</dt> </dt>
<dd class="mt-0.5 break-words">{{ peopleNames.join(', ') }}</dd> <dd class="mt-0.5 break-words">{{ peopleNames.join(', ') }}</dd>
</div> </div>
<div v-if="shows('linka') && linkaEmails.length" class="min-w-0"> <div v-if="shows('linka') && linkaEmails.length" class="mb-3 break-inside-avoid">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.linka') }} {{ $t('reservation.details.linka') }}
</dt> </dt>
@ -148,7 +151,10 @@ function format(iso: string) {
</button> </button>
</dd> </dd>
</div> </div>
</div>
<!-- Free text, and the longest field there is: it gets the whole width,
outside the columns, so it never wraps in a narrow one -->
<div v-if="shows('reason') && reservation.description" class="min-w-0"> <div v-if="shows('reason') && reservation.description" class="min-w-0">
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase"> <dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{{ $t('reservation.details.reason') }} {{ $t('reservation.details.reason') }}

View file

@ -18,6 +18,7 @@ import { toast } from 'vue-sonner'
import { HttpStatus } from 'http-status-ts' 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 TimePicker from '@/components/TimePicker.vue' import TimePicker from '@/components/TimePicker.vue'
import ReservationDetails from '@/components/reservation/ReservationDetails.vue' import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@ -52,6 +53,7 @@ const { t, locale } = useI18n()
const update = useUpdateReservation() const update = useUpdateReservation()
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
const SLOT_MINUTES = 15 const SLOT_MINUTES = 15
const startDate = shallowRef<DateValue>() const startDate = shallowRef<DateValue>()
@ -60,6 +62,8 @@ const form = reactive({
startTime: '' as string | undefined, startTime: '' as string | undefined,
endTime: '' as string | undefined, endTime: '' as string | undefined,
bikes: [] as number[], bikes: [] as number[],
// Without the leading @, which `TelegramInput` shows as a prefix
telegram: '',
emails: [''], emails: [''],
}) })
const errors = reactive<Record<string, string>>({}) const errors = reactive<Record<string, string>>({})
@ -105,6 +109,7 @@ function load() {
form.startTime = toSlot(start) form.startTime = toSlot(start)
form.endTime = toSlot(end) form.endTime = toSlot(end)
form.bikes = props.reservation.bikes.map((held) => held.id) form.bikes = props.reservation.bikes.map((held) => held.id)
form.telegram = props.reservation.telegram.replace(/^@/, '')
Object.keys(overrides).forEach((key) => delete overrides[Number(key)]) Object.keys(overrides).forEach((key) => delete overrides[Number(key)])
for (const held of props.reservation.bikes) { for (const held of props.reservation.bikes) {
if (held.custom) overrides[held.id] = toPeriod(held.start_time, held.end_time) if (held.custom) overrides[held.id] = toPeriod(held.start_time, held.end_time)
@ -125,8 +130,8 @@ function toDate(date: DateValue | undefined, time: string | undefined): Date | n
} }
/** Whatever this dialog edits below is not repeated in the context block */ /** Whatever this dialog edits below is not repeated in the context block */
const omitted = computed<('period' | 'bikes' | 'linka')[]>(() => const omitted = computed<('period' | 'bikes' | 'linka' | 'telegram')[]>(() =>
props.emailsOnly ? ['linka'] : ['period', 'bikes', 'linka'], props.emailsOnly ? ['linka'] : ['period', 'bikes', 'linka', 'telegram'],
) )
const start = computed(() => toDate(startDate.value, form.startTime)) const start = computed(() => toDate(startDate.value, form.startTime))
@ -268,6 +273,10 @@ 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')
if (!form.telegram) errors.telegram = t('reservation.error-required')
else if (!TELEGRAM_RE.test(`@${form.telegram}`)) {
errors.telegram = t('reservation.error-telegram')
}
// The backend refuses these too; saying so here saves a round trip and // The backend refuses these too; saying so here saves a round trip and
// names the bike rather than the whole reservation // names the bike rather than the whole reservation
@ -305,6 +314,7 @@ function save() {
id: props.reservation.id, id: props.reservation.id,
// Sent back as stored when they are not editable, which is exactly what // Sent back as stored when they are not editable, which is exactly what
// the backend checks before accepting the change // the backend checks before accepting the change
telegram: props.emailsOnly ? props.reservation.telegram : `@${form.telegram}`,
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 bikes: props.emailsOnly
@ -413,6 +423,13 @@ 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>
<!-- Whoever picks the bikes up may change, and with them the handle -->
<div v-if="!emailsOnly" class="grid gap-2">
<Label for="edit-telegram">{{ $t('reservation.telegram') }}</Label>
<TelegramInput id="edit-telegram" v-model="form.telegram" :invalid="!!errors.telegram" />
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
</div>
<!-- One period per bike, which is how a conflict gets settled without <!-- One period per bike, which is how a conflict gets settled without
moving the whole booking --> moving the whole booking -->
<div v-if="!emailsOnly && form.bikes.length" class="grid gap-2"> <div v-if="!emailsOnly && form.bikes.length" class="grid gap-2">

View file

@ -0,0 +1,489 @@
<script setup lang="ts">
/**
* The reservation form itself, without the page around it.
*
* Two callers: the booking page, where somebody files for themselves, and the
* admin dialog, where somebody files for another person — same fields, same
* validation, same conflict handling, so the two can never drift apart. The
* parent owns the mutation and passes `pending`; this only emits the payload.
*/
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus, TriangleAlert, X } from '@lucide/vue'
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
import { toast } from 'vue-sonner'
import DatePicker from '@/components/DatePicker.vue'
import TelegramInput from '@/components/TelegramInput.vue'
import UnitPicker, { type UnitChoice } from '@/components/UnitPicker.vue'
import TimePicker from '@/components/TimePicker.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { useBikes } from '@/services/api/bikes'
import { useConflicts } from '@/services/api/reservations'
import { useSession } from '@/services/api/auth'
import type { Bike, NewReservation, Person } from '@/utils/types'
const props = withDefaults(
defineProps<{
/**
* Filing for somebody else, from the admin page: the person is named here,
* and the one-month limit does not apply — an admin catching up on a
* booking made by hand may well be filing it late, or far ahead.
*/
admin?: boolean
pending?: boolean
}>(),
{ admin: false, pending: false },
)
const emit = defineEmits<{ submit: [payload: ReservationPayload] }>()
/** What the parent sends off; `requester` only in admin mode */
export type ReservationPayload = NewReservation & { requester?: Person }
const { t } = useI18n()
const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
type Form = {
association: UnitChoice | undefined
reason: string
startTime: string | undefined
endTime: string | undefined
bikes: number[]
telegram: string
emails: string[]
/** Admin mode only: who the reservation is for */
requesterEmail: string
requesterFirstname: string
requesterName: string
}
function emptyForm(): Form {
return {
association: undefined,
reason: '',
startTime: undefined,
endTime: undefined,
bikes: [],
telegram: '',
emails: [''],
requesterEmail: '',
requesterFirstname: '',
requesterName: '',
}
}
const form = reactive<Form>(emptyForm())
const startDate = shallowRef<DateValue>()
const endDate = shallowRef<DateValue>()
const errors = reactive<Record<string, string>>({})
const submitted = ref(false)
const minDate = today(getLocalTimeZone())
/** Bookings open one month ahead, no further — unless an admin is filing it */
const oneMonthAhead = minDate.add({ months: 1 })
const maxDate = computed(() => (props.admin ? undefined : oneMonthAhead))
const { user } = useSession()
const units = computed(() => user.value?.units ?? [])
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
if (!date || !time) return null
const [hours, minutes] = time.split(':').map(Number)
const local = date.toDate(getLocalTimeZone())
local.setHours(hours, minutes, 0, 0)
return local
}
const start = computed(() => toDate(startDate.value, form.startTime))
const end = computed(() => toDate(endDate.value, form.endTime))
const periodPicked = computed(() => start.value !== null && end.value !== null)
const { data: bikes, isPending: bikesPending, isError: bikesError } = useBikes()
/**
* Which bikes an approved or ongoing reservation already holds over the period
* asked for. The overlap is worked out by the backend against the whole table:
* the browser is told "these are taken", not handed everybody's bookings to
* work it out itself.
*/
const probe = computed(() => {
if (!start.value || !end.value || end.value <= start.value) return null
return {
start_time: start.value.toISOString(),
end_time: end.value.toISOString(),
bikes: (bikes.value ?? []).map((bike) => ({ id: bike.id })),
}
})
const { data: conflicts } = useConflicts(probe)
const taken = computed(() => new Set((conflicts.value ?? []).map((conflict) => conflict.bike)))
function isTaken(bike: Bike) {
return taken.value.has(bike.id)
}
/** Out of service or already booked: either way it cannot be picked */
function isUnavailable(bike: Bike) {
return bike.status === 'out_of_service' || isTaken(bike)
}
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
// The fleet is presented one column per frame size, since that is what the
// requester actually chooses. The size lives on the bike, so adding a sixth one
// only means giving it a size in the admin, never touching this file.
const BIKE_SIZES = ['large', 'small'] as const
const bikeGroups = computed(() =>
BIKE_SIZES.map((size) => ({
size,
bikes: (bikes.value ?? []).filter((bike) => bike.size === size),
})),
)
watch(availableBikes, (available) => {
const ids = new Set(available.map((bike) => bike.id))
form.bikes = form.bikes.filter((id) => ids.has(id))
})
function toggleBike(bike: Bike) {
if (isUnavailable(bike)) return
const index = form.bikes.indexOf(bike.id)
if (index >= 0) form.bikes.splice(index, 1)
else form.bikes.push(bike.id)
}
function addEmail() {
form.emails.push('')
}
function removeEmail(index: number) {
form.emails.splice(index, 1)
if (form.emails.length === 0) form.emails.push('')
}
function validate(): boolean {
Object.keys(errors).forEach((key) => delete errors[key])
if (!form.association) errors.association = t('reservation.error-required')
if (!form.reason.trim()) errors.reason = t('reservation.error-required')
if (!start.value) errors.start = t('reservation.error-datetime-required')
if (!end.value) errors.end = t('reservation.error-datetime-required')
// Strictly after: a reservation of zero length is not one
if (start.value && end.value && end.value <= start.value) {
errors.end = t('reservation.error-end-before-start')
}
// The pickers already refuse these dates; re-checked in case the model was
// filled another way
if (maxDate.value && startDate.value && startDate.value.compare(maxDate.value) > 0) {
errors.start = t('reservation.error-too-far')
}
if (maxDate.value && endDate.value && endDate.value.compare(maxDate.value) > 0) {
errors.end = t('reservation.error-too-far')
}
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
if (!form.telegram) errors.telegram = t('reservation.error-required')
else if (!TELEGRAM_RE.test(`@${form.telegram}`)) {
errors.telegram = t('reservation.error-telegram')
}
if (props.admin) {
if (!EMAIL_RE.test(form.requesterEmail.trim())) {
errors.requester = t('reservation.error-email')
}
if (!form.requesterFirstname.trim() || !form.requesterName.trim()) {
errors.requester = t('reservation.error-required')
}
}
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
if (emails.length === 0) errors.emails = t('reservation.error-required')
else if (!emails.every((email) => EMAIL_RE.test(email))) {
errors.emails = t('reservation.error-email')
}
return Object.keys(errors).length === 0
}
function reset() {
Object.assign(form, emptyForm())
startDate.value = undefined
endDate.value = undefined
Object.keys(errors).forEach((key) => delete errors[key])
submitted.value = false
}
/** The form, as the api wants it. Both are non-null once `validate()` passed. */
function payload(): ReservationPayload {
const association = form.association!
return {
unit:
association.kind === 'known'
? { kind: 'known', id: association.id }
: { kind: 'free', name: association.name.trim() },
start_time: start.value!.toISOString(),
end_time: end.value!.toISOString(),
// The requester is resolved by the backend — from the session, or from the
// address given below when an admin files for somebody else
users: [],
telegram: `@${form.telegram}`,
description: form.reason.trim(),
bikes: [...form.bikes],
linka_emails: form.emails.map((email) => email.trim()).filter(Boolean),
...(props.admin
? {
requester: {
email: form.requesterEmail.trim(),
firstname: form.requesterFirstname.trim(),
name: form.requesterName.trim(),
},
}
: {}),
}
}
function submit() {
submitted.value = true
if (!validate()) {
toast.error(t('reservation.error-form'))
return
}
// The parent owns the mutation: the same fields file a request on /reservations
// and a reservation for somebody else from the admin page.
emit('submit', payload())
}
defineExpose({ reset })
</script>
<template>
<form class="grid gap-5" novalidate @submit.prevent="submit">
<!-- Admin only: whose reservation this is. The address is what binds it to
a profile, today or the day that person first logs in. -->
<div v-if="admin" class="grid gap-2 rounded-md border p-3">
<span class="text-sm font-medium">{{ $t('admin.reservations.create.for') }}</span>
<Input
v-model.trim="form.requesterEmail"
type="email"
inputmode="email"
:placeholder="$t('admin.reservations.create.email')"
:aria-label="$t('admin.reservations.create.email')"
:aria-invalid="!!errors.requester || undefined"
/>
<div class="grid gap-2 sm:grid-cols-2">
<Input
v-model.trim="form.requesterFirstname"
:placeholder="$t('admin.reservations.create.firstname')"
:aria-label="$t('admin.reservations.create.firstname')"
:aria-invalid="!!errors.requester || undefined"
/>
<Input
v-model.trim="form.requesterName"
:placeholder="$t('admin.reservations.create.name')"
:aria-label="$t('admin.reservations.create.name')"
:aria-invalid="!!errors.requester || undefined"
/>
</div>
<p class="text-muted-foreground text-xs">{{ $t('admin.reservations.create.hint') }}</p>
<p v-if="errors.requester" class="text-destructive text-xs">{{ errors.requester }}</p>
</div>
<!-- Association -->
<div class="grid gap-2">
<Label for="association">{{ $t('reservation.association') }}</Label>
<UnitPicker
id="association"
v-model="form.association"
:units="units"
:invalid="!!errors.association"
/>
<p v-if="errors.association" class="text-destructive text-xs">
{{ errors.association }}
</p>
</div>
<!-- Reason -->
<div class="grid gap-2">
<Label for="reason">{{ $t('reservation.reason') }}</Label>
<Textarea
id="reason"
v-model.trim="form.reason"
:placeholder="$t('reservation.reason-placeholder')"
:aria-invalid="!!errors.reason || undefined"
rows="2"
/>
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
</div>
<!-- Start. The caption names the date/time pair rather than one of
them: a `<label for>` on the date would make a click on the text
open the calendar, since the browser forwards the activation. -->
<div class="grid gap-2">
<span id="start-label" class="text-sm leading-none font-medium">
{{ $t('reservation.start') }}
</span>
<div role="group" aria-labelledby="start-label" class="grid gap-2 sm:grid-cols-2">
<DatePicker
id="start-date"
v-model="startDate"
:min-value="minDate"
:max-value="maxDate"
:invalid="!!errors.start"
/>
<TimePicker id="start-time" v-model="form.startTime" :invalid="!!errors.start" />
</div>
<p v-if="errors.start" class="text-destructive text-xs">{{ errors.start }}</p>
</div>
<!-- End -->
<div class="grid gap-2">
<span id="end-label" class="text-sm leading-none font-medium">
{{ $t('reservation.end') }}
</span>
<div role="group" aria-labelledby="end-label" class="grid gap-2 sm:grid-cols-2">
<DatePicker
id="end-date"
v-model="endDate"
:min-value="startDate ?? minDate"
:max-value="maxDate"
:invalid="!!errors.end"
/>
<TimePicker id="end-time" v-model="form.endTime" :invalid="!!errors.end" />
</div>
<p v-if="errors.end" class="text-destructive text-xs">{{ errors.end }}</p>
</div>
<!-- Bikes -->
<div class="grid gap-2">
<span class="text-sm font-medium">{{ $t('reservation.bikes') }}</span>
<div
v-if="!periodPicked"
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ $t('reservation.bikes-pick-period') }}
</div>
<div v-else-if="bikesPending" class="grid gap-2 sm:grid-cols-2">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
</div>
<div
v-else-if="bikesError"
class="border-destructive/50 text-destructive flex items-center gap-2 rounded-md border p-3 text-sm"
>
<TriangleAlert class="size-4 shrink-0" />
{{ $t('reservation.bikes-error') }}
</div>
<p v-else-if="availableBikes.length === 0" class="text-muted-foreground text-sm">
{{ $t('reservation.bikes-empty') }}
</p>
<div v-else class="grid gap-4 sm:grid-cols-2">
<div
v-for="group in bikeGroups"
:key="group.size"
role="group"
:aria-labelledby="`bike-size-${group.size}`"
class="grid content-start gap-2"
>
<span
:id="`bike-size-${group.size}`"
class="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
>
{{ $t(`reservation.bike-size-${group.size}`) }}
</span>
<p v-if="group.bikes.length === 0" class="text-muted-foreground text-sm">
{{ $t('reservation.bike-size-empty') }}
</p>
<button
v-for="bike in group.bikes"
:key="bike.id"
type="button"
:disabled="isUnavailable(bike)"
:aria-pressed="form.bikes.includes(bike.id)"
class="flex h-10 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="
form.bikes.includes(bike.id)
? 'border-primary bg-primary/10 text-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
"
@click="toggleBike(bike)"
>
<span class="truncate">{{ bike.name }}</span>
<span
v-if="isUnavailable(bike)"
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase"
>
{{
bike.status === 'out_of_service'
? $t('reservation.bike-out-of-service')
: $t('reservation.bike-taken')
}}
</span>
</button>
</div>
</div>
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
</div>
<!-- Telegram -->
<div class="grid gap-2">
<Label for="telegram">{{ $t('reservation.telegram') }}</Label>
<TelegramInput id="telegram" v-model="form.telegram" :invalid="!!errors.telegram" />
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
</div>
<!-- Linka Go emails -->
<div class="grid gap-2">
<span class="text-sm font-medium">{{ $t('reservation.emails') }}</span>
<div v-for="(_, index) in form.emails" :key="index" class="flex gap-2">
<Input
v-model.trim="form.emails[index]"
type="email"
inputmode="email"
:aria-label="$t('reservation.email-nth', { n: index + 1 })"
placeholder="prenom.nom@exemple.com"
:aria-invalid="!!errors.emails || undefined"
/>
<Button
v-if="form.emails.length > 1"
type="button"
variant="outline"
size="icon"
:aria-label="$t('reservation.remove-email')"
@click="removeEmail(index)"
>
<X class="size-4" />
</Button>
</div>
<p v-if="errors.emails" class="text-destructive text-xs">{{ errors.emails }}</p>
<div>
<Button type="button" variant="secondary" size="sm" @click="addEmail()">
<Plus class="size-4" />
{{ $t('reservation.add-email') }}
</Button>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap gap-2">
<Button type="submit" :disabled="pending">
{{ pending ? $t('reservation.submitting') : $t('reservation.submit') }}
</Button>
<Button type="button" variant="outline" :disabled="pending" @click="reset()">
{{ $t('reservation.reset') }}
</Button>
</div>
</form>
</template>

View file

@ -335,6 +335,56 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/api/bikes/live': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get what the platform says about the fleet */
get: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description The whole picture, refreshed by the ticker and read by the admin page */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['FleetLive']
}
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be an admin */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/bikes/{id}/status': { '/api/bikes/{id}/status': {
parameters: { parameters: {
query?: never query?: never
@ -387,6 +437,13 @@ export interface paths {
} }
content?: never content?: never
} }
/** @description Linka Go refused the change: nothing was stored */
502: {
headers: {
[name: string]: unknown
}
content?: never
}
} }
} }
post?: never post?: never
@ -644,6 +701,85 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/api/reservations/for': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/**
* File a reservation for somebody else
* @description Admin only. The requester is named by address rather than by id: an address nobody is known at creates the person, and the first time they log in they land on that profile, with this reservation already on it. Conflicts are not refused here — an admin filing by hand is the one who arbitrates.
*/
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** @description Filing on somebody else's behalf, from the admin page. */
requestBody: {
content: {
'application/json': components['schemas']['ReservationForForm']
}
}
responses: {
/** @description The reservation, as stored */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Reservation']
}
}
/** @description The reservation or the person is malformed */
400: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be an admin */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description One of the bikes is out of service */
409: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description The unit or one of the bikes does not exist */
422: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/reservations/conflicts': { '/api/reservations/conflicts': {
parameters: { parameters: {
query?: never query?: never
@ -726,10 +862,10 @@ export interface paths {
cookie?: never cookie?: never
} }
/** /**
* @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 and
* telegram handle and the reason are shown but not editable, so they are not * the reason are shown but not editable, so they are not in the body at all:
* in the body at all: the handler reads them back from the stored reservation * the handler reads them back from the stored reservation rather than trusting
* rather than trusting a client to send them unchanged. * a client to send them unchanged.
*/ */
requestBody: { requestBody: {
content: { content: {
@ -805,7 +941,7 @@ export interface paths {
get?: never get?: never
/** /**
* Move a reservation through its state machine * 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.
*/ */
put: { put: {
parameters: { parameters: {
@ -866,10 +1002,192 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/api/users/admins': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** List the administrators */
get: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Administrator'][]
}
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be an admin */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
put?: never
/**
* Make somebody an administrator, by email
* @description The person need not have logged in yet: the row created is adopted at their first login. Granting to somebody who already is one changes nothing.
*/
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['GrantAdminForm']
}
}
responses: {
/**
* @description One administrator, as the page that manages them lists them.
*
* `pending` is somebody named by their address who has never logged in: the
* row is a placeholder waiting to be adopted at their first login, and the
* names on it are not to be trusted.
*/
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Administrator']
}
}
/** @description Not an email address */
400: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be an admin */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/users/admins/{id}': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
post?: never
/** Take the administrator rights away */
delete: {
parameters: {
query?: never
header?: never
path: {
id: number
}
cookie?: never
}
requestBody?: never
responses: {
/** @description no content */
200: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - the user must be an admin */
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description no content */
404: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description An administrator cannot demote themselves */
409: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
options?: never
head?: never
patch?: never
trace?: never
}
} }
export type webhooks = Record<string, never> export type webhooks = Record<string, never>
export interface components { export interface components {
schemas: { schemas: {
/**
* @description One administrator, as the page that manages them lists them.
*
* `pending` is somebody named by their address who has never logged in: the
* row is a placeholder waiting to be adopted at their first login, and the
* names on it are not to be trusted.
*/
Administrator: {
email: string
firstname: string
/** Format: int32 */
id: number
name: string
pending: boolean
}
Bike: { Bike: {
battery?: string | null battery?: string | null
drivetrain?: string | null drivetrain?: string | null
@ -882,6 +1200,24 @@ export interface components {
size: components['schemas']['BikeSize'] size: components['schemas']['BikeSize']
status: components['schemas']['BikeStatus'] status: components['schemas']['BikeStatus']
} }
/** @description One bike, as the platform sees it right now */
BikeLive: {
/**
* Format: int32
* @description Battery of the lock itself, in percent
*/
battery?: number | null
/** Format: int32 */
bike: number
lock_state: components['schemas']['LockState']
/** @description Out of service on the platform. The app's own status follows it. */
out_of_service: boolean
/**
* @description Who is riding it, if anybody. This is what "in use" means: a ride under
* way on this very bike, not a reservation that happens to cover it.
*/
rider?: components['schemas']['Rider'] | null
}
/** /**
* @description Frame size. The reservation form lets a requester ask for one kind or the * @description Frame size. The reservation form lets a requester ask for one kind or the
* other, so it is part of the bike rather than being read off its name. * other, so it is part of the bike rather than being read off its name.
@ -962,13 +1298,40 @@ export interface components {
firstname: string firstname: string
name: string name: string
} }
/** @description The whole picture, refreshed by the ticker and read by the admin page */
FleetLive: {
alerts: components['schemas']['UsageAlert'][]
bikes: components['schemas']['BikeLive'][]
/**
* Format: date-time
* @description When the platform was last reached. `None` means never — either it is
* not configured, or every attempt so far has failed.
*/
updated_at?: string | null
}
GetAuthorizeResponse: { GetAuthorizeResponse: {
redirect_to: string redirect_to: string
} }
GrantAdminForm: {
/**
* @description The address of the person to promote, whether or not they have ever
* logged in
*/
email: string
}
IdPath: { IdPath: {
/** Format: int32 */ /** Format: int32 */
id: number id: number
} }
/**
* @description Whether the bike is physically locked.
*
* `Unknown` is not a failure to answer: it is the platform reporting something
* this app does not recognise, which is worth showing as such rather than
* guessing "locked".
* @enum {string}
*/
LockState: 'locked' | 'unlocked' | 'unknown'
LoginDevForm: { LoginDevForm: {
/** @description Email of one of the `dev_users` of the configuration */ /** @description Email of one of the `dev_users` of the configuration */
user: string user: string
@ -1010,6 +1373,18 @@ export interface components {
kind: 'free' kind: 'free'
name: string name: string
} }
/**
* @description Somebody named by an admin filing a reservation for them.
*
* The address is the identity: it is what binds the reservation to a profile,
* today or the day that person first logs in. The names only matter when
* nobody is known at that address yet.
*/
Person: {
email: string
firstname: string
name: string
}
PostCallbackParams: { PostCallbackParams: {
code: string code: string
state: string state: string
@ -1057,10 +1432,10 @@ export interface components {
start_time: string 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 and
* telegram handle and the reason are shown but not editable, so they are not * the reason are shown but not editable, so they are not in the body at all:
* in the body at all: the handler reads them back from the stored reservation * the handler reads them back from the stored reservation rather than trusting
* rather than trusting a client to send them unchanged. * a client to send them unchanged.
*/ */
ReservationEditForm: { ReservationEditForm: {
/** /**
@ -1073,6 +1448,29 @@ export interface components {
linka_emails: string[] linka_emails: string[]
/** Format: date-time */ /** Format: date-time */
start_time: string start_time: string
/**
* @description Whoever picks the bikes up may change, and with them the handle to
* reach on the day
*/
telegram: string
}
/** @description Filing on somebody else's behalf, from the admin page. */
ReservationForForm: {
description: string
bikes: number[]
/** Format: date-time */
end_time: string
linka_emails: string[]
/**
* @description Whose reservation it is. Named by address: an unknown one creates the
* person, and their first login lands on that same profile.
*/
requester: components['schemas']['Person']
/** Format: date-time */
start_time: string
telegram: string
unit: components['schemas']['NewReservationUnit']
users: number[]
} }
/** /**
* @description One page of a listing, with the size of the whole so the caller can page * @description One page of a listing, with the size of the whole so the caller can page
@ -1134,10 +1532,21 @@ export interface components {
kind: 'free' kind: 'free'
name: string name: string
} }
Rider: {
email: string
name: string
/** Format: date-time */
since?: string | null
}
SetStatusForm: { SetStatusForm: {
status: components['schemas']['BikeStatus'] status: components['schemas']['BikeStatus']
} }
SetStatusForm2: { SetStatusForm2: {
/**
* @description Shown to the people on the reservation when it is cancelled, and ignored
* otherwise. Nothing stores it.
*/
reason?: string | null
status: components['schemas']['ReservationStatus'] status: components['schemas']['ReservationStatus']
} }
Unit: { Unit: {
@ -1146,6 +1555,25 @@ export interface components {
/** @description The Whiskey group name */ /** @description The Whiskey group name */
name: string name: string
} }
/**
* @description A bike being ridden by somebody no reservation entitles to it.
*
* Two cases, told apart by `reservation`: nobody's booking covers this rider
* at all, or their booking is for other bikes.
*/
UsageAlert: {
/** @description The bikes that reservation is for */
allowed: string[]
/** Format: int32 */
bike: number
bike_name: string
/**
* Format: int32
* @description The reservation the rider does have running, when there is one
*/
reservation?: number | null
rider: components['schemas']['Rider']
}
User: { User: {
admin: boolean admin: boolean
email: string email: string

View file

@ -4,6 +4,7 @@ app:
header: header:
admin: Admin admin: Admin
my-reservations: My reservations my-reservations: My reservations
admins: 'Administrators'
logout: Log out logout: Log out
logout-error: Unable to log out. logout-error: Unable to log out.
reserve: Book reserve: Book
@ -162,6 +163,16 @@ admin:
in_service: In service in_service: In service
out_of_service: Out of service out_of_service: Out of service
in_use: In use in_use: In use
lock:
locked: 'Locked'
unlocked: 'Unlocked'
unknown: 'Unknown state'
alert:
title: 'Used without a reservation'
without: 'Cargobike {bike} has been out with {rider} ({email}) since {since}, with no reservation running.'
outside: 'Cargobike {bike} has been out with {rider} ({email}) since {since}, but their reservation #{id} is for: {allowed}.'
platform-error: 'Linka Go would not take the change: the bike was left as it was.'
ridden-by: 'Out with {rider} since {since}'
reservations: reservations:
title: Reservations title: Reservations
intro: Requests are waiting for a decision; approved reservations show up below. intro: Requests are waiting for a decision; approved reservations show up below.
@ -181,6 +192,29 @@ admin:
all-status: Any status all-status: Any status
count: No result | 1 reservation | {count} reservations count: No result | 1 reservation | {count} reservations
empty: No reservation matches this search. empty: No reservation matches this search.
create:
action: Create a reservation
title: Create a reservation
intro: >-
For a request made in person or agreed elsewhere. The reservation starts
as a request, like any other.
for: Reservation for
email: E-mail address
firstname: First name
name: Last name
hint: >-
A known address attaches the reservation to that profile; otherwise the
person is created and finds the reservation waiting at their first login.
done: Reservation created.
cancel:
title: 'Cancel reservation #{id}?'
intro: >-
The reservation's Linka Go accounts will be told by e-mail. The
cargobikes become available for other reservations again.
reason: Reason (optional)
reason-placeholder: Included in the e-mail sent to the people involved.
back: Go back
confirm: Cancel the reservation
conflict: conflict:
title: 'Cannot approve: cargobike already booked' title: 'Cannot approve: cargobike already booked'
line: 'The {bike} is already held by {unit} (#{id}), from {from} to {to}.' line: 'The {bike} is already held by {unit} (#{id}), from {from} to {to}.'
@ -193,6 +227,29 @@ admin:
action: action:
approved: Approve approved: Approve
refused: Refuse refused: Refuse
requested: Put back in the queue
cancelled: Cancel cancelled: Cancel
ongoing: Start ongoing: Start
archived: Archive archived: Archive
admins:
title: 'Administrators'
intro: 'Add or remove the people who can administer the app.'
email: 'Email address'
email-placeholder: "firstname.name{'@'}epfl.ch"
add: 'Add'
hint: 'They need not have logged in yet: the rights wait for them at their first login.'
remove: 'Remove'
empty: 'No administrator.'
load-error: 'Could not load the list of administrators.'
you: 'You'
pending: 'Never logged in'
added: '{email} is now an administrator.'
removed: '{email} is no longer an administrator.'
error: 'That did not work. Try again.'
error-email: 'That address is not valid.'
error-already: 'That person is already an administrator.'
error-self: 'You cannot take your own rights away.'
confirm-title: 'Remove the rights?'
confirm-intro: '{email} will lose access to the administration page. You can add them back at any time.'
confirm-back: 'Cancel'

View file

@ -4,6 +4,7 @@ app:
header: header:
admin: Admin admin: Admin
my-reservations: Mes réservations my-reservations: Mes réservations
admins: 'Administrateurs'
logout: Se déconnecter logout: Se déconnecter
logout-error: Impossible de se déconnecter. logout-error: Impossible de se déconnecter.
reserve: Réserver reserve: Réserver
@ -163,6 +164,16 @@ admin:
in_service: En service in_service: En service
out_of_service: Hors service out_of_service: Hors service
in_use: En usage in_use: En usage
lock:
locked: 'Verrouillé'
unlocked: 'Déverrouillé'
unknown: 'État inconnu'
alert:
title: 'Utilisation sans réservation'
without: 'Le cargobike {bike} est utilisé par {rider} ({email}) depuis le {since}, sans aucune réservation en cours.'
outside: 'Le cargobike {bike} est utilisé par {rider} ({email}) depuis le {since}, mais sa réservation #{id} porte sur : {allowed}.'
platform-error: "Linka Go n'a pas accepté le changement : le vélo n'a pas été modifié."
ridden-by: 'Utilisé par {rider} depuis le {since}'
reservations: reservations:
title: Réservations title: Réservations
intro: Les demandes attendent une décision ; les réservations validées apparaissent ensuite. intro: Les demandes attendent une décision ; les réservations validées apparaissent ensuite.
@ -182,6 +193,30 @@ admin:
all-status: Tous les statuts all-status: Tous les statuts
count: Aucun résultat | 1 réservation | {count} réservations count: Aucun résultat | 1 réservation | {count} réservations
empty: Aucune réservation ne correspond à cette recherche. empty: Aucune réservation ne correspond à cette recherche.
create:
action: Créer une réservation
title: Créer une réservation
intro: >-
Pour une demande faite de vive voix ou par écrit ailleurs. La réservation
part en attente, comme les autres.
for: Réservation pour
email: Adresse e-mail
firstname: Prénom
name: Nom
hint: >-
Si cette adresse est déjà connue, la réservation est rattachée à ce
profil ; sinon la personne est créée et retrouvera sa réservation à sa
première connexion.
done: Réservation créée.
cancel:
title: 'Annuler la réservation #{id} ?'
intro: >-
Les comptes Linka Go de la réservation seront prévenus par e-mail. Les
cargobikes redeviennent disponibles pour d'autres réservations.
reason: Raison (facultative)
reason-placeholder: Indiquée dans l'e-mail envoyé aux personnes concernées.
back: Revenir en arrière
confirm: Annuler la réservation
conflict: conflict:
title: 'Validation impossible : cargobike déjà réservé' title: 'Validation impossible : cargobike déjà réservé'
line: 'Le {bike} est déjà pris par {unit} (#{id}), du {from} au {to}.' line: 'Le {bike} est déjà pris par {unit} (#{id}), du {from} au {to}.'
@ -194,6 +229,29 @@ admin:
action: action:
approved: Valider approved: Valider
refused: Refuser refused: Refuser
requested: Remettre en attente
cancelled: Annuler cancelled: Annuler
ongoing: Démarrer ongoing: Démarrer
archived: Archiver archived: Archiver
admins:
title: 'Administrateurs'
intro: "Ajoutez ou retirez les personnes qui peuvent administrer l'application."
email: 'Adresse e-mail'
email-placeholder: "prenom.nom{'@'}epfl.ch"
add: 'Ajouter'
hint: "La personne n'a pas besoin de s'être déjà connectée : ses droits l'attendent à sa première connexion."
remove: 'Retirer'
empty: 'Aucun administrateur.'
load-error: 'Impossible de charger la liste des administrateurs.'
you: 'Vous'
pending: 'Jamais connecté'
added: '{email} est désormais administrateur.'
removed: "{email} n'est plus administrateur."
error: "L'opération a échoué. Réessayez."
error-email: "Cette adresse n'est pas valide."
error-already: 'Cette personne est déjà administratrice.'
error-self: 'Vous ne pouvez pas retirer vos propres droits.'
confirm-title: 'Retirer les droits ?'
confirm-intro: "{email} n'aura plus accès à la page d'administration. Vous pourrez le rajouter à tout moment."
confirm-back: 'Annuler'

View file

@ -6,6 +6,7 @@ import ReservationView from '@/views/ReservationView.vue'
import MyReservationsView from '@/views/MyReservationsView.vue' import MyReservationsView from '@/views/MyReservationsView.vue'
import CalendarView from '@/views/CalendarView.vue' import CalendarView from '@/views/CalendarView.vue'
import AdminView from '@/views/AdminView.vue' import AdminView from '@/views/AdminView.vue'
import AdminsView from '@/views/AdminsView.vue'
import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue' import WhiskeyCallbackView from '@/views/WhiskeyCallbackView.vue'
declare module 'vue-router' { declare module 'vue-router' {
@ -40,6 +41,12 @@ const router = createRouter({
component: AdminView, component: AdminView,
meta: { requiresAuth: true, requiresAdmin: true }, meta: { requiresAuth: true, requiresAdmin: true },
}, },
{
name: 'admins',
path: '/admins',
component: AdminsView,
meta: { requiresAuth: true, requiresAdmin: true },
},
// Registered as the OIDC redirect uri, see `server.base_url` in config.yml // Registered as the OIDC redirect uri, see `server.base_url` in config.yml
{ name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView }, { name: 'whiskey-callback', path: '/whiskey/callback', component: WhiskeyCallbackView },
], ],

View file

@ -5,10 +5,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts' import { HttpStatus } from 'http-status-ts'
import type { Bike, BikeStatus } from '@/utils/types' import type { Bike, BikeStatus, FleetLive } from '@/utils/types'
import { getClient } from './client' import { getClient } from './client'
export const BIKES_KEY = ['bikes'] export const BIKES_KEY = ['bikes']
export const FLEET_LIVE_KEY = ['bikes', 'live']
/** How often the platform's picture of the fleet is asked for again */
const LIVE_REFRESH_MS = 30 * 1000
export function useBikes() { export function useBikes() {
return useQuery({ return useQuery({
@ -24,12 +28,36 @@ export function useBikes() {
}) })
} }
/**
* What Linka Go last said about the fleet: which bike is locked, which is being
* ridden, and by whom.
*
* The backend answers from a snapshot its own ticker refreshes, so this is a
* cheap call: polling it costs the platform nothing. Admin only.
*/
export function useFleetLive() {
return useQuery({
queryKey: FLEET_LIVE_KEY,
refetchInterval: LIVE_REFRESH_MS,
queryFn: async (): Promise<FleetLive> => {
const { data, response } = await getClient().GET('/api/bikes/live')
if (response.status === HttpStatus.OK && data) {
return data
}
throw new Error(`Unexpected status code received: ${response.status}`)
},
})
}
/** Takes a bike in or out of the fleet. Admin only, the backend enforces it. */ /** Takes a bike in or out of the fleet. Admin only, the backend enforces it. */
export function useSetBikeStatus() { export function useSetBikeStatus() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
retry: 0, retry: 0,
mutationFn: async ({ id, status }: { id: number; status: BikeStatus }) => { mutationFn: async ({ id, status }: { id: number; status: BikeStatus }) => {
// A refusal from the platform comes back as a 502, which the client
// turns into an `ApiError`: the card must not pretend the lock heard
// about a change it refused
await getClient().PUT('/api/bikes/{id}/status', { await getClient().PUT('/api/bikes/{id}/status', {
params: { path: { id } }, params: { path: { id } },
body: { status }, body: { status },

View file

@ -27,7 +27,9 @@ const client = createClient<paths>({
client.use({ client.use({
async onResponse({ response }) { async onResponse({ response }) {
if (!response.ok) { if (!response.ok) {
if (response.status >= 500) { // A 502 is not a bug here but a third party refusing: its detail is
// meant to be shown, so it falls through to the branch below
if (response.status >= 500 && response.status !== HttpStatus.BAD_GATEWAY) {
console.error( console.error(
`Server error from ${response.url}: ${response.status} ${response.statusText}`, `Server error from ${response.url}: ${response.status} ${response.statusText}`,
) )

View file

@ -15,6 +15,7 @@ import type {
Conflict, Conflict,
NewReservation, NewReservation,
NewReservationBike, NewReservationBike,
Person,
Reservation, Reservation,
ReservationEdit, ReservationEdit,
ReservationStatus, ReservationStatus,
@ -127,10 +128,19 @@ export function useSetReservationStatus() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
retry: 0, retry: 0,
mutationFn: async ({ id, status }: { id: number; status: ReservationStatus }) => { mutationFn: async ({
id,
status,
reason,
}: {
id: number
status: ReservationStatus
/** Only for a cancellation: what the people on it are told */
reason?: string
}) => {
await getClient().PUT('/api/reservations/{id}/status', { await getClient().PUT('/api/reservations/{id}/status', {
params: { path: { id } }, params: { path: { id } },
body: { status }, body: { status, reason },
}) })
return { id, status } return { id, status }
}, },
@ -216,6 +226,28 @@ export function useCreateReservation() {
}) })
} }
/**
* Files a reservation for somebody else, from the admin page. The person is
* named by address; the backend creates them when nobody is known there yet,
* and their first login lands on that same profile.
*/
export function useCreateReservationFor() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async (reservation: NewReservation & { requester?: Person }) => {
const { data, response, error } = await getClient().POST('/api/reservations/for', {
body: reservation as NewReservation & { requester: Person },
})
if (response.status !== HttpStatus.CREATED) {
throw new Error(typeof error === 'string' ? error : `Unexpected status: ${response.status}`)
}
return data as Reservation
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
})
}
/** /**
* Edits the period, the bikes and the Linka Go accounts. Everything else is * Edits the period, the bikes and the Linka Go accounts. Everything else is
* left as stored: the backend reads it back rather than taking it from here. * left as stored: the backend reads it back rather than taking it from here.

View file

@ -0,0 +1,53 @@
/**
* Who administers the app. One file per domain area, exposing vue-query hooks:
* views never call `fetch` themselves.
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts'
import type { Administrator } from '@/utils/types'
import { getClient } from './client'
export const ADMINS_KEY = ['users', 'admins']
export function useAdmins() {
return useQuery({
queryKey: ADMINS_KEY,
queryFn: async (): Promise<Administrator[]> => {
const { data, response } = await getClient().GET('/api/users/admins')
if (response.status === HttpStatus.OK && data) {
return data
}
throw new Error(`Unexpected status code received: ${response.status}`)
},
})
}
/**
* Makes whoever holds this address an administrator. They need not have logged
* in: the backend binds the rights to the address, and the profile that turns
* up at the first login is the same one.
*/
export function useGrantAdmin() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async (email: string) => {
const { data } = await getClient().POST('/api/users/admins', { body: { email } })
return data
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMINS_KEY }),
})
}
export function useRevokeAdmin() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async (id: number) => {
await getClient().DELETE('/api/users/admins/{id}', { params: { path: { id } } })
return id
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ADMINS_KEY }),
})
}

View file

@ -50,6 +50,11 @@ export type CalendarReservation = components['schemas']['CalendarReservation']
export type ReservationBike = components['schemas']['ReservationBike'] export type ReservationBike = components['schemas']['ReservationBike']
export type NewReservationBike = components['schemas']['NewReservationBike'] export type NewReservationBike = components['schemas']['NewReservationBike']
export type Conflict = components['schemas']['Conflict'] export type Conflict = components['schemas']['Conflict']
export type Person = components['schemas']['Person']
export type Administrator = components['schemas']['Administrator']
export type FleetLive = components['schemas']['FleetLive']
export type BikeLive = components['schemas']['BikeLive']
export type UsageAlert = components['schemas']['UsageAlert']
/** /**
* What the details block can render: the fields the public calendar carries, * What the details block can render: the fields the public calendar carries,

View file

@ -79,7 +79,7 @@ const updatedAt = computed(() =>
</CardContent> </CardContent>
</Card> </Card>
<BikeFleet class="min-w-0" :reservations="activeList" /> <BikeFleet class="min-w-0" />
<ReservationAdmin <ReservationAdmin
class="min-w-0" class="min-w-0"

View file

@ -0,0 +1,211 @@
<script setup lang="ts">
/**
* Who administers the app.
*
* Rights are held by an **address**, not by an account: somebody can be made an
* administrator before they have ever logged in, and the profile that turns up
* at their first login is the one that was named here. Until then the row is
* shown as pending, and the name on it is a stand-in.
*/
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ShieldCheck, Trash2, UserPlus } from '@lucide/vue'
import { toast } from 'vue-sonner'
import { HttpStatus } from 'http-status-ts'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/services/api/auth'
import { useAdmins, useGrantAdmin, useRevokeAdmin } from '@/services/api/users'
import { ApiError, type Administrator } from '@/utils/types'
const { t } = useI18n()
const { user } = useSession()
const { data: admins, isPending, isError } = useAdmins()
const grant = useGrantAdmin()
const revoke = useRevokeAdmin()
const email = ref('')
const error = ref('')
/** The same shape the backend accepts: something, an @, something */
const EMAIL_RE = /^[^\s@]+@[^\s@]+$/
const list = computed(() => admins.value ?? [])
function label(administrator: Administrator) {
const name = `${administrator.firstname} ${administrator.name}`.trim()
return name || administrator.email
}
function add() {
const address = email.value.trim()
error.value = ''
if (!EMAIL_RE.test(address)) {
error.value = t('admins.error-email')
return
}
if (
list.value.some((administrator) => administrator.email.toLowerCase() === address.toLowerCase())
) {
error.value = t('admins.error-already')
return
}
grant.mutate(address, {
onSuccess: () => {
email.value = ''
toast.success(t('admins.added', { email: address }))
},
onError: () => toast.error(t('admins.error')),
})
}
/**
* Nobody is removed on a stray click: the dialog names who is losing what.
*
* Who is being removed is held apart from whether the dialog is open — closing
* it is what runs the action, so a single flag would clear the target before
* the click is handled.
*/
const removing = ref<Administrator | null>(null)
const confirming = ref(false)
function askToRemove(administrator: Administrator) {
removing.value = administrator
confirming.value = true
}
function confirmRemove() {
confirming.value = false
const administrator = removing.value
if (!administrator) return
revoke.mutate(administrator.id, {
onSuccess: () => toast.success(t('admins.removed', { email: administrator.email })),
onError: (err) =>
toast.error(
// The backend refuses to let anybody demote themselves
err instanceof ApiError && err.status === HttpStatus.CONFLICT
? t('admins.error-self')
: t('admins.error'),
),
})
}
</script>
<template>
<div class="mx-auto w-full max-w-3xl">
<Card>
<CardHeader>
<CardTitle class="text-xl">{{ $t('admins.title') }}</CardTitle>
<CardDescription>{{ $t('admins.intro') }}</CardDescription>
</CardHeader>
<CardContent class="grid gap-6">
<!-- Adding one -->
<!-- `novalidate`: the address is checked below, so the message is the
translated one rather than the browser's own bubble -->
<form class="grid gap-2" novalidate @submit.prevent="add()">
<Label for="admin-email">{{ $t('admins.email') }}</Label>
<div class="flex flex-wrap items-start gap-2">
<Input
id="admin-email"
v-model="email"
type="email"
class="min-w-56 flex-1"
:placeholder="$t('admins.email-placeholder')"
:aria-invalid="!!error || undefined"
@input="error = ''"
/>
<Button type="submit" :disabled="grant.isPending.value">
<UserPlus class="size-4" />
{{ $t('admins.add') }}
</Button>
</div>
<p v-if="error" class="text-destructive text-xs">{{ error }}</p>
<p v-else class="text-muted-foreground text-xs">{{ $t('admins.hint') }}</p>
</form>
<!-- The current ones -->
<div class="grid gap-3">
<div v-if="isPending" class="grid gap-2">
<Skeleton v-for="i in 3" :key="i" class="h-14 w-full" />
</div>
<p v-else-if="isError" class="text-destructive text-sm">
{{ $t('admins.load-error') }}
</p>
<p v-else-if="!list.length" class="text-muted-foreground text-sm">
{{ $t('admins.empty') }}
</p>
<div
v-for="administrator in list"
v-else
:key="administrator.id"
class="bg-card flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3"
>
<div class="grid min-w-0 gap-0.5">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
<ShieldCheck class="text-primary size-4 shrink-0" />
{{ label(administrator) }}
<Badge v-if="administrator.id === user?.id" variant="secondary">
{{ $t('admins.you') }}
</Badge>
<Badge v-else-if="administrator.pending" variant="secondary">
{{ $t('admins.pending') }}
</Badge>
</span>
<span class="text-muted-foreground truncate text-sm">{{ administrator.email }}</span>
</div>
<Button
variant="outline"
size="sm"
:disabled="administrator.id === user?.id || revoke.isPending.value"
:title="administrator.id === user?.id ? $t('admins.error-self') : undefined"
@click="askToRemove(administrator)"
>
<Trash2 class="size-4" />
{{ $t('admins.remove') }}
</Button>
</div>
</div>
</CardContent>
</Card>
<AlertDialog v-model:open="confirming">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('admins.confirm-title') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ $t('admins.confirm-intro', { email: removing?.email }) }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('admins.confirm-back') }}</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive hover:bg-destructive/90 text-white"
@click="confirmRemove()"
>
{{ $t('admins.remove') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>

View file

@ -52,6 +52,19 @@ const BADGE_CLASS: Record<ReservationStatus, string> = {
archived: 'bg-muted text-muted-foreground', archived: 'bg-muted text-muted-foreground',
} }
/**
* The rail down the left edge: a status is seen before it is read, which is
* what an administrator scanning the queue actually does.
*/
const RAIL_CLASS: Record<ReservationStatus, string> = {
requested: 'border-l-amber-500',
approved: 'border-l-emerald-500',
ongoing: 'border-l-primary',
refused: 'border-l-destructive',
cancelled: 'border-l-destructive',
archived: 'border-l-muted-foreground/40',
}
const sections = computed(() => [ const sections = computed(() => [
{ key: 'requested', reservations: requested.data.value?.items ?? [] }, { key: 'requested', reservations: requested.data.value?.items ?? [] },
{ key: 'active', reservations: active.data.value?.items ?? [] }, { key: 'active', reservations: active.data.value?.items ?? [] },
@ -133,7 +146,8 @@ function editableEmails(reservation: Reservation) {
<div <div
v-for="reservation in section.reservations" v-for="reservation in section.reservations"
:key="reservation.id" :key="reservation.id"
class="rounded-lg border p-4" class="bg-card rounded-lg border border-l-4 p-4 shadow-sm transition-shadow hover:shadow-md"
:class="RAIL_CLASS[reservation.status]"
> >
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 flex-wrap items-center gap-2"> <div class="flex min-w-0 flex-wrap items-center gap-2">

View file

@ -1,219 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, shallowRef, watch } from 'vue' /**
* Filing a reservation for oneself. The form itself lives in
* `ReservationForm.vue`, shared with the admin dialog that files for somebody
* else, so the two never drift apart; this page only owns the mutation.
*/
import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Plus, TriangleAlert, X } from '@lucide/vue'
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { HttpStatus } from 'http-status-ts' import { HttpStatus } from 'http-status-ts'
import DatePicker from '@/components/DatePicker.vue' import ReservationForm, {
import TelegramInput from '@/components/TelegramInput.vue' type ReservationPayload,
import UnitPicker, { type UnitChoice } from '@/components/UnitPicker.vue' } from '@/components/reservation/ReservationForm.vue'
import TimePicker from '@/components/TimePicker.vue'
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 { Input } from '@/components/ui/input' import { useCreateReservation } from '@/services/api/reservations'
import { Label } from '@/components/ui/label' import { ApiError } from '@/utils/types'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { useBikes } from '@/services/api/bikes'
import { useConflicts, useCreateReservation } from '@/services/api/reservations'
import { useSession } from '@/services/api/auth'
import { ApiError, type Bike, type NewReservation } from '@/utils/types'
const { t } = useI18n()
const WIKI_URL = 'https://go.agepoly.ch/cargobikes' const WIKI_URL = 'https://go.agepoly.ch/cargobikes'
const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
type Form = {
association: UnitChoice | undefined
reason: string
startTime: string | undefined
endTime: string | undefined
bikes: number[]
telegram: string
emails: string[]
}
function emptyForm(): Form {
return {
association: undefined,
reason: '',
startTime: undefined,
endTime: undefined,
bikes: [],
telegram: '',
emails: [''],
}
}
const form = reactive<Form>(emptyForm())
const startDate = shallowRef<DateValue>()
const endDate = shallowRef<DateValue>()
const errors = reactive<Record<string, string>>({})
const submitted = ref(false)
const minDate = today(getLocalTimeZone())
/** Bookings open one month ahead, no further */
const maxDate = minDate.add({ months: 1 })
const { user } = useSession()
const units = computed(() => user.value?.units ?? [])
function toDate(date: DateValue | undefined, time: string | undefined): Date | null {
if (!date || !time) return null
const [hours, minutes] = time.split(':').map(Number)
const local = date.toDate(getLocalTimeZone())
local.setHours(hours, minutes, 0, 0)
return local
}
const start = computed(() => toDate(startDate.value, form.startTime))
const end = computed(() => toDate(endDate.value, form.endTime))
const periodPicked = computed(() => start.value !== null && end.value !== null)
const { data: bikes, isPending: bikesPending, isError: bikesError } = useBikes()
/**
* Which bikes an approved or ongoing reservation already holds over the period
* asked for. The overlap is worked out by the backend against the whole table:
* the browser is told "these are taken", not handed everybody's bookings to
* work it out itself.
*/
const probe = computed(() => {
if (!start.value || !end.value || end.value <= start.value) return null
return {
start_time: start.value.toISOString(),
end_time: end.value.toISOString(),
bikes: (bikes.value ?? []).map((bike) => ({ id: bike.id })),
}
})
const { data: conflicts } = useConflicts(probe)
const taken = computed(() => new Set((conflicts.value ?? []).map((conflict) => conflict.bike)))
function isTaken(bike: Bike) {
return taken.value.has(bike.id)
}
/** Out of service or already booked: either way it cannot be picked */
function isUnavailable(bike: Bike) {
return bike.status === 'out_of_service' || isTaken(bike)
}
const availableBikes = computed(() => (bikes.value ?? []).filter((bike) => !isUnavailable(bike)))
// The fleet is presented one column per frame size, since that is what the
// requester actually chooses. The size lives on the bike, so adding a sixth one
// only means giving it a size in the admin, never touching this file.
const BIKE_SIZES = ['large', 'small'] as const
const bikeGroups = computed(() =>
BIKE_SIZES.map((size) => ({
size,
bikes: (bikes.value ?? []).filter((bike) => bike.size === size),
})),
)
watch(availableBikes, (available) => {
const ids = new Set(available.map((bike) => bike.id))
form.bikes = form.bikes.filter((id) => ids.has(id))
})
function toggleBike(bike: Bike) {
if (isUnavailable(bike)) return
const index = form.bikes.indexOf(bike.id)
if (index >= 0) form.bikes.splice(index, 1)
else form.bikes.push(bike.id)
}
function addEmail() {
form.emails.push('')
}
function removeEmail(index: number) {
form.emails.splice(index, 1)
if (form.emails.length === 0) form.emails.push('')
}
function validate(): boolean {
Object.keys(errors).forEach((key) => delete errors[key])
if (!form.association) errors.association = t('reservation.error-required')
if (!form.reason.trim()) errors.reason = t('reservation.error-required')
if (!start.value) errors.start = t('reservation.error-datetime-required')
if (!end.value) errors.end = t('reservation.error-datetime-required')
// Strictly after: a reservation of zero length is not one
if (start.value && end.value && end.value <= start.value) {
errors.end = t('reservation.error-end-before-start')
}
// The pickers already refuse these dates; re-checked in case the model was
// filled another way
if (startDate.value && startDate.value.compare(maxDate) > 0) {
errors.start = t('reservation.error-too-far')
}
if (endDate.value && endDate.value.compare(maxDate) > 0) {
errors.end = t('reservation.error-too-far')
}
if (form.bikes.length === 0) errors.bikes = t('reservation.error-no-bike')
if (!form.telegram) errors.telegram = t('reservation.error-required')
else if (!TELEGRAM_RE.test(`@${form.telegram}`)) {
errors.telegram = t('reservation.error-telegram')
}
const emails = form.emails.map((email) => email.trim()).filter(Boolean)
if (emails.length === 0) errors.emails = t('reservation.error-required')
else if (!emails.every((email) => EMAIL_RE.test(email))) {
errors.emails = t('reservation.error-email')
}
return Object.keys(errors).length === 0
}
function reset() {
Object.assign(form, emptyForm())
startDate.value = undefined
endDate.value = undefined
Object.keys(errors).forEach((key) => delete errors[key])
submitted.value = false
}
const { t } = useI18n()
const create = useCreateReservation() const create = useCreateReservation()
const form = ref<InstanceType<typeof ReservationForm> | null>(null)
/** The form, as the api wants it. Both are non-null once `validate()` passed. */ function submit(payload: ReservationPayload) {
function payload(): NewReservation { create.mutate(payload, {
const association = form.association!
return {
unit:
association.kind === 'known'
? { kind: 'known', id: association.id }
: { kind: 'free', name: association.name.trim() },
start_time: start.value!.toISOString(),
end_time: end.value!.toISOString(),
// The backend adds the requester itself; nobody else is picked here yet
users: [],
telegram: `@${form.telegram}`,
description: form.reason.trim(),
bikes: [...form.bikes],
linka_emails: form.emails.map((email) => email.trim()).filter(Boolean),
}
}
function submit() {
submitted.value = true
if (!validate()) {
toast.error(t('reservation.error-form'))
return
}
create.mutate(payload(), {
onSuccess: () => { onSuccess: () => {
toast.success(t('reservation.submitted')) toast.success(t('reservation.submitted'))
reset() form.value?.reset()
}, },
// 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) => onError: (error) =>
toast.error( toast.error(
@ -244,205 +59,7 @@ function submit() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form class="grid gap-5" novalidate @submit.prevent="submit"> <ReservationForm ref="form" :pending="create.isPending.value" @submit="submit" />
<!-- Association -->
<div class="grid gap-2">
<Label for="association">{{ $t('reservation.association') }}</Label>
<UnitPicker
id="association"
v-model="form.association"
:units="units"
:invalid="!!errors.association"
/>
<p v-if="errors.association" class="text-destructive text-xs">
{{ errors.association }}
</p>
</div>
<!-- Reason -->
<div class="grid gap-2">
<Label for="reason">{{ $t('reservation.reason') }}</Label>
<Textarea
id="reason"
v-model.trim="form.reason"
:placeholder="$t('reservation.reason-placeholder')"
:aria-invalid="!!errors.reason || undefined"
rows="2"
/>
<p v-if="errors.reason" class="text-destructive text-xs">{{ errors.reason }}</p>
</div>
<!-- Start. The caption names the date/time pair rather than one of
them: a `<label for>` on the date would make a click on the text
open the calendar, since the browser forwards the activation. -->
<div class="grid gap-2">
<span id="start-label" class="text-sm leading-none font-medium">
{{ $t('reservation.start') }}
</span>
<div role="group" aria-labelledby="start-label" class="grid gap-2 sm:grid-cols-2">
<DatePicker
id="start-date"
v-model="startDate"
:min-value="minDate"
:max-value="maxDate"
:invalid="!!errors.start"
/>
<TimePicker id="start-time" v-model="form.startTime" :invalid="!!errors.start" />
</div>
<p v-if="errors.start" class="text-destructive text-xs">{{ errors.start }}</p>
</div>
<!-- End -->
<div class="grid gap-2">
<span id="end-label" class="text-sm leading-none font-medium">
{{ $t('reservation.end') }}
</span>
<div role="group" aria-labelledby="end-label" class="grid gap-2 sm:grid-cols-2">
<DatePicker
id="end-date"
v-model="endDate"
:min-value="startDate ?? minDate"
:max-value="maxDate"
:invalid="!!errors.end"
/>
<TimePicker id="end-time" v-model="form.endTime" :invalid="!!errors.end" />
</div>
<p v-if="errors.end" class="text-destructive text-xs">{{ errors.end }}</p>
</div>
<!-- Bikes -->
<div class="grid gap-2">
<span class="text-sm font-medium">{{ $t('reservation.bikes') }}</span>
<div
v-if="!periodPicked"
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ $t('reservation.bikes-pick-period') }}
</div>
<div v-else-if="bikesPending" class="grid gap-2 sm:grid-cols-2">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
</div>
<div
v-else-if="bikesError"
class="border-destructive/50 text-destructive flex items-center gap-2 rounded-md border p-3 text-sm"
>
<TriangleAlert class="size-4 shrink-0" />
{{ $t('reservation.bikes-error') }}
</div>
<p v-else-if="availableBikes.length === 0" class="text-muted-foreground text-sm">
{{ $t('reservation.bikes-empty') }}
</p>
<div v-else class="grid gap-4 sm:grid-cols-2">
<div
v-for="group in bikeGroups"
:key="group.size"
role="group"
:aria-labelledby="`bike-size-${group.size}`"
class="grid content-start gap-2"
>
<span
:id="`bike-size-${group.size}`"
class="text-muted-foreground text-xs font-semibold tracking-wide uppercase"
>
{{ $t(`reservation.bike-size-${group.size}`) }}
</span>
<p v-if="group.bikes.length === 0" class="text-muted-foreground text-sm">
{{ $t('reservation.bike-size-empty') }}
</p>
<button
v-for="bike in group.bikes"
:key="bike.id"
type="button"
:disabled="isUnavailable(bike)"
:aria-pressed="form.bikes.includes(bike.id)"
class="flex h-10 items-center justify-between gap-2 rounded-md border px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
:class="
form.bikes.includes(bike.id)
? 'border-primary bg-primary/10 text-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
"
@click="toggleBike(bike)"
>
<span class="truncate">{{ bike.name }}</span>
<span
v-if="isUnavailable(bike)"
class="text-destructive shrink-0 rounded px-1.5 py-0.5 text-[0.65rem] font-bold uppercase"
>
{{
bike.status === 'out_of_service'
? $t('reservation.bike-out-of-service')
: $t('reservation.bike-taken')
}}
</span>
</button>
</div>
</div>
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
</div>
<!-- Telegram -->
<div class="grid gap-2">
<Label for="telegram">{{ $t('reservation.telegram') }}</Label>
<TelegramInput id="telegram" v-model="form.telegram" :invalid="!!errors.telegram" />
<p v-if="errors.telegram" class="text-destructive text-xs">{{ errors.telegram }}</p>
</div>
<!-- Linka Go emails -->
<div class="grid gap-2">
<span class="text-sm font-medium">{{ $t('reservation.emails') }}</span>
<div v-for="(_, index) in form.emails" :key="index" class="flex gap-2">
<Input
v-model.trim="form.emails[index]"
type="email"
inputmode="email"
:aria-label="$t('reservation.email-nth', { n: index + 1 })"
placeholder="prenom.nom@exemple.com"
:aria-invalid="!!errors.emails || undefined"
/>
<Button
v-if="form.emails.length > 1"
type="button"
variant="outline"
size="icon"
:aria-label="$t('reservation.remove-email')"
@click="removeEmail(index)"
>
<X class="size-4" />
</Button>
</div>
<p v-if="errors.emails" class="text-destructive text-xs">{{ errors.emails }}</p>
<div>
<Button type="button" variant="secondary" size="sm" @click="addEmail()">
<Plus class="size-4" />
{{ $t('reservation.add-email') }}
</Button>
</div>
</div>
<!-- Actions -->
<div class="flex flex-wrap gap-2">
<Button type="submit" :disabled="create.isPending.value">
{{ create.isPending.value ? $t('reservation.submitting') : $t('reservation.submit') }}
</Button>
<Button
type="button"
variant="outline"
:disabled="create.isPending.value"
@click="reset()"
>
{{ $t('reservation.reset') }}
</Button>
</div>
</form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View file

@ -13,16 +13,22 @@ use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
api::helpers::{IdPath, admin, admin_desc, unexpected_error}, api::helpers::{IdPath, admin, admin_desc, desc, unexpected_error},
core::{ core::{
controller::{AnonAppController, AppController}, controller::{
models::bike::{Bike, BikeStatus}, AnonAppController, AppController, ControllerError, bikes::BikesControllerError,
},
models::{
bike::{Bike, BikeStatus},
linka::FleetLive,
},
}, },
}; };
pub fn routes() -> ApiRouter { pub fn routes() -> ApiRouter {
ApiRouter::new() ApiRouter::new()
.api_route("/", get_with(get_bikes, get_bikes_docs)) .api_route("/", get_with(get_bikes, get_bikes_docs))
.api_route("/live", get_with(get_live, get_live_docs))
.api_route("/{id}/status", put_with(set_status, set_status_docs)) .api_route("/{id}/status", put_with(set_status, set_status_docs))
} }
@ -42,6 +48,11 @@ async fn set_status(
match admin(ac)?.set_bike_status(id, status).await { match admin(ac)?.set_bike_status(id, status).await {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such bike".to_owned())), Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such bike".to_owned())),
// The platform would not take the change, so it did not happen here
// either: saying so is the only honest answer
Err(ControllerError::Bike(err @ BikesControllerError::PlatformRefused(_))) => {
Err((StatusCode::BAD_GATEWAY, err.to_string()))
}
Err(err) => unexpected_error("set_bike_status", err), Err(err) => unexpected_error("set_bike_status", err),
} }
} }
@ -51,6 +62,26 @@ fn set_status_docs(op: TransformOperation) -> TransformOperation {
.summary("Put a bike in or out of service") .summary("Put a bike in or out of service")
.response_with::<403, (), _>(admin_desc) .response_with::<403, (), _>(admin_desc)
.response::<404, ()>() .response::<404, ()>()
.response_with::<502, (), _>(desc("Linka Go refused the change: nothing was stored"))
}
/// What Linka Go last said about the fleet: lock state, battery, who is riding
/// what, and the bikes taken out with no reservation covering them.
///
/// Read from the snapshot the ticker keeps, so the page never waits on the
/// platform. Admin only: it names riders.
#[axum::debug_handler]
async fn get_live(ac: AppController) -> Result<Json<FleetLive>, (StatusCode, String)> {
match admin(ac)?.fleet_live() {
Ok(live) => Ok(Json(live)),
Err(err) => unexpected_error("get_live", err),
}
}
fn get_live_docs(op: TransformOperation) -> TransformOperation {
op.tag("Bikes")
.summary("Get what the platform says about the fleet")
.response_with::<403, (), _>(admin_desc)
} }
#[axum::debug_handler] #[axum::debug_handler]

View file

@ -36,6 +36,7 @@ mod bikes;
mod docs; mod docs;
mod helpers; mod helpers;
mod reservations; mod reservations;
mod users;
pub fn get_router(aac: AnonAppController) -> Router { pub fn get_router(aac: AnonAppController) -> Router {
aide::generate::on_error(|err| error!("aide generated error: {err}")); aide::generate::on_error(|err| error!("aide generated error: {err}"));
@ -63,6 +64,7 @@ pub fn get_router(aac: AnonAppController) -> Router {
.merge(auth::routes()) .merge(auth::routes())
.nest_api_service("/api/bikes", bikes::routes()) .nest_api_service("/api/bikes", bikes::routes())
.nest_api_service("/api/reservations", reservations::routes()) .nest_api_service("/api/reservations", reservations::routes())
.nest_api_service("/api/users", users::routes())
.nest_api_service("/api/docs", docs::routes()) .nest_api_service("/api/docs", docs::routes())
.finish_api_with(&mut api, docs::api_docs_metadata) .finish_api_with(&mut api, docs::api_docs_metadata)
.layer(Extension(aac)) .layer(Extension(aac))

View file

@ -29,11 +29,14 @@ use crate::{
AnonAppController, AppController, ControllerError, AnonAppController, AppController, ControllerError,
reservations::ReservationsControllerError, reservations::ReservationsControllerError,
}, },
models::reservation::{ models::{
reservation::{
CalendarReservation, Conflict, ConflictProbe, NewReservation, NewReservationBike, CalendarReservation, Conflict, ConflictProbe, NewReservation, NewReservationBike,
Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery, Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery,
ReservationStatus, ReservationStatus,
}, },
user::Person,
},
}, },
}; };
@ -53,6 +56,10 @@ 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(
"/for",
post_with(create_reservation_for, create_reservation_for_docs),
)
.api_route("/conflicts", post_with(find_conflicts, find_conflicts_docs)) .api_route("/conflicts", post_with(find_conflicts, find_conflicts_docs))
.api_route( .api_route(
"/{id}", "/{id}",
@ -175,6 +182,63 @@ fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
.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"))
} }
/// Filing on somebody else's behalf, from the admin page.
#[derive(Debug, Deserialize, JsonSchema)]
struct ReservationForForm {
/// Whose reservation it is. Named by address: an unknown one creates the
/// person, and their first login lands on that same profile.
requester: Person,
#[serde(flatten)]
reservation: NewReservation,
}
#[axum::debug_handler]
async fn create_reservation_for(
ac: AppController,
Json(form): Json<ReservationForForm>,
) -> Result<(StatusCode, Json<Reservation>), (StatusCode, String)> {
if !form.requester.is_valid() {
return Err((
StatusCode::BAD_REQUEST,
"The person named is incomplete".to_owned(),
));
}
match admin(ac)?
.create_reservation_for(form.reservation, form.requester)
.await
{
Ok(reservation) => Ok((StatusCode::CREATED, Json(reservation))),
Err(ControllerError::Reservation(
err @ ReservationsControllerError::ReservationInvalid,
)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
Err(ControllerError::Reservation(
err @ ReservationsControllerError::BikeOutOfService(_),
)) => Err((StatusCode::CONFLICT, err.to_string())),
Err(err) if err.is_not_found() => Err((
StatusCode::UNPROCESSABLE_ENTITY,
"Unknown unit or bike".to_owned(),
)),
Err(err) => unexpected_error("create_reservation_for", err),
}
}
fn create_reservation_for_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("File a reservation for somebody else")
.description(
"Admin only. The requester is named by address rather than by id: an \
address nobody is known at creates the person, and the first time they \
log in they land on that profile, with this reservation already on it. \
Conflicts are not refused here — an admin filing by hand is the one who \
arbitrates.",
)
.response_with::<201, Json<Reservation>, _>(desc("The reservation, as stored"))
.response_with::<400, (), _>(desc("The reservation or the person is malformed"))
.response_with::<403, (), _>(admin_desc)
.response_with::<409, (), _>(desc("One of the bikes is out of service"))
.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. /// The window a calendar draws, so only that window is read.
@ -363,13 +427,16 @@ fn update_reservation_docs(op: TransformOperation) -> TransformOperation {
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct SetStatusForm { struct SetStatusForm {
status: ReservationStatus, status: ReservationStatus,
/// Shown to the people on the reservation when it is cancelled, and ignored
/// otherwise. Nothing stores it.
reason: Option<String>,
} }
#[axum::debug_handler] #[axum::debug_handler]
async fn set_status( async fn set_status(
ac: AppController, ac: AppController,
Path(IdPath { id }): Path<IdPath>, Path(IdPath { id }): Path<IdPath>,
Json(SetStatusForm { status }): Json<SetStatusForm>, Json(SetStatusForm { status, reason }): Json<SetStatusForm>,
) -> Result<(), (StatusCode, String)> { ) -> Result<(), (StatusCode, String)> {
// The unit is not in the body: it is the reservation's own // The unit is not in the body: it is the reservation's own
let reservation = match ac.get_reservation(id).await { let reservation = match ac.get_reservation(id).await {
@ -381,7 +448,7 @@ async fn set_status(
}; };
match manager(ac, reservation.unit.scope())? match manager(ac, reservation.unit.scope())?
.set_reservation_status(id, status) .set_reservation_status(id, status, reason)
.await .await
{ {
Ok(()) => Ok(()), Ok(()) => Ok(()),

101
src/api/users.rs Normal file
View file

@ -0,0 +1,101 @@
//! Who administers the app.
//!
//! Users themselves are not managed here: they come from the authentication
//! provider. The one decision that belongs to this app is `admin`, and this is
//! where it is taken.
use aide::{
axum::{
ApiRouter,
routing::{delete_with, get_with, post_with},
},
transform::TransformOperation,
};
use axum::{Json, extract::Path, http::StatusCode};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
api::helpers::{IdPath, admin, admin_desc, desc, unexpected_error},
core::{
controller::{AppController, ControllerError, users::UsersControllerError},
models::user::Administrator,
},
};
pub fn routes() -> ApiRouter {
ApiRouter::new()
.api_route("/admins", get_with(get_admins, get_admins_docs))
.api_route("/admins", post_with(grant_admin, grant_admin_docs))
.api_route("/admins/{id}", delete_with(revoke_admin, revoke_admin_docs))
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GrantAdminForm {
/// The address of the person to promote, whether or not they have ever
/// logged in
email: String,
}
#[axum::debug_handler]
async fn get_admins(ac: AppController) -> Result<Json<Vec<Administrator>>, (StatusCode, String)> {
match admin(ac)?.get_admins().await {
Ok(admins) => Ok(Json(admins)),
Err(err) => unexpected_error("get_admins", err),
}
}
fn get_admins_docs(op: TransformOperation) -> TransformOperation {
op.tag("Users")
.summary("List the administrators")
.response_with::<403, (), _>(admin_desc)
}
#[axum::debug_handler]
async fn grant_admin(
ac: AppController,
Json(GrantAdminForm { email }): Json<GrantAdminForm>,
) -> Result<Json<Administrator>, (StatusCode, String)> {
match admin(ac)?.grant_admin(&email).await {
Ok(administrator) => Ok(Json(administrator)),
Err(ControllerError::User(err @ UsersControllerError::EmailInvalid)) => {
Err((StatusCode::BAD_REQUEST, err.to_string()))
}
Err(err) => unexpected_error("grant_admin", err),
}
}
fn grant_admin_docs(op: TransformOperation) -> TransformOperation {
op.tag("Users")
.summary("Make somebody an administrator, by email")
.description(
"The person need not have logged in yet: the row created is adopted \
at their first login. Granting to somebody who already is one \
changes nothing.",
)
.response_with::<403, (), _>(admin_desc)
.response_with::<400, (), _>(desc("Not an email address"))
}
#[axum::debug_handler]
async fn revoke_admin(
ac: AppController,
Path(IdPath { id }): Path<IdPath>,
) -> Result<(), (StatusCode, String)> {
match admin(ac)?.revoke_admin(id).await {
Ok(()) => Ok(()),
Err(ControllerError::User(err @ UsersControllerError::CannotDemoteSelf)) => {
Err((StatusCode::CONFLICT, err.to_string()))
}
Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such user".to_owned())),
Err(err) => unexpected_error("revoke_admin", err),
}
}
fn revoke_admin_docs(op: TransformOperation) -> TransformOperation {
op.tag("Users")
.summary("Take the administrator rights away")
.response_with::<403, (), _>(admin_desc)
.response_with::<409, (), _>(desc("An administrator cannot demote themselves"))
.response::<404, ()>()
}

View file

@ -1,8 +1,12 @@
use thiserror::Error; use thiserror::Error;
use tracing::warn;
use crate::core::{ use crate::{
core::{
controller::{AdminAppController, AnonAppController, ControllerError}, controller::{AdminAppController, AnonAppController, ControllerError},
models::bike::{Bike, BikeId, BikeStatus, NewBike}, models::bike::{Bike, BikeId, BikeStatus, NewBike},
},
services::linka::{self, LinkaError, locks},
}; };
/// Reading the fleet needs no session: the reservation form shows it before /// Reading the fleet needs no session: the reservation form shows it before
@ -30,9 +34,13 @@ impl AdminAppController {
if bike.key_quantity < 0 || bike.name.trim().is_empty() { if bike.key_quantity < 0 || bike.name.trim().is_empty() {
return Err(BikesControllerError::BikeInvalid.into()); return Err(BikesControllerError::BikeInvalid.into());
} }
if bike == self.db.get_bike(bike.id).await? { let stored = self.db.get_bike(bike.id).await?;
if bike == stored {
return Ok(()); return Ok(());
} }
if bike.status != stored.status {
self.push_service_state(&stored, bike.status).await?;
}
self.db.update_bike(bike).await.map_err(Into::into) self.db.update_bike(bike).await.map_err(Into::into)
} }
@ -41,11 +49,44 @@ impl AdminAppController {
id: BikeId, id: BikeId,
status: BikeStatus, status: BikeStatus,
) -> Result<(), ControllerError> { ) -> Result<(), ControllerError> {
let bike = self.db.get_bike(id).await?;
if bike.status == status {
return Ok(());
}
self.push_service_state(&bike, status).await?;
self.db self.db
.set_bike_status(id, status) .set_bike_status(id, status)
.await .await
.map_err(Into::into) .map_err(Into::into)
} }
/// Tells the platform about a bike taken in or out of service here.
///
/// Awaited, and a failure stops the change: the platform is where a lock
/// actually refuses to open, so a status stored here that never reached it
/// would be a promise this app cannot keep — and the next synchronisation
/// would silently undo it anyway.
async fn push_service_state(
&self,
bike: &Bike,
status: BikeStatus,
) -> Result<(), ControllerError> {
if !linka::configured() {
return Ok(());
}
let Some(serial) = linka::serial_of(&bike.name) else {
// A bike with no lock configured is this app's own business
warn!(
"[LINKA] no lock serial for {}: its service state stays here",
bike.name
);
return Ok(());
};
match locks::set_service_state(&serial, status == BikeStatus::OutOfService).await {
Ok(()) | Err(LinkaError::NotConfigured | LinkaError::DryRun) => Ok(()),
Err(err) => Err(BikesControllerError::PlatformRefused(err.to_string()).into()),
}
}
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -54,4 +95,6 @@ pub enum BikesControllerError {
BikeInvalid, BikeInvalid,
#[error("The bike appears in a reservation: take it out of service instead of deleting it")] #[error("The bike appears in a reservation: take it out of service instead of deleting it")]
BikeReserved, BikeReserved,
#[error("Linka Go refused the change: {0}")]
PlatformRefused(String),
} }

View file

@ -0,0 +1,385 @@
//! Keeping this app and the Linka Go platform in step.
//!
//! Three things happen on every tick, in this order:
//!
//! 1. the fleet is read — lock state, battery, and who is riding what — and
//! kept as one snapshot the admin page reads without ever waiting on the
//! platform;
//! 2. a bike the platform reports out of service is taken out of service here
//! too (the platform is the truth: the mechanic works there, and the same
//! lock refuses to open either way);
//! 3. the access list is reconciled: everybody a live reservation entitles is
//! on it, and nobody else.
//!
//! The reconciliation replaces the flags the old system kept on each booking.
//! Flags could not survive a booking being edited, an address being added after
//! approval, or a call that failed once — a set difference survives all three,
//! and a failed call is simply retried at the next tick.
use std::{
collections::{HashSet, VecDeque},
sync::{Mutex, OnceLock},
};
use chrono::Utc;
use tracing::{debug, error, info, warn};
use crate::{
core::{
controller::{AnonAppController, ControllerError},
models::{
bike::{Bike, BikeStatus},
linka::{BikeLive, FleetLive, LiveBooking, Rider, UsageAlert},
},
},
services::{
linka::{self, LinkaError, locks, rentals, whitelist},
telegram::{self, Notification},
},
};
/// How much of the access list a pass goes over.
///
/// The platform cannot be read back, so this app works from its own record of
/// what it has posted. That record is only ever an assumption: somebody may
/// change the list on the platform, and an entry can go missing without this
/// app hearing about it. A [`Sweep::Full`] is what repairs that.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sweep {
/// Only the difference with what this app believes it has already posted.
/// Cheap enough to run every minute.
Diff,
/// Every entitled address is asserted again, whatever the record says, and
/// everybody else is still taken off. Costs one call per live address.
Full,
}
/// Riders are let in half an hour before their reservation starts and taken off
/// the list half an hour after it ends — the time to walk to the bike, and to
/// bring it back.
const GRACE_MINUTES: i32 = 30;
impl AnonAppController {
/// One pass of the synchronisation. Never fails the caller: a platform that
/// is down leaves the app exactly as it was, and the next tick tries again.
pub async fn sync_linka(&self, sweep: Sweep) {
if !linka::configured() {
debug!("[LINKA] not configured, nothing to synchronise");
return;
}
self.sync_fleet().await;
self.sync_access_list(sweep).await;
}
/// Reads the fleet, follows the platform's service state, and raises the
/// alerts.
async fn sync_fleet(&self) {
let bikes = match self.db.get_bikes().await {
Ok(bikes) => bikes,
Err(err) => return error!("[LINKA] cannot read the fleet: {err}"),
};
let locks = match locks::fetch().await {
Ok(locks) => locks,
Err(err) => return warn!("[LINKA] cannot read the locks: {err}"),
};
let rides = match rentals::ongoing().await {
Ok(rides) => rides,
Err(err) => return warn!("[LINKA] cannot read the ongoing rides: {err}"),
};
let mut live = Vec::new();
for lock in &locks {
let Some(bike) = bikes.iter().find(|bike| bike.name == lock.number) else {
debug!("[LINKA] lock {} matches no bike here", lock.number);
continue;
};
// Whoever is on this very bike, if anybody
let rider = rides
.iter()
.find(|ride| ride.bikes.contains(&lock.number))
.map(|ride| Rider {
name: ride.rider.clone(),
email: ride.email.clone(),
since: ride.since,
});
live.push(BikeLive {
bike: bike.id,
lock_state: lock.state,
battery: lock.battery,
out_of_service: lock.out_of_service,
rider,
});
self.follow_service_state(bike, lock.out_of_service).await;
}
let alerts = match self.db.live_bookings().await {
Ok(bookings) => alerts(&rides, &bikes, &bookings),
Err(err) => {
error!("[LINKA] cannot read the live reservations: {err}");
Vec::new()
}
};
announce(&alerts);
linka::store(FleetLive {
updated_at: Some(Utc::now()),
bikes: live,
alerts,
});
}
/// The platform is where a bike is taken out of service for real; this app
/// follows. The other direction is pushed as it happens, in
/// `set_bike_status`.
async fn follow_service_state(&self, bike: &Bike, out_of_service: bool) {
let wanted = if out_of_service {
BikeStatus::OutOfService
} else {
BikeStatus::InService
};
if bike.status == wanted {
return;
}
info!(
"[LINKA] {} is {} on the platform: following",
bike.name,
if out_of_service {
"out of service"
} else {
"back in service"
}
);
if let Err(err) = self.db.set_bike_status(bike.id, wanted).await {
error!(
"[LINKA] cannot follow the service state of {}: {err}",
bike.name
);
}
}
/// Puts everybody a live reservation entitles on the platform's access
/// list, and takes off everybody else.
pub async fn sync_access_list(&self, sweep: Sweep) {
if !linka::configured() {
return;
}
let (wanted, current) = match (
self.db.emails_to_allow(GRACE_MINUTES).await,
self.db.allowed_emails().await,
) {
(Ok(wanted), Ok(current)) => (wanted, current),
(Err(err), _) | (_, Err(err)) => {
return error!("[LINKA] cannot work out the access list: {err}");
}
};
let wanted: HashSet<String> = wanted.into_iter().collect();
let current: HashSet<String> = current.into_iter().collect();
// A full sweep asks for everybody again, including those the record
// already counts as posted: an address the platform lost — taken off
// there, or an answer this app misread — is put back rather than
// missing until the reservation ends.
let to_allow: Vec<&String> = match sweep {
Sweep::Diff => wanted.difference(&current).collect(),
Sweep::Full => wanted.iter().collect(),
};
for email in to_allow {
match whitelist::allow(email).await {
// Recorded only once the platform has taken it: a failure is
// retried at the next tick rather than forgotten
Ok(()) => match self.db.record_allowed(email).await {
Ok(()) => info!("[LINKA] {email} may now unlock the bikes"),
Err(err) => error!("[LINKA] cannot record {email}: {err}"),
},
Err(LinkaError::DryRun) => {}
Err(err) => warn!("[LINKA] cannot allow {email}: {err}"),
}
}
for email in current.difference(&wanted) {
match whitelist::revoke(email).await {
Ok(()) => match self.db.forget_allowed(email).await {
Ok(()) => info!("[LINKA] {email} may no longer unlock the bikes"),
Err(err) => error!("[LINKA] cannot forget {email}: {err}"),
},
Err(LinkaError::DryRun) => {}
Err(err) => warn!("[LINKA] cannot revoke {email}: {err}"),
}
}
}
/// The last picture of the fleet, as read by the admin page
pub fn fleet_live(&self) -> Result<FleetLive, ControllerError> {
Ok(linka::snapshot())
}
}
/// Every bike being ridden by somebody no live reservation entitles to it.
///
/// Two shapes of trouble, told apart by whether the rider has a reservation
/// running at all — the group is told which, because the answer is not the
/// same: a stranger on a bike, or somebody on the wrong one.
fn alerts(rides: &[rentals::Rental], bikes: &[Bike], bookings: &[LiveBooking]) -> Vec<UsageAlert> {
let mut alerts = Vec::new();
for ride in rides {
let theirs: Vec<&LiveBooking> = bookings
.iter()
.filter(|booking| booking.covers(&ride.email))
.collect();
for number in &ride.bikes {
if theirs.iter().any(|booking| booking.allows(number)) {
continue;
}
let Some(bike) = bikes.iter().find(|bike| bike.name == *number) else {
continue;
};
alerts.push(UsageAlert {
bike: bike.id,
bike_name: bike.name.clone(),
rider: Rider {
name: ride.rider.clone(),
email: ride.email.clone(),
since: ride.since,
},
// Named when there is exactly one: "your reservation is for the
// 1000" only makes sense when there is one to point at
reservation: theirs.first().map(|booking| booking.reservation),
allowed: theirs
.iter()
.flat_map(|booking| booking.bikes.clone())
.collect(),
});
}
}
alerts
}
/// Tells the group about the alerts it has not been told about yet.
///
/// An episode is one rider on one bike: the message goes out when it starts,
/// and again only if it stops and starts anew. Without this the group would be
/// told once a minute for as long as the ride lasts.
fn announce(alerts: &[UsageAlert]) {
static ANNOUNCED: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
/// Enough to remember the rides of a busy day; the oldest keys fall out
const REMEMBERED: usize = 64;
let mut announced = ANNOUNCED
.get_or_init(|| Mutex::new(VecDeque::new()))
.lock()
.expect("the announced alerts lock is poisoned");
let live: HashSet<String> = alerts.iter().map(UsageAlert::key).collect();
// An episode that is over is forgotten, so the next one is announced
announced.retain(|key| live.contains(key));
for alert in alerts {
let key = alert.key();
if announced.contains(&key) {
continue;
}
announced.push_back(key);
while announced.len() > REMEMBERED {
announced.pop_front();
}
telegram::notify(match alert.reservation {
Some(_) => Notification::BikeRiddenOutsideReservation(alert.clone()),
None => Notification::BikeRiddenWithoutReservation(alert.clone()),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::models::bike::{BikeSize, BikeStatus};
fn bike(id: i32, name: &str) -> Bike {
Bike {
id,
name: name.to_owned(),
key_number: None,
key_quantity: 0,
drivetrain: None,
battery: None,
size: BikeSize::Large,
status: BikeStatus::InService,
}
}
fn ride(email: &str, bikes: &[&str]) -> rentals::Rental {
rentals::Rental {
rider: "Edgar Wolff".to_owned(),
email: email.to_owned(),
bikes: bikes.iter().map(|name| (*name).to_owned()).collect(),
since: None,
}
}
fn booking(id: i32, emails: &[&str], bikes: &[&str]) -> LiveBooking {
LiveBooking {
reservation: id,
emails: emails.iter().map(|email| (*email).to_owned()).collect(),
bikes: bikes.iter().map(|name| (*name).to_owned()).collect(),
}
}
#[test]
fn a_ride_covered_by_a_reservation_raises_nothing() {
let alerts = alerts(
&[ride("edgar@epfl.ch", &["3000"])],
&[bike(3, "3000")],
&[booking(7, &["Edgar@epfl.ch"], &["3000"])],
);
assert!(alerts.is_empty(), "{alerts:?}");
}
#[test]
fn a_ride_by_a_stranger_names_no_reservation() {
let alerts = alerts(
&[ride("nobody@epfl.ch", &["3000"])],
&[bike(3, "3000")],
&[booking(7, &["edgar@epfl.ch"], &["3000"])],
);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].bike, 3);
assert_eq!(alerts[0].reservation, None);
assert!(alerts[0].allowed.is_empty());
}
#[test]
fn a_ride_on_a_bike_the_reservation_does_not_hold_names_it() {
let alerts = alerts(
&[ride("edgar@epfl.ch", &["3000"])],
&[bike(1, "1000"), bike(3, "3000")],
&[booking(7, &["edgar@epfl.ch"], &["1000"])],
);
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].bike_name, "3000");
assert_eq!(alerts[0].reservation, Some(7));
assert_eq!(alerts[0].allowed, vec!["1000"]);
}
#[test]
fn a_bike_this_app_does_not_know_is_ignored() {
let alerts = alerts(&[ride("a@epfl.ch", &["9000"])], &[bike(1, "1000")], &[]);
assert!(alerts.is_empty());
}
#[test]
fn two_reservations_of_the_same_rider_are_both_honoured() {
let alerts = alerts(
&[ride("edgar@epfl.ch", &["1000", "3000"])],
&[bike(1, "1000"), bike(3, "3000")],
&[
booking(7, &["edgar@epfl.ch"], &["1000"]),
booking(8, &["edgar@epfl.ch"], &["3000"]),
],
);
assert!(alerts.is_empty(), "{alerts:?}");
}
}

View file

@ -23,7 +23,7 @@ use thiserror::Error;
use crate::core::{ use crate::core::{
controller::{ controller::{
authn::AuthnControllerError, bikes::BikesControllerError, authn::AuthnControllerError, bikes::BikesControllerError,
reservations::ReservationsControllerError, reservations::ReservationsControllerError, users::UsersControllerError,
}, },
models::{unit::UnitId, user::User}, models::{unit::UnitId, user::User},
repositories::{DatabaseRepository, RepositoryError}, repositories::{DatabaseRepository, RepositoryError},
@ -31,6 +31,7 @@ use crate::core::{
pub mod authn; pub mod authn;
pub mod bikes; pub mod bikes;
pub mod linka;
pub mod reservations; pub mod reservations;
pub mod users; pub mod users;
@ -152,6 +153,8 @@ pub enum ControllerError {
Bike(#[from] BikesControllerError), Bike(#[from] BikesControllerError),
#[error("Reservation specific error: {0}")] #[error("Reservation specific error: {0}")]
Reservation(#[from] ReservationsControllerError), Reservation(#[from] ReservationsControllerError),
#[error("User specific error: {0}")]
User(#[from] UsersControllerError),
} }
impl ControllerError { impl ControllerError {

View file

@ -1,9 +1,13 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use thiserror::Error; use thiserror::Error;
use tracing::{debug, error};
use crate::{ use crate::{
core::{ core::{
controller::{AnonAppController, AppController, ControllerError, ManagerAppController}, controller::{
AdminAppController, AnonAppController, AppController, ControllerError,
ManagerAppController, linka::Sweep,
},
models::{ models::{
bike::{BikeId, BikeStatus}, bike::{BikeId, BikeStatus},
reservation::{ reservation::{
@ -13,10 +17,21 @@ use crate::{
ReservationStatus, ReservationStatus,
}, },
unit::UnitId, unit::UnitId,
user::Person,
}, },
repositories::RepositoryError, repositories::RepositoryError,
}, },
services::telegram::{self, Notification}, services::{
mail::{
self,
mails::{
ReservationSummary, reservation_approved, reservation_bikes_changed,
reservation_cancelled, reservation_period_changed, reservation_shared,
reservation_updated,
},
},
telegram::{self, Notification, messages::ReservationCard},
},
}; };
/// Reading the reservations needs no session: the calendar is public. /// Reading the reservations needs no session: the calendar is public.
@ -44,6 +59,179 @@ impl AnonAppController {
self.db.get_reservation(id).await.map_err(Into::into) self.db.get_reservation(id).await.map_err(Into::into)
} }
/// Moves every reservation the clock has caught up with: approved becomes
/// ongoing once the period has started, ongoing becomes archived once it is
/// over. Called on a timer, and again right after an edit so a period moved
/// by hand takes effect at once rather than at the next tick.
pub async fn advance_reservation_statuses(&self) {
match self.db.advance_reservation_statuses().await {
Ok(0) => {}
Ok(moved) => debug!("[RESERVATIONS] {moved} reservation(s) moved on by the clock"),
Err(err) => error!("[RESERVATIONS] could not advance the statuses: {err}"),
}
}
/// The decision taken from the Telegram group.
///
/// There is no session behind it: being in the group is the authorisation,
/// which the poller checks before calling this. The rules are the ones a
/// manager goes through — the state machine, and the refusal to approve a
/// reservation whose bikes are already held — minus the unit check, since a
/// group message names no unit.
pub async fn decide_from_group(
&self,
id: ReservationId,
status: ReservationStatus,
) -> Result<Reservation, ControllerError> {
let reservation = self.db.get_reservation(id).await?;
if reservation.status == status {
return Ok(reservation);
}
if !reservation.status.can_transition_to(status) {
return Err(
ReservationsControllerError::InvalidTransition(reservation.status, status).into(),
);
}
self.refuse_if_taken(&reservation, status).await?;
self.db.set_reservation_status(id, status).await?;
let updated = self.db.get_reservation(id).await?;
self.announce_approval(&updated).await;
// A booking that starts within the half hour is one somebody may be
// standing next to: the access list is settled now, not at the next tick
self.sync_access_list(Sweep::Diff).await;
Ok(updated)
}
/// The names of the bikes a reservation holds, for a mail or a message
async fn bike_names(&self, reservation: &Reservation) -> Vec<String> {
let mut names = Vec::with_capacity(reservation.bikes.len());
for held in &reservation.bikes {
names.push(match self.get_bike(held.id).await {
Ok(bike) => bike.name,
Err(_) => format!("#{}", held.id),
});
}
names
}
/// Whether a reservation is one people are counting on: only those are
/// worth writing about. A request has not been granted yet, and a final one
/// is over — in both cases a mail would be noise.
fn is_live(reservation: &Reservation) -> bool {
matches!(
reservation.status,
ReservationStatus::Approved | ReservationStatus::Ongoing
)
}
/// The mail that goes out the moment a reservation is approved, whether the
/// button pressed was the admin page's or the group's.
///
/// It goes to the Linka Go accounts rather than to whoever filled the form
/// in: those are the addresses that can actually unlock the bikes, and so
/// the ones that need the pickup instructions.
async fn announce_approval(&self, reservation: &Reservation) {
if reservation.status != ReservationStatus::Approved {
return;
}
let summary = ReservationSummary::new(reservation, self.bike_names(reservation).await);
mail::send(reservation_approved::mail(
reservation.linka_emails.clone(),
&summary,
));
}
/// The mails an edit produces, if any.
///
/// Which one depends on what actually moved: the dates, the fleet, or both.
/// Addresses that have just been added get their own mail instead — they
/// were not there when it was approved, so "what changed" would mean
/// nothing to them, and they still need the pickup instructions.
async fn announce_edit(&self, before: &Reservation, after: &Reservation) {
if !Self::is_live(after) {
return;
}
let period_changed =
before.start_time != after.start_time || before.end_time != after.end_time;
let fleet_changed = !same_held(&before.bikes, &after.bikes);
let added = added_emails(&before.linka_emails, &after.linka_emails);
if !period_changed && !fleet_changed && added.is_empty() {
return;
}
let summary = ReservationSummary::new(after, self.bike_names(after).await);
if !added.is_empty() {
mail::send(reservation_shared::mail(added.clone(), &summary));
}
// Everybody who was already on it hears about the change itself
let existing: Vec<String> = after
.linka_emails
.iter()
.filter(|email| !added.iter().any(|new| new.eq_ignore_ascii_case(email)))
.cloned()
.collect();
if existing.is_empty() {
return;
}
match (period_changed, fleet_changed) {
(true, true) => mail::send(reservation_updated::mail(existing, &summary)),
(true, false) => mail::send(reservation_period_changed::mail(existing, &summary)),
(false, true) => mail::send(reservation_bikes_changed::mail(existing, &summary)),
(false, false) => {}
}
}
/// The mail a cancellation produces, with the reason when one was given.
/// Only a reservation people were counting on is worth one.
async fn announce_cancellation(&self, reservation: &Reservation, reason: Option<&str>) {
let summary = ReservationSummary::new(reservation, self.bike_names(reservation).await);
mail::send(reservation_cancelled::mail(
reservation.linka_emails.clone(),
&summary,
reason,
));
}
/// 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 that transition is guarded — a reservation already
/// approved is allowed to start, whatever was overridden earlier.
async fn refuse_if_taken(
&self,
reservation: &Reservation,
status: ReservationStatus,
) -> Result<(), ControllerError> {
if status != ReservationStatus::Approved {
return Ok(());
}
let conflicts = self
.db
.find_conflicts(ConflictProbe {
reservation: Some(reservation.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() {
Ok(())
} else {
Err(ReservationsControllerError::BikesTaken(taken(&conflicts)).into())
}
}
/// What the availability calendar shows: the reservations that actually /// What the availability calendar shows: the reservations that actually
/// hold a bike over the window asked for, stripped of everything personal. /// hold a bike over the window asked for, stripped of everything personal.
/// Reading it needs no session. /// Reading it needs no session.
@ -159,7 +347,9 @@ impl AppController {
Err(_) => format!("#{}", held.id), Err(_) => format!("#{}", held.id),
}); });
} }
telegram::notify(Notification::reservation_requested(&created, names)); telegram::notify(Notification::ReservationRequested(ReservationCard::new(
&created, names,
)));
Ok(created) Ok(created)
} }
@ -175,6 +365,60 @@ impl AppController {
} }
} }
/// Filing on somebody else's behalf, which only an admin does.
impl AdminAppController {
/// Creates a reservation for `requester`, who is named by address.
///
/// The address is the identity: an unknown one creates the person, and the
/// first time they log in they land on that same row — with this
/// reservation already waiting for them — rather than on a second one.
/// The bike and unit checks are the ordinary ones; the conflict rule is
/// not applied, since an admin filing by hand is the one who arbitrates.
pub async fn create_reservation_for(
&self,
mut reservation: NewReservation,
requester: Person,
) -> Result<Reservation, ControllerError> {
let requester = self
.db
.get_or_create_user(&requester.email, &requester.firstname, &requester.name)
.await?;
if !reservation.users.contains(&requester.id) {
reservation.users.push(requester.id);
}
if !reservation.is_valid() {
return Err(ReservationsControllerError::ReservationInvalid.into());
}
if let NewReservationUnit::Known { id } = reservation.unit
&& !self.db.get_units().await?.iter().any(|unit| unit.id == id)
{
return Err(RepositoryError::NotFound(format!("unit {id}")).into());
}
for id in &reservation.bikes {
if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
return Err(ReservationsControllerError::BikeOutOfService(*id).into());
}
}
let created = self
.db
.create_reservation(reservation, requester.id)
.await?;
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::ReservationRequested(ReservationCard::new(
&created, names,
)));
Ok(created)
}
}
impl AppController { impl AppController {
/// Who may edit, and how much: /// Who may edit, and how much:
/// ///
@ -255,10 +499,15 @@ impl AppController {
} }
} }
self.db self.db.update_reservation(reservation).await?;
.update_reservation(reservation) // A period moved into the present should show as ongoing straight away
.await self.advance_reservation_statuses().await;
.map_err(Into::into)
let after = self.db.get_reservation(current.id).await?;
self.announce_edit(&current, &after).await;
// An address added to a live booking can unlock a bike straight away
self.sync_access_list(Sweep::Diff).await;
Ok(())
} }
/// The same three ways `get_involved_reservations` looks for, applied to one /// The same three ways `get_involved_reservations` looks for, applied to one
@ -274,6 +523,39 @@ impl AppController {
} }
} }
/// Whether the two lists are the same fleet: the same bikes, each with the same
/// period *of its own*.
///
/// The periods that merely follow the reservation are left out on purpose —
/// they move whenever the reservation moves, and counting that as a change to
/// the fleet would make every date edit look like a bike edit too.
fn same_held(left: &[ReservationBike], right: &[ReservationBike]) -> bool {
let key = |bikes: &[ReservationBike]| {
let mut keys: Vec<_> = bikes
.iter()
.map(|held| {
(
held.id,
held.custom.then_some((held.start_time, held.end_time)),
)
})
.collect();
keys.sort_unstable();
keys
};
key(left) == key(right)
}
/// The addresses `after` has and `before` did not. Case carries no meaning in
/// an address, and neither does order.
fn added_emails(before: &[String], after: &[String]) -> Vec<String> {
after
.iter()
.filter(|email| !before.iter().any(|known| known.eq_ignore_ascii_case(email)))
.cloned()
.collect()
}
/// The bikes a set of conflicts is about, once each /// The bikes a set of conflicts is about, once each
fn taken(conflicts: &[Conflict]) -> Vec<BikeId> { fn taken(conflicts: &[Conflict]) -> Vec<BikeId> {
let mut bikes: Vec<BikeId> = conflicts.iter().map(|conflict| conflict.bike).collect(); let mut bikes: Vec<BikeId> = conflicts.iter().map(|conflict| conflict.bike).collect();
@ -312,10 +594,13 @@ fn same_fleet(
/// Touching an existing reservation is reserved to its unit (or an admin) /// Touching an existing reservation is reserved to its unit (or an admin)
impl ManagerAppController { impl ManagerAppController {
/// `reason` is only ever used to explain a cancellation to the people who
/// were counting on the reservation; nothing stores it.
pub async fn set_reservation_status( pub async fn set_reservation_status(
&self, &self,
id: ReservationId, id: ReservationId,
status: ReservationStatus, status: ReservationStatus,
reason: Option<String>,
) -> Result<(), ControllerError> { ) -> Result<(), ControllerError> {
let reservation = self.db.get_reservation(id).await?; let reservation = self.db.get_reservation(id).await?;
if reservation.unit.scope() != self.unit { if reservation.unit.scope() != self.unit {
@ -329,38 +614,27 @@ impl ManagerAppController {
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 self.refuse_if_taken(&reservation, status).await?;
// 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 // A cancellation is announced with the reservation as it stood: the mail
.set_reservation_status(id, status) // describes what people were counting on, not what is left of it.
.await let was_live = AnonAppController::is_live(&reservation);
.map_err(Into::into) self.db.set_reservation_status(id, status).await?;
match status {
ReservationStatus::Approved => {
self.announce_approval(&self.db.get_reservation(id).await?)
.await;
}
ReservationStatus::Cancelled if was_live => {
self.announce_cancellation(&reservation, reason.as_deref())
.await;
}
_ => {}
}
// Approving lets its riders in; anything else may take them back out
self.sync_access_list(Sweep::Diff).await;
Ok(())
} }
pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> { pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> {

View file

@ -1,9 +1,11 @@
//! Users are not created by the app: they are mirrored from the authentication //! Users are not created by the app: they are mirrored from the authentication
//! provider on login. The only decision that belongs to us is `admin`. //! provider on login. The only decision that belongs to us is `admin`.
use thiserror::Error;
use crate::core::{ use crate::core::{
controller::{AdminAppController, AnonAppController, ControllerError}, controller::{AdminAppController, AnonAppController, ControllerError},
models::user::{User, UserId}, models::user::{Administrator, User, UserId, is_valid_email},
}; };
impl AnonAppController { impl AnonAppController {
@ -27,4 +29,57 @@ impl AdminAppController {
pub async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), ControllerError> { pub async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), ControllerError> {
self.db.set_user_admin(id, admin).await.map_err(Into::into) self.db.set_user_admin(id, admin).await.map_err(Into::into)
} }
pub async fn get_admins(&self) -> Result<Vec<Administrator>, ControllerError> {
self.db.get_admins().await.map_err(Into::into)
}
/// Makes whoever holds `email` an administrator.
///
/// The address is the identity, so nobody has to have logged in first: a
/// placeholder row is created and adopted the day that person does, which
/// is the same rule as filing a reservation for somebody.
///
/// Granting to somebody who already is one changes nothing and is not an
/// error: the page asks for a state, not for a transition.
pub async fn grant_admin(&self, email: &str) -> Result<Administrator, ControllerError> {
let email = email.trim();
if !is_valid_email(email) {
return Err(UsersControllerError::EmailInvalid.into());
}
// Nothing is known of a person named by their address alone; the local
// part is a stand-in until their first login brings the real names
let placeholder = email.split('@').next().unwrap_or(email);
let user = self.db.get_or_create_user(email, placeholder, "").await?;
if !user.admin {
self.db.set_user_admin(user.id, true).await?;
}
Ok(Administrator {
id: user.id,
firstname: user.firstname,
name: user.name,
email: user.email,
pending: user.oidc_sub.starts_with("pending:"),
})
}
/// Takes the rights away from `id`.
///
/// Never from oneself: an admin who demotes themselves cannot undo it, and
/// the last one doing so would leave the app with nobody able to grant them
/// back.
pub async fn revoke_admin(&self, id: UserId) -> Result<(), ControllerError> {
if self.user().id == id {
return Err(UsersControllerError::CannotDemoteSelf.into());
}
self.db.set_user_admin(id, false).await.map_err(Into::into)
}
}
#[derive(Error, Debug)]
pub enum UsersControllerError {
#[error("That is not an email address")]
EmailInvalid,
#[error("An administrator cannot take their own rights away")]
CannotDemoteSelf,
} }

110
src/core/models/linka.rs Normal file
View file

@ -0,0 +1,110 @@
//! What the Linka Go platform says about the fleet, in the app's own terms.
//!
//! The platform knows locks and rides; this app knows bikes and reservations.
//! Everything below is already translated: a lock has been matched to a bike, a
//! rental to the address that started it. Nothing here carries a serial number
//! or a lock id — those stay in `services/linka`.
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::core::models::{bike::BikeId, reservation::ReservationId};
/// Whether the bike is physically locked.
///
/// `Unknown` is not a failure to answer: it is the platform reporting something
/// this app does not recognise, which is worth showing as such rather than
/// guessing "locked".
#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LockState {
Locked,
Unlocked,
Unknown,
}
impl LockState {
pub fn parse(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"locked" => LockState::Locked,
"unlocked" => LockState::Unlocked,
_ => LockState::Unknown,
}
}
}
/// One bike, as the platform sees it right now
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
pub struct BikeLive {
pub bike: BikeId,
pub lock_state: LockState,
/// Battery of the lock itself, in percent
pub battery: Option<i32>,
/// Out of service on the platform. The app's own status follows it.
pub out_of_service: bool,
/// Who is riding it, if anybody. This is what "in use" means: a ride under
/// way on this very bike, not a reservation that happens to cover it.
pub rider: Option<Rider>,
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Rider {
pub name: String,
pub email: String,
pub since: Option<DateTime<Utc>>,
}
/// A bike being ridden by somebody no reservation entitles to it.
///
/// Two cases, told apart by `reservation`: nobody's booking covers this rider
/// at all, or their booking is for other bikes.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct UsageAlert {
pub bike: BikeId,
pub bike_name: String,
pub rider: Rider,
/// The reservation the rider does have running, when there is one
pub reservation: Option<ReservationId>,
/// The bikes that reservation is for
pub allowed: Vec<String>,
}
impl UsageAlert {
/// Identifies the episode, so the group is told about it once rather than
/// once a minute for as long as the ride lasts
pub fn key(&self) -> String {
format!("{}@{}", self.rider.email.to_lowercase(), self.bike_name)
}
}
/// The whole picture, refreshed by the ticker and read by the admin page
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Default)]
pub struct FleetLive {
/// When the platform was last reached. `None` means never — either it is
/// not configured, or every attempt so far has failed.
pub updated_at: Option<DateTime<Utc>>,
pub bikes: Vec<BikeLive>,
pub alerts: Vec<UsageAlert>,
}
/// Who may ride what, right now: one live reservation, its Linka Go accounts,
/// and the bikes it holds at this instant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveBooking {
pub reservation: ReservationId,
pub emails: Vec<String>,
pub bikes: Vec<String>,
}
impl LiveBooking {
pub fn covers(&self, email: &str) -> bool {
self.emails
.iter()
.any(|known| known.eq_ignore_ascii_case(email))
}
pub fn allows(&self, bike_name: &str) -> bool {
self.bikes.iter().any(|name| name == bike_name)
}
}

View file

@ -1,4 +1,5 @@
pub mod bike; pub mod bike;
pub mod linka;
pub mod localized_string; pub mod localized_string;
pub mod reservation; pub mod reservation;
pub mod unit; pub mod unit;

View file

@ -30,8 +30,6 @@ 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, Requested)
| (Approved, Cancelled) | (Approved, Cancelled)
| (Approved, Ongoing) | (Approved, Ongoing)

View file

@ -26,6 +26,54 @@ pub struct NewUser {
pub oidc_sub: String, pub oidc_sub: String,
} }
/// Somebody named by an admin filing a reservation for them.
///
/// The address is the identity: it is what binds the reservation to a profile,
/// today or the day that person first logs in. The names only matter when
/// nobody is known at that address yet.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Person {
pub email: String,
pub firstname: String,
pub name: String,
}
impl Person {
pub fn is_valid(&self) -> bool {
is_valid_email(&self.email)
&& !self.firstname.trim().is_empty()
&& !self.name.trim().is_empty()
}
}
/// The shape an address must have to be worth storing.
///
/// Deliberately loose — something, an `@`, something — because the only real
/// check is that mail reaches it, and refusing an unusual but valid address
/// would be worse than accepting a wrong one.
pub fn is_valid_email(email: &str) -> bool {
let email = email.trim();
email.len() >= 3
&& email.split('@').count() == 2
&& !email.starts_with('@')
&& !email.ends_with('@')
&& !email.contains(char::is_whitespace)
}
/// One administrator, as the page that manages them lists them.
///
/// `pending` is somebody named by their address who has never logged in: the
/// row is a placeholder waiting to be adopted at their first login, and the
/// names on it are not to be trusted.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
pub struct Administrator {
pub id: UserId,
pub firstname: String,
pub name: String,
pub email: String,
pub pending: bool,
}
/// A user as they appear inside another object (a reservation, ...): enough to /// A user as they appear inside another object (a reservation, ...): enough to
/// show who they are, without dragging their units along. /// show who they are, without dragging their units along.
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
@ -35,3 +83,20 @@ pub struct UserSummary {
pub name: String, pub name: String,
pub email: String, pub email: String,
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_address_needs_a_local_part_an_at_and_a_domain() {
assert!(is_valid_email("antoine.pelletier@epfl.ch"));
assert!(is_valid_email(" spaced@epfl.ch "));
assert!(!is_valid_email("@epfl.ch"));
assert!(!is_valid_email("nobody@"));
assert!(!is_valid_email("no-at-sign"));
assert!(!is_valid_email("two@at@signs"));
assert!(!is_valid_email("a space@epfl.ch"));
assert!(!is_valid_email(""));
}
}

View file

@ -0,0 +1,27 @@
//! What the Linka Go synchronisation needs from the database: who should be
//! allowed to unlock a bike right now, and what has already been asked of the
//! platform.
use async_trait::async_trait;
use crate::core::{models::linka::LiveBooking, repositories::RepositoryError};
#[async_trait]
pub trait LinkaRepository {
/// The Linka Go accounts of every reservation whose period — widened by
/// `grace_minutes` on both sides — contains this instant.
///
/// Refused and cancelled reservations are left out, so cancelling one takes
/// its riders off the list at the next tick.
async fn emails_to_allow(&self, grace_minutes: i32) -> Result<Vec<String>, RepositoryError>;
/// The addresses this app has put on the platform's list and not taken off
async fn allowed_emails(&self) -> Result<Vec<String>, RepositoryError>;
async fn record_allowed(&self, email: &str) -> Result<(), RepositoryError>;
async fn forget_allowed(&self, email: &str) -> Result<(), RepositoryError>;
/// Every reservation under way right now, with the bikes it holds at this
/// instant — a bike handed back early is already out of it.
async fn live_bookings(&self) -> Result<Vec<LiveBooking>, RepositoryError>;
}

View file

@ -8,12 +8,13 @@ use async_trait::async_trait;
use thiserror::Error; use thiserror::Error;
use crate::core::repositories::{ use crate::core::repositories::{
bikes_repository::BikesRepository, oidc_states_repository::OidcStatesRepository, bikes_repository::BikesRepository, linka_repository::LinkaRepository,
reservations_repository::ReservationsRepository, units_repository::UnitsRepository, oidc_states_repository::OidcStatesRepository, reservations_repository::ReservationsRepository,
users_repository::UsersRepository, units_repository::UnitsRepository, users_repository::UsersRepository,
}; };
pub mod bikes_repository; pub mod bikes_repository;
pub mod linka_repository;
pub mod oidc_states_repository; pub mod oidc_states_repository;
pub mod reservations_repository; pub mod reservations_repository;
pub mod units_repository; pub mod units_repository;
@ -27,6 +28,7 @@ pub trait DatabaseRepository:
+ ReservationsRepository + ReservationsRepository
+ UnitsRepository + UnitsRepository
+ OidcStatesRepository + OidcStatesRepository
+ LinkaRepository
+ Send + Send
+ Sync + Sync
{ {

View file

@ -46,6 +46,15 @@ pub trait ReservationsRepository {
async fn update_reservation(&self, reservation: ReservationEdit) async fn update_reservation(&self, reservation: ReservationEdit)
-> Result<(), RepositoryError>; -> Result<(), RepositoryError>;
/// Moves the reservations the clock has caught up with: an approved one
/// whose period has started becomes ongoing, an ongoing one whose period is
/// over becomes archived. Returns how many moved.
///
/// Done in one statement rather than read-then-write: the rule is a
/// comparison between two columns and `now()`, which is the database's own
/// business, and it has to hold for the whole table at once.
async fn advance_reservation_statuses(&self) -> Result<u64, RepositoryError>;
async fn set_reservation_status( async fn set_reservation_status(
&self, &self,
id: ReservationId, id: ReservationId,

View file

@ -3,7 +3,7 @@ use async_trait::async_trait;
use crate::core::{ use crate::core::{
models::{ models::{
unit::UnitId, unit::UnitId,
user::{NewUser, User, UserId}, user::{Administrator, NewUser, User, UserId},
}, },
repositories::RepositoryError, repositories::RepositoryError,
}; };
@ -16,12 +16,33 @@ pub trait UsersRepository {
async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, RepositoryError>; async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, RepositoryError>;
/// Creates the user, or refreshes the row from the provider claims. /// Creates the user, or refreshes the row from the provider claims.
/// `oidc_sub` is the identity. `admin` and the units are ours and are left ///
/// untouched, so a login never demotes anybody nor loses their units. /// A row is found by its `oidc_sub` first — so a change of address at the
/// provider follows the person rather than splitting them in two — and by
/// its address otherwise, which is what adopts somebody an admin named
/// before they had ever logged in. `admin` and the units are ours and are
/// left untouched, so a login never demotes anybody nor loses their units.
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>; async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>;
/// The person at this address, created if nobody is known there yet.
///
/// Used when an admin files a reservation for somebody: the address is the
/// identity, so the row created here is the one that person lands on the
/// first time they log in — `upsert_user` adopts it rather than making a
/// second one. The names are only used when creating: a row that already
/// exists knows better than a form.
async fn get_or_create_user(
&self,
email: &str,
firstname: &str,
name: &str,
) -> Result<User, RepositoryError>;
/// Replaces the whole set of units the user belongs to /// Replaces the whole set of units the user belongs to
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError>; async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError>;
/// Everybody who can administer the app, by name
async fn get_admins(&self) -> Result<Vec<Administrator>, RepositoryError>;
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>; async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
} }

View file

@ -9,7 +9,10 @@ use tower_http::services::{ServeDir, ServeFile};
use tracing::info; use tracing::info;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
use crate::{core::controller::AnonAppController, services::database::SqlxDatabase}; use crate::{
core::controller::{AnonAppController, linka::Sweep},
services::database::SqlxDatabase,
};
mod api; mod api;
mod core; mod core;
@ -34,6 +37,18 @@ async fn main() {
let aac = AnonAppController::new(Arc::new(Box::new(db))); let aac = AnonAppController::new(Arc::new(Box::new(db)));
// The bot answers its own buttons: accepting or refusing from the group
// goes through the same rules as the admin page.
services::telegram::spawn_poller(aac.clone());
// A reservation starts and ends on its own: nobody presses a button for
// that, so the clock does it.
spawn_status_ticker(aac.clone());
// The bikes are unlocked through Linka Go: the fleet is read from it, and
// the access list is kept in step with the reservations.
spawn_linka_ticker(aac.clone());
// Anything that is not an api route is served from the built frontend, // Anything that is not an api route is served from the built frontend,
// falling back on index.html so the vue router can handle the path. // falling back on index.html so the vue router can handle the path.
// (`fallback` and not `not_found_service`, which would force a 404 status) // (`fallback` and not `not_found_service`, which would force a 404 status)
@ -51,6 +66,47 @@ async fn main() {
.unwrap() .unwrap()
} }
/// Moves the reservations the clock has caught up with, once a minute. A minute
/// is plenty: the statuses it maintains are read by people, not by anything
/// that needs them to the second.
fn spawn_status_ticker(controller: AnonAppController) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
controller.advance_reservation_statuses().await;
}
});
}
/// Keeps the app and the Linka Go platform in step, once a minute.
///
/// The same pass reads the fleet and reconciles the access list, so a rider
/// whose booking starts in half an hour is let in within the minute — and a
/// decision taken in the meantime does not wait for the tick, since approving
/// reconciles straight away.
fn spawn_linka_ticker(controller: AnonAppController) {
/// The platform cannot be read back, so the access list is asserted in full
/// every so often — and always on the first pass. That is what repairs a
/// list changed on the platform itself, or while this app was down.
const FULL_SWEEP_EVERY: u32 = 30;
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
let mut passes: u32 = 0;
loop {
tick.tick().await;
let sweep = if passes.is_multiple_of(FULL_SWEEP_EVERY) {
Sweep::Full
} else {
Sweep::Diff
};
controller.sync_linka(sweep).await;
passes = passes.wrapping_add(1);
}
});
}
async fn shutdown_signal() { async fn shutdown_signal() {
let ctrl_c = async { let ctrl_c = async {
tokio::signal::ctrl_c() tokio::signal::ctrl_c()

View file

@ -0,0 +1,92 @@
//! Postgres side of the Linka Go synchronisation.
use async_trait::async_trait;
use sqlx::{query, query_scalar};
use crate::{
core::{
models::linka::LiveBooking,
repositories::{RepositoryError, linka_repository::LinkaRepository},
},
services::database::SqlxDatabase,
};
#[async_trait]
impl LinkaRepository for SqlxDatabase {
async fn emails_to_allow(&self, grace_minutes: i32) -> Result<Vec<String>, RepositoryError> {
// `archived` is included on purpose: a reservation is archived the
// moment it ends, and its riders keep their access for the grace period
// that follows — the time to bring the bike back and lock it.
let emails = query_scalar!(
r#"SELECT DISTINCT lower(email) AS "email!"
FROM reservations r, UNNEST(r.linka_emails) AS email
WHERE r.status IN ('approved', 'ongoing', 'archived')
AND now() >= r.start_time - make_interval(mins => $1)
AND now() <= r.end_time + make_interval(mins => $1)"#,
grace_minutes
)
.fetch_all(&self.pool)
.await?;
Ok(emails)
}
async fn allowed_emails(&self) -> Result<Vec<String>, RepositoryError> {
Ok(query_scalar!(r#"SELECT email FROM linka_whitelist"#)
.fetch_all(&self.pool)
.await?)
}
async fn record_allowed(&self, email: &str) -> Result<(), RepositoryError> {
query!(
r#"INSERT INTO linka_whitelist (email) VALUES ($1)
ON CONFLICT (email) DO NOTHING"#,
email
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn forget_allowed(&self, email: &str) -> Result<(), RepositoryError> {
query!(r#"DELETE FROM linka_whitelist WHERE email = $1"#, email)
.execute(&self.pool)
.await?;
Ok(())
}
async fn live_bookings(&self) -> Result<Vec<LiveBooking>, RepositoryError> {
// Left join, and the instant is checked in the join itself: a
// reservation whose bikes have all been handed back early still shows
// up, with no bike — which is what tells "riding somebody else's bike"
// apart from "riding with no booking at all".
let rows = query!(
r#"SELECT r.id AS "reservation!",
r.linka_emails AS "emails!",
COALESCE(
ARRAY_AGG(b.name) FILTER (WHERE b.name IS NOT NULL),
'{}'
) AS "bikes!"
FROM reservations r
LEFT JOIN reservations_bikes rb
ON rb.reservation_id = r.id
AND now() >= COALESCE(rb.start_time, r.start_time)
AND now() <= COALESCE(rb.end_time, r.end_time)
LEFT JOIN bikes b ON b.id = rb.bike_id
WHERE r.status IN ('approved', 'ongoing')
AND now() >= r.start_time
AND now() <= r.end_time
GROUP BY r.id"#
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| LiveBooking {
reservation: row.reservation,
emails: row.emails,
bikes: row.bikes,
})
.collect())
}
}

View file

@ -5,6 +5,7 @@
//! data must be present (`cargo sqlx prepare`). //! data must be present (`cargo sqlx prepare`).
mod bikes; mod bikes;
mod linka;
mod oidc_states; mod oidc_states;
mod reservations; mod reservations;
mod units; mod units;

View file

@ -630,6 +630,25 @@ impl ReservationsRepository for SqlxDatabase {
Ok(()) Ok(())
} }
async fn advance_reservation_statuses(&self) -> Result<u64, RepositoryError> {
let moved = query!(
// A reservation whose period is already over goes straight to
// archived, without a pointless second through `ongoing`. This is
// the clock moving it, not somebody pressing a button, which is why
// it does not go through `can_transition_to`.
r#"UPDATE reservations SET status = CASE
WHEN end_time <= now() THEN 'archived'::reservation_status
ELSE 'ongoing'::reservation_status
END
WHERE (status = 'approved' AND start_time <= now())
OR (status = 'ongoing' AND end_time <= now())"#
)
.execute(&self.pool)
.await?
.rows_affected();
Ok(moved)
}
async fn set_reservation_status( async fn set_reservation_status(
&self, &self,
id: ReservationId, id: ReservationId,

View file

@ -5,7 +5,7 @@ use crate::{
core::{ core::{
models::{ models::{
unit::{Unit, UnitId}, unit::{Unit, UnitId},
user::{NewUser, User, UserId}, user::{Administrator, NewUser, User, UserId},
}, },
repositories::{RepositoryError, users_repository::UsersRepository}, repositories::{RepositoryError, users_repository::UsersRepository},
}, },
@ -110,16 +110,18 @@ impl UsersRepository for SqlxDatabase {
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError> { async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
let user_db = query_as!( // Known by their subject: refresh everything, address included. Two
// conflict targets cannot be given to one statement, and this one has
// to come first — otherwise a change of address at the provider would
// land on somebody else's row, or create a second one.
let existing = query_as!(
UserDB, UserDB,
r#"INSERT INTO users (external_id, firstname, "name", email, oidc_sub) r#"UPDATE users SET
VALUES ($1, $2, $3, $4, $5) external_id = $1,
ON CONFLICT (oidc_sub) firstname = $2,
DO UPDATE SET "name" = $3,
external_id = EXCLUDED.external_id, email = $4
firstname = EXCLUDED.firstname, WHERE oidc_sub = $5
"name" = EXCLUDED.name,
email = EXCLUDED.email
RETURNING *"#, RETURNING *"#,
user.external_id, user.external_id,
user.firstname, user.firstname,
@ -127,6 +129,66 @@ impl UsersRepository for SqlxDatabase {
user.email, user.email,
user.oidc_sub user.oidc_sub
) )
.fetch_optional(&mut *tx)
.await?;
// Otherwise the address is the identity: this is where somebody an
// admin named before they had ever logged in is adopted, rather than
// being duplicated. Their reservations are already attached to the row.
let user_db = match existing {
Some(user_db) => user_db,
None => {
query_as!(
UserDB,
r#"INSERT INTO users (external_id, firstname, "name", email, oidc_sub)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (email)
DO UPDATE SET
external_id = EXCLUDED.external_id,
firstname = EXCLUDED.firstname,
"name" = EXCLUDED.name,
oidc_sub = EXCLUDED.oidc_sub
RETURNING *"#,
user.external_id,
user.firstname,
user.name,
user.email,
user.oidc_sub
)
.fetch_one(&mut *tx)
.await?
}
};
let user = Self::user_with_units(user_db, &mut *tx).await?;
tx.commit().await?;
Ok(user)
}
async fn get_or_create_user(
&self,
email: &str,
firstname: &str,
name: &str,
) -> Result<User, RepositoryError> {
let mut tx = self.pool.begin().await?;
// The placeholder subject is what marks a row nobody has logged into
// yet. It never collides with a real one — those come from the provider
// — and `upsert_user` replaces it on the first login.
let user_db = query_as!(
UserDB,
r#"INSERT INTO users (firstname, "name", email, oidc_sub)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email)
-- A no-op update, so the row comes back either way
DO UPDATE SET email = users.email
RETURNING *"#,
firstname.trim(),
name.trim(),
email.trim(),
format!("pending:{}", email.trim().to_lowercase())
)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await?; .await?;
@ -153,6 +215,22 @@ impl UsersRepository for SqlxDatabase {
Ok(()) Ok(())
} }
async fn get_admins(&self) -> Result<Vec<Administrator>, RepositoryError> {
// The placeholder subject is what marks somebody named by an admin who
// has never logged in — see `get_or_create_user`
let admins = query_as!(
Administrator,
r#"SELECT id, firstname, "name", email,
oidc_sub LIKE 'pending:%' AS "pending!"
FROM users
WHERE admin = true
ORDER BY lower(email)"#
)
.fetch_all(&self.pool)
.await?;
Ok(admins)
}
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError> { async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError> {
let result = query!(r#"UPDATE users SET admin = $2 WHERE id = $1"#, id, admin) let result = query!(r#"UPDATE users SET admin = $2 WHERE id = $1"#, id, admin)
.execute(&self.pool) .execute(&self.pool)

115
src/services/linka/locks.rs Normal file
View file

@ -0,0 +1,115 @@
//! The locks: what they report, and putting one in or out of service.
use openidconnect::reqwest::Method;
use serde_json::{Value, json};
use tracing::info;
use crate::{
core::models::linka::LockState,
services::linka::{LinkaError, Result, dry_run, fleet, linka},
};
/// One lock, reduced to what this app shows or acts on
#[derive(Debug, Clone, PartialEq)]
pub struct Lock {
/// The number written on the bike — the name it goes by here too
pub number: String,
pub state: LockState,
pub battery: Option<i32>,
pub out_of_service: bool,
}
/// Every lock of the account.
///
/// The bounding box is the whole planet: the platform filters by map area, and
/// this app wants the fleet, wherever it happens to be parked.
pub async fn fetch() -> Result<Vec<Lock>> {
let answer = fleet(
"merchantlocks",
Method::PUT,
json!({ "longitudeX": -180, "latitudeX": -90, "longitudeY": 180, "latitudeY": 90 }),
)
.await?;
let locks = answer
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| LinkaError::failed("merchantlocks answered without a list"))?;
Ok(locks.iter().filter_map(read).collect())
}
/// A lock with no number is of no use here: it could not be matched to a bike.
fn read(lock: &Value) -> Option<Lock> {
let number = match lock.get("lock_number") {
Some(Value::String(number)) => number.trim().to_owned(),
Some(Value::Number(number)) => number.to_string(),
_ => return None,
};
Some(Lock {
number,
state: lock
.get("lock_state")
.and_then(Value::as_str)
.map_or(LockState::Unknown, LockState::parse),
battery: lock
.get("lock_battery_percent")
.and_then(Value::as_i64)
.map(|percent| percent as i32),
out_of_service: lock
.get("out_of_service")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
/// Takes the lock of `serial` in or out of service on the platform.
pub async fn set_service_state(serial: &str, out_of_service: bool) -> Result<()> {
let linka = linka().ok_or(LinkaError::NotConfigured)?;
if dry_run() {
info!("[LINKA] (dry run) would set {serial} out_of_service={out_of_service}");
return Err(LinkaError::DryRun);
}
super::authenticated(
&linka.service_state_url.clone(),
Method::POST,
json!({ "lock_serial_no": serial, "out_of_service": out_of_service }),
)
.await
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_lock_is_read_whether_its_number_is_written_as_text_or_as_a_number() {
let text = read(&json!({
"lock_number": "3000", "lock_state": "Unlocked",
"lock_battery_percent": 95, "out_of_service": false
}))
.unwrap();
assert_eq!(text.number, "3000");
assert_eq!(text.state, LockState::Unlocked);
assert_eq!(text.battery, Some(95));
let number = read(&json!({ "lock_number": 1000, "lock_state": "Locked" })).unwrap();
assert_eq!(number.number, "1000");
assert_eq!(number.state, LockState::Locked);
assert_eq!(number.battery, None);
assert!(!number.out_of_service);
}
#[test]
fn a_lock_without_a_number_is_dropped_rather_than_guessed_at() {
assert!(read(&json!({ "lock_state": "Locked" })).is_none());
}
#[test]
fn an_unexpected_lock_state_is_reported_as_unknown() {
let lock = read(&json!({ "lock_number": 1, "lock_state": "Ajar" })).unwrap();
assert_eq!(lock.state, LockState::Unknown);
}
}

279
src/services/linka/mod.rs Normal file
View file

@ -0,0 +1,279 @@
//! Linka Go: the platform the locks, the rides and the access list live on.
//!
//! One file per family of calls — [`locks`], [`rentals`], [`whitelist`] — over
//! the transport kept here: the access token and its refresh, the headers the
//! fleetview api insists on, and the answer shape (`{"status": "success", …}`)
//! every endpoint shares. A caller says *what it wants of the platform*, never
//! how the platform is spoken to.
//!
//! Without the `LINKA_*` variables every call answers [`LinkaError::NotConfigured`]
//! and nothing is attempted, which is what a development machine and the test
//! suite want.
//!
//! What the platform reports about the fleet is kept in one place — [`snapshot`]
//! — refreshed by the ticker and read by the admin page, so a page load never
//! waits on a third party.
pub mod locks;
pub mod rentals;
pub mod whitelist;
use std::sync::{OnceLock, RwLock};
use chrono::{DateTime, Utc};
use openidconnect::reqwest::{self, Method};
use serde_json::{Value, json};
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::debug;
use crate::core::models::linka::FleetLive;
/// The platform itself. Only ever overridden — with `LINKA_BASE_URL` — to point
/// the calls at a stub, the way the Telegram sender can be.
const BASE_URL: &str = "https://app.linkalock.com";
fn base_url() -> &'static str {
static BASE: OnceLock<String> = OnceLock::new();
BASE.get_or_init(|| {
std::env::var("LINKA_BASE_URL")
.ok()
.map(|url| url.trim().trim_end_matches('/').to_owned())
.filter(|url| !url.is_empty())
.unwrap_or_else(|| BASE_URL.to_owned())
})
}
/// The fleetview api answers 403 without these
const ORIGIN: &str = "https://fleetview.linkalock.com";
const REFERER: &str = "https://fleetview.linkalock.com/";
#[derive(Debug, Error)]
pub enum LinkaError {
#[error("Linka Go is not configured")]
NotConfigured,
#[error("dry run: nothing was sent to Linka Go")]
DryRun,
#[error("{0}")]
Failed(String),
}
impl LinkaError {
fn failed(message: impl Into<String>) -> Self {
LinkaError::Failed(message.into())
}
}
pub type Result<T> = std::result::Result<T, LinkaError>;
/// The account this app acts as.
///
/// Read from plain `LINKA_*` variables rather than the `APP__*` configuration:
/// these are the names the platform documents, and `.env` already carries them.
struct Linka {
/// The access list endpoint (`addRemoveRestriction`)
restriction_url: String,
service_state_url: String,
user_id: String,
auth_token: String,
api_key: String,
secret_key: String,
}
fn linka() -> Option<&'static Linka> {
static LINKA: OnceLock<Option<Linka>> = OnceLock::new();
LINKA
.get_or_init(|| {
let read = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
Some(Linka {
restriction_url: read("LINKA_API_URL")?,
service_state_url: read("LINKA_API_SERVICE_STATE_URL")?,
user_id: read("LINKA_USER_ID")?,
auth_token: read("LINKA_AUTH_TOKEN")?,
api_key: read("LINKA_API_KEY")?,
secret_key: read("LINKA_SECRET_KEY")?,
})
})
.as_ref()
}
/// Whether writing to the platform is held back.
///
/// `LINKA_DRY_RUN=1` lets a development machine read the real fleet — locks,
/// rides, alerts — without ever touching the access list or the service state
/// of the real bikes. Nothing is recorded as done either, so switching it off
/// puts everything in step at the next tick.
pub fn dry_run() -> bool {
static DRY_RUN: OnceLock<bool> = OnceLock::new();
*DRY_RUN.get_or_init(|| {
std::env::var("LINKA_DRY_RUN")
.map(|value| matches!(value.trim(), "1" | "true" | "yes"))
.unwrap_or(false)
})
}
pub fn configured() -> bool {
linka().is_some()
}
/// The serial of the lock bolted to the bike named `bike_name`.
///
/// The mapping is configuration — `LINKA_LOCK_SERIAL_1000=D8:4F:…` — keyed by
/// the name the bike carries here and on the platform (its lock number). Adding
/// a bike is therefore one line in `.env`, and a bike with no line is simply
/// left alone rather than guessed at.
pub fn serial_of(bike_name: &str) -> Option<String> {
std::env::var(format!("LINKA_LOCK_SERIAL_{bike_name}"))
.ok()
.map(|serial| serial.trim().to_uppercase())
.filter(|serial| !serial.is_empty())
}
// ---------------------------------------------------------------------------
// The access token
// ---------------------------------------------------------------------------
/// Tokens last about three months; asked for once and kept until shortly before
/// they lapse. The mutex is held across the refresh so a burst of calls on a
/// cold cache asks for one token, not one each.
async fn access_token(linka: &Linka) -> Result<String> {
/// A token and the moment it lapses
type Cached = Mutex<Option<(String, DateTime<Utc>)>>;
static TOKEN: OnceLock<Cached> = OnceLock::new();
let mut cached = TOKEN.get_or_init(|| Mutex::new(None)).lock().await;
if let Some((token, expires_at)) = cached.as_ref()
&& Utc::now() + chrono::Duration::minutes(5) < *expires_at
{
return Ok(token.clone());
}
let answer = post(
&format!("{}/api/merchant_api/fetch_access_token", base_url()),
Method::POST,
json!({ "api_key": linka.api_key, "secret_key": linka.secret_key }),
None,
)
.await?;
let data = answer.get("data").unwrap_or(&answer);
let token = data
.get("access_token")
.and_then(Value::as_str)
.ok_or_else(|| LinkaError::failed("fetch_access_token answered without a token"))?
.to_owned();
// A token with no readable expiry is trusted for a day, so a change of
// format costs one extra call a day rather than every call failing
let expires_at = data
.get("access_token_expireAt")
.and_then(Value::as_str)
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
.map(|at| at.with_timezone(&Utc))
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(1));
*cached = Some((token.clone(), expires_at));
debug!("[LINKA] new access token, good until {expires_at}");
Ok(token)
}
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
/// Calls `url` as the configured account, with the access token in the body.
///
/// Every endpoint of the platform answers `{"status": "success", "data": …}` or
/// `{"status": "error", "message": …}` — with a 200 either way — so the status
/// field, not the http code, is what says whether it worked.
async fn authenticated(url: &str, method: Method, body: Value) -> Result<Value> {
let linka = linka().ok_or(LinkaError::NotConfigured)?;
let token = access_token(linka).await?;
let mut payload = json!({ "access_token": token });
if let (Some(payload), Some(body)) = (payload.as_object_mut(), body.as_object()) {
payload.extend(body.clone());
}
post(url, method, payload, Some(linka)).await
}
/// The same, on a path of the fleetview api
async fn fleet(path: &str, method: Method, body: Value) -> Result<Value> {
authenticated(
&format!("{}/api/fleetview/{path}", base_url()),
method,
body,
)
.await
}
async fn post(url: &str, method: Method, body: Value, linka: Option<&Linka>) -> Result<Value> {
// Serialised by hand: the http client is the one `openidconnect` brings,
// and it is built without its `json` feature.
let payload =
serde_json::to_string(&body).map_err(|err| LinkaError::failed(err.to_string()))?;
let mut request = http_client()
.request(method, url)
.header("content-type", "application/json; charset=UTF-8")
.header("origin", ORIGIN)
.header("referer", REFERER)
.body(payload);
if let Some(linka) = linka {
request = request
.header("x-user-id", &linka.user_id)
.header("x-auth-token", &linka.auth_token);
}
let response = request
.send()
.await
.map_err(|err| LinkaError::failed(err.to_string()))?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
let answer: Value =
serde_json::from_str(&text).map_err(|_| LinkaError::failed(format!("{status}: {text}")))?;
if answer.get("status").and_then(Value::as_str) != Some("success") {
let message = answer
.get("message")
.and_then(Value::as_str)
.unwrap_or(&text)
.to_owned();
return Err(LinkaError::failed(message));
}
Ok(answer)
}
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(20))
.build()
.expect("Unable to build the Linka http client")
})
}
// ---------------------------------------------------------------------------
// What the platform last said
// ---------------------------------------------------------------------------
fn cache() -> &'static RwLock<FleetLive> {
static SNAPSHOT: OnceLock<RwLock<FleetLive>> = OnceLock::new();
SNAPSHOT.get_or_init(|| RwLock::new(FleetLive::default()))
}
/// The last picture of the fleet. Empty until the first successful tick.
pub fn snapshot() -> FleetLive {
cache()
.read()
.expect("the snapshot lock is poisoned")
.clone()
}
pub fn store(snapshot: FleetLive) {
*cache().write().expect("the snapshot lock is poisoned") = snapshot;
}

View file

@ -0,0 +1,121 @@
//! The rides under way right now.
//!
//! This is what "in use" means in this app: somebody has a bike out, whatever
//! the reservations say. A bike nobody is riding is not in use, even during the
//! reservation that holds it.
use chrono::{DateTime, Utc};
use openidconnect::reqwest::Method;
use serde_json::{Value, json};
use crate::services::linka::{LinkaError, Result, fleet};
/// One ride under way
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rental {
pub rider: String,
pub email: String,
/// The numbers of the bikes taken out — the names they go by here
pub bikes: Vec<String>,
pub since: Option<DateTime<Utc>>,
}
pub async fn ongoing() -> Result<Vec<Rental>> {
let answer = fleet("ongoing_rental", Method::POST, json!({})).await?;
let rentals = answer
.get("data")
.and_then(Value::as_array)
.ok_or_else(|| LinkaError::failed("ongoing_rental answered without a list"))?;
Ok(rentals.iter().filter_map(read).collect())
}
/// A ride with no address behind it cannot be matched to a reservation, and is
/// left out rather than reported as somebody riding without one.
fn read(rental: &Value) -> Option<Rental> {
let email = rental
.get("user_email")
.and_then(Value::as_str)
.map(|email| email.trim().to_lowercase())
.filter(|email| !email.is_empty())?;
let locks = rental.get("locks").and_then(Value::as_array);
let bikes = locks
.map(|locks| {
locks
.iter()
.filter_map(|lock| match lock.get("merchantlock_lock_number") {
Some(Value::String(number)) => Some(number.trim().to_owned()),
Some(Value::Number(number)) => Some(number.to_string()),
_ => None,
})
.collect()
})
.unwrap_or_default();
// The platform dates each lock of a ride; the earliest is when the ride
// started
let since = locks.and_then(|locks| {
locks
.iter()
.filter_map(|lock| lock.get("start_date").and_then(Value::as_str))
.filter_map(|at| DateTime::parse_from_rfc3339(at).ok())
.map(|at| at.with_timezone(&Utc))
.min()
});
Some(Rental {
rider: rental
.get("name")
.and_then(Value::as_str)
.map(str::to_owned)
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| email.clone()),
email,
bikes,
since,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn rental() -> Value {
json!({
"name": "Edgar Wolff",
"user_email": "Edgar.Wolff@epfl.ch",
"locks": [
{ "merchantlock_lock_number": "3000", "start_date": "2026-08-22T12:44:12.082Z" },
{ "merchantlock_lock_number": 1000, "start_date": "2026-08-22T10:00:00.000Z" }
]
})
}
#[test]
fn a_ride_carries_its_rider_its_bikes_and_when_it_started() {
let rental = read(&rental()).unwrap();
assert_eq!(rental.rider, "Edgar Wolff");
// Lower-cased: it is matched against the addresses of a reservation
assert_eq!(rental.email, "edgar.wolff@epfl.ch");
assert_eq!(rental.bikes, vec!["3000", "1000"]);
assert_eq!(
rental.since.map(|at| at.to_rfc3339()),
Some("2026-08-22T10:00:00+00:00".to_owned())
);
}
#[test]
fn a_ride_without_an_address_is_dropped() {
assert!(read(&json!({ "name": "Nobody", "locks": [] })).is_none());
}
#[test]
fn a_rider_with_no_name_is_known_by_their_address() {
let rental = read(&json!({ "user_email": "a@epfl.ch", "locks": [] })).unwrap();
assert_eq!(rental.rider, "a@epfl.ch");
assert!(rental.bikes.is_empty());
assert_eq!(rental.since, None);
}
}

View file

@ -0,0 +1,86 @@
//! The access list: who may unlock a bike.
//!
//! The platform calls it the restriction list — the merchant restricts riding
//! to the addresses on it — so putting somebody on it is what grants access.
//! There is no endpoint to read it back: what this app has asked for is
//! remembered in `linka_whitelist`, and reconciled from there.
use openidconnect::reqwest::Method;
use serde_json::json;
use tracing::{debug, info};
use crate::services::linka::{LinkaError, Result, authenticated, dry_run, linka};
/// Lets `email` unlock the bikes.
///
/// An address already on the list is the outcome asked for, so it counts as
/// done — the platform reports it as an error, but there is nothing left to do
/// and nothing to retry.
pub async fn allow(email: &str) -> Result<()> {
match call(email, Method::PUT).await {
Err(LinkaError::Failed(message)) if already_allowed(&message) => {
debug!("[LINKA] {email} was already allowed");
Ok(())
}
outcome => outcome,
}
}
/// Takes `email` back off the list.
///
/// Removing an address that is not on it answers success, so no such case has
/// to be recognised here.
pub async fn revoke(email: &str) -> Result<()> {
call(email, Method::POST).await
}
/// Whether the platform is saying "it is already so" rather than "it failed".
///
/// Matched on the wording because there is no code to match on: the answer is
/// `{"status": "error", "message": "email is already in user list"}`. Anything
/// else stays an error, and is tried again at the next tick.
fn already_allowed(message: &str) -> bool {
message.to_lowercase().contains("already in user list")
}
/// The same endpoint either way: the verb is what says which
async fn call(email: &str, method: Method) -> Result<()> {
let linka = linka().ok_or(LinkaError::NotConfigured)?;
if dry_run() {
info!(
"[LINKA] (dry run) would {} {email}",
if method == Method::PUT {
"allow"
} else {
"revoke"
}
);
return Err(LinkaError::DryRun);
}
authenticated(
&linka.restriction_url.clone(),
method,
json!({ "email": email }),
)
.await
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_platforms_already_there_answer_is_recognised() {
assert!(already_allowed("email is already in user list"));
// The platform's wording is not to be counted on to the letter
assert!(already_allowed("Email is Already in user list."));
}
#[test]
fn a_real_failure_is_still_a_failure() {
assert!(!already_allowed("invalid access token"));
assert!(!already_allowed("email is not in user list"));
}
}

View file

@ -0,0 +1,165 @@
//! One file per mail the app sends.
//!
//! Adding one means adding a module here and calling it — the wording never
//! leaks into the rest of the app, and changing a mail is a change to one file
//! with no logic in it. What every mail shares — the block of facts, the
//! signature, the frame — lives here so the six of them cannot drift apart.
pub mod reservation_approved;
pub mod reservation_bikes_changed;
pub mod reservation_cancelled;
pub mod reservation_period_changed;
pub mod reservation_shared;
pub mod reservation_updated;
use chrono::{DateTime, Utc};
use crate::core::models::reservation::Reservation;
/// Who to ask about anything the mail does not answer
const BOT_URL: &str = "https://t.me/Logistique_Agepoly_Bot";
const BOT_HANDLE: &str = "@Logistique_Agepoly_Bot";
/// The facts every mail about a reservation shows.
///
/// Built from the reservation as stored plus the names of the bikes it holds:
/// a mail reads nothing itself, so writing one is only a matter of words.
#[derive(Debug, Clone)]
pub struct ReservationSummary {
pub id: i32,
pub unit: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub bikes: Vec<String>,
}
impl ReservationSummary {
pub fn new(reservation: &Reservation, bikes: Vec<String>) -> Self {
ReservationSummary {
id: reservation.id,
unit: reservation.unit.label().to_owned(),
start_time: reservation.start_time,
end_time: reservation.end_time,
bikes,
}
}
pub fn period(&self) -> String {
format!(
"du {} au {}",
format_moment(self.start_time),
format_moment(self.end_time)
)
}
pub fn bikes(&self) -> String {
if self.bikes.is_empty() {
"—".to_owned()
} else {
self.bikes.join(", ")
}
}
/// The three lines every mail repeats, so the reader never has to look the
/// reservation up to know which one is being talked about
pub fn html_details(&self) -> String {
format!(
"<ul>\
<li><strong>Association :</strong> {unit}</li>\
<li><strong>Période :</strong> {period}</li>\
<li><strong>Cargobikes réservés :</strong> {bikes}</li>\
</ul>",
unit = escape(&self.unit),
period = escape(&self.period()),
bikes = escape(&self.bikes()),
)
}
pub fn text_details(&self) -> String {
format!(
"- Association : {}\n- Période : {}\n- Cargobikes réservés : {}",
self.unit,
self.period(),
self.bikes()
)
}
}
/// The closing every mail ends on
pub fn signature_html() -> String {
format!(
"<p>Pour toute question, n'hésitez pas à contacter le bot logistique \
<a href=\"{BOT_URL}\">{BOT_HANDLE}</a>.</p>\
<p>Cordialement,<br>L'équipe AGEPoly.</p>"
)
}
pub fn signature_text() -> String {
format!(
"Pour toute question, n'hésitez pas à contacter le bot logistique {BOT_HANDLE} \
({BOT_URL}).\n\nCordialement,\nL'équipe AGEPoly."
)
}
/// The four characters an HTML body reads as markup
pub fn escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// Swiss local time, in the form the mails use
pub 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()
}
/// The plain frame every mail is poured into: a readable column, the system
/// font, and nothing a mail client has to fetch.
pub fn wrap(body: &str) -> String {
format!(
"<div style=\"font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; \
font-size: 15px; line-height: 1.5; color: #1a1a1a; max-width: 40rem;\">{body}</div>"
)
}
#[cfg(test)]
pub mod tests {
use chrono::TimeZone;
use super::*;
/// The fixture every mail test renders
pub fn summary() -> ReservationSummary {
ReservationSummary {
id: 65,
unit: "AGEPoly 2".to_owned(),
start_time: Utc.with_ymd_and_hms(2026, 8, 18, 22, 0, 0).unwrap(),
end_time: Utc.with_ymd_and_hms(2026, 8, 24, 22, 0, 0).unwrap(),
bikes: vec!["3000".to_owned()],
}
}
#[test]
fn the_period_is_written_in_local_time() {
// 22:00 UTC is midnight the next day in Lausanne
assert_eq!(
summary().period(),
"du 19/08/2026 00:00 au 25/08/2026 00:00"
);
}
#[test]
fn markup_in_a_unit_name_is_escaped() {
let mut summary = summary();
summary.unit = "<b>Fake</b> & Co".to_owned();
let details = summary.html_details();
assert!(details.contains("&lt;b&gt;Fake&lt;/b&gt; &amp; Co"));
assert!(details.contains("<strong>Association :</strong>"));
}
}

View file

@ -0,0 +1,80 @@
//! Sent to the Linka Go accounts of a reservation, the moment it is approved.
//!
//! Those addresses are the ones that can actually unlock the bikes, so they are
//! the ones that need the pickup instructions — not necessarily whoever filled
//! the form in.
use crate::services::mail::{
Mail,
mails::{ReservationSummary, signature_html, signature_text, wrap},
};
/// Where the bikes are picked up, on the EPFL map
const PARKING_URL: &str = "https://plan.epfl.ch/?room==GR%20B0%2094.1";
pub fn mail(to: Vec<String>, reservation: &ReservationSummary) -> Mail {
let html = wrap(&format!(
"<p>Bonjour,</p>\
<p>Votre réservation de cargobike (ID #{id}) a été acceptée.</p>\
<p>Voici les détails de votre réservation :</p>\
{details}\
<p>Vous pourrez désormais récupérer les cargobikes pour la période indiquée au \
parking à vélo devant l'<a href=\"{PARKING_URL}\">entrée du GR B</a>, vers la sortie \
restaurateur du CM. Merci de faire un état du cargobike avant et après son \
utilisation, ainsi que nous communiquer tout problème rencontré.</p>\
{signature}",
id = reservation.id,
details = reservation.html_details(),
signature = signature_html(),
));
let text = format!(
"Bonjour,\n\n\
Votre réservation de cargobike (ID #{id}) a été acceptée.\n\n\
Voici les détails de votre réservation :\n\n\
{details}\n\n\
Vous pourrez désormais récupérer les cargobikes pour la période indiquée au parking \
à vélo devant l'entrée du GR B ({PARKING_URL}), vers la sortie restaurateur du CM. \
Merci de faire un état du cargobike avant et après son utilisation, ainsi que nous \
communiquer tout problème rencontré.\n\n\
{signature}",
id = reservation.id,
details = reservation.text_details(),
signature = signature_text(),
);
Mail {
to,
subject: format!(
"Votre réservation de cargobike #{} a été acceptée",
reservation.id
),
html,
text,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::mail::mails::tests::summary;
#[test]
fn the_mail_carries_the_reservation_and_its_two_links() {
let mail = mail(vec!["a@epfl.ch".to_owned()], &summary());
assert_eq!(mail.to, vec!["a@epfl.ch"]);
assert!(mail.subject.contains("#65"));
assert!(
mail.html
.contains("du 19/08/2026 00:00 au 25/08/2026 00:00")
);
assert!(mail.html.contains("AGEPoly 2"));
assert!(mail.html.contains("3000"));
assert!(mail.html.contains(&format!("href=\"{PARKING_URL}\"")));
assert!(mail.html.contains("t.me/Logistique_Agepoly_Bot"));
// The plain part says the same thing, links spelled out
assert!(mail.text.contains("ID #65"));
assert!(mail.text.contains(PARKING_URL));
}
}

View file

@ -0,0 +1,46 @@
//! Sent when the cargobikes of a live reservation change, and only those.
use crate::services::mail::{
Mail,
mails::{ReservationSummary, signature_html, signature_text, wrap},
};
pub fn mail(to: Vec<String>, reservation: &ReservationSummary) -> Mail {
let html = wrap(&format!(
"<p>Bonjour,</p>\
<p>Votre réservation a été mise à jour : les cargobikes qui vous sont attribués \
(ID #{id}) ont changé.</p>\
<p>Voici les détails de votre réservation :</p>\
{details}\
<p>Merci de récupérer les cargobikes indiqués ci-dessus, et non ceux prévus \
initialement.</p>\
{signature}",
id = reservation.id,
details = reservation.html_details(),
signature = signature_html(),
));
let text = format!(
"Bonjour,\n\n\
Votre réservation a été mise à jour : les cargobikes qui vous sont attribués \
(ID #{id}) ont changé.\n\n\
Voici les détails de votre réservation :\n\n\
{details}\n\n\
Merci de récupérer les cargobikes indiqués ci-dessus, et non ceux prévus \
initialement.\n\n\
{signature}",
id = reservation.id,
details = reservation.text_details(),
signature = signature_text(),
);
Mail {
to,
subject: format!(
"Votre réservation de cargobike #{} a été mise à jour",
reservation.id
),
html,
text,
}
}

View file

@ -0,0 +1,81 @@
//! Sent when a live reservation is cancelled, with the reason when one was
//! given.
use crate::services::mail::{
Mail,
mails::{ReservationSummary, escape, signature_html, signature_text, wrap},
};
pub fn mail(to: Vec<String>, reservation: &ReservationSummary, reason: Option<&str>) -> Mail {
let reason = reason.map(str::trim).filter(|reason| !reason.is_empty());
let html_reason = reason.map_or_else(String::new, |reason| {
format!("<p><strong>Raison :</strong> {}</p>", escape(reason))
});
let html = wrap(&format!(
"<p>Bonjour,</p>\
<p>Votre réservation de cargobike (ID #{id}) a été annulée. Les cargobikes \
concernés ne vous sont plus attribués.</p>\
{html_reason}\
<p>Pour mémoire, voici la réservation annulée :</p>\
{details}\
{signature}",
id = reservation.id,
details = reservation.html_details(),
signature = signature_html(),
));
let text_reason = reason.map_or_else(String::new, |reason| format!("Raison : {reason}\n\n"));
let text = format!(
"Bonjour,\n\n\
Votre réservation de cargobike (ID #{id}) a été annulée. Les cargobikes concernés ne \
vous sont plus attribués.\n\n\
{text_reason}\
Pour mémoire, voici la réservation annulée :\n\n\
{details}\n\n\
{signature}",
id = reservation.id,
details = reservation.text_details(),
signature = signature_text(),
);
Mail {
to,
subject: format!(
"Votre réservation de cargobike #{} a été annulée",
reservation.id
),
html,
text,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::mail::mails::tests::summary;
#[test]
fn the_reason_is_only_there_when_one_was_given() {
let without = mail(vec!["a@epfl.ch".to_owned()], &summary(), None);
assert!(!without.html.contains("Raison"));
assert!(!without.text.contains("Raison"));
let blank = mail(vec!["a@epfl.ch".to_owned()], &summary(), Some(" "));
assert!(
!blank.html.contains("Raison"),
"a blank reason is no reason"
);
let with = mail(
vec!["a@epfl.ch".to_owned()],
&summary(),
Some("Cargobike en réparation"),
);
assert!(
with.html
.contains("<strong>Raison :</strong> Cargobike en réparation")
);
assert!(with.text.contains("Raison : Cargobike en réparation"));
}
}

View file

@ -0,0 +1,44 @@
//! Sent when the period of a live reservation moves, and only that.
use crate::services::mail::{
Mail,
mails::{ReservationSummary, signature_html, signature_text, wrap},
};
pub fn mail(to: Vec<String>, reservation: &ReservationSummary) -> Mail {
let html = wrap(&format!(
"<p>Bonjour,</p>\
<p>Les dates de votre réservation de cargobike (ID #{id}) ont été modifiées.</p>\
<p>Voici les nouveaux détails de votre réservation :</p>\
{details}\
<p>Merci de tenir compte de cette nouvelle période pour récupérer et rendre les \
cargobikes.</p>\
{signature}",
id = reservation.id,
details = reservation.html_details(),
signature = signature_html(),
));
let text = format!(
"Bonjour,\n\n\
Les dates de votre réservation de cargobike (ID #{id}) ont été modifiées.\n\n\
Voici les nouveaux détails de votre réservation :\n\n\
{details}\n\n\
Merci de tenir compte de cette nouvelle période pour récupérer et rendre les \
cargobikes.\n\n\
{signature}",
id = reservation.id,
details = reservation.text_details(),
signature = signature_text(),
);
Mail {
to,
subject: format!(
"Les dates de votre réservation de cargobike #{} ont changé",
reservation.id
),
html,
text,
}
}

View file

@ -0,0 +1,68 @@
//! Sent to an address that has just been added to a live reservation.
//!
//! They were not there when it was approved, so they never got the pickup
//! instructions: this mail carries them, and is the only one they receive about
//! a change they were not part of.
use crate::services::mail::{
Mail,
mails::{ReservationSummary, signature_html, signature_text, wrap},
};
/// Where the bikes are picked up, on the EPFL map
const PARKING_URL: &str = "https://plan.epfl.ch/?room==GR%20B0%2094.1";
pub fn mail(to: Vec<String>, reservation: &ReservationSummary) -> Mail {
let html = wrap(&format!(
"<p>Bonjour,</p>\
<p>Vous avez été ajouté à la réservation de cargobike #{id}. Votre compte Linka Go \
peut désormais déverrouiller les cargobikes concernés.</p>\
<p>Voici les détails de la réservation :</p>\
{details}\
<p>Les cargobikes se récupèrent au parking à vélo devant l'\
<a href=\"{PARKING_URL}\">entrée du GR B</a>, vers la sortie restaurateur du CM. \
Merci de faire un état du cargobike avant et après son utilisation, ainsi que nous \
communiquer tout problème rencontré.</p>\
{signature}",
id = reservation.id,
details = reservation.html_details(),
signature = signature_html(),
));
let text = format!(
"Bonjour,\n\n\
Vous avez été ajouté à la réservation de cargobike #{id}. Votre compte Linka Go peut \
désormais déverrouiller les cargobikes concernés.\n\n\
Voici les détails de la réservation :\n\n\
{details}\n\n\
Les cargobikes se récupèrent au parking à vélo devant l'entrée du GR B \
({PARKING_URL}), vers la sortie restaurateur du CM. Merci de faire un état du \
cargobike avant et après son utilisation, ainsi que nous communiquer tout problème \
rencontré.\n\n\
{signature}",
id = reservation.id,
details = reservation.text_details(),
signature = signature_text(),
);
Mail {
to,
subject: format!("Vous avez été ajouté à la réservation #{}", reservation.id),
html,
text,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::mail::mails::tests::summary;
#[test]
fn the_subject_names_the_reservation() {
let mail = mail(vec!["nouvelle@epfl.ch".to_owned()], &summary());
assert_eq!(mail.subject, "Vous avez été ajouté à la réservation #65");
// Newcomers get the pickup instructions, which they never received
assert!(mail.html.contains(PARKING_URL));
}
}

View file

@ -0,0 +1,51 @@
//! Sent when both the period and the cargobikes of a live reservation change.
//!
//! Naming each change would read worse than pointing at the whole thing: the
//! details block below is the reservation as it now stands, which is what the
//! reader actually needs.
use crate::services::mail::{
Mail,
mails::{ReservationSummary, signature_html, signature_text, wrap},
};
pub fn mail(to: Vec<String>, reservation: &ReservationSummary) -> Mail {
let html = wrap(&format!(
"<p>Bonjour,</p>\
<p>Votre réservation de cargobike (ID #{id}) a été mise à jour : les dates et les \
cargobikes attribués ont changé.</p>\
<p>Voici les détails à jour de votre réservation :</p>\
{details}\
<p>Merci de tenir compte de ces informations plutôt que de celles prévues \
initialement.</p>\
{signature}",
id = reservation.id,
details = reservation.html_details(),
signature = signature_html(),
));
let text = format!(
"Bonjour,\n\n\
Votre réservation de cargobike (ID #{id}) a été mise à jour : les dates et les \
cargobikes attribués ont changé.\n\n\
Voici les détails à jour de votre réservation :\n\n\
{details}\n\n\
Merci de tenir compte de ces informations plutôt que de celles prévues \
initialement.\n\n\
{signature}",
id = reservation.id,
details = reservation.text_details(),
signature = signature_text(),
);
Mail {
to,
// Distinct from the bikes-only mail, which keeps "mise à jour"
subject: format!(
"Votre réservation de cargobike #{} a été modifiée",
reservation.id
),
html,
text,
}
}

156
src/services/mail/mod.rs Normal file
View file

@ -0,0 +1,156 @@
//! Outgoing mail, through Postal.
//!
//! The wording lives in [`mails`], one file per mail; this module only carries
//! them. Adding a mail is a module there and a call to [`send`] — the rest of
//! the app says *what happened*, never what to write.
//!
//! Sending never blocks the request that triggered it and never fails it: a
//! reservation is approved whether or not the mail went out. Without the four
//! `POSTAL_*` variables, [`send`] is a no-op, which is what a development
//! machine and the test suite want.
pub mod mails;
use std::sync::OnceLock;
use openidconnect::reqwest;
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::{debug, error, warn};
/// One mail, already written
#[derive(Debug, Clone)]
pub struct Mail {
/// Every address the mail goes to. For a reservation these are its Linka Go
/// accounts: the people who can actually unlock the bikes.
pub to: Vec<String>,
pub subject: String,
/// What most clients show
pub html: String,
/// The same thing for the ones that do not, and for spam filters
pub text: String,
}
/// The Postal server to hand mail to.
///
/// Read from plain `POSTAL_*` variables rather than the `APP__*` configuration:
/// these are the names the mail server itself documents, and `.env` already
/// carries them.
struct Postal {
url: String,
api_key: String,
from: String,
from_name: String,
}
impl Postal {
/// `AGEPoly cargobike <cargobikes@postal.agepoly.ch>`, as a mail header
/// wants it
fn sender(&self) -> String {
if self.from_name.is_empty() {
self.from.clone()
} else {
format!("{} <{}>", self.from_name, self.from)
}
}
}
fn postal() -> Option<&'static Postal> {
static POSTAL: OnceLock<Option<Postal>> = OnceLock::new();
POSTAL
.get_or_init(|| {
let read = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
let (url, api_key, from) = (
read("POSTAL_URL")?,
read("POSTAL_API_KEY")?,
read("POSTAL_FROM")?,
);
Some(Postal {
// The variable is usually written as a bare host
url: if url.starts_with("http") {
url.trim_end_matches('/').to_owned()
} else {
format!("https://{}", url.trim_end_matches('/'))
},
api_key,
from,
from_name: read("POSTAL_FROM_NAME").unwrap_or_default(),
})
})
.as_ref()
}
/// Sends `mail`, 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 the mail server is
/// down would be worse than a missing mail.
pub fn send(mail: Mail) {
let Some(postal) = postal() else {
debug!("[MAIL] not configured, dropping \"{}\"", mail.subject);
return;
};
if mail.to.iter().all(|to| to.trim().is_empty()) {
warn!("[MAIL] no recipient for \"{}\"", mail.subject);
return;
}
tokio::spawn(async move {
if let Err(err) = post(postal, &mail).await {
error!("[MAIL] could not send \"{}\": {err}", mail.subject);
} else {
debug!("[MAIL] sent \"{}\" to {:?}", mail.subject, mail.to);
}
});
}
#[derive(Deserialize)]
struct PostalResponse {
status: String,
data: Option<Value>,
}
async fn post(postal: &Postal, mail: &Mail) -> Result<(), String> {
let body = json!({
"to": mail.to,
"from": postal.sender(),
"subject": mail.subject,
"html_body": mail.html,
"plain_body": mail.text,
});
// Serialised by hand: the http client is the one `openidconnect` brings,
// and it is built without its `json` feature.
let payload = serde_json::to_string(&body).map_err(|err| err.to_string())?;
let response = http_client()
.post(format!("{}/api/v1/send/message", postal.url))
.header("content-type", "application/json")
.header("X-Server-API-Key", &postal.api_key)
.body(payload)
.send()
.await
.map_err(|err| err.to_string())?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
let parsed: PostalResponse =
serde_json::from_str(&text).map_err(|_| format!("{status}: {text}"))?;
// Postal answers 200 with `status: error` and the reason in `data`
if parsed.status != "success" {
return Err(parsed
.data
.map(|data| data.to_string())
.unwrap_or_else(|| text.clone()));
}
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(15))
.build()
.expect("Unable to build the mail http client")
})
}

View file

@ -1,3 +1,5 @@
//! 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 linka;
pub mod mail;
pub mod telegram; pub mod telegram;

View file

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

View file

@ -0,0 +1,39 @@
//! A cargobike is out, taken by somebody who does have a reservation running —
//! but for other bikes.
use crate::{
core::models::linka::UsageAlert,
services::telegram::messages::{alert_details, escape},
};
pub fn message(alert: &UsageAlert) -> String {
let allowed = if alert.allowed.is_empty() {
"—".to_owned()
} else {
alert.allowed.join(", ")
};
format!(
"⚠️ <b>Cargobike utilisé hors réservation</b>\n{details}\n\
<b>Réservation en cours :</b> #{id}\n\
<b>Cargobike(s) réservé(s) :</b> {allowed}",
details = alert_details(alert),
id = alert.reservation.unwrap_or_default(),
allowed = escape(&allowed),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::telegram::messages::tests::alert;
#[test]
fn the_message_names_the_reservation_and_what_it_is_for() {
let rendered = message(&alert(Some(7)));
assert!(rendered.starts_with("⚠️ <b>Cargobike utilisé hors réservation</b>"));
assert!(rendered.contains("<b>Réservation en cours :</b> #7"));
assert!(rendered.contains("<b>Cargobike(s) réservé(s) :</b> 1000, 2000"));
assert!(rendered.contains("3000"));
}
}

View file

@ -0,0 +1,29 @@
//! A cargobike is out, and nobody's reservation covers the person riding it.
use crate::{core::models::linka::UsageAlert, services::telegram::messages::alert_details};
pub fn message(alert: &UsageAlert) -> String {
format!(
"⚠️ <b>Cargobike utilisé sans réservation</b>\n{}",
alert_details(alert)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::telegram::messages::tests::alert;
#[test]
fn the_message_names_the_bike_and_the_rider() {
let rendered = message(&alert(None));
assert!(rendered.starts_with("⚠️ <b>Cargobike utilisé sans réservation</b>"));
assert!(rendered.contains("3000"));
assert!(rendered.contains("Edgar Wolff"));
assert!(rendered.contains("edgar.wolff@epfl.ch"));
assert!(rendered.contains("22.08.2026 14:44"));
// Nothing to say about a reservation there is none of
assert!(!rendered.contains("Réservation"));
}
}

View file

@ -0,0 +1,227 @@
//! One file per message the group receives.
//!
//! Adding one means adding a module here and a variant on
//! [`super::Notification`] — the wording never leaks into the rest of the app,
//! and changing a message is a change to one file with no logic in it.
pub mod bike_ridden_outside_reservation;
pub mod bike_ridden_without_reservation;
pub mod reservation_decided;
pub mod reservation_requested;
use chrono::{DateTime, Utc};
use crate::core::models::{
linka::UsageAlert,
reservation::{Reservation, ReservationId, ReservationStatus},
};
/// The facts every message about a reservation shows.
///
/// Built once, from the reservation as stored plus the names of the bikes it
/// holds: a renderer reads nothing itself, so it can neither fail nor be slow.
#[derive(Debug, Clone)]
pub struct ReservationCard {
pub id: ReservationId,
pub unit: String,
pub requester: String,
pub telegram: String,
/// The Linka Go accounts on the reservation — the addresses that end up on
/// the bikes' whitelist. Whoever filed the request is named above; their own
/// address means nothing here unless it is one of these.
pub emails: Vec<String>,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub bikes: Vec<String>,
pub description: String,
pub status: ReservationStatus,
}
impl ReservationCard {
pub fn new(reservation: &Reservation, bikes: Vec<String>) -> Self {
let requester = reservation
.users
.iter()
.find(|user| user.id == reservation.requester);
ReservationCard {
id: reservation.id,
unit: reservation.unit.label().to_owned(),
requester: requester.map_or_else(
|| format!("#{}", reservation.requester),
|user| format!("{} {}", user.firstname, user.name),
),
telegram: reservation.telegram.clone(),
emails: reservation.linka_emails.clone(),
start_time: reservation.start_time,
end_time: reservation.end_time,
bikes,
description: reservation.description.clone(),
status: reservation.status,
}
}
/// The block shared by every message about this reservation, so a request
/// and the decision it becomes carry exactly the same facts.
pub fn details(&self) -> String {
let list = |values: &[String]| {
if values.is_empty() {
"—".to_owned()
} else {
values.join(", ")
}
};
let bikes = list(&self.bikes);
let emails = list(&self.emails);
format!(
"<b>Association :</b> {unit}\n\
<b>Demandeur :</b> {requester}\n\
<b>Telegram :</b> {telegram}\n\
<b>Comptes Linka Go :</b> {emails}\n\
<b>Période :</b> {start} → {end}\n\
<b>Cargobike(s) :</b> {bikes}\n\
<b>Raison :</b> {description}",
unit = escape(&self.unit),
requester = escape(&self.requester),
telegram = escape(&self.telegram),
emails = escape(&emails),
start = format_moment(self.start_time),
end = format_moment(self.end_time),
bikes = escape(&bikes),
description = escape(&self.description),
)
}
}
/// The facts both alerts about a bike being ridden share: which bike, who has
/// it, and since when.
pub fn alert_details(alert: &UsageAlert) -> String {
let since = alert
.rider
.since
.map_or_else(|| "—".to_owned(), format_moment);
format!(
"<b>Cargobike :</b> {bike}\n\
<b>Utilisateur :</b> {rider}\n\
<b>E-mail :</b> {email}\n\
<b>Depuis :</b> {since}",
bike = escape(&alert.bike_name),
rider = escape(&alert.rider.name),
email = escape(&alert.rider.email),
since = escape(&since),
)
}
/// The three characters Telegram's HTML mode reads as markup
pub fn escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Swiss local time, which is the only one the group cares about
pub 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)]
pub mod tests {
use chrono::Timelike;
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()
}
/// The fixture every message test renders
pub fn card(status: ReservationStatus) -> ReservationCard {
ReservationCard {
id: 12,
unit: "PolyNite".to_owned(),
requester: "Milan Hyenne".to_owned(),
telegram: "@tibiscuit_18".to_owned(),
emails: vec![
"milan.hyenne@epfl.ch".to_owned(),
"alice.martin@epfl.ch".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(),
status,
}
}
/// The group is shown the addresses that end up on the bikes' whitelist —
/// the Linka Go accounts — and not the address of whoever filled the form
/// in, which unlocks nothing.
#[test]
fn the_card_carries_the_linka_accounts_rather_than_the_filers_address() {
use crate::core::models::{reservation::ReservationUnit, user::UserSummary};
let reservation = Reservation {
id: 12,
unit: ReservationUnit::Free {
name: "PolyNite".to_owned(),
},
start_time: moment(25, 10),
end_time: moment(26, 16),
requester: 7,
users: vec![UserSummary {
id: 7,
firstname: "Milan".to_owned(),
name: "Hyenne".to_owned(),
email: "milan.hyenne@epfl.ch".to_owned(),
}],
telegram: "@tibiscuit_18".to_owned(),
description: "Transport du matériel".to_owned(),
bikes: vec![],
linka_emails: vec!["whitelist@epfl.ch".to_owned()],
status: ReservationStatus::Requested,
};
let card = ReservationCard::new(&reservation, vec!["1000".to_owned()]);
let details = card.details();
assert_eq!(card.emails, vec!["whitelist@epfl.ch"]);
assert!(details.contains("whitelist@epfl.ch"));
assert!(!details.contains("milan.hyenne@epfl.ch"));
// Whoever filed it is still named, by name
assert!(details.contains("Milan Hyenne"));
}
/// The fixture both alert tests render
pub fn alert(reservation: Option<ReservationId>) -> UsageAlert {
use crate::core::models::linka::Rider;
UsageAlert {
bike: 3,
bike_name: "3000".to_owned(),
rider: Rider {
name: "Edgar Wolff".to_owned(),
email: "edgar.wolff@epfl.ch".to_owned(),
since: Some(moment(22, 12).with_minute(44).unwrap()),
},
reservation,
allowed: reservation
.map(|_| vec!["1000".to_owned(), "2000".to_owned()])
.unwrap_or_default(),
}
}
#[test]
fn markup_in_a_user_value_is_escaped() {
let mut card = card(ReservationStatus::Requested);
card.unit = "<b>Fake</b> & Co".to_owned();
let details = card.details();
assert!(details.contains("&lt;b&gt;Fake&lt;/b&gt; &amp; Co"));
// Our own headings stay markup
assert!(details.contains("<b>Association :</b>"));
}
}

View file

@ -0,0 +1,50 @@
//! What the request message becomes once somebody has decided.
//!
//! The same block of facts, under a new first line: the group keeps the whole
//! story where the request was, and nothing has to be scrolled for.
use crate::{
core::models::reservation::ReservationStatus, services::telegram::messages::ReservationCard,
};
pub fn message(card: &ReservationCard) -> String {
format!("{}\n{}", headline(card.status), card.details())
}
/// A status the buttons cannot produce still gets a line, so pressing one on a
/// reservation settled elsewhere leaves an honest message rather than a stale
/// one.
fn headline(status: ReservationStatus) -> &'static str {
match status {
ReservationStatus::Approved | ReservationStatus::Ongoing => {
"✅ <b>Demande acceptée.</b> Un e-mail de confirmation a été envoyé."
}
ReservationStatus::Refused => "❌ <b>Demande refusée.</b>",
ReservationStatus::Cancelled => "🚫 <b>Réservation annulée.</b>",
ReservationStatus::Archived => "📦 <b>Réservation archivée.</b>",
ReservationStatus::Requested => "🕓 <b>Demande remise en attente.</b>",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::telegram::messages::tests::card;
#[test]
fn a_decision_keeps_the_same_block_under_a_new_first_line() {
let approved = message(&card(ReservationStatus::Approved));
let refused = message(&card(ReservationStatus::Refused));
assert!(approved.starts_with("✅ <b>Demande acceptée.</b>"));
assert!(refused.starts_with("❌ <b>Demande refusée.</b>"));
// Everything below the first line is the same in both, and is exactly
// what the request showed
let body = |message: &str| message.split_once('\n').unwrap().1.to_owned();
assert_eq!(body(&approved), body(&refused));
assert_eq!(
body(&approved),
card(ReservationStatus::Requested).details()
);
}
}

View file

@ -0,0 +1,68 @@
//! A reservation request has just been filed, with the buttons that settle it.
use serde_json::{Value, json};
use crate::{
core::models::reservation::ReservationId, services::telegram::messages::ReservationCard,
};
/// Where the "open the admin" button leads. Fixed on purpose: it is the address
/// of the deployed app, not something an environment gets to change, and
/// Telegram only accepts an https one.
const ADMIN_URL: &str = "https://cargobike.agepoly.ch/admin";
pub fn message(card: &ReservationCard) -> String {
format!(
"🚲 <b>Nouvelle demande de cargobike #{}</b>\n{}",
card.id,
card.details()
)
}
/// Accept, refuse, and a way into the admin page
pub fn keyboard(id: ReservationId) -> Value {
json!({
"inline_keyboard": [[
{ "text": "✅ Accepter", "callback_data": format!("approve:{id}") },
{ "text": "❌ Refuser", "callback_data": format!("refuse:{id}") },
{ "text": "📋 Ouvrir l'admin", "url": ADMIN_URL },
]]
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::models::reservation::ReservationStatus;
use crate::services::telegram::messages::tests::card;
#[test]
fn a_request_carries_every_field() {
let rendered = message(&card(ReservationStatus::Requested));
for expected in [
"#12",
"PolyNite",
"Milan Hyenne",
"@tibiscuit_18",
// Both Linka Go accounts, which is what the whitelist is built from
"milan.hyenne@epfl.ch, alice.martin@epfl.ch",
"1000, 2000",
"Transport du matériel",
// Rendered in local time: 10:00 UTC is noon in Lausanne
"25.08.2026 12:00",
] {
assert!(
rendered.contains(expected),
"{expected} missing from\n{rendered}"
);
}
}
#[test]
fn the_buttons_name_the_reservation() {
let keyboard = keyboard(12).to_string();
assert!(keyboard.contains("approve:12"));
assert!(keyboard.contains("refuse:12"));
assert!(keyboard.contains(ADMIN_URL));
}
}

View file

@ -0,0 +1,377 @@
//! Telegram: getting messages to the cargobikes group, and handling the buttons
//! it answers with.
//!
//! The wording lives in [`messages`], one file per message; this module only
//! carries them. Adding a message is a module there and a variant on
//! [`Notification`] — nothing else in the app has to know that Telegram exists,
//! nor how a message is worded: a caller says *what happened*.
//!
//! 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 and no poller is started, which
//! is what a development machine and the test suite want.
//!
//! The buttons come back through a long poll rather than a webhook, so the bot
//! works from a laptop with no public address.
use std::sync::OnceLock;
use openidconnect::reqwest;
use serde::Deserialize;
use serde_json::{Value, json};
use tracing::{debug, error, info, warn};
use crate::{
core::{
controller::AnonAppController,
models::{
linka::UsageAlert,
reservation::{ReservationId, ReservationStatus},
},
},
services::telegram::messages::{
ReservationCard, bike_ridden_outside_reservation, bike_ridden_without_reservation,
reservation_decided, reservation_requested,
},
utils::config::{self, TelegramConfig},
};
pub mod messages;
/// Something worth telling the group about.
#[derive(Debug, Clone)]
pub enum Notification {
/// A reservation request has just been filed, with its decision buttons
ReservationRequested(ReservationCard),
/// A bike is being ridden by somebody with no reservation running
BikeRiddenWithoutReservation(UsageAlert),
/// A bike is being ridden by somebody whose reservation is for other bikes
BikeRiddenOutsideReservation(UsageAlert),
}
impl Notification {
fn render(&self) -> String {
match self {
Notification::ReservationRequested(card) => reservation_requested::message(card),
Notification::BikeRiddenWithoutReservation(alert) => {
bike_ridden_without_reservation::message(alert)
}
Notification::BikeRiddenOutsideReservation(alert) => {
bike_ridden_outside_reservation::message(alert)
}
}
}
/// The buttons the message carries, if any
fn keyboard(&self) -> Option<Value> {
match self {
Notification::ReservationRequested(card) => {
Some(reservation_requested::keyboard(card.id))
}
// An alert is read, not answered: whoever acts on it does so in the
// admin page, where the same alert is shown
Notification::BikeRiddenWithoutReservation(_)
| Notification::BikeRiddenOutsideReservation(_) => None,
}
}
}
/// 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 telegram = telegram.clone();
tokio::spawn(async move {
let body = message_body(&telegram.chat_id, &notification);
if let Err(err) = call(&telegram, "sendMessage", body).await {
error!("[TELEGRAM] could not send the notification: {err}");
}
});
}
/// The body of a `sendMessage` call.
///
/// `reply_markup` is left out entirely when the message carries no buttons:
/// Telegram wants an object there or nothing at all, and answers
/// "object expected as reply markup" to a `null`.
fn message_body(chat_id: &str, notification: &Notification) -> Value {
let mut body = json!({
"chat_id": chat_id,
"text": notification.render(),
"parse_mode": "HTML",
"link_preview_options": { "is_disabled": true },
});
if let (Some(keyboard), Some(body)) = (notification.keyboard(), body.as_object_mut()) {
body.insert("reply_markup".to_owned(), keyboard);
}
body
}
// ---------------------------------------------------------------------------
// The buttons coming back
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct Update {
update_id: i64,
callback_query: Option<CallbackQuery>,
}
#[derive(Debug, Deserialize)]
struct CallbackQuery {
id: String,
data: Option<String>,
message: Option<CallbackMessage>,
}
#[derive(Debug, Deserialize)]
struct CallbackMessage {
message_id: i64,
chat: Chat,
}
#[derive(Debug, Deserialize)]
struct Chat {
id: i64,
}
/// Starts listening for the decision buttons, if the bot is configured.
///
/// Long polling rather than a webhook: it needs no public address, so the same
/// code works from a laptop and from the server. Being in the group is the
/// authorisation — a callback from any other chat is ignored.
pub fn spawn_poller(controller: AnonAppController) {
let Some(telegram) = config::get().telegram.as_ref() else {
debug!("[TELEGRAM] not configured, no poller started");
return;
};
let telegram = telegram.clone();
info!("[TELEGRAM] listening for the decision buttons");
tokio::spawn(async move {
let mut offset: i64 = 0;
loop {
let body = json!({
"offset": offset,
"timeout": 30,
"allowed_updates": ["callback_query"],
});
match call(&telegram, "getUpdates", body).await {
Ok(result) => {
let updates: Vec<Update> =
serde_json::from_value(result).unwrap_or_else(|err| {
warn!("[TELEGRAM] unreadable updates: {err}");
Vec::new()
});
for update in updates {
offset = offset.max(update.update_id + 1);
if let Some(query) = update.callback_query {
handle_callback(&telegram, &controller, query).await;
}
}
}
Err(err) => {
// A network hiccup, or Telegram rate limiting us: back off
// rather than hammering it
warn!("[TELEGRAM] getUpdates failed: {err}");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
}
});
}
async fn handle_callback(
telegram: &TelegramConfig,
controller: &AnonAppController,
query: CallbackQuery,
) {
let Some(message) = query.message else { return };
// Only the group the bot was configured for may decide anything
if message.chat.id.to_string() != telegram.chat_id {
warn!(
"[TELEGRAM] ignoring a callback from chat {}",
message.chat.id
);
answer(telegram, &query.id, "Ce bouton n'est pas pour ce salon.").await;
return;
}
let Some((status, id)) = query.data.as_deref().and_then(parse_action) else {
answer(telegram, &query.id, "Bouton inconnu.").await;
return;
};
let (reply, card) = match controller.decide_from_group(id, status).await {
Ok(reservation) => {
let mut names = Vec::with_capacity(reservation.bikes.len());
for held in &reservation.bikes {
names.push(match controller.get_bike(held.id).await {
Ok(bike) => bike.name,
Err(_) => format!("#{}", held.id),
});
}
let card = ReservationCard::new(&reservation, names);
let reply = match card.status {
ReservationStatus::Approved => "Demande acceptée.",
ReservationStatus::Refused => "Demande refusée.",
_ => "Déjà traitée.",
};
(reply.to_owned(), Some(card))
}
Err(err) => {
warn!("[TELEGRAM] decision on reservation {id} refused: {err}");
(
"Impossible : la réservation a changé entre-temps, ou un cargobike \
est déjà réservé sur cette plage."
.to_owned(),
None,
)
}
};
answer(telegram, &query.id, &reply).await;
// The message becomes the decision, buttons included: leaving them there
// would invite a second press on something already settled.
if let Some(card) = card {
let body = json!({
"chat_id": telegram.chat_id,
"message_id": message.message_id,
"text": reservation_decided::message(&card),
"parse_mode": "HTML",
"link_preview_options": { "is_disabled": true },
});
if let Err(err) = call(telegram, "editMessageText", body).await {
error!("[TELEGRAM] could not rewrite the message: {err}");
}
}
}
/// `approve:12` / `refuse:12`, as the buttons carry it
fn parse_action(data: &str) -> Option<(ReservationStatus, ReservationId)> {
let (action, id) = data.split_once(':')?;
let id = id.parse().ok()?;
match action {
"approve" => Some((ReservationStatus::Approved, id)),
"refuse" => Some((ReservationStatus::Refused, id)),
_ => None,
}
}
/// Stops the spinner on the pressed button, with a word on what happened
async fn answer(telegram: &TelegramConfig, callback_id: &str, text: &str) {
let body = json!({ "callback_query_id": callback_id, "text": text });
if let Err(err) = call(telegram, "answerCallbackQuery", body).await {
warn!("[TELEGRAM] could not answer the callback: {err}");
}
}
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct ApiResponse {
ok: bool,
description: Option<String>,
result: Option<Value>,
}
/// One bot api call, returning whatever the method answers with.
async fn call(telegram: &TelegramConfig, method: &str, body: Value) -> Result<Value, String> {
// Serialised by hand: the http client is the one `openidconnect` brings,
// and it is built without its `json` feature.
let payload = serde_json::to_string(&body).map_err(|err| err.to_string())?;
let response = http_client()
.post(format!(
"{}/bot{}/{method}",
telegram.get_api_url(),
telegram.bot_token
))
.header("content-type", "application/json")
.body(payload)
.send()
.await
.map_err(|err| err.to_string())?;
let status = response.status();
let text = response.text().await.unwrap_or_default();
let parsed: ApiResponse =
serde_json::from_str(&text).map_err(|_| format!("{status}: {text}"))?;
if !parsed.ok {
// Telegram explains itself in the body, and that is the only way to
// tell "wrong token" from "the bot is not in that group"
return Err(parsed.description.unwrap_or(text));
}
Ok(parsed.result.unwrap_or(Value::Null))
}
fn http_client() -> &'static reqwest::Client {
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
HTTP_CLIENT.get_or_init(|| {
reqwest::ClientBuilder::new()
// Long polling holds the request open for the timeout it asks for
.timeout(std::time::Duration::from_secs(60))
.build()
.expect("Unable to build the telegram http client")
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_message_with_buttons_carries_them() {
use crate::services::telegram::messages::tests::card;
let body = message_body(
"-1",
&Notification::ReservationRequested(card(ReservationStatus::Requested)),
);
assert!(body["reply_markup"]["inline_keyboard"].is_array());
assert_eq!(body["chat_id"], "-1");
assert_eq!(body["parse_mode"], "HTML");
}
#[test]
fn a_message_without_buttons_leaves_reply_markup_out() {
use crate::services::telegram::messages::tests::alert;
let body = message_body(
"-1",
&Notification::BikeRiddenWithoutReservation(alert(None)),
);
// Absent, not null: Telegram answers "object expected as reply markup"
// to a null, and every alert is a message with no buttons
assert!(
body.get("reply_markup").is_none(),
"reply_markup should be absent, got {body}"
);
assert!(body["text"].as_str().unwrap().contains("3000"));
}
#[test]
fn buttons_carry_the_reservation_they_decide() {
assert_eq!(
parse_action("approve:12"),
Some((ReservationStatus::Approved, 12))
);
assert_eq!(
parse_action("refuse:7"),
Some((ReservationStatus::Refused, 7))
);
assert_eq!(parse_action("approve:x"), None);
assert_eq!(parse_action("delete:12"), None);
assert_eq!(parse_action("nonsense"), None);
}
}