//! 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, post_with, put_with}, }, transform::TransformOperation, }; use axum::{ Json, extract::{Path, Query}, 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::{ reservation::{ CalendarReservation, Conflict, ConflictProbe, NewReservation, NewReservationBike, Reservation, ReservationEdit, ReservationId, ReservationPage, ReservationQuery, ReservationStatus, }, user::Person, }, }, }; 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( "/for", post_with(create_reservation_for, create_reservation_for_docs), ) .api_route("/conflicts", post_with(find_conflicts, find_conflicts_docs)) .api_route( "/{id}", put_with(update_reservation, update_reservation_docs), ) .api_route("/{id}/status", put_with(set_status, set_status_docs)) } /// The listing filters, the way a query string carries them. /// /// Everything is optional and everything narrows, so the same route serves the /// three sections of the admin page and the archive browser. The alternative — /// handing the whole table to the browser and filtering it there — stops /// working the day the archive is a few thousand rows long. #[derive(Debug, Deserialize, JsonSchema)] struct ReservationSearchParams { /// Comma-separated statuses (`requested,approved`); left out means any status: Option, /// Free text: the unit, the telegram handle, the people, the Linka Go /// addresses, the reason, or the number of a reservation q: Option, /// Keeps only what overlaps the window from: Option>, to: Option>, limit: Option, offset: Option, } impl TryFrom for ReservationQuery { type Error = String; fn try_from(params: ReservationSearchParams) -> Result { let statuses = params .status .as_deref() .unwrap_or_default() .split(',') .map(str::trim) .filter(|status| !status.is_empty()) .map(|status| status.parse::()) .collect::, _>>() .map_err(|err| err.to_string())?; Ok(ReservationQuery { statuses, // Set by the handler that needs it, from the session involving: None, search: params.q, from: params.from, to: params.to, limit: params.limit.unwrap_or(ReservationQuery::DEFAULT_LIMIT), offset: params.offset.unwrap_or(0), }) } } #[axum::debug_handler] async fn get_reservations( ac: AppController, Query(params): Query, ) -> Result, (StatusCode, String)> { let query = ReservationQuery::try_from(params).map_err(|err| (StatusCode::BAD_REQUEST, err))?; match admin(ac)?.search_reservations(query).await { Ok(page) => Ok(Json(page)), Err(err) => unexpected_error("get_reservations", err), } } fn get_reservations_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Get a page of the reservations, filtered") .description( "The filtering, the text search and the paging all happen in the database: no caller ever receives the whole table.", ) .response_with::<400, (), _>(desc("A status in the filter is not a known one")) .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(_) | ReservationsControllerError::BikesTaken(_)), )) => 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, or is already held over that period", )) .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, ) -> Result<(StatusCode, Json), (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, _>(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. #[derive(Debug, Deserialize, JsonSchema)] struct CalendarWindow { from: Option>, to: Option>, } #[axum::debug_handler] async fn get_calendar_reservations( aac: AnonAppController, Query(window): Query, ) -> Result>, (StatusCode, String)> { match aac.get_calendar_reservations(window.from, window.to).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( "Give `from` and `to` to read one week rather than the whole table. 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, Query(params): Query, ) -> Result, (StatusCode, String)> { let query = ReservationQuery::try_from(params).map_err(|err| (StatusCode::BAD_REQUEST, err))?; match ac.get_my_reservations(query).await { Ok(page) => Ok(Json(page)), Err(err) => unexpected_error("get_my_reservations", err), } } fn get_my_reservations_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Get a page of the reservations the session user is part of") .description( "Takes the same filters as the listing, and answers the same page. An \ address listed among the Linka Go accounts is enough to be part of a \ reservation, which is how somebody added before they ever logged in \ finds the one waiting for them.", ) .response_with::<400, (), _>(desc("A status in the filter is not a known one")) } /// What a would-be booking asks about: a period, the bikes wanted, and the /// reservation to leave out of the answer when one is being edited. #[derive(Debug, Deserialize, JsonSchema)] struct ConflictProbeForm { /// The reservation being edited, so it does not conflict with itself reservation: Option, start_time: DateTime, end_time: DateTime, bikes: Vec, } /// A read, not a write, but the question does not fit in a query string: each /// bike may carry a period of its own. #[axum::debug_handler] async fn find_conflicts( ac: AppController, Json(form): Json, ) -> Result>, (StatusCode, String)> { if form.end_time <= form.start_time { return Err((StatusCode::BAD_REQUEST, "Empty period".to_owned())); } let probe = ConflictProbe { reservation: form.reservation, start_time: form.start_time, end_time: form.end_time, bikes: form.bikes, }; match ac.find_conflicts(probe).await { Ok(conflicts) => Ok(Json(conflicts)), Err(err) => unexpected_error("find_conflicts", err), } } fn find_conflicts_docs(op: TransformOperation) -> TransformOperation { op.tag("Reservations") .summary("Which of the bikes wanted are already held over the same period") .description( "Only approved and ongoing reservations hold a bike, so only they appear \ here. The booking form uses it to grey out what is taken, and the admin \ page to warn before double-booking on purpose.", ) .response_with::<400, (), _>(desc("The period is empty or the wrong way round")) } /// 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. #[derive(Debug, Deserialize, JsonSchema)] struct ReservationEditForm { start_time: DateTime, end_time: DateTime, /// Whoever picks the bikes up may change, and with them the handle to /// reach on the day telegram: String, /// Each with its own period when a conflict was settled by handing it over /// early, and without one when it simply follows the reservation 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: form.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 already held over that period \ (managers may double-book on purpose, so this only reaches anybody else), \ or the reservation is final", )) .response_with::<422, (), _>(desc("One of the bikes does not exist")) } #[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, } #[axum::debug_handler] async fn set_status( ac: AppController, Path(IdPath { id }): Path, Json(SetStatusForm { status, reason }): 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, reason) .await { Ok(()) => Ok(()), Err(ControllerError::Reservation( err @ (ReservationsControllerError::InvalidTransition(..) | ReservationsControllerError::BikesTaken(_)), )) => 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 — and, \ with the same status, approving a reservation whose bikes an approved \ one already holds over the same period.", ) .response_with::<403, (), _>(manager_desc) .response::<404, ()>() .response::<409, ()>() }