cargagep-v2/src/services/database/reservations.rs
2026-08-24 12:00:58 +02:00

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(())
}
}