278 lines
11 KiB
Rust
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, ()>()
|
|
}
|