feat: reservation page final

This commit is contained in:
Antoine Pelletier 2026-08-24 12:00:58 +02:00
parent b50d1fe855
commit 035f82cb88
22 changed files with 389 additions and 73 deletions

View file

@ -0,0 +1,16 @@
-- migrate:up
-- The Linka Go accounts allowed to unlock the bikes for this reservation. They
-- are plain addresses, not app users: somebody who never logs in can still be
-- authorised, so this is not a link to `users`.
ALTER TABLE reservations ADD COLUMN linka_emails TEXT[] NOT NULL DEFAULT '{}';
-- A check constraint cannot hold a subquery, so this catches the empty string
-- rather than any blank one. The repository trims before writing, which turns a
-- whitespace-only address into the empty string caught here.
ALTER TABLE reservations ADD CONSTRAINT reservations_linka_emails_filled CHECK (
array_position(linka_emails, NULL) IS NULL AND NOT ('' = ANY (linka_emails)));
-- migrate:down
ALTER TABLE reservations DROP CONSTRAINT reservations_linka_emails_filled;
ALTER TABLE reservations DROP COLUMN linka_emails;

View file

@ -115,6 +115,8 @@ CREATE TABLE public.reservations (
status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL, status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL,
unit_id integer, unit_id integer,
unit_label text, unit_label text,
linka_emails text[] DEFAULT '{}'::text[] NOT NULL,
CONSTRAINT reservations_linka_emails_filled CHECK (((array_position(linka_emails, NULL::text) IS NULL) AND (NOT (''::text = ANY (linka_emails))))),
CONSTRAINT reservations_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)), CONSTRAINT reservations_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)),
CONSTRAINT reservations_time_order CHECK ((end_time > start_time)), CONSTRAINT reservations_time_order CHECK ((end_time > start_time)),
CONSTRAINT reservations_unit_label_not_blank CHECK (((unit_label IS NULL) OR (btrim(unit_label) <> ''::text))), CONSTRAINT reservations_unit_label_not_blank CHECK (((unit_label IS NULL) OR (btrim(unit_label) <> ''::text))),
@ -502,4 +504,5 @@ INSERT INTO public.schema_migrations (version) VALUES
('20260823170000'), ('20260823170000'),
('20260823210000'), ('20260823210000'),
('20260823230000'), ('20260823230000'),
('20260824120000'); ('20260824120000'),
('20260824140000');

View file

@ -42,40 +42,48 @@ ON CONFLICT DO NOTHING;
-- Reservations across the current week, one per status, so the admin page and -- Reservations across the current week, one per status, so the admin page and
-- the calendar always have something to show. Times are relative to `now()`. -- the calendar always have something to show. Times are relative to `now()`.
INSERT INTO public.reservations INSERT INTO public.reservations
(id, unit_id, start_time, end_time, requester_id, telegram, "description", status) (id, unit_id, start_time, end_time, requester_id, telegram, "description", linka_emails, status)
SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description", r.status SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description",
r.linka_emails, r.status
FROM (VALUES FROM (VALUES
(1, 'agepoly', (1, 'agepoly',
date_trunc('day', now()) - interval '1 day' + interval '8 hours', date_trunc('day', now()) - interval '1 day' + interval '8 hours',
date_trunc('day', now()) + interval '1 day' + interval '18 hours', date_trunc('day', now()) + interval '1 day' + interval '18 hours',
1, '@alice_martin', 'Transport du matériel pour la rentrée', 1, '@alice_martin', 'Transport du matériel pour la rentrée',
ARRAY['alice.martin@epfl.ch'],
'ongoing'::reservation_status), 'ongoing'::reservation_status),
(2, 'clic', (2, 'clic',
date_trunc('day', now()) + interval '1 day' + interval '9 hours', date_trunc('day', now()) + interval '1 day' + interval '9 hours',
date_trunc('day', now()) + interval '2 days' + interval '17 hours', date_trunc('day', now()) + interval '2 days' + interval '17 hours',
3, '@chloe_favre', 'Déménagement du stock de la commission', 3, '@chloe_favre', 'Déménagement du stock de la commission',
ARRAY['chloe.favre@epfl.ch'],
'approved'::reservation_status), 'approved'::reservation_status),
(3, 'agepoly', (3, 'agepoly',
date_trunc('day', now()) + interval '3 days' + interval '10 hours', date_trunc('day', now()) + interval '3 days' + interval '10 hours',
date_trunc('day', now()) + interval '3 days' + interval '19 hours', date_trunc('day', now()) + interval '3 days' + interval '19 hours',
2, '@bob_dupont', 'Livraison des boissons pour la soirée', 2, '@bob_dupont', 'Livraison des boissons pour la soirée',
ARRAY['bob.dupont@epfl.ch','alice.martin@epfl.ch'],
'requested'::reservation_status), 'requested'::reservation_status),
(4, 'clic', (4, 'clic',
date_trunc('day', now()) + interval '4 days' + interval '7 hours', date_trunc('day', now()) + interval '4 days' + interval '7 hours',
date_trunc('day', now()) + interval '4 days' + interval '12 hours', date_trunc('day', now()) + interval '4 days' + interval '12 hours',
3, '@chloe_favre', 'Récupération de matériel informatique', 3, '@chloe_favre', 'Récupération de matériel informatique',
ARRAY['chloe.favre@epfl.ch'],
'requested'::reservation_status), 'requested'::reservation_status),
(5, 'agepoly', (5, 'agepoly',
date_trunc('day', now()) - interval '20 days' + interval '9 hours', date_trunc('day', now()) - interval '20 days' + interval '9 hours',
date_trunc('day', now()) - interval '19 days' + interval '18 hours', date_trunc('day', now()) - interval '19 days' + interval '18 hours',
1, '@alice_martin', 'Ancienne sortie, archivée', 1, '@alice_martin', 'Ancienne sortie, archivée',
ARRAY['alice.martin@epfl.ch'],
'archived'::reservation_status), 'archived'::reservation_status),
(6, 'clic', (6, 'clic',
date_trunc('day', now()) + interval '6 days' + interval '14 hours', date_trunc('day', now()) + interval '6 days' + interval '14 hours',
date_trunc('day', now()) + interval '6 days' + interval '18 hours', date_trunc('day', now()) + interval '6 days' + interval '18 hours',
2, '@bob_dupont', 'Annulée faute de conducteur', 2, '@bob_dupont', 'Annulée faute de conducteur',
ARRAY['bob.dupont@epfl.ch'],
'cancelled'::reservation_status) 'cancelled'::reservation_status)
) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description", status) ) AS r (id, unit_name, start_time, end_time, requester_id, telegram, "description",
linka_emails, status)
JOIN public.units u ON u."name" = r.unit_name JOIN public.units u ON u."name" = r.unit_name
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;

View file

@ -109,6 +109,10 @@ function move(status: ReservationStatus) {
{{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }} {{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }}
</dd> </dd>
</div> </div>
<div v-if="reservation.linka_emails.length" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.linka') }}</dt>
<dd class="min-w-0 break-words">{{ reservation.linka_emails.join(', ') }}</dd>
</div>
<div v-if="reservation.description" class="flex gap-2"> <div v-if="reservation.description" class="flex gap-2">
<dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt> <dt class="text-muted-foreground shrink-0">{{ $t('admin.reservations.reason') }}</dt>
<dd class="min-w-0 break-words italic">{{ reservation.description }}</dd> <dd class="min-w-0 break-words italic">{{ reservation.description }}</dd>

View file

@ -438,7 +438,69 @@ export interface paths {
} }
} }
put?: never put?: never
post?: never /**
* File a reservation request
* @description The requester is the session user and the status starts at `requested`; neither is taken from the body.
*/
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['NewReservation']
}
}
responses: {
/** @description The reservation, as stored */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Reservation']
}
}
/** @description The reservation is malformed */
400: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Unauthenticated - a session is required */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description The requester does not belong to the unit named */
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 delete?: never
options?: never options?: never
head?: never head?: never
@ -557,6 +619,31 @@ export interface components {
/** @description Email of one of the `dev_users` of the configuration */ /** @description Email of one of the `dev_users` of the configuration */
user: string user: string
} }
NewReservation: {
description: string
bikes: number[]
/** Format: date-time */
end_time: string
linka_emails: string[]
/** Format: date-time */
start_time: string
telegram: string
unit: components['schemas']['NewReservationUnit']
users: number[]
}
/** @description The same choice, as the client sends it: only the id travels for a known unit. */
NewReservationUnit:
| {
/** Format: int32 */
id: number
/** @constant */
kind: 'known'
}
| {
/** @constant */
kind: 'free'
name: string
}
PostCallbackParams: { PostCallbackParams: {
code: string code: string
state: string state: string
@ -568,6 +655,11 @@ export interface components {
end_time: string end_time: string
/** Format: int32 */ /** Format: int32 */
id: number id: number
/**
* @description Linka Go accounts allowed to unlock the bikes. Plain addresses: they
* need not belong to anybody who ever logs into this app.
*/
linka_emails: string[]
/** Format: int32 */ /** Format: int32 */
requester: number requester: number
/** Format: date-time */ /** Format: date-time */

View file

@ -52,7 +52,9 @@ reservation:
error-telegram: "Invalid Telegram username (example: {'@'}my_username)." error-telegram: "Invalid Telegram username (example: {'@'}my_username)."
error-email: One of the email addresses is invalid. error-email: One of the email addresses is invalid.
error-form: The form contains errors. error-form: The form contains errors.
not-implemented: Submitting is not wired to the backend yet. submitted: Request sent. It will show up in the pending requests.
submitting: Sending…
submit-error: Could not send the request.
login: login:
title: Log in title: Log in
intro: Log in with your AGEPoly account to book a cargobike. intro: Log in with your AGEPoly account to book a cargobike.
@ -96,6 +98,7 @@ admin:
bikes: Cargobike(s) bikes: Cargobike(s)
telegram: Telegram telegram: Telegram
people: People people: People
linka: Linka Go accounts
reason: Reason reason: Reason
load-error: Unable to load the reservations. load-error: Unable to load the reservations.
error: The status change failed. error: The status change failed.

View file

@ -53,7 +53,9 @@ reservation:
error-telegram: "Username Telegram invalide (exemple : {'@'}mon_username)." error-telegram: "Username Telegram invalide (exemple : {'@'}mon_username)."
error-email: Une des adresses e-mail est invalide. error-email: Une des adresses e-mail est invalide.
error-form: Le formulaire contient des erreurs. error-form: Le formulaire contient des erreurs.
not-implemented: L'envoi n'est pas encore branché sur le backend. submitted: Demande envoyée. Elle apparaîtra dans les demandes en attente.
submitting: Envoi…
submit-error: L'envoi de la demande a échoué.
login: login:
title: Connexion title: Connexion
intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike. intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike.
@ -97,6 +99,7 @@ admin:
bikes: Cargobike(s) bikes: Cargobike(s)
telegram: Telegram telegram: Telegram
people: Personnes people: Personnes
linka: Comptes Linka Go
reason: Raison reason: Raison
load-error: Impossible de charger les réservations. load-error: Impossible de charger les réservations.
error: Le changement de statut a échoué. error: Le changement de statut a échoué.

View file

@ -1,11 +1,11 @@
/** /**
* Reservations, from the administration side. Reading the whole list is an * Reservations. Filing a request only needs a session; reading the whole list is
* admin action, so these hooks are only ever mounted on /admin. * an admin action, so `useReservations` is only ever mounted on /admin.
*/ */
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { HttpStatus } from 'http-status-ts' import { HttpStatus } from 'http-status-ts'
import type { Reservation, ReservationStatus } from '@/utils/types' import type { NewReservation, Reservation, ReservationStatus } from '@/utils/types'
import { getClient } from './client' import { getClient } from './client'
export const RESERVATIONS_KEY = ['reservations'] export const RESERVATIONS_KEY = ['reservations']
@ -47,3 +47,27 @@ export function useSetReservationStatus() {
}, },
}) })
} }
/**
* Files a request. The backend fills in the requester and the `requested`
* status, so neither is part of the body.
*/
export function useCreateReservation() {
const queryClient = useQueryClient()
return useMutation({
retry: 0,
mutationFn: async (reservation: NewReservation) => {
const { data, response, error } = await getClient().POST('/api/reservations', {
body: reservation,
})
if (response.status !== HttpStatus.CREATED) {
// The handler answers with a plain string, which is what to show
throw new Error(typeof error === 'string' ? error : `Unexpected status: ${response.status}`)
}
return data as Reservation
},
// The admin list is a different query: let it refetch rather than guessing
// where the new reservation belongs in its ordering.
onSuccess: () => queryClient.invalidateQueries({ queryKey: RESERVATIONS_KEY }),
})
}

View file

@ -27,6 +27,7 @@ export type Reservation = components['schemas']['Reservation']
export type ReservationStatus = components['schemas']['ReservationStatus'] export type ReservationStatus = components['schemas']['ReservationStatus']
export type UserSummary = components['schemas']['UserSummary'] export type UserSummary = components['schemas']['UserSummary']
export type ReservationUnit = components['schemas']['ReservationUnit'] export type ReservationUnit = components['schemas']['ReservationUnit']
export type NewReservation = components['schemas']['NewReservation']
/** /**
* What to display for a reservation's unit: the name of the unit it points at, * What to display for a reservation's unit: the name of the unit it points at,

View file

@ -16,8 +16,9 @@ import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { useBikes } from '@/services/api/bikes' import { useBikes } from '@/services/api/bikes'
import { useCreateReservation } from '@/services/api/reservations'
import { useSession } from '@/services/api/auth' import { useSession } from '@/services/api/auth'
import type { Bike } from '@/utils/types' import type { Bike, NewReservation } from '@/utils/types'
const { t } = useI18n() const { t } = useI18n()
@ -156,6 +157,27 @@ function reset() {
submitted.value = false submitted.value = false
} }
const create = useCreateReservation()
/** 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() { function submit() {
submitted.value = true submitted.value = true
if (!validate()) { if (!validate()) {
@ -163,9 +185,15 @@ function submit() {
return return
} }
// TODO: replace with a `useCreateReservation` mutation once create.mutate(payload(), {
// `POST /api/reservations` exists. onSuccess: () => {
toast.info(t('reservation.not-implemented')) toast.success(t('reservation.submitted'))
reset()
},
// 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(error.message || t('reservation.submit-error')),
})
} }
</script> </script>
@ -370,8 +398,15 @@ function submit() {
<!-- Actions --> <!-- Actions -->
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button type="submit">{{ $t('reservation.submit') }}</Button> <Button type="submit" :disabled="create.isPending.value">
<Button type="button" variant="outline" @click="reset()"> {{ create.isPending.value ? $t('reservation.submitting') : $t('reservation.submit') }}
</Button>
<Button
type="button"
variant="outline"
:disabled="create.isPending.value"
@click="reset()"
>
{{ $t('reservation.reset') }} {{ $t('reservation.reset') }}
</Button> </Button>
</div> </div>

View file

@ -41,9 +41,7 @@ async fn set_status(
) -> Result<(), (StatusCode, String)> { ) -> Result<(), (StatusCode, String)> {
match admin(ac)?.set_bike_status(id, status).await { match admin(ac)?.set_bike_status(id, status).await {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(err) if err.is_not_found() => { Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such bike".to_owned())),
Err((StatusCode::NOT_FOUND, "No such bike".to_owned()))
}
Err(err) => unexpected_error("set_bike_status", err), Err(err) => unexpected_error("set_bike_status", err),
} }
} }

View file

@ -1,7 +1,7 @@
use aide::transform::TransformResponse; use aide::transform::TransformResponse;
use axum::http::StatusCode;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use axum::http::StatusCode;
use tracing::error; use tracing::error;
use crate::core::{ use crate::core::{

View file

@ -1,6 +1,7 @@
//! Reservations, from the administration side. //! Reservations.
//! //!
//! Reading the whole list is an admin action for now; changing a status is //! Filing a request only needs a session — the requester is taken from it, never
//! from the body. Reading the whole list is an admin action for now; changing a status is
//! reserved to the unit the reservation belongs to (an admin manages every //! reserved to the unit the reservation belongs to (an admin manages every
//! unit), which is why the handler resolves the unit before narrowing the //! unit), which is why the handler resolves the unit before narrowing the
//! controller down. //! controller down.
@ -12,25 +13,25 @@ use aide::{
}, },
transform::TransformOperation, transform::TransformOperation,
}; };
use axum::{ use axum::{Json, extract::Path, http::StatusCode};
Json,
extract::Path,
http::StatusCode,
};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
api::helpers::{IdPath, admin, admin_desc, manager, manager_desc, unexpected_error}, api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error},
core::{ core::{
controller::{AppController, ControllerError, reservations::ReservationsControllerError}, controller::{AppController, ControllerError, reservations::ReservationsControllerError},
models::reservation::{Reservation, ReservationStatus}, models::reservation::{NewReservation, Reservation, ReservationStatus},
}, },
}; };
pub fn routes() -> ApiRouter { pub fn routes() -> ApiRouter {
ApiRouter::new() ApiRouter::new()
.api_route("/", get_with(get_reservations, get_reservations_docs)) .api_route(
"/",
get_with(get_reservations, get_reservations_docs)
.post_with(create_reservation, create_reservation_docs),
)
.api_route("/{id}/status", put_with(set_status, set_status_docs)) .api_route("/{id}/status", put_with(set_status, set_status_docs))
} }
@ -50,6 +51,47 @@ fn get_reservations_docs(op: TransformOperation) -> TransformOperation {
.response_with::<403, (), _>(admin_desc) .response_with::<403, (), _>(admin_desc)
} }
/// Files a request. The reservation always starts in `requested`: the status is
/// not part of the body, so a requester cannot approve their own booking.
#[axum::debug_handler]
async fn create_reservation(
ac: AppController,
Json(reservation): Json<NewReservation>,
) -> Result<(StatusCode, Json<Reservation>), (StatusCode, String)> {
match ac.create_reservation(reservation).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(ControllerError::Reservation(
err @ ReservationsControllerError::NotAMemberOfUnit(_),
)) => Err((StatusCode::FORBIDDEN, err.to_string())),
// An unknown unit id or bike id: the client named something that is gone
Err(err) if err.is_not_found() => Err((
StatusCode::UNPROCESSABLE_ENTITY,
"Unknown unit or bike".to_owned(),
)),
Err(err) => unexpected_error("create_reservation", err),
}
}
fn create_reservation_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations")
.summary("File a reservation request")
.description(
"The requester is the session user and the status starts at `requested`; \
neither is taken from the body.",
)
.response_with::<201, Json<Reservation>, _>(desc("The reservation, as stored"))
.response_with::<400, (), _>(desc("The reservation is malformed"))
.response_with::<403, (), _>(desc("The requester does not belong to the unit named"))
.response_with::<409, (), _>(desc("One of the bikes is out of service"))
.response_with::<422, (), _>(desc("The unit or one of the bikes does not exist"))
}
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct SetStatusForm { struct SetStatusForm {
status: ReservationStatus, status: ReservationStatus,
@ -75,9 +117,9 @@ async fn set_status(
.await .await
{ {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(ControllerError::Reservation(err @ ReservationsControllerError::InvalidTransition(..))) => { Err(ControllerError::Reservation(
Err((StatusCode::CONFLICT, err.to_string())) err @ ReservationsControllerError::InvalidTransition(..),
} )) => Err((StatusCode::CONFLICT, err.to_string())),
Err(err) => unexpected_error("set_status", err), Err(err) => unexpected_error("set_status", err),
} }
} }
@ -85,9 +127,7 @@ async fn set_status(
fn set_status_docs(op: TransformOperation) -> TransformOperation { fn set_status_docs(op: TransformOperation) -> TransformOperation {
op.tag("Reservations") op.tag("Reservations")
.summary("Move a reservation through its state machine") .summary("Move a reservation through its state machine")
.description( .description("Refuses a transition the state machine does not allow, with a 409.")
"Refuses a transition the state machine does not allow, with a 409.",
)
.response_with::<403, (), _>(manager_desc) .response_with::<403, (), _>(manager_desc)
.response::<404, ()>() .response::<404, ()>()
.response::<409, ()>() .response::<409, ()>()

View file

@ -76,9 +76,7 @@ impl AnonAppController {
self.db.save_whiskey_data(authorize_backend_data).await?; self.db.save_whiskey_data(authorize_backend_data).await?;
Ok(redirect_to) Ok(redirect_to)
} }
Err(WhiskeyError::ProtocolError) => { Err(WhiskeyError::ProtocolError) => Err(AuthnControllerError::OIDCProtocolError.into()),
Err(AuthnControllerError::OIDCProtocolError.into())
}
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError( Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"Originated from Whiskey".to_owned(), "Originated from Whiskey".to_owned(),
)), )),
@ -93,16 +91,18 @@ impl AnonAppController {
state: String, state: String,
) -> Result<User, ControllerError> { ) -> Result<User, ControllerError> {
// Consumes the state: a callback can never be replayed // Consumes the state: a callback can never be replayed
let backend_data = self.db.take_whiskey_data(state.clone()).await.map_err(|_| { let backend_data = self
.db
.take_whiskey_data(state.clone())
.await
.map_err(|_| {
debug!("unknown, already used or expired oidc state"); debug!("unknown, already used or expired oidc state");
AuthnControllerError::OIDCProtocolError AuthnControllerError::OIDCProtocolError
})?; })?;
match callback(code, state, backend_data).await { match callback(code, state, backend_data).await {
Ok(user_info) => self.upsert_oidc_user(user_info).await, Ok(user_info) => self.upsert_oidc_user(user_info).await,
Err(WhiskeyError::ProtocolError) => { Err(WhiskeyError::ProtocolError) => Err(AuthnControllerError::OIDCProtocolError.into()),
Err(AuthnControllerError::OIDCProtocolError.into())
}
Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError( Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError(
"originated from Whiskey".to_owned(), "originated from Whiskey".to_owned(),
)), )),

View file

@ -15,7 +15,6 @@ impl AnonAppController {
pub async fn get_bike(&self, id: BikeId) -> Result<Bike, ControllerError> { pub async fn get_bike(&self, id: BikeId) -> Result<Bike, ControllerError> {
self.db.get_bike(id).await.map_err(Into::into) self.db.get_bike(id).await.map_err(Into::into)
} }
} }
/// Changing the fleet is an admin action /// Changing the fleet is an admin action

View file

@ -5,10 +5,12 @@ use crate::core::{
models::{ models::{
bike::BikeStatus, bike::BikeStatus,
reservation::{ reservation::{
NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
ReservationStatus,
}, },
unit::UnitId, unit::UnitId,
}, },
repositories::RepositoryError,
}; };
/// Reading the reservations needs no session: the calendar is public. /// Reading the reservations needs no session: the calendar is public.
@ -30,7 +32,6 @@ impl AnonAppController {
pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> { pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> {
self.db.get_reservation(id).await.map_err(Into::into) self.db.get_reservation(id).await.map_err(Into::into)
} }
} }
/// Filing a request is done in one's own name: the requester is the session /// Filing a request is done in one's own name: the requester is the session
@ -43,6 +44,20 @@ impl AppController {
if !reservation.is_valid() { if !reservation.is_valid() {
return Err(ReservationsControllerError::ReservationInvalid.into()); return Err(ReservationsControllerError::ReservationInvalid.into());
} }
// Naming a known unit means claiming to act for it. An admin may file
// for any unit, but it still has to exist: without this the foreign key
// would be what rejects the insert, and that surfaces as a 500.
if let NewReservationUnit::Known { id } = reservation.unit {
let user = self.user();
if !user.units.iter().any(|unit| unit.id == id) {
if !user.admin {
return Err(ReservationsControllerError::NotAMemberOfUnit(id).into());
}
if !self.db.get_units().await?.iter().any(|unit| unit.id == id) {
return Err(RepositoryError::NotFound(format!("unit {id}")).into());
}
}
}
// A bike out of service cannot be booked // A bike out of service cannot be booked
for id in &reservation.bikes { for id in &reservation.bikes {
if self.get_bike(*id).await?.status == BikeStatus::OutOfService { if self.get_bike(*id).await?.status == BikeStatus::OutOfService {
@ -120,4 +135,6 @@ pub enum ReservationsControllerError {
ReservationFinal(ReservationStatus), ReservationFinal(ReservationStatus),
#[error("Bike {0} is out of service and cannot be booked")] #[error("Bike {0} is out of service and cannot be booked")]
BikeOutOfService(i32), BikeOutOfService(i32),
#[error("The requester does not belong to unit {0}")]
NotAMemberOfUnit(UnitId),
} }

View file

@ -21,7 +21,6 @@ impl AnonAppController {
.await .await
.map_err(Into::into) .map_err(Into::into)
} }
} }
impl AdminAppController { impl AdminAppController {

View file

@ -43,7 +43,6 @@ impl ReservationStatus {
} }
} }
/// The unit a reservation is filed for. /// The unit a reservation is filed for.
/// ///
/// Either one we know — a Whiskey group, with its row — or a plain name the /// Either one we know — a Whiskey group, with its row — or a plain name the
@ -107,6 +106,9 @@ pub struct Reservation {
pub telegram: String, pub telegram: String,
pub description: String, pub description: String,
pub bikes: Vec<BikeId>, pub bikes: Vec<BikeId>,
/// Linka Go accounts allowed to unlock the bikes. Plain addresses: they
/// need not belong to anybody who ever logs into this app.
pub linka_emails: Vec<String>,
pub status: ReservationStatus, pub status: ReservationStatus,
} }
@ -119,6 +121,7 @@ pub struct NewReservation {
pub telegram: String, pub telegram: String,
pub description: String, pub description: String,
pub bikes: Vec<BikeId>, pub bikes: Vec<BikeId>,
pub linka_emails: Vec<String>,
} }
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
@ -131,11 +134,36 @@ pub struct ReservationEdit {
pub telegram: String, pub telegram: String,
pub description: String, pub description: String,
pub bikes: Vec<BikeId>, pub bikes: Vec<BikeId>,
pub linka_emails: Vec<String>,
}
/// At least one address, none of them blank: a reservation nobody can unlock
/// is of no use to the admin who has to authorise it.
fn linka_emails_valid(emails: &[String]) -> bool {
!emails.is_empty() && emails.iter().all(|email| !email.trim().is_empty())
}
/// Mirrors the `reservations_telegram_handle` check constraint. Duplicated on
/// purpose: without it a bad handle only fails once it reaches postgres, which
/// surfaces as a 500 instead of "your handle is malformed".
fn telegram_valid(handle: &str) -> bool {
let Some(rest) = handle.strip_prefix('@') else {
return false;
};
let mut chars = rest.chars();
if !matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) {
return false;
}
(5..=32).contains(&rest.len()) && rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
} }
impl NewReservation { impl NewReservation {
pub fn is_valid(&self) -> bool { pub fn is_valid(&self) -> bool {
self.unit.is_valid() && self.end_time > self.start_time && !self.bikes.is_empty() self.unit.is_valid()
&& self.end_time > self.start_time
&& !self.bikes.is_empty()
&& telegram_valid(&self.telegram)
&& linka_emails_valid(&self.linka_emails)
} }
} }
@ -145,5 +173,41 @@ impl ReservationEdit {
&& self.end_time > self.start_time && self.end_time > self.start_time
&& !self.bikes.is_empty() && !self.bikes.is_empty()
&& !self.users.is_empty() && !self.users.is_empty()
&& telegram_valid(&self.telegram)
&& linka_emails_valid(&self.linka_emails)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn telegram_handles_match_the_database_constraint() {
assert!(telegram_valid("@milan_hyenne"));
assert!(telegram_valid("@abcde"));
assert!(telegram_valid(&format!("@a{}", "b".repeat(31))));
assert!(!telegram_valid("milan_hyenne"), "missing @");
assert!(!telegram_valid("@abcd"), "too short");
assert!(
!telegram_valid(&format!("@a{}", "b".repeat(32))),
"too long"
);
assert!(!telegram_valid("@1abcde"), "must start with a letter");
assert!(!telegram_valid("@abc-de"), "no dash");
assert!(!telegram_valid("@"), "nothing after the @");
assert!(
!telegram_valid("@élodie"),
"ascii only, as in the constraint"
);
}
#[test]
fn linka_emails_must_hold_at_least_one_filled_address() {
assert!(linka_emails_valid(&["a@b.ch".to_owned()]));
assert!(!linka_emails_valid(&[]));
assert!(!linka_emails_valid(&[" ".to_owned()]));
assert!(!linka_emails_valid(&["a@b.ch".to_owned(), "".to_owned()]));
} }
} }

View file

@ -21,8 +21,7 @@ pub trait UsersRepository {
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>; async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>;
/// Replaces the whole set of units the user belongs to /// Replaces the whole set of units the user belongs to
async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) async fn set_user_units(&self, id: UserId, units: Vec<UnitId>) -> Result<(), RepositoryError>;
-> Result<(), RepositoryError>;
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>; async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
} }

View file

@ -37,12 +37,9 @@ async fn main() {
// Anything that is not an api route is served from the built frontend, // Anything that is not an api route is served from the built frontend,
// falling back on index.html so the vue router can handle the path. // falling back on index.html so the vue router can handle the path.
// (`fallback` and not `not_found_service`, which would force a 404 status) // (`fallback` and not `not_found_service`, which would force a 404 status)
let app = api::get_router(aac).fallback_service( let app = api::get_router(aac).fallback_service(ServeDir::new(&config.frontend_dir).fallback(
ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!( ServeFile::new(format!("{}/index.html", config.frontend_dir)),
"{}/index.html", ));
config.frontend_dir
))),
);
let bind_address = config.get_bind_address(); let bind_address = config.get_bind_address();
let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap(); let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap();

View file

@ -71,6 +71,7 @@ struct ReservationDB {
pub telegram: String, pub telegram: String,
pub description: String, pub description: String,
pub status: ReservationStatusDB, pub status: ReservationStatusDB,
pub linka_emails: Vec<String>,
pub users: Value, pub users: Value,
pub bikes: Vec<i32>, pub bikes: Vec<i32>,
} }
@ -100,6 +101,7 @@ impl TryFrom<ReservationDB> for Reservation {
telegram: value.telegram, telegram: value.telegram,
description: value.description, description: value.description,
bikes: value.bikes, bikes: value.bikes,
linka_emails: value.linka_emails,
status: value.status.into(), status: value.status.into(),
}) })
} }
@ -155,6 +157,13 @@ fn split_unit(unit: &NewReservationUnit) -> (Option<i32>, Option<String>) {
} }
} }
/// Surrounding spaces never belong to an address, and trimming is what turns a
/// whitespace-only one into the empty string the `reservations_linka_emails_filled`
/// check rejects.
fn trim_emails(emails: &[String]) -> Vec<String> {
emails.iter().map(|email| email.trim().to_owned()).collect()
}
#[async_trait] #[async_trait]
impl ReservationsRepository for SqlxDatabase { impl ReservationsRepository for SqlxDatabase {
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> { async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> {
@ -172,6 +181,7 @@ impl ReservationsRepository for SqlxDatabase {
r.requester_id, r.requester_id,
r.telegram, r.telegram,
r."description", r."description",
r.linka_emails,
r.status AS "status: ReservationStatusDB", r.status AS "status: ReservationStatusDB",
COALESCE(( COALESCE((
SELECT json_agg(json_build_object( SELECT json_agg(json_build_object(
@ -217,6 +227,7 @@ impl ReservationsRepository for SqlxDatabase {
r.requester_id, r.requester_id,
r.telegram, r.telegram,
r."description", r."description",
r.linka_emails,
r.status AS "status: ReservationStatusDB", r.status AS "status: ReservationStatusDB",
COALESCE(( COALESCE((
SELECT json_agg(json_build_object( SELECT json_agg(json_build_object(
@ -261,6 +272,7 @@ impl ReservationsRepository for SqlxDatabase {
r.requester_id, r.requester_id,
r.telegram, r.telegram,
r."description", r."description",
r.linka_emails,
r.status AS "status: ReservationStatusDB", r.status AS "status: ReservationStatusDB",
COALESCE(( COALESCE((
SELECT json_agg(json_build_object( SELECT json_agg(json_build_object(
@ -297,10 +309,12 @@ impl ReservationsRepository for SqlxDatabase {
// No status here: the column defaults to 'requested', the start of the // No status here: the column defaults to 'requested', the start of the
// state machine. // state machine.
let (unit_id, unit_label) = split_unit(&reservation.unit); let (unit_id, unit_label) = split_unit(&reservation.unit);
let linka_emails = trim_emails(&reservation.linka_emails);
let id = query!( let id = query!(
r#"INSERT INTO reservations r#"INSERT INTO reservations
(unit_id, unit_label, start_time, end_time, requester_id, telegram, "description") (unit_id, unit_label, start_time, end_time, requester_id, telegram,
VALUES ($1, $2, $3, $4, $5, $6, $7) "description", linka_emails)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id"#, RETURNING id"#,
unit_id, unit_id,
unit_label, unit_label,
@ -308,7 +322,8 @@ impl ReservationsRepository for SqlxDatabase {
reservation.end_time, reservation.end_time,
requester, requester,
reservation.telegram, reservation.telegram,
reservation.description reservation.description,
&linka_emails
) )
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await? .await?
@ -336,10 +351,11 @@ impl ReservationsRepository for SqlxDatabase {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
let (unit_id, unit_label) = split_unit(&reservation.unit); let (unit_id, unit_label) = split_unit(&reservation.unit);
let linka_emails = trim_emails(&reservation.linka_emails);
let result = query!( let result = query!(
r#"UPDATE reservations r#"UPDATE reservations
SET unit_id = $2, unit_label = $3, start_time = $4, end_time = $5, SET unit_id = $2, unit_label = $3, start_time = $4, end_time = $5,
telegram = $6, "description" = $7 telegram = $6, "description" = $7, linka_emails = $8
WHERE id = $1"#, WHERE id = $1"#,
reservation.id, reservation.id,
unit_id, unit_id,
@ -347,7 +363,8 @@ impl ReservationsRepository for SqlxDatabase {
reservation.start_time, reservation.start_time,
reservation.end_time, reservation.end_time,
reservation.telegram, reservation.telegram,
reservation.description reservation.description,
&linka_emails
) )
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;

View file

@ -55,9 +55,7 @@ async fn get_client() -> &'static Client {
ClientId::new(config.oidc.client_id), ClientId::new(config.oidc.client_id),
Some(ClientSecret::new(config.oidc.client_secret)), Some(ClientSecret::new(config.oidc.client_secret)),
) )
.set_redirect_uri( .set_redirect_uri(RedirectUrl::new(redirect_url).unwrap())
RedirectUrl::new(redirect_url).unwrap(),
)
}) })
.await .await
} }
@ -247,12 +245,11 @@ async fn groups_from_userinfo(
} }
}; };
match request match request.request_async(get_http_client()).await.map(
.request_async(get_http_client()) |claims: openidconnect::UserInfoClaims<WhiskeyClaims, CoreGenderClaim>| {
.await
.map(|claims: openidconnect::UserInfoClaims<WhiskeyClaims, CoreGenderClaim>| {
claims.additional_claims().groups.clone() claims.additional_claims().groups.clone()
}) { },
) {
Ok(groups) => groups, Ok(groups) => groups,
Err(err) => { Err(err) => {
debug!("userinfo request failed: {err:?}"); debug!("userinfo request failed: {err:?}");