This commit is contained in:
Antoine Pelletier 2026-08-25 10:38:05 +02:00
parent ce88b58003
commit 51df9e75ad
39 changed files with 2805 additions and 800 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:

View file

@ -0,0 +1,44 @@
# 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
# 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

@ -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[]) =>
@ -43,6 +45,13 @@ const activeSorted = computed(() => byStart(props.active))
</CardHeader> </CardHeader>
<CardContent class="grid gap-6"> <CardContent class="grid gap-6">
<div>
<Button size="sm" @click="createOpen = true">
<Plus class="size-4" />
{{ $t('admin.reservations.create.action') }}
</Button>
</div>
<div v-if="isPending" class="grid gap-3"> <div v-if="isPending" class="grid gap-3">
<Skeleton class="h-28 w-full" /> <Skeleton class="h-28 w-full" />
<Skeleton class="h-28 w-full" /> <Skeleton class="h-28 w-full" />
@ -85,7 +94,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 +103,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: [],
@ -99,11 +112,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 +148,7 @@ function move(status: ReservationStatus) {
</script> </script>
<template> <template>
<div class="rounded-lg border p-4"> <div class="bg-muted/60 rounded-lg border p-4">
<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 +174,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 +210,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

@ -644,6 +644,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 +805,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 +884,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: {
@ -1010,6 +1089,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 +1148,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 +1164,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

View file

@ -181,6 +181,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 +216,7 @@ 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

View file

@ -182,6 +182,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 +218,7 @@ 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

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

@ -50,6 +50,7 @@ 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']
/** /**
* 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

@ -133,7 +133,7 @@ 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-muted/60 rounded-lg border p-4"
> >
<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

@ -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(()),

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,
},
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,176 @@ 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;
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 +344,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 +362,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 +496,13 @@ 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;
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 +518,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 +589,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 +609,25 @@ 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;
}
_ => {}
}
Ok(())
} }
pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> { pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> {

View file

@ -26,6 +26,31 @@ 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 {
let email = self.email.trim();
// Same shape the form checks: something, an @, something with a dot
email.len() >= 3
&& email.split('@').count() == 2
&& !email.starts_with('@')
&& !email.ends_with('@')
&& !self.firstname.trim().is_empty()
&& !self.name.trim().is_empty()
}
}
/// 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)]

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

@ -16,10 +16,28 @@ 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>;

View file

@ -34,6 +34,14 @@ 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());
// 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 +59,19 @@ 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;
}
});
}
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

@ -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

@ -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?;

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,4 @@
//! 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 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,134 @@
//! 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 reservation_decided;
pub mod reservation_requested;
use chrono::{DateTime, Utc};
use crate::core::models::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,
pub email: 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(),
email: requester.map(|user| user.email.clone()).unwrap_or_default(),
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 bikes = if self.bikes.is_empty() {
"—".to_owned()
} else {
self.bikes.join(", ")
};
format!(
"<b>Association :</b> {unit}\n\
<b>Demandeur :</b> {requester}\n\
<b>Telegram :</b> {telegram}\n\
<b>E-mail :</b> {email}\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),
email = escape(&self.email),
start = format_moment(self.start_time),
end = format_moment(self.end_time),
bikes = escape(&bikes),
description = escape(&self.description),
)
}
}
/// 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 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(),
email: "milan.hyenne@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,
}
}
#[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,67 @@
//! 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",
"milan.hyenne@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,313 @@
//! 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::reservation::{ReservationId, ReservationStatus},
},
services::telegram::messages::{ReservationCard, 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),
}
impl Notification {
fn render(&self) -> String {
match self {
Notification::ReservationRequested(card) => reservation_requested::message(card),
}
}
/// The buttons the message carries, if any
fn keyboard(&self) -> Option<Value> {
match self {
Notification::ReservationRequested(card) => {
Some(reservation_requested::keyboard(card.id))
}
}
}
}
/// 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 = json!({
"chat_id": telegram.chat_id,
"text": notification.render(),
"parse_mode": "HTML",
"link_preview_options": { "is_disabled": true },
"reply_markup": notification.keyboard(),
});
if let Err(err) = call(&telegram, "sendMessage", body).await {
error!("[TELEGRAM] could not send the notification: {err}");
}
});
}
// ---------------------------------------------------------------------------
// 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 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);
}
}