wip
This commit is contained in:
parent
ce88b58003
commit
51df9e75ad
39 changed files with 2805 additions and 800 deletions
15
.env
15
.env
|
|
@ -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
2
.gitignore
vendored
|
|
@ -3,3 +3,5 @@ config.yml
|
|||
config.yaml
|
||||
/LEGACY
|
||||
summary.ai
|
||||
.env
|
||||
.env.example
|
||||
|
|
@ -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
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -8,12 +8,13 @@
|
|||
* ever holds the whole table.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { Archive } from '@lucide/vue'
|
||||
import { Archive, Plus } from '@lucide/vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import ReservationArchiveDialog from './ReservationArchiveDialog.vue'
|
||||
import ReservationCreateDialog from './ReservationCreateDialog.vue'
|
||||
import ReservationCard from './ReservationCard.vue'
|
||||
import type { Bike, Reservation } from '@/utils/types'
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const archiveOpen = ref(false)
|
||||
const createOpen = ref(false)
|
||||
|
||||
/** Soonest first: both sections are read as "what comes next" */
|
||||
const byStart = (list: Reservation[]) =>
|
||||
|
|
@ -43,6 +45,13 @@ const activeSorted = computed(() => byStart(props.active))
|
|||
</CardHeader>
|
||||
|
||||
<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">
|
||||
<Skeleton class="h-28 w-full" />
|
||||
<Skeleton class="h-28 w-full" />
|
||||
|
|
@ -85,7 +94,7 @@ const activeSorted = computed(() => byStart(props.active))
|
|||
/>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" @click="archiveOpen = true">
|
||||
<Archive class="size-4" />
|
||||
{{ $t('admin.reservations.archive.action') }}
|
||||
|
|
@ -94,6 +103,7 @@ const activeSorted = computed(() => byStart(props.active))
|
|||
</template>
|
||||
|
||||
<ReservationArchiveDialog v-model:open="archiveOpen" :bikes="bikes" />
|
||||
<ReservationCreateDialog v-model:open="createOpen" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,19 @@ import { toast } from 'vue-sonner'
|
|||
|
||||
import ReservationDetails from '@/components/reservation/ReservationDetails.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 { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useConflicts, useSetReservationStatus } from '@/services/api/reservations'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
|
|
@ -34,7 +46,8 @@ const TRANSITIONS: Record<ReservationStatus, ReservationStatus[]> = {
|
|||
requested: ['approved', 'refused'],
|
||||
// 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.
|
||||
approved: ['cancelled'],
|
||||
// "requested" puts it back in the queue, which also frees its bikes.
|
||||
approved: ['requested', 'cancelled'],
|
||||
ongoing: ['archived', 'cancelled'],
|
||||
refused: [],
|
||||
cancelled: [],
|
||||
|
|
@ -99,11 +112,29 @@ const editable = computed(
|
|||
)
|
||||
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
|
||||
// from may be a few seconds old: the backend has the last word.
|
||||
setStatus.mutate(
|
||||
{ id: props.reservation.id, status },
|
||||
{ id: props.reservation.id, status, reason },
|
||||
{
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
|
|
@ -117,7 +148,7 @@ function move(status: ReservationStatus) {
|
|||
</script>
|
||||
|
||||
<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 min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="font-semibold">#{{ reservation.id }}</span>
|
||||
|
|
@ -143,7 +174,7 @@ function move(status: ReservationStatus) {
|
|||
? $t('admin.reservations.conflict.title')
|
||||
: undefined
|
||||
"
|
||||
@click="move(status)"
|
||||
@click="status === 'cancelled' ? askToCancel() : move(status)"
|
||||
>
|
||||
{{ $t(`admin.reservations.action.${status}`) }}
|
||||
</Button>
|
||||
|
|
@ -179,6 +210,39 @@ function move(status: ReservationStatus) {
|
|||
|
||||
<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
|
||||
v-if="editable"
|
||||
v-model:open="editing"
|
||||
|
|
|
|||
63
frontend/src/components/admin/ReservationCreateDialog.vue
Normal file
63
frontend/src/components/admin/ReservationCreateDialog.vue
Normal 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>
|
||||
|
|
@ -74,81 +74,87 @@ function format(iso: string) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<dl class="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
|
||||
<div v-if="shows('period')" class="min-w-0">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.period') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 flex flex-wrap items-center gap-x-2 font-medium tabular-nums">
|
||||
<span>{{ format(reservation.start_time) }}</span>
|
||||
<span class="text-muted-foreground" aria-hidden="true">→</span>
|
||||
<span>{{ format(reservation.end_time) }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="shows('bikes') && heldBikes.length" class="min-w-0">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.bikes') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="held in heldBikes"
|
||||
:key="held.id"
|
||||
class="rounded-md px-2 py-0.5 font-medium tabular-nums"
|
||||
:class="held.custom ? 'bg-amber-500/15 text-amber-700 dark:text-amber-400' : 'bg-muted'"
|
||||
:title="held.custom ? $t('reservation.details.own-period') : undefined"
|
||||
>
|
||||
{{ held.name }}
|
||||
</span>
|
||||
</dd>
|
||||
<dd v-if="rescheduled.length" class="mt-1 grid gap-0.5">
|
||||
<span
|
||||
v-for="held in rescheduled"
|
||||
:key="held.id"
|
||||
class="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{{ held.name }} · {{ format(held.start_time) }} → {{ format(held.end_time) }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="shows('telegram') && reservation.telegram" class="min-w-0">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.telegram') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-medium break-all">{{ reservation.telegram }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="shows('people') && peopleNames.length" class="min-w-0">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.people') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 break-words">{{ peopleNames.join(', ') }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="shows('linka') && linkaEmails.length" class="min-w-0">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.linka') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5">
|
||||
<ul class="grid gap-0.5">
|
||||
<li v-for="email in linkaShown" :key="email" class="break-all">{{ email }}</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="linkaHidden"
|
||||
type="button"
|
||||
class="text-primary mt-1 cursor-pointer text-xs font-medium hover:underline"
|
||||
@click="linkaExpanded = !linkaExpanded"
|
||||
>
|
||||
{{
|
||||
linkaExpanded
|
||||
? $t('reservation.details.show-less')
|
||||
: $t('reservation.details.show-more', { n: linkaHidden })
|
||||
}}
|
||||
</button>
|
||||
</dd>
|
||||
<dl class="text-sm">
|
||||
<!-- 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">
|
||||
{{ $t('reservation.details.period') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 flex flex-wrap items-center gap-x-2 font-medium tabular-nums">
|
||||
<span>{{ format(reservation.start_time) }}</span>
|
||||
<span class="text-muted-foreground" aria-hidden="true">→</span>
|
||||
<span>{{ format(reservation.end_time) }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="shows('bikes') && heldBikes.length" class="mb-3 break-inside-avoid">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.bikes') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="held in heldBikes"
|
||||
:key="held.id"
|
||||
class="rounded-md px-2 py-0.5 font-medium tabular-nums"
|
||||
:class="held.custom ? 'bg-amber-500/15 text-amber-700 dark:text-amber-400' : 'bg-muted'"
|
||||
:title="held.custom ? $t('reservation.details.own-period') : undefined"
|
||||
>
|
||||
{{ held.name }}
|
||||
</span>
|
||||
</dd>
|
||||
<dd v-if="rescheduled.length" class="mt-1 grid gap-0.5">
|
||||
<span
|
||||
v-for="held in rescheduled"
|
||||
:key="held.id"
|
||||
class="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{{ held.name }} · {{ format(held.start_time) }} → {{ format(held.end_time) }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="shows('telegram') && reservation.telegram" class="mb-3 break-inside-avoid">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.telegram') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 font-medium break-all">{{ reservation.telegram }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="shows('people') && peopleNames.length" class="mb-3 break-inside-avoid">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.people') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5 break-words">{{ peopleNames.join(', ') }}</dd>
|
||||
</div>
|
||||
|
||||
<div v-if="shows('linka') && linkaEmails.length" class="mb-3 break-inside-avoid">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.linka') }}
|
||||
</dt>
|
||||
<dd class="mt-0.5">
|
||||
<ul class="grid gap-0.5">
|
||||
<li v-for="email in linkaShown" :key="email" class="break-all">{{ email }}</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="linkaHidden"
|
||||
type="button"
|
||||
class="text-primary mt-1 cursor-pointer text-xs font-medium hover:underline"
|
||||
@click="linkaExpanded = !linkaExpanded"
|
||||
>
|
||||
{{
|
||||
linkaExpanded
|
||||
? $t('reservation.details.show-less')
|
||||
: $t('reservation.details.show-more', { n: linkaHidden })
|
||||
}}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<dt class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{{ $t('reservation.details.reason') }}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { toast } from 'vue-sonner'
|
|||
import { HttpStatus } from 'http-status-ts'
|
||||
|
||||
import DatePicker from '@/components/DatePicker.vue'
|
||||
import TelegramInput from '@/components/TelegramInput.vue'
|
||||
import TimePicker from '@/components/TimePicker.vue'
|
||||
import ReservationDetails from '@/components/reservation/ReservationDetails.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -52,6 +53,7 @@ const { t, locale } = useI18n()
|
|||
const update = useUpdateReservation()
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const TELEGRAM_RE = /^@[A-Za-z][A-Za-z0-9_]{4,31}$/
|
||||
const SLOT_MINUTES = 15
|
||||
|
||||
const startDate = shallowRef<DateValue>()
|
||||
|
|
@ -60,6 +62,8 @@ const form = reactive({
|
|||
startTime: '' as string | undefined,
|
||||
endTime: '' as string | undefined,
|
||||
bikes: [] as number[],
|
||||
// Without the leading @, which `TelegramInput` shows as a prefix
|
||||
telegram: '',
|
||||
emails: [''],
|
||||
})
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
|
@ -105,6 +109,7 @@ function load() {
|
|||
form.startTime = toSlot(start)
|
||||
form.endTime = toSlot(end)
|
||||
form.bikes = props.reservation.bikes.map((held) => held.id)
|
||||
form.telegram = props.reservation.telegram.replace(/^@/, '')
|
||||
Object.keys(overrides).forEach((key) => delete overrides[Number(key)])
|
||||
for (const held of props.reservation.bikes) {
|
||||
if (held.custom) overrides[held.id] = toPeriod(held.start_time, held.end_time)
|
||||
|
|
@ -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 */
|
||||
const omitted = computed<('period' | 'bikes' | 'linka')[]>(() =>
|
||||
props.emailsOnly ? ['linka'] : ['period', 'bikes', 'linka'],
|
||||
const omitted = computed<('period' | 'bikes' | 'linka' | 'telegram')[]>(() =>
|
||||
props.emailsOnly ? ['linka'] : ['period', 'bikes', 'linka', 'telegram'],
|
||||
)
|
||||
|
||||
const start = computed(() => toDate(startDate.value, form.startTime))
|
||||
|
|
@ -268,6 +273,10 @@ function validate(): boolean {
|
|||
errors.end = t('reservation.error-end-before-start')
|
||||
}
|
||||
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
|
||||
// names the bike rather than the whole reservation
|
||||
|
|
@ -305,6 +314,7 @@ function save() {
|
|||
id: props.reservation.id,
|
||||
// Sent back as stored when they are not editable, which is exactly what
|
||||
// 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(),
|
||||
end_time: props.emailsOnly ? props.reservation.end_time : end.value!.toISOString(),
|
||||
bikes: props.emailsOnly
|
||||
|
|
@ -413,6 +423,13 @@ function save() {
|
|||
<p v-if="errors.bikes" class="text-destructive text-xs">{{ errors.bikes }}</p>
|
||||
</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
|
||||
moving the whole booking -->
|
||||
<div v-if="!emailsOnly && form.bikes.length" class="grid gap-2">
|
||||
|
|
|
|||
489
frontend/src/components/reservation/ReservationForm.vue
Normal file
489
frontend/src/components/reservation/ReservationForm.vue
Normal 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>
|
||||
132
frontend/src/lib/api.d.ts
vendored
132
frontend/src/lib/api.d.ts
vendored
|
|
@ -644,6 +644,85 @@ export interface paths {
|
|||
patch?: 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': {
|
||||
parameters: {
|
||||
query?: never
|
||||
|
|
@ -726,10 +805,10 @@ export interface paths {
|
|||
cookie?: never
|
||||
}
|
||||
/**
|
||||
* @description Only what the admin page lets somebody change. The unit, the requester, the
|
||||
* telegram handle and the reason are shown but not editable, so they are not
|
||||
* in the body at all: the handler reads them back from the stored reservation
|
||||
* rather than trusting a client to send them unchanged.
|
||||
* @description Only what the admin page lets somebody change. The unit, the requester and
|
||||
* the reason are shown but not editable, so they are not in the body at all:
|
||||
* the handler reads them back from the stored reservation rather than trusting
|
||||
* a client to send them unchanged.
|
||||
*/
|
||||
requestBody: {
|
||||
content: {
|
||||
|
|
@ -805,7 +884,7 @@ export interface paths {
|
|||
get?: never
|
||||
/**
|
||||
* 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: {
|
||||
parameters: {
|
||||
|
|
@ -1010,6 +1089,18 @@ export interface components {
|
|||
kind: 'free'
|
||||
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: {
|
||||
code: string
|
||||
state: string
|
||||
|
|
@ -1057,10 +1148,10 @@ export interface components {
|
|||
start_time: string
|
||||
}
|
||||
/**
|
||||
* @description Only what the admin page lets somebody change. The unit, the requester, the
|
||||
* telegram handle and the reason are shown but not editable, so they are not
|
||||
* in the body at all: the handler reads them back from the stored reservation
|
||||
* rather than trusting a client to send them unchanged.
|
||||
* @description Only what the admin page lets somebody change. The unit, the requester and
|
||||
* the reason are shown but not editable, so they are not in the body at all:
|
||||
* the handler reads them back from the stored reservation rather than trusting
|
||||
* a client to send them unchanged.
|
||||
*/
|
||||
ReservationEditForm: {
|
||||
/**
|
||||
|
|
@ -1073,6 +1164,29 @@ export interface components {
|
|||
linka_emails: string[]
|
||||
/** Format: date-time */
|
||||
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
|
||||
|
|
|
|||
|
|
@ -181,6 +181,29 @@ admin:
|
|||
all-status: Any status
|
||||
count: No result | 1 reservation | {count} reservations
|
||||
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:
|
||||
title: 'Cannot approve: cargobike already booked'
|
||||
line: 'The {bike} is already held by {unit} (#{id}), from {from} to {to}.'
|
||||
|
|
@ -193,6 +216,7 @@ admin:
|
|||
action:
|
||||
approved: Approve
|
||||
refused: Refuse
|
||||
requested: Put back in the queue
|
||||
cancelled: Cancel
|
||||
ongoing: Start
|
||||
archived: Archive
|
||||
|
|
|
|||
|
|
@ -182,6 +182,30 @@ admin:
|
|||
all-status: Tous les statuts
|
||||
count: Aucun résultat | 1 réservation | {count} réservations
|
||||
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:
|
||||
title: 'Validation impossible : cargobike déjà réservé'
|
||||
line: 'Le {bike} est déjà pris par {unit} (#{id}), du {from} au {to}.'
|
||||
|
|
@ -194,6 +218,7 @@ admin:
|
|||
action:
|
||||
approved: Valider
|
||||
refused: Refuser
|
||||
requested: Remettre en attente
|
||||
cancelled: Annuler
|
||||
ongoing: Démarrer
|
||||
archived: Archiver
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
Conflict,
|
||||
NewReservation,
|
||||
NewReservationBike,
|
||||
Person,
|
||||
Reservation,
|
||||
ReservationEdit,
|
||||
ReservationStatus,
|
||||
|
|
@ -127,10 +128,19 @@ export function useSetReservationStatus() {
|
|||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
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', {
|
||||
params: { path: { id } },
|
||||
body: { status },
|
||||
body: { status, reason },
|
||||
})
|
||||
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
|
||||
* left as stored: the backend reads it back rather than taking it from here.
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export type CalendarReservation = components['schemas']['CalendarReservation']
|
|||
export type ReservationBike = components['schemas']['ReservationBike']
|
||||
export type NewReservationBike = components['schemas']['NewReservationBike']
|
||||
export type Conflict = components['schemas']['Conflict']
|
||||
export type Person = components['schemas']['Person']
|
||||
|
||||
/**
|
||||
* What the details block can render: the fields the public calendar carries,
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ function editableEmails(reservation: Reservation) {
|
|||
<div
|
||||
v-for="reservation in section.reservations"
|
||||
: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 min-w-0 flex-wrap items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,219 +1,34 @@
|
|||
<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 { Plus, TriangleAlert, X } from '@lucide/vue'
|
||||
import { getLocalTimeZone, today, type DateValue } from '@internationalized/date'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { HttpStatus } from 'http-status-ts'
|
||||
|
||||
import DatePicker from '@/components/DatePicker.vue'
|
||||
import TelegramInput from '@/components/TelegramInput.vue'
|
||||
import UnitPicker, { type UnitChoice } from '@/components/UnitPicker.vue'
|
||||
import TimePicker from '@/components/TimePicker.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ReservationForm, {
|
||||
type ReservationPayload,
|
||||
} from '@/components/reservation/ReservationForm.vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { 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()
|
||||
import { useCreateReservation } from '@/services/api/reservations'
|
||||
import { ApiError } from '@/utils/types'
|
||||
|
||||
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 form = ref<InstanceType<typeof ReservationForm> | null>(null)
|
||||
|
||||
/** The form, as the api wants it. Both are non-null once `validate()` passed. */
|
||||
function payload(): NewReservation {
|
||||
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(), {
|
||||
function submit(payload: ReservationPayload) {
|
||||
create.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
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.
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
|
|
@ -244,205 +59,7 @@ function submit() {
|
|||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form class="grid gap-5" novalidate @submit.prevent="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>
|
||||
<ReservationForm ref="form" :pending="create.isPending.value" @submit="submit" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,10 +29,13 @@ use crate::{
|
|||
AnonAppController, AppController, ControllerError,
|
||||
reservations::ReservationsControllerError,
|
||||
},
|
||||
models::reservation::{
|
||||
CalendarReservation, Conflict, ConflictProbe, NewReservation, NewReservationBike,
|
||||
Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery,
|
||||
ReservationStatus,
|
||||
models::{
|
||||
reservation::{
|
||||
CalendarReservation, Conflict, ConflictProbe, NewReservation, NewReservationBike,
|
||||
Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery,
|
||||
ReservationStatus,
|
||||
},
|
||||
user::Person,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -53,6 +56,10 @@ pub fn routes() -> ApiRouter {
|
|||
"/calendar",
|
||||
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(
|
||||
"/{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"))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// which association, with nothing personal attached.
|
||||
/// 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)]
|
||||
struct SetStatusForm {
|
||||
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]
|
||||
async fn set_status(
|
||||
ac: AppController,
|
||||
Path(IdPath { id }): Path<IdPath>,
|
||||
Json(SetStatusForm { status }): Json<SetStatusForm>,
|
||||
Json(SetStatusForm { status, reason }): Json<SetStatusForm>,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
// The unit is not in the body: it is the reservation's own
|
||||
let reservation = match ac.get_reservation(id).await {
|
||||
|
|
@ -381,7 +448,7 @@ async fn set_status(
|
|||
};
|
||||
|
||||
match manager(ac, reservation.unit.scope())?
|
||||
.set_reservation_status(id, status)
|
||||
.set_reservation_status(id, status, reason)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
controller::{AnonAppController, AppController, ControllerError, ManagerAppController},
|
||||
controller::{
|
||||
AdminAppController, AnonAppController, AppController, ControllerError,
|
||||
ManagerAppController,
|
||||
},
|
||||
models::{
|
||||
bike::{BikeId, BikeStatus},
|
||||
reservation::{
|
||||
|
|
@ -13,10 +17,21 @@ use crate::{
|
|||
ReservationStatus,
|
||||
},
|
||||
unit::UnitId,
|
||||
user::Person,
|
||||
},
|
||||
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.
|
||||
|
|
@ -44,6 +59,176 @@ impl AnonAppController {
|
|||
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
|
||||
/// hold a bike over the window asked for, stripped of everything personal.
|
||||
/// Reading it needs no session.
|
||||
|
|
@ -159,7 +344,9 @@ impl AppController {
|
|||
Err(_) => format!("#{}", held.id),
|
||||
});
|
||||
}
|
||||
telegram::notify(Notification::reservation_requested(&created, names));
|
||||
telegram::notify(Notification::ReservationRequested(ReservationCard::new(
|
||||
&created, names,
|
||||
)));
|
||||
|
||||
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 {
|
||||
/// Who may edit, and how much:
|
||||
///
|
||||
|
|
@ -255,10 +496,13 @@ impl AppController {
|
|||
}
|
||||
}
|
||||
|
||||
self.db
|
||||
.update_reservation(reservation)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
self.db.update_reservation(reservation).await?;
|
||||
// A period moved into the present should show as ongoing straight away
|
||||
self.advance_reservation_statuses().await;
|
||||
|
||||
let after = self.db.get_reservation(current.id).await?;
|
||||
self.announce_edit(¤t, &after).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
fn taken(conflicts: &[Conflict]) -> Vec<BikeId> {
|
||||
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)
|
||||
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(
|
||||
&self,
|
||||
id: ReservationId,
|
||||
status: ReservationStatus,
|
||||
reason: Option<String>,
|
||||
) -> Result<(), ControllerError> {
|
||||
let reservation = self.db.get_reservation(id).await?;
|
||||
if reservation.unit.scope() != self.unit {
|
||||
|
|
@ -329,38 +609,25 @@ impl ManagerAppController {
|
|||
return Err(ReservationsControllerError::InvalidTransition(current, status).into());
|
||||
}
|
||||
|
||||
// Approving is the moment a reservation really takes its bikes, so it is
|
||||
// the moment a double booking stops being a warning and becomes a
|
||||
// refusal: two approved reservations over one bike means somebody turns
|
||||
// up to an empty rack. Only this transition is guarded — a reservation
|
||||
// already approved is allowed to start, whatever was overridden earlier.
|
||||
if status == ReservationStatus::Approved {
|
||||
let conflicts = self
|
||||
.db
|
||||
.find_conflicts(ConflictProbe {
|
||||
reservation: Some(id),
|
||||
start_time: reservation.start_time,
|
||||
end_time: reservation.end_time,
|
||||
bikes: reservation
|
||||
.bikes
|
||||
.iter()
|
||||
.map(|held| NewReservationBike {
|
||||
id: held.id,
|
||||
start_time: Some(held.start_time),
|
||||
end_time: Some(held.end_time),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.await?;
|
||||
if !conflicts.is_empty() {
|
||||
return Err(ReservationsControllerError::BikesTaken(taken(&conflicts)).into());
|
||||
}
|
||||
}
|
||||
self.refuse_if_taken(&reservation, status).await?;
|
||||
|
||||
self.db
|
||||
.set_reservation_status(id, status)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
// A cancellation is announced with the reservation as it stood: the mail
|
||||
// describes what people were counting on, not what is left of it.
|
||||
let was_live = AnonAppController::is_live(&reservation);
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,31 @@ pub struct NewUser {
|
|||
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
|
||||
/// show who they are, without dragging their units along.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -46,6 +46,15 @@ pub trait ReservationsRepository {
|
|||
async fn update_reservation(&self, reservation: ReservationEdit)
|
||||
-> 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(
|
||||
&self,
|
||||
id: ReservationId,
|
||||
|
|
|
|||
|
|
@ -16,10 +16,28 @@ pub trait UsersRepository {
|
|||
async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, RepositoryError>;
|
||||
|
||||
/// 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>;
|
||||
|
||||
/// 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
|
||||
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError>;
|
||||
|
||||
|
|
|
|||
21
src/main.rs
21
src/main.rs
|
|
@ -34,6 +34,14 @@ async fn main() {
|
|||
|
||||
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,
|
||||
// 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)
|
||||
|
|
@ -51,6 +59,19 @@ async fn main() {
|
|||
.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() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
|
|
|
|||
|
|
@ -630,6 +630,25 @@ impl ReservationsRepository for SqlxDatabase {
|
|||
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(
|
||||
&self,
|
||||
id: ReservationId,
|
||||
|
|
|
|||
|
|
@ -110,16 +110,18 @@ impl UsersRepository for SqlxDatabase {
|
|||
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError> {
|
||||
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,
|
||||
r#"INSERT INTO users (external_id, firstname, "name", email, oidc_sub)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (oidc_sub)
|
||||
DO UPDATE SET
|
||||
external_id = EXCLUDED.external_id,
|
||||
firstname = EXCLUDED.firstname,
|
||||
"name" = EXCLUDED.name,
|
||||
email = EXCLUDED.email
|
||||
r#"UPDATE users SET
|
||||
external_id = $1,
|
||||
firstname = $2,
|
||||
"name" = $3,
|
||||
email = $4
|
||||
WHERE oidc_sub = $5
|
||||
RETURNING *"#,
|
||||
user.external_id,
|
||||
user.firstname,
|
||||
|
|
@ -127,6 +129,66 @@ impl UsersRepository for SqlxDatabase {
|
|||
user.email,
|
||||
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)
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
165
src/services/mail/mails/mod.rs
Normal file
165
src/services/mail/mails/mod.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// 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("<b>Fake</b> & Co"));
|
||||
assert!(details.contains("<strong>Association :</strong>"));
|
||||
}
|
||||
}
|
||||
80
src/services/mail/mails/reservation_approved.rs
Normal file
80
src/services/mail/mails/reservation_approved.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
46
src/services/mail/mails/reservation_bikes_changed.rs
Normal file
46
src/services/mail/mails/reservation_bikes_changed.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
81
src/services/mail/mails/reservation_cancelled.rs
Normal file
81
src/services/mail/mails/reservation_cancelled.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
44
src/services/mail/mails/reservation_period_changed.rs
Normal file
44
src/services/mail/mails/reservation_period_changed.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
68
src/services/mail/mails/reservation_shared.rs
Normal file
68
src/services/mail/mails/reservation_shared.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
51
src/services/mail/mails/reservation_updated.rs
Normal file
51
src/services/mail/mails/reservation_updated.rs
Normal 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
156
src/services/mail/mod.rs
Normal 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")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
//! External services used by the core: database, and any third party api you add.
|
||||
pub mod database;
|
||||
pub mod mail;
|
||||
pub mod telegram;
|
||||
|
|
|
|||
|
|
@ -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, ¬ification.render()).await {
|
||||
error!("[TELEGRAM] could not send the notification: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SendMessage<'a> {
|
||||
chat_id: &'a str,
|
||||
text: &'a str,
|
||||
parse_mode: &'a str,
|
||||
/// Link previews turn a reservation into a wall of nothing
|
||||
disable_web_page_preview: bool,
|
||||
}
|
||||
|
||||
async fn send(api_url: &str, token: &str, chat_id: &str, text: &str) -> Result<(), String> {
|
||||
// Serialised by hand: the http client is the one `openidconnect` brings, and
|
||||
// it is built without its `json` feature.
|
||||
let body = serde_json::to_string(&SendMessage {
|
||||
chat_id,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let response = http_client()
|
||||
.post(format!("{api_url}/bot{token}/sendMessage"))
|
||||
.header("content-type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// Telegram explains itself in the body, and that is the only way to
|
||||
// tell "wrong token" from "the bot is not in that group"
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
warn!("[TELEGRAM] {status}: {body}");
|
||||
return Err(format!("{status}: {body}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_client() -> &'static reqwest::Client {
|
||||
static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
HTTP_CLIENT.get_or_init(|| {
|
||||
reqwest::ClientBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Unable to build the telegram http client")
|
||||
})
|
||||
}
|
||||
|
||||
/// The three characters Telegram's HTML mode reads as markup
|
||||
fn escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// Swiss local time, which is the only one the group cares about
|
||||
fn format_moment(moment: DateTime<Utc>) -> String {
|
||||
moment
|
||||
.with_timezone(&chrono::FixedOffset::east_opt(2 * 3600).expect("valid offset"))
|
||||
.format("%d.%m.%Y %H:%M")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn moment(day: u32, hour: u32) -> DateTime<Utc> {
|
||||
use chrono::TimeZone;
|
||||
Utc.with_ymd_and_hms(2026, 8, day, hour, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_reads_as_a_message() {
|
||||
let message = Notification::ReservationRequested {
|
||||
id: 12,
|
||||
unit: "PolyNite".to_owned(),
|
||||
requester: "Milan Hyenne".to_owned(),
|
||||
start_time: moment(25, 10),
|
||||
end_time: moment(26, 16),
|
||||
bikes: vec!["1000".to_owned(), "2000".to_owned()],
|
||||
description: "Transport du matériel".to_owned(),
|
||||
}
|
||||
.render();
|
||||
|
||||
assert!(message.contains("#12"));
|
||||
assert!(message.contains("PolyNite"));
|
||||
assert!(message.contains("1000, 2000"));
|
||||
// Rendered in local time: 10:00 UTC is noon in Lausanne
|
||||
assert!(message.contains("25.08.2026 12:00"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markup_in_a_user_value_is_escaped() {
|
||||
let message = Notification::ReservationRequested {
|
||||
id: 1,
|
||||
unit: "<b>Fake</b> & Co".to_owned(),
|
||||
requester: "A".to_owned(),
|
||||
start_time: moment(25, 10),
|
||||
end_time: moment(25, 12),
|
||||
bikes: vec![],
|
||||
description: String::new(),
|
||||
}
|
||||
.render();
|
||||
|
||||
assert!(message.contains("<b>Fake</b> & Co"));
|
||||
// The heading is ours, and stays markup
|
||||
assert!(message.contains("<b>Nouvelle demande"));
|
||||
}
|
||||
}
|
||||
134
src/services/telegram/messages/mod.rs
Normal file
134
src/services/telegram/messages/mod.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// 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("<b>Fake</b> & Co"));
|
||||
// Our own headings stay markup
|
||||
assert!(details.contains("<b>Association :</b>"));
|
||||
}
|
||||
}
|
||||
50
src/services/telegram/messages/reservation_decided.rs
Normal file
50
src/services/telegram/messages/reservation_decided.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
67
src/services/telegram/messages/reservation_requested.rs
Normal file
67
src/services/telegram/messages/reservation_requested.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
313
src/services/telegram/mod.rs
Normal file
313
src/services/telegram/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue