cargagep-v2/src/api/reservations.rs
Antoine Pelletier 51df9e75ad wip
2026-08-25 10:38:05 +02:00

474 lines
19 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, 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<String>,
/// Free text: the unit, the telegram handle, the people, the Linka Go
/// addresses, the reason, or the number of a reservation
q: Option<String>,
/// Keeps only what overlaps the window
from: Option<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
limit: Option<i64>,
offset: Option<i64>,
}
impl TryFrom<ReservationSearchParams> for ReservationQuery {
type Error = String;
fn try_from(params: ReservationSearchParams) -> Result<Self, Self::Error> {
let statuses = params
.status
.as_deref()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|status| !status.is_empty())
.map(|status| status.parse::<ReservationStatus>())
.collect::<Result<Vec<_>, _>>()
.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<ReservationSearchParams>,
) -> Result<Json<ReservationPage>, (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<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(_)
| 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<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, 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<ReservationForForm>,
) -> Result<(StatusCode, Json<Reservation>), (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<Reservation>, _>(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<DateTime<Utc>>,
to: Option<DateTime<Utc>>,
}
#[axum::debug_handler]
async fn get_calendar_reservations(
aac: AnonAppController,
Query(window): Query<CalendarWindow>,
) -> Result<Json<Vec<CalendarReservation>>, (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<ReservationSearchParams>,
) -> Result<Json<ReservationPage>, (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<ReservationId>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
bikes: Vec<NewReservationBike>,
}
/// 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<ConflictProbeForm>,
) -> Result<Json<Vec<Conflict>>, (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<Utc>,
end_time: DateTime<Utc>,
/// 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<NewReservationBike>,
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: 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<String>,
}
#[axum::debug_handler]
async fn set_status(
ac: AppController,
Path(IdPath { id }): Path<IdPath>,
Json(SetStatusForm { status, reason }): 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, 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, ()>()
}