474 lines
19 KiB
Rust
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, ()>()
|
|
}
|