From 035f82cb88c99109986ec22b4eccb11540e42f79 Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Mon, 24 Aug 2026 12:00:58 +0200 Subject: [PATCH] feat: reservation page final --- ...0260824140000_reservation_linka_emails.sql | 16 ++++ db/schema.sql | 5 +- db/seed.sql | 14 ++- .../src/components/admin/ReservationCard.vue | 4 + frontend/src/lib/api.d.ts | 94 ++++++++++++++++++- frontend/src/locales/en.yml | 5 +- frontend/src/locales/fr.yml | 5 +- frontend/src/services/api/reservations.ts | 30 +++++- frontend/src/utils/types.ts | 1 + frontend/src/views/ReservationView.vue | 47 ++++++++-- src/api/bikes.rs | 4 +- src/api/helpers.rs | 2 +- src/api/reservations.rs | 72 ++++++++++---- src/core/controller/authn.rs | 20 ++-- src/core/controller/bikes.rs | 1 - src/core/controller/reservations.rs | 21 ++++- src/core/controller/users.rs | 1 - src/core/models/reservation.rs | 68 +++++++++++++- src/core/repositories/users_repository.rs | 3 +- src/main.rs | 9 +- src/services/database/reservations.rs | 27 +++++- src/utils/whiskey.rs | 13 +-- 22 files changed, 389 insertions(+), 73 deletions(-) create mode 100644 db/migrations/20260824140000_reservation_linka_emails.sql diff --git a/db/migrations/20260824140000_reservation_linka_emails.sql b/db/migrations/20260824140000_reservation_linka_emails.sql new file mode 100644 index 0000000..d42032b --- /dev/null +++ b/db/migrations/20260824140000_reservation_linka_emails.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql index f3eac39..c182d8d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -115,6 +115,8 @@ CREATE TABLE public.reservations ( status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL, unit_id integer, 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_time_order CHECK ((end_time > start_time)), 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'), ('20260823210000'), ('20260823230000'), - ('20260824120000'); + ('20260824120000'), + ('20260824140000'); diff --git a/db/seed.sql b/db/seed.sql index 0048f40..4d537c0 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -42,40 +42,48 @@ ON CONFLICT DO NOTHING; -- 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()`. INSERT INTO public.reservations - (id, unit_id, start_time, end_time, requester_id, telegram, "description", status) -SELECT r.id, u.id, r.start_time, r.end_time, r.requester_id, r.telegram, r."description", r.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.linka_emails, r.status FROM (VALUES (1, 'agepoly', date_trunc('day', now()) - interval '1 day' + interval '8 hours', date_trunc('day', now()) + interval '1 day' + interval '18 hours', 1, '@alice_martin', 'Transport du matériel pour la rentrée', + ARRAY['alice.martin@epfl.ch'], 'ongoing'::reservation_status), (2, 'clic', date_trunc('day', now()) + interval '1 day' + interval '9 hours', date_trunc('day', now()) + interval '2 days' + interval '17 hours', 3, '@chloe_favre', 'Déménagement du stock de la commission', + ARRAY['chloe.favre@epfl.ch'], 'approved'::reservation_status), (3, 'agepoly', date_trunc('day', now()) + interval '3 days' + interval '10 hours', date_trunc('day', now()) + interval '3 days' + interval '19 hours', 2, '@bob_dupont', 'Livraison des boissons pour la soirée', + ARRAY['bob.dupont@epfl.ch','alice.martin@epfl.ch'], 'requested'::reservation_status), (4, 'clic', date_trunc('day', now()) + interval '4 days' + interval '7 hours', date_trunc('day', now()) + interval '4 days' + interval '12 hours', 3, '@chloe_favre', 'Récupération de matériel informatique', + ARRAY['chloe.favre@epfl.ch'], 'requested'::reservation_status), (5, 'agepoly', date_trunc('day', now()) - interval '20 days' + interval '9 hours', date_trunc('day', now()) - interval '19 days' + interval '18 hours', 1, '@alice_martin', 'Ancienne sortie, archivée', + ARRAY['alice.martin@epfl.ch'], 'archived'::reservation_status), (6, 'clic', date_trunc('day', now()) + interval '6 days' + interval '14 hours', date_trunc('day', now()) + interval '6 days' + interval '18 hours', 2, '@bob_dupont', 'Annulée faute de conducteur', + ARRAY['bob.dupont@epfl.ch'], '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 ON CONFLICT DO NOTHING; diff --git a/frontend/src/components/admin/ReservationCard.vue b/frontend/src/components/admin/ReservationCard.vue index b94794a..c24c4d8 100644 --- a/frontend/src/components/admin/ReservationCard.vue +++ b/frontend/src/components/admin/ReservationCard.vue @@ -109,6 +109,10 @@ function move(status: ReservationStatus) { {{ reservation.users.map((u) => `${u.firstname} ${u.name} <${u.email}>`).join(', ') }} +
+
{{ $t('admin.reservations.linka') }}
+
{{ reservation.linka_emails.join(', ') }}
+
{{ $t('admin.reservations.reason') }}
{{ reservation.description }}
diff --git a/frontend/src/lib/api.d.ts b/frontend/src/lib/api.d.ts index 4bd6b1e..dbd891a 100644 --- a/frontend/src/lib/api.d.ts +++ b/frontend/src/lib/api.d.ts @@ -438,7 +438,69 @@ export interface paths { } } 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 options?: never head?: never @@ -557,6 +619,31 @@ export interface components { /** @description Email of one of the `dev_users` of the configuration */ 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: { code: string state: string @@ -568,6 +655,11 @@ export interface components { end_time: string /** Format: int32 */ 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 */ requester: number /** Format: date-time */ diff --git a/frontend/src/locales/en.yml b/frontend/src/locales/en.yml index 922e6ba..1a8e77c 100644 --- a/frontend/src/locales/en.yml +++ b/frontend/src/locales/en.yml @@ -52,7 +52,9 @@ reservation: error-telegram: "Invalid Telegram username (example: {'@'}my_username)." error-email: One of the email addresses is invalid. 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: title: Log in intro: Log in with your AGEPoly account to book a cargobike. @@ -96,6 +98,7 @@ admin: bikes: Cargobike(s) telegram: Telegram people: People + linka: Linka Go accounts reason: Reason load-error: Unable to load the reservations. error: The status change failed. diff --git a/frontend/src/locales/fr.yml b/frontend/src/locales/fr.yml index 1fca4b6..a70412a 100644 --- a/frontend/src/locales/fr.yml +++ b/frontend/src/locales/fr.yml @@ -53,7 +53,9 @@ reservation: error-telegram: "Username Telegram invalide (exemple : {'@'}mon_username)." error-email: Une des adresses e-mail est invalide. 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: title: Connexion intro: Connectez-vous avec votre compte AGEPoly pour réserver un cargobike. @@ -97,6 +99,7 @@ admin: bikes: Cargobike(s) telegram: Telegram people: Personnes + linka: Comptes Linka Go reason: Raison load-error: Impossible de charger les réservations. error: Le changement de statut a échoué. diff --git a/frontend/src/services/api/reservations.ts b/frontend/src/services/api/reservations.ts index cf7c0c0..b7bd8bb 100644 --- a/frontend/src/services/api/reservations.ts +++ b/frontend/src/services/api/reservations.ts @@ -1,11 +1,11 @@ /** - * Reservations, from the administration side. Reading the whole list is an - * admin action, so these hooks are only ever mounted on /admin. + * Reservations. Filing a request only needs a session; reading the whole list is + * an admin action, so `useReservations` is only ever mounted on /admin. */ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' 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' 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 }), + }) +} diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index a8a3840..4557f5d 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -27,6 +27,7 @@ export type Reservation = components['schemas']['Reservation'] export type ReservationStatus = components['schemas']['ReservationStatus'] export type UserSummary = components['schemas']['UserSummary'] 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, diff --git a/frontend/src/views/ReservationView.vue b/frontend/src/views/ReservationView.vue index a38f9c6..1a92c1c 100644 --- a/frontend/src/views/ReservationView.vue +++ b/frontend/src/views/ReservationView.vue @@ -16,8 +16,9 @@ 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 { useCreateReservation } from '@/services/api/reservations' import { useSession } from '@/services/api/auth' -import type { Bike } from '@/utils/types' +import type { Bike, NewReservation } from '@/utils/types' const { t } = useI18n() @@ -156,6 +157,27 @@ function reset() { 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() { submitted.value = true if (!validate()) { @@ -163,9 +185,15 @@ function submit() { return } - // TODO: replace with a `useCreateReservation` mutation once - // `POST /api/reservations` exists. - toast.info(t('reservation.not-implemented')) + create.mutate(payload(), { + onSuccess: () => { + 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')), + }) } @@ -370,8 +398,15 @@ function submit() {
- - +
diff --git a/src/api/bikes.rs b/src/api/bikes.rs index 2cc4dae..908ecc3 100644 --- a/src/api/bikes.rs +++ b/src/api/bikes.rs @@ -41,9 +41,7 @@ async fn set_status( ) -> Result<(), (StatusCode, String)> { match admin(ac)?.set_bike_status(id, status).await { Ok(()) => Ok(()), - Err(err) if err.is_not_found() => { - Err((StatusCode::NOT_FOUND, "No such bike".to_owned())) - } + Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such bike".to_owned())), Err(err) => unexpected_error("set_bike_status", err), } } diff --git a/src/api/helpers.rs b/src/api/helpers.rs index f0d4270..f070143 100644 --- a/src/api/helpers.rs +++ b/src/api/helpers.rs @@ -1,7 +1,7 @@ use aide::transform::TransformResponse; +use axum::http::StatusCode; use schemars::JsonSchema; use serde::Deserialize; -use axum::http::StatusCode; use tracing::error; use crate::core::{ diff --git a/src/api/reservations.rs b/src/api/reservations.rs index e1882b0..3a412bb 100644 --- a/src/api/reservations.rs +++ b/src/api/reservations.rs @@ -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 //! unit), which is why the handler resolves the unit before narrowing the //! controller down. @@ -12,25 +13,25 @@ use aide::{ }, transform::TransformOperation, }; -use axum::{ - Json, - extract::Path, - http::StatusCode, -}; +use axum::{Json, extract::Path, http::StatusCode}; use schemars::JsonSchema; use serde::Deserialize; 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::{ controller::{AppController, ControllerError, reservations::ReservationsControllerError}, - models::reservation::{Reservation, ReservationStatus}, + models::reservation::{NewReservation, Reservation, ReservationStatus}, }, }; pub fn routes() -> ApiRouter { 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)) } @@ -50,6 +51,47 @@ fn get_reservations_docs(op: TransformOperation) -> TransformOperation { .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, +) -> Result<(StatusCode, Json), (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, _>(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)] struct SetStatusForm { status: ReservationStatus, @@ -75,9 +117,9 @@ async fn set_status( .await { Ok(()) => Ok(()), - Err(ControllerError::Reservation(err @ ReservationsControllerError::InvalidTransition(..))) => { - Err((StatusCode::CONFLICT, err.to_string())) - } + Err(ControllerError::Reservation( + err @ ReservationsControllerError::InvalidTransition(..), + )) => Err((StatusCode::CONFLICT, err.to_string())), Err(err) => unexpected_error("set_status", err), } } @@ -85,9 +127,7 @@ async fn set_status( fn set_status_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Move a reservation through its state machine") - .description( - "Refuses a transition the state machine does not allow, with a 409.", - ) + .description("Refuses a transition the state machine does not allow, with a 409.") .response_with::<403, (), _>(manager_desc) .response::<404, ()>() .response::<409, ()>() diff --git a/src/core/controller/authn.rs b/src/core/controller/authn.rs index ca30590..176cc88 100644 --- a/src/core/controller/authn.rs +++ b/src/core/controller/authn.rs @@ -76,9 +76,7 @@ impl AnonAppController { self.db.save_whiskey_data(authorize_backend_data).await?; Ok(redirect_to) } - Err(WhiskeyError::ProtocolError) => { - Err(AuthnControllerError::OIDCProtocolError.into()) - } + Err(WhiskeyError::ProtocolError) => Err(AuthnControllerError::OIDCProtocolError.into()), Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError( "Originated from Whiskey".to_owned(), )), @@ -93,16 +91,18 @@ impl AnonAppController { state: String, ) -> Result { // Consumes the state: a callback can never be replayed - let backend_data = self.db.take_whiskey_data(state.clone()).await.map_err(|_| { - debug!("unknown, already used or expired oidc state"); - AuthnControllerError::OIDCProtocolError - })?; + let backend_data = self + .db + .take_whiskey_data(state.clone()) + .await + .map_err(|_| { + debug!("unknown, already used or expired oidc state"); + AuthnControllerError::OIDCProtocolError + })?; match callback(code, state, backend_data).await { Ok(user_info) => self.upsert_oidc_user(user_info).await, - Err(WhiskeyError::ProtocolError) => { - Err(AuthnControllerError::OIDCProtocolError.into()) - } + Err(WhiskeyError::ProtocolError) => Err(AuthnControllerError::OIDCProtocolError.into()), Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError( "originated from Whiskey".to_owned(), )), diff --git a/src/core/controller/bikes.rs b/src/core/controller/bikes.rs index 3eb046c..04b5218 100644 --- a/src/core/controller/bikes.rs +++ b/src/core/controller/bikes.rs @@ -15,7 +15,6 @@ impl AnonAppController { pub async fn get_bike(&self, id: BikeId) -> Result { self.db.get_bike(id).await.map_err(Into::into) } - } /// Changing the fleet is an admin action diff --git a/src/core/controller/reservations.rs b/src/core/controller/reservations.rs index 19882a9..0f41ac0 100644 --- a/src/core/controller/reservations.rs +++ b/src/core/controller/reservations.rs @@ -5,10 +5,12 @@ use crate::core::{ models::{ bike::BikeStatus, reservation::{ - NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, + NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId, + ReservationStatus, }, unit::UnitId, }, + repositories::RepositoryError, }; /// 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 { 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 @@ -43,6 +44,20 @@ impl AppController { if !reservation.is_valid() { 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 for id in &reservation.bikes { if self.get_bike(*id).await?.status == BikeStatus::OutOfService { @@ -120,4 +135,6 @@ pub enum ReservationsControllerError { ReservationFinal(ReservationStatus), #[error("Bike {0} is out of service and cannot be booked")] BikeOutOfService(i32), + #[error("The requester does not belong to unit {0}")] + NotAMemberOfUnit(UnitId), } diff --git a/src/core/controller/users.rs b/src/core/controller/users.rs index 594599c..28b0300 100644 --- a/src/core/controller/users.rs +++ b/src/core/controller/users.rs @@ -21,7 +21,6 @@ impl AnonAppController { .await .map_err(Into::into) } - } impl AdminAppController { diff --git a/src/core/models/reservation.rs b/src/core/models/reservation.rs index 9a1645b..c617ae5 100644 --- a/src/core/models/reservation.rs +++ b/src/core/models/reservation.rs @@ -43,7 +43,6 @@ impl ReservationStatus { } } - /// The unit a reservation is filed for. /// /// 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 description: String, pub bikes: Vec, + /// 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, pub status: ReservationStatus, } @@ -119,6 +121,7 @@ pub struct NewReservation { pub telegram: String, pub description: String, pub bikes: Vec, + pub linka_emails: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] @@ -131,11 +134,36 @@ pub struct ReservationEdit { pub telegram: String, pub description: String, pub bikes: Vec, + pub linka_emails: Vec, +} + +/// 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 { 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.bikes.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()])); } } diff --git a/src/core/repositories/users_repository.rs b/src/core/repositories/users_repository.rs index d049e90..3e3d40a 100644 --- a/src/core/repositories/users_repository.rs +++ b/src/core/repositories/users_repository.rs @@ -21,8 +21,7 @@ pub trait UsersRepository { async fn upsert_user(&self, user: NewUser) -> Result; /// Replaces the whole set of units the user belongs to - async fn set_user_units(&self, id: UserId, units: Vec) - -> Result<(), RepositoryError>; + async fn set_user_units(&self, id: UserId, units: Vec) -> Result<(), RepositoryError>; async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>; } diff --git a/src/main.rs b/src/main.rs index e0d6c39..e639032 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,12 +37,9 @@ async fn main() { // 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) - let app = api::get_router(aac).fallback_service( - ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!( - "{}/index.html", - config.frontend_dir - ))), - ); + let app = api::get_router(aac).fallback_service(ServeDir::new(&config.frontend_dir).fallback( + ServeFile::new(format!("{}/index.html", config.frontend_dir)), + )); let bind_address = config.get_bind_address(); let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap(); diff --git a/src/services/database/reservations.rs b/src/services/database/reservations.rs index 4048527..edc1dae 100644 --- a/src/services/database/reservations.rs +++ b/src/services/database/reservations.rs @@ -71,6 +71,7 @@ struct ReservationDB { pub telegram: String, pub description: String, pub status: ReservationStatusDB, + pub linka_emails: Vec, pub users: Value, pub bikes: Vec, } @@ -100,6 +101,7 @@ impl TryFrom for Reservation { telegram: value.telegram, description: value.description, bikes: value.bikes, + linka_emails: value.linka_emails, status: value.status.into(), }) } @@ -155,6 +157,13 @@ fn split_unit(unit: &NewReservationUnit) -> (Option, Option) { } } +/// 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 { + emails.iter().map(|email| email.trim().to_owned()).collect() +} + #[async_trait] impl ReservationsRepository for SqlxDatabase { async fn get_reservations(&self) -> Result, RepositoryError> { @@ -172,6 +181,7 @@ impl ReservationsRepository for SqlxDatabase { r.requester_id, r.telegram, r."description", + r.linka_emails, r.status AS "status: ReservationStatusDB", COALESCE(( SELECT json_agg(json_build_object( @@ -217,6 +227,7 @@ impl ReservationsRepository for SqlxDatabase { r.requester_id, r.telegram, r."description", + r.linka_emails, r.status AS "status: ReservationStatusDB", COALESCE(( SELECT json_agg(json_build_object( @@ -261,6 +272,7 @@ impl ReservationsRepository for SqlxDatabase { r.requester_id, r.telegram, r."description", + r.linka_emails, r.status AS "status: ReservationStatusDB", COALESCE(( 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 // state machine. let (unit_id, unit_label) = split_unit(&reservation.unit); + let linka_emails = trim_emails(&reservation.linka_emails); let id = query!( r#"INSERT INTO reservations - (unit_id, unit_label, start_time, end_time, requester_id, telegram, "description") - VALUES ($1, $2, $3, $4, $5, $6, $7) + (unit_id, unit_label, start_time, end_time, requester_id, telegram, + "description", linka_emails) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id"#, unit_id, unit_label, @@ -308,7 +322,8 @@ impl ReservationsRepository for SqlxDatabase { reservation.end_time, requester, reservation.telegram, - reservation.description + reservation.description, + &linka_emails ) .fetch_one(&mut *tx) .await? @@ -336,10 +351,11 @@ impl ReservationsRepository for SqlxDatabase { let mut tx = self.pool.begin().await?; let (unit_id, unit_label) = split_unit(&reservation.unit); + let linka_emails = trim_emails(&reservation.linka_emails); let result = query!( r#"UPDATE reservations 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"#, reservation.id, unit_id, @@ -347,7 +363,8 @@ impl ReservationsRepository for SqlxDatabase { reservation.start_time, reservation.end_time, reservation.telegram, - reservation.description + reservation.description, + &linka_emails ) .execute(&mut *tx) .await?; diff --git a/src/utils/whiskey.rs b/src/utils/whiskey.rs index 064c989..47516bf 100644 --- a/src/utils/whiskey.rs +++ b/src/utils/whiskey.rs @@ -55,9 +55,7 @@ async fn get_client() -> &'static Client { ClientId::new(config.oidc.client_id), Some(ClientSecret::new(config.oidc.client_secret)), ) - .set_redirect_uri( - RedirectUrl::new(redirect_url).unwrap(), - ) + .set_redirect_uri(RedirectUrl::new(redirect_url).unwrap()) }) .await } @@ -247,12 +245,11 @@ async fn groups_from_userinfo( } }; - match request - .request_async(get_http_client()) - .await - .map(|claims: openidconnect::UserInfoClaims| { + match request.request_async(get_http_client()).await.map( + |claims: openidconnect::UserInfoClaims| { claims.additional_claims().groups.clone() - }) { + }, + ) { Ok(groups) => groups, Err(err) => { debug!("userinfo request failed: {err:?}");