//! Reservations. //! //! 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. use aide::{ axum::{ ApiRouter, routing::{get_with, put_with}, }, transform::TransformOperation, }; use axum::{Json, extract::Path, http::StatusCode}; use chrono::{DateTime, Utc}; use schemars::JsonSchema; use serde::Deserialize; use crate::{ api::helpers::{IdPath, admin, admin_desc, desc, manager, manager_desc, unexpected_error}, core::{ controller::{ AnonAppController, AppController, ControllerError, reservations::ReservationsControllerError, }, models::{ bike::BikeId, reservation::{ CalendarReservation, NewReservation, Reservation, ReservationEdit, ReservationStatus, }, }, }, }; pub fn routes() -> ApiRouter { ApiRouter::new() .api_route( "/", get_with(get_reservations, get_reservations_docs) .post_with(create_reservation, create_reservation_docs), ) // Before `/{id}`, which would otherwise be a candidate for these .api_route( "/mine", get_with(get_my_reservations, get_my_reservations_docs), ) .api_route( "/calendar", get_with(get_calendar_reservations, get_calendar_reservations_docs), ) .api_route( "/{id}", put_with(update_reservation, update_reservation_docs), ) .api_route("/{id}/status", put_with(set_status, set_status_docs)) } #[axum::debug_handler] async fn get_reservations( ac: AppController, ) -> Result>, (StatusCode, String)> { match admin(ac)?.get_reservations().await { Ok(reservations) => Ok(Json(reservations)), Err(err) => unexpected_error("get_reservations", err), } } fn get_reservations_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Get every reservation") .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")) } /// The availability calendar, open to everybody: when the bikes are taken and by /// which association, with nothing personal attached. #[axum::debug_handler] async fn get_calendar_reservations( aac: AnonAppController, ) -> Result>, (StatusCode, String)> { match aac.get_calendar_reservations().await { Ok(reservations) => Ok(Json(reservations)), Err(err) => unexpected_error("get_calendar_reservations", err), } } fn get_calendar_reservations_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Get the approved and ongoing reservations, for the public calendar") .description( "No session needed, and no personal field travels: the telegram handle, \ the people, the Linka Go addresses and the reason are left out.", ) } /// Everything the session user is part of: what they filed, what they were /// added to, and what lists their address among the Linka Go accounts. #[axum::debug_handler] async fn get_my_reservations( ac: AppController, ) -> Result>, (StatusCode, String)> { match ac.get_my_reservations().await { Ok(reservations) => Ok(Json(reservations)), Err(err) => unexpected_error("get_my_reservations", err), } } fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Get the reservations the session user is part of") .description( "An address listed among the Linka Go accounts is enough, which is how \ somebody added before they ever logged in finds the reservation waiting \ for them.", ) } /// 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. #[derive(Debug, Deserialize, JsonSchema)] struct ReservationEditForm { start_time: DateTime, end_time: DateTime, bikes: Vec, linka_emails: Vec, } #[axum::debug_handler] async fn update_reservation( ac: AppController, Path(IdPath { id }): Path, Json(form): Json, ) -> Result<(), (StatusCode, String)> { let current = match ac.get_reservation(id).await { Ok(reservation) => reservation, Err(err) if err.is_not_found() => { return Err((StatusCode::NOT_FOUND, "No such reservation".to_owned())); } Err(err) => return unexpected_error("update_reservation", err), }; let edit = ReservationEdit { id, unit: current.unit.as_new(), start_time: form.start_time, end_time: form.end_time, users: current.users.iter().map(|user| user.id).collect(), telegram: current.telegram, description: current.description, bikes: form.bikes, linka_emails: form.linka_emails, }; match ac.update_reservation(edit).await { Ok(()) => Ok(()), Err(ControllerError::Reservation( err @ ReservationsControllerError::ReservationInvalid, )) => Err((StatusCode::BAD_REQUEST, err.to_string())), Err(ControllerError::Reservation( err @ (ReservationsControllerError::BikeOutOfService(_) | ReservationsControllerError::ReservationFinal(_)), )) => Err((StatusCode::CONFLICT, err.to_string())), Err(ControllerError::Reservation( err @ (ReservationsControllerError::NotOnTheReservation | ReservationsControllerError::OnlyEmailsEditable(_)), )) => Err((StatusCode::FORBIDDEN, err.to_string())), Err(err) if err.is_not_found() => { Err((StatusCode::UNPROCESSABLE_ENTITY, "Unknown bike".to_owned())) } Err(err) => unexpected_error("update_reservation", err), } } fn update_reservation_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Edit the period, the bikes and the Linka Go accounts") .description( "Everything else is left as stored. A manager may change all three until \ the reservation is final; anybody else on it may do so only while it is \ still a request, and afterwards only the Linka Go accounts.", ) .response_with::<403, (), _>(desc( "The user is not on the reservation, or tried to change more than the \ Linka Go accounts on one that is already approved", )) .response::<404, ()>() .response_with::<400, (), _>(desc("The reservation would become malformed")) .response_with::<409, (), _>(desc( "A newly added bike is out of service, or the reservation is final", )) .response_with::<422, (), _>(desc("One of the bikes does not exist")) } #[derive(Debug, Deserialize, JsonSchema)] struct SetStatusForm { status: ReservationStatus, } #[axum::debug_handler] async fn set_status( ac: AppController, Path(IdPath { id }): Path, Json(SetStatusForm { status }): Json, ) -> Result<(), (StatusCode, String)> { // The unit is not in the body: it is the reservation's own let reservation = match ac.get_reservation(id).await { Ok(reservation) => reservation, Err(err) if err.is_not_found() => { return Err((StatusCode::NOT_FOUND, "No such reservation".to_owned())); } Err(err) => return unexpected_error("set_status", err), }; match manager(ac, reservation.unit.scope())? .set_reservation_status(id, status) .await { Ok(()) => Ok(()), Err(ControllerError::Reservation( err @ ReservationsControllerError::InvalidTransition(..), )) => Err((StatusCode::CONFLICT, err.to_string())), Err(err) => unexpected_error("set_status", err), } } 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.") .response_with::<403, (), _>(manager_desc) .response::<404, ()>() .response::<409, ()>() }