419 lines
14 KiB
Rust
419 lines
14 KiB
Rust
//! Reservations, with their users and their bikes.
|
|
//!
|
|
//! The two link tables are read back with `ARRAY(SELECT ...)` subqueries rather
|
|
//! than joins, so listing reservations stays a single round trip.
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use serde_json::Value;
|
|
use sqlx::{query, query_as};
|
|
|
|
use crate::{
|
|
core::{
|
|
models::{
|
|
reservation::{
|
|
NewReservation, NewReservationUnit, Reservation, ReservationEdit, ReservationId,
|
|
ReservationStatus, ReservationUnit,
|
|
},
|
|
unit::{Unit, UnitId},
|
|
user::UserId,
|
|
},
|
|
repositories::{RepositoryError, reservations_repository::ReservationsRepository},
|
|
},
|
|
services::database::SqlxDatabase,
|
|
};
|
|
|
|
#[derive(Debug, Clone, Copy, sqlx::Type)]
|
|
#[sqlx(type_name = "reservation_status", rename_all = "snake_case")]
|
|
enum ReservationStatusDB {
|
|
Requested,
|
|
Refused,
|
|
Approved,
|
|
Cancelled,
|
|
Ongoing,
|
|
Archived,
|
|
}
|
|
|
|
impl From<ReservationStatusDB> for ReservationStatus {
|
|
fn from(value: ReservationStatusDB) -> Self {
|
|
match value {
|
|
ReservationStatusDB::Requested => ReservationStatus::Requested,
|
|
ReservationStatusDB::Refused => ReservationStatus::Refused,
|
|
ReservationStatusDB::Approved => ReservationStatus::Approved,
|
|
ReservationStatusDB::Cancelled => ReservationStatus::Cancelled,
|
|
ReservationStatusDB::Ongoing => ReservationStatus::Ongoing,
|
|
ReservationStatusDB::Archived => ReservationStatus::Archived,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ReservationStatus> for ReservationStatusDB {
|
|
fn from(value: ReservationStatus) -> Self {
|
|
match value {
|
|
ReservationStatus::Requested => ReservationStatusDB::Requested,
|
|
ReservationStatus::Refused => ReservationStatusDB::Refused,
|
|
ReservationStatus::Approved => ReservationStatusDB::Approved,
|
|
ReservationStatus::Cancelled => ReservationStatusDB::Cancelled,
|
|
ReservationStatus::Ongoing => ReservationStatusDB::Ongoing,
|
|
ReservationStatus::Archived => ReservationStatusDB::Archived,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ReservationDB {
|
|
pub id: i32,
|
|
pub unit_id: Option<i32>,
|
|
pub unit_name: Option<String>,
|
|
pub unit_label: Option<String>,
|
|
pub start_time: DateTime<Utc>,
|
|
pub end_time: DateTime<Utc>,
|
|
pub requester_id: i32,
|
|
pub telegram: String,
|
|
pub description: String,
|
|
pub status: ReservationStatusDB,
|
|
pub linka_emails: Vec<String>,
|
|
pub users: Value,
|
|
pub bikes: Vec<i32>,
|
|
}
|
|
|
|
impl TryFrom<ReservationDB> for Reservation {
|
|
type Error = RepositoryError;
|
|
|
|
fn try_from(value: ReservationDB) -> Result<Self, Self::Error> {
|
|
Ok(Reservation {
|
|
id: value.id,
|
|
unit: match (value.unit_id, value.unit_name, value.unit_label) {
|
|
(Some(id), Some(name), None) => ReservationUnit::Known {
|
|
unit: Unit { id, name },
|
|
},
|
|
(None, None, Some(name)) => ReservationUnit::Free { name },
|
|
// The `reservations_unit_xor` check makes this unreachable
|
|
other => {
|
|
return Err(RepositoryError::TypeConversion(format!(
|
|
"reservation with an inconsistent unit: {other:?}"
|
|
)));
|
|
}
|
|
},
|
|
start_time: value.start_time,
|
|
end_time: value.end_time,
|
|
requester: value.requester_id,
|
|
users: serde_json::from_value(value.users)?,
|
|
telegram: value.telegram,
|
|
description: value.description,
|
|
bikes: value.bikes,
|
|
linka_emails: value.linka_emails,
|
|
status: value.status.into(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl SqlxDatabase {
|
|
async fn set_reservation_links(
|
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
id: ReservationId,
|
|
users: &[i32],
|
|
bikes: &[i32],
|
|
) -> Result<(), RepositoryError> {
|
|
query!(
|
|
r#"DELETE FROM reservations_users WHERE reservation_id = $1"#,
|
|
id
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
query!(
|
|
r#"INSERT INTO reservations_users (reservation_id, user_id)
|
|
SELECT $1, UNNEST($2::integer[])"#,
|
|
id,
|
|
users
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
query!(
|
|
r#"DELETE FROM reservations_bikes WHERE reservation_id = $1"#,
|
|
id
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
query!(
|
|
r#"INSERT INTO reservations_bikes (reservation_id, bike_id)
|
|
SELECT $1, UNNEST($2::integer[])"#,
|
|
id,
|
|
bikes
|
|
)
|
|
.execute(&mut **tx)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// The two exclusive columns behind `NewReservationUnit`: exactly one is `Some`,
|
|
/// which is what the `reservations_unit_xor` check enforces.
|
|
fn split_unit(unit: &NewReservationUnit) -> (Option<i32>, Option<String>) {
|
|
match unit {
|
|
NewReservationUnit::Known { id } => (Some(*id), None),
|
|
NewReservationUnit::Free { name } => (None, Some(name.trim().to_owned())),
|
|
}
|
|
}
|
|
|
|
/// Surrounding spaces never belong to an address, and trimming is what turns a
|
|
/// whitespace-only one into the empty string the `reservations_linka_emails_filled`
|
|
/// check rejects.
|
|
fn trim_emails(emails: &[String]) -> Vec<String> {
|
|
emails.iter().map(|email| email.trim().to_owned()).collect()
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ReservationsRepository for SqlxDatabase {
|
|
async fn get_reservations(&self) -> Result<Vec<Reservation>, RepositoryError> {
|
|
Ok(query_as!(
|
|
ReservationDB,
|
|
r#"SELECT
|
|
r.id,
|
|
r.unit_id,
|
|
-- `?` forces the nullability sqlx cannot infer: `units.name` is
|
|
-- NOT NULL, but the LEFT JOIN makes it null for a free label
|
|
un."name" AS "unit_name?",
|
|
r.unit_label,
|
|
r.start_time,
|
|
r.end_time,
|
|
r.requester_id,
|
|
r.telegram,
|
|
r."description",
|
|
r.linka_emails,
|
|
r.status AS "status: ReservationStatusDB",
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object(
|
|
'id', u.id,
|
|
'firstname', u.firstname,
|
|
'name', u."name",
|
|
'email', u.email
|
|
) ORDER BY u."name", u.firstname)
|
|
FROM reservations_users ru
|
|
JOIN users u ON u.id = ru.user_id
|
|
WHERE ru.reservation_id = r.id
|
|
), '[]'::json) AS "users!",
|
|
ARRAY(
|
|
SELECT bike_id FROM reservations_bikes
|
|
WHERE reservation_id = r.id ORDER BY bike_id
|
|
) AS "bikes!"
|
|
FROM reservations r
|
|
LEFT JOIN units un ON un.id = r.unit_id
|
|
ORDER BY r.start_time DESC"#
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await?
|
|
.into_iter()
|
|
.map(TryInto::try_into)
|
|
.collect::<Result<Vec<_>, _>>()?)
|
|
}
|
|
|
|
async fn get_unit_reservations(
|
|
&self,
|
|
unit: UnitId,
|
|
) -> Result<Vec<Reservation>, RepositoryError> {
|
|
Ok(query_as!(
|
|
ReservationDB,
|
|
r#"SELECT
|
|
r.id,
|
|
r.unit_id,
|
|
-- `?` forces the nullability sqlx cannot infer: `units.name` is
|
|
-- NOT NULL, but the LEFT JOIN makes it null for a free label
|
|
un."name" AS "unit_name?",
|
|
r.unit_label,
|
|
r.start_time,
|
|
r.end_time,
|
|
r.requester_id,
|
|
r.telegram,
|
|
r."description",
|
|
r.linka_emails,
|
|
r.status AS "status: ReservationStatusDB",
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object(
|
|
'id', u.id,
|
|
'firstname', u.firstname,
|
|
'name', u."name",
|
|
'email', u.email
|
|
) ORDER BY u."name", u.firstname)
|
|
FROM reservations_users ru
|
|
JOIN users u ON u.id = ru.user_id
|
|
WHERE ru.reservation_id = r.id
|
|
), '[]'::json) AS "users!",
|
|
ARRAY(
|
|
SELECT bike_id FROM reservations_bikes
|
|
WHERE reservation_id = r.id ORDER BY bike_id
|
|
) AS "bikes!"
|
|
FROM reservations r
|
|
LEFT JOIN units un ON un.id = r.unit_id
|
|
WHERE r.unit_id = $1
|
|
ORDER BY r.start_time DESC"#,
|
|
unit
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await?
|
|
.into_iter()
|
|
.map(TryInto::try_into)
|
|
.collect::<Result<Vec<_>, _>>()?)
|
|
}
|
|
|
|
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError> {
|
|
Ok(query_as!(
|
|
ReservationDB,
|
|
r#"SELECT
|
|
r.id,
|
|
r.unit_id,
|
|
-- `?` forces the nullability sqlx cannot infer: `units.name` is
|
|
-- NOT NULL, but the LEFT JOIN makes it null for a free label
|
|
un."name" AS "unit_name?",
|
|
r.unit_label,
|
|
r.start_time,
|
|
r.end_time,
|
|
r.requester_id,
|
|
r.telegram,
|
|
r."description",
|
|
r.linka_emails,
|
|
r.status AS "status: ReservationStatusDB",
|
|
COALESCE((
|
|
SELECT json_agg(json_build_object(
|
|
'id', u.id,
|
|
'firstname', u.firstname,
|
|
'name', u."name",
|
|
'email', u.email
|
|
) ORDER BY u."name", u.firstname)
|
|
FROM reservations_users ru
|
|
JOIN users u ON u.id = ru.user_id
|
|
WHERE ru.reservation_id = r.id
|
|
), '[]'::json) AS "users!",
|
|
ARRAY(
|
|
SELECT bike_id FROM reservations_bikes
|
|
WHERE reservation_id = r.id ORDER BY bike_id
|
|
) AS "bikes!"
|
|
FROM reservations r
|
|
LEFT JOIN units un ON un.id = r.unit_id
|
|
WHERE r.id = $1"#,
|
|
id
|
|
)
|
|
.fetch_one(&self.pool)
|
|
.await?
|
|
.try_into()?)
|
|
}
|
|
|
|
async fn create_reservation(
|
|
&self,
|
|
reservation: NewReservation,
|
|
requester: UserId,
|
|
) -> Result<Reservation, RepositoryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
// No status here: the column defaults to 'requested', the start of the
|
|
// state machine.
|
|
let (unit_id, unit_label) = split_unit(&reservation.unit);
|
|
let linka_emails = trim_emails(&reservation.linka_emails);
|
|
let id = query!(
|
|
r#"INSERT INTO reservations
|
|
(unit_id, unit_label, start_time, end_time, requester_id, telegram,
|
|
"description", linka_emails)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING id"#,
|
|
unit_id,
|
|
unit_label,
|
|
reservation.start_time,
|
|
reservation.end_time,
|
|
requester,
|
|
reservation.telegram,
|
|
reservation.description,
|
|
&linka_emails
|
|
)
|
|
.fetch_one(&mut *tx)
|
|
.await?
|
|
.id;
|
|
|
|
// The requester is always allowed to pick the bikes up
|
|
let mut users = reservation.users.clone();
|
|
if !users.contains(&requester) {
|
|
users.push(requester);
|
|
}
|
|
Self::set_reservation_links(&mut tx, id, &users, &reservation.bikes).await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
// Read it back through the normal query rather than rebuilding it by
|
|
// hand: the caller gets the unit and the users exactly as any other read
|
|
// would return them.
|
|
self.get_reservation(id).await
|
|
}
|
|
|
|
async fn update_reservation(
|
|
&self,
|
|
reservation: ReservationEdit,
|
|
) -> Result<(), RepositoryError> {
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
let (unit_id, unit_label) = split_unit(&reservation.unit);
|
|
let linka_emails = trim_emails(&reservation.linka_emails);
|
|
let result = query!(
|
|
r#"UPDATE reservations
|
|
SET unit_id = $2, unit_label = $3, start_time = $4, end_time = $5,
|
|
telegram = $6, "description" = $7, linka_emails = $8
|
|
WHERE id = $1"#,
|
|
reservation.id,
|
|
unit_id,
|
|
unit_label,
|
|
reservation.start_time,
|
|
reservation.end_time,
|
|
reservation.telegram,
|
|
reservation.description,
|
|
&linka_emails
|
|
)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(RepositoryError::NotFound(format!(
|
|
"reservation {}",
|
|
reservation.id
|
|
)));
|
|
}
|
|
|
|
Self::set_reservation_links(
|
|
&mut tx,
|
|
reservation.id,
|
|
&reservation.users,
|
|
&reservation.bikes,
|
|
)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn set_reservation_status(
|
|
&self,
|
|
id: ReservationId,
|
|
status: ReservationStatus,
|
|
) -> Result<(), RepositoryError> {
|
|
let status: ReservationStatusDB = status.into();
|
|
let result = query!(
|
|
r#"UPDATE reservations SET status = $2 WHERE id = $1"#,
|
|
id,
|
|
status as ReservationStatusDB
|
|
)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(RepositoryError::NotFound(format!("reservation {id}")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_reservation(&self, id: ReservationId) -> Result<(), RepositoryError> {
|
|
// The link tables cascade
|
|
let result = query!(r#"DELETE FROM reservations WHERE id = $1"#, id)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(RepositoryError::NotFound(format!("reservation {id}")));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|