//! 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 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 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, pub unit_name: Option, pub unit_label: Option, pub start_time: DateTime, pub end_time: DateTime, pub requester_id: i32, pub telegram: String, pub description: String, pub status: ReservationStatusDB, pub linka_emails: Vec, pub users: Value, pub bikes: Vec, } impl TryFrom for Reservation { type Error = RepositoryError; fn try_from(value: ReservationDB) -> Result { 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, Option) { 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 { emails.iter().map(|email| email.trim().to_owned()).collect() } #[async_trait] impl ReservationsRepository for SqlxDatabase { async fn get_reservations(&self) -> Result, 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::, _>>()?) } async fn get_unit_reservations( &self, unit: UnitId, ) -> Result, 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::, _>>()?) } async fn get_reservation(&self, id: ReservationId) -> Result { 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 { 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(()) } }