cargagep-v2/src/api/reservations.rs
2026-08-24 13:02:01 +02:00

278 lines
11 KiB
Rust

//! 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<Json<Vec<Reservation>>, (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<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"))
}
/// 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<Json<Vec<CalendarReservation>>, (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<Json<Vec<Reservation>>, (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<Utc>,
end_time: DateTime<Utc>,
bikes: Vec<BikeId>,
linka_emails: Vec<String>,
}
#[axum::debug_handler]
async fn update_reservation(
ac: AppController,
Path(IdPath { id }): Path<IdPath>,
Json(form): Json<ReservationEditForm>,
) -> 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<IdPath>,
Json(SetStatusForm { status }): Json<SetStatusForm>,
) -> Result<(), (StatusCode, String)> {
// The unit is not in the body: it is the reservation's own
let reservation = match ac.get_reservation(id).await {
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, ()>()
}