From b65b4c7a00b30059f9e14497c3e2a76df6235b18 Mon Sep 17 00:00:00 2001 From: Max Hohlfeld Date: Sun, 22 Jun 2025 22:13:18 +0200 Subject: [PATCH] feat: validate availabilit changeset --- ...2be4c563788b3b988b721bd12797fd19a7a95.json | 49 +++++++++++++++ web/src/endpoints/availability/mod.rs | 15 +---- web/src/endpoints/availability/post_new.rs | 30 ++++----- web/src/endpoints/availability/post_update.rs | 43 ++++++------- web/src/models/availability.rs | 43 +++++++++++++ web/src/models/availability_changeset.rs | 61 +++++++++++++------ web/src/models/user_changeset.rs | 4 +- web/src/utils/validation/mod.rs | 14 +++++ 8 files changed, 186 insertions(+), 73 deletions(-) create mode 100644 .sqlx/query-2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95.json diff --git a/.sqlx/query-2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95.json b/.sqlx/query-2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95.json new file mode 100644 index 00000000..64e0abcc --- /dev/null +++ b/.sqlx/query-2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n availability.id,\n availability.userId,\n availability.startTimestamp,\n availability.endTimestamp,\n availability.comment\n FROM availability\n WHERE availability.userId = $1\n AND (availability.endtimestamp = $2\n OR availability.starttimestamp = $3)\n AND (availability.id <> $4 OR $4 IS NULL);\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "userid", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "starttimestamp", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "endtimestamp", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "comment", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int4", + "Timestamptz", + "Timestamptz", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "2288f64f63f07e7dd947c036e5c2be4c563788b3b988b721bd12797fd19a7a95" +} diff --git a/web/src/endpoints/availability/mod.rs b/web/src/endpoints/availability/mod.rs index eaf28a42..9951e9db 100644 --- a/web/src/endpoints/availability/mod.rs +++ b/web/src/endpoints/availability/mod.rs @@ -3,7 +3,7 @@ use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; use serde::Deserialize; use crate::filters; -use crate::models::{Availability, AvailabilityChangeset, Role, User}; +use crate::models::{Role, User}; use crate::utils::DateTimeFormat::{DayMonth, DayMonthYear, DayMonthYearHourMinute, HourMinute}; pub mod delete; @@ -35,16 +35,3 @@ pub struct AvailabilityForm { pub endtime: NaiveTime, pub comment: Option, } - -fn find_adjacend_availability<'a>( - changeset: &AvailabilityChangeset, - availability_id_to_be_updated: Option, - existing_availabilities: &'a [Availability], -) -> Option<&'a Availability> { - let existing_availability = existing_availabilities - .iter() - .filter(|a| availability_id_to_be_updated.is_none_or(|id| a.id != id)) - .find(|a| a.start == changeset.time.1 || a.end == changeset.time.0); - - return existing_availability; -} diff --git a/web/src/endpoints/availability/post_new.rs b/web/src/endpoints/availability/post_new.rs index cd251aee..865c1910 100644 --- a/web/src/endpoints/availability/post_new.rs +++ b/web/src/endpoints/availability/post_new.rs @@ -1,11 +1,10 @@ use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; -use garde::Validate; use sqlx::PgPool; use crate::{ - endpoints::availability::{find_adjacend_availability, AvailabilityForm}, + endpoints::availability::AvailabilityForm, models::{Availability, AvailabilityChangeset, AvailabilityContext, User}, - utils::{self, ApplicationError}, + utils::{self, validation::AsyncValidate, ApplicationError}, }; #[actix_web::post("/availability/new")] @@ -14,32 +13,33 @@ pub async fn post( pool: web::Data, form: web::Form, ) -> Result { - let existing_availabilities = - Availability::read_by_user_and_date(pool.get_ref(), user.id, &form.startdate).await?; - let context = AvailabilityContext { - existing_availabilities: existing_availabilities.clone(), - }; - let start = form.startdate.and_time(form.starttime); let end = form.enddate.and_time(form.endtime); + let context = AvailabilityContext { + pool: pool.get_ref(), + user_id: user.id, + availability_to_get_edited: None, + }; + let mut changeset = AvailabilityChangeset { time: (start, end), comment: form.comment.clone(), }; - if let Err(e) = changeset.validate_with(&context) { + if let Err(e) = changeset.validate_with_context(&context).await { return Ok(HttpResponse::BadRequest().body(e.to_string())); }; - if let Some(a) = find_adjacend_availability(&changeset, None, &existing_availabilities) { - let (changeset_start, changeset_end) = changeset.time; - - if a.end == changeset_start { + if let Some(a) = + Availability::find_adjacent_by_time_for_user(pool.get_ref(), &start, &end, user.id, None) + .await? + { + if a.end == start { changeset.time.0 = a.start; } - if a.start == changeset_end { + if a.start == end { changeset.time.1 = a.end; } diff --git a/web/src/endpoints/availability/post_update.rs b/web/src/endpoints/availability/post_update.rs index 236b792a..a8130980 100644 --- a/web/src/endpoints/availability/post_update.rs +++ b/web/src/endpoints/availability/post_update.rs @@ -1,14 +1,10 @@ use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; -use garde::Validate; use sqlx::PgPool; use crate::{ - endpoints::{ - availability::{find_adjacend_availability, AvailabilityForm}, - IdPath, - }, + endpoints::{availability::AvailabilityForm, IdPath}, models::{Availability, AvailabilityChangeset, AvailabilityContext, User}, - utils::{self, ApplicationError}, + utils::{self, validation::AsyncValidate, ApplicationError}, }; #[actix_web::post("/availability/edit/{id}")] @@ -26,39 +22,38 @@ pub async fn post( return Err(ApplicationError::Unauthorized); } - let existing_availabilities: Vec = - Availability::read_by_user_and_date(pool.get_ref(), user.id, &availability.start.date()) - .await? - .into_iter() - .filter(|a| a.id != availability.id) - .collect(); - - let context = AvailabilityContext { - existing_availabilities: existing_availabilities.clone(), - }; - let start = form.startdate.and_time(form.starttime); let end = form.enddate.and_time(form.endtime); + let context = AvailabilityContext { + pool: pool.get_ref(), + user_id: user.id, + availability_to_get_edited: Some(availability.id), + }; + let mut changeset = AvailabilityChangeset { time: (start, end), comment: form.comment.clone(), }; - if let Err(e) = changeset.validate_with(&context) { + if let Err(e) = changeset.validate_with_context(&context).await { return Ok(HttpResponse::BadRequest().body(e.to_string())); }; - if let Some(a) = - find_adjacend_availability(&changeset, Some(availability.id), &existing_availabilities) + if let Some(a) = Availability::find_adjacent_by_time_for_user( + pool.get_ref(), + &start, + &end, + user.id, + Some(availability.id), + ) + .await? { - let (changeset_start, changeset_end) = changeset.time; - - if a.end == changeset_start { + if a.end == start { changeset.time.0 = a.start; } - if a.start == changeset_end { + if a.start == end { changeset.time.1 = a.end; } diff --git a/web/src/models/availability.rs b/web/src/models/availability.rs index d679cb77..cd3cc14d 100644 --- a/web/src/models/availability.rs +++ b/web/src/models/availability.rs @@ -341,6 +341,49 @@ impl Availability { Ok(availabilities) } + pub async fn find_adjacent_by_time_for_user( + pool: &PgPool, + start: &NaiveDateTime, + end: &NaiveDateTime, + user: i32, + availability_to_ignore: Option, + ) -> Result> { + let records = query!( + r##" + SELECT + availability.id, + availability.userId, + availability.startTimestamp, + availability.endTimestamp, + availability.comment + FROM availability + WHERE availability.userId = $1 + AND (availability.endtimestamp = $2 + OR availability.starttimestamp = $3) + AND (availability.id <> $4 OR $4 IS NULL); + "##, + user, + start.and_utc(), + end.and_utc(), + availability_to_ignore + ) + .fetch_all(pool) // possible to find up to two availabilities (upper and lower), for now we only pick one and extend it + .await?; + + let adjacent_avaialability = records.first().and_then(|r| { + Some(Availability { + id: r.id, + user_id: r.userid, + user: None, + start: r.starttimestamp.naive_utc(), + end: r.endtimestamp.naive_utc(), + comment: r.comment.clone(), + }) + }); + + Ok(adjacent_avaialability) + } + pub async fn update(pool: &PgPool, id: i32, changeset: AvailabilityChangeset) -> Result<()> { query!( "UPDATE availability SET startTimestamp = $1, endTimestamp = $2, comment = $3 WHERE id = $4", diff --git a/web/src/models/availability_changeset.rs b/web/src/models/availability_changeset.rs index 072fd101..faf2b10f 100644 --- a/web/src/models/availability_changeset.rs +++ b/web/src/models/availability_changeset.rs @@ -1,38 +1,63 @@ use chrono::{Days, NaiveDateTime}; -use garde::Validate; +use sqlx::PgPool; -use crate::{END_OF_DAY, START_OF_DAY}; +use crate::{ + utils::validation::{ + start_date_time_lies_before_end_date_time, AsyncValidate, AsyncValidateError, + }, + END_OF_DAY, START_OF_DAY, +}; -use super::{start_date_time_lies_before_end_date_time, Availability}; +use super::Availability; -#[derive(Validate)] -#[garde(allow_unvalidated)] -#[garde(context(AvailabilityContext))] pub struct AvailabilityChangeset { - #[garde( - custom(time_is_not_already_made_available), - custom(start_date_time_lies_before_end_date_time) - )] pub time: (NaiveDateTime, NaiveDateTime), pub comment: Option, } -pub struct AvailabilityContext { - pub existing_availabilities: Vec, +pub struct AvailabilityContext<'a> { + pub pool: &'a PgPool, + pub user_id: i32, + pub availability_to_get_edited: Option, +} + +impl<'a> AsyncValidate<'a> for AvailabilityChangeset { + type Context = AvailabilityContext<'a>; + + async fn validate_with_context( + &self, + context: &'a Self::Context, + ) -> Result<(), AsyncValidateError> { + let mut existing_availabilities = + Availability::read_by_user_and_date(context.pool, context.user_id, &self.time.0.date()) + .await?; + + if let Some(existing) = context.availability_to_get_edited { + existing_availabilities = existing_availabilities + .into_iter() + .filter(|a| a.id != existing) + .collect(); + } + + time_is_not_already_made_available(&self.time, &existing_availabilities)?; + start_date_time_lies_before_end_date_time(&self.time.0, &self.time.1)?; + + Ok(()) + } } fn time_is_not_already_made_available( value: &(NaiveDateTime, NaiveDateTime), - context: &AvailabilityContext, -) -> garde::Result { - if context.existing_availabilities.is_empty() { + existing_availabilities: &Vec, +) -> Result<(), AsyncValidateError> { + if existing_availabilities.is_empty() { return Ok(()); } - let free_slots = find_free_date_time_slots(&context.existing_availabilities); + let free_slots = find_free_date_time_slots(existing_availabilities); if free_slots.is_empty() { - return Err(garde::Error::new( + return Err(AsyncValidateError::new( "cant create a availability as every time slot is already filled", )); } @@ -41,7 +66,7 @@ fn time_is_not_already_made_available( let free_block_found_for_end = free_slots.iter().any(|s| s.0 <= value.1 && s.1 >= value.1); if !free_block_found_for_start || !free_block_found_for_end { - return Err(garde::Error::new( + return Err(AsyncValidateError::new( "cant create availability as there exists already a availability with the desired time", )); } diff --git a/web/src/models/user_changeset.rs b/web/src/models/user_changeset.rs index 5ca14d3e..a58ae382 100644 --- a/web/src/models/user_changeset.rs +++ b/web/src/models/user_changeset.rs @@ -23,14 +23,14 @@ pub struct UserChangeset { } impl <'a>AsyncValidate<'a> for UserChangeset { + type Context = DbContext<'a>; + async fn validate_with_context(&self, context: &'a Self::Context) -> Result<(), AsyncValidateError> { email_is_valid(&self.email)?; area_exists(context.pool, self.area_id).await?; Ok(()) } - - type Context = DbContext<'a>; } async fn area_exists(pool: &PgPool, id: i32) -> Result<(), AsyncValidateError> { diff --git a/web/src/utils/validation/mod.rs b/web/src/utils/validation/mod.rs index f659f62a..1d6af51e 100644 --- a/web/src/utils/validation/mod.rs +++ b/web/src/utils/validation/mod.rs @@ -2,6 +2,7 @@ mod email; mod error; mod r#trait; +use chrono::NaiveDateTime; pub use email::email_is_valid; pub use error::AsyncValidateError; pub use r#trait::AsyncValidate; @@ -16,3 +17,16 @@ impl<'a> DbContext<'a> { Self { pool } } } + +pub fn start_date_time_lies_before_end_date_time( + start: &NaiveDateTime, + end: &NaiveDateTime, +) -> Result<(), AsyncValidateError> { + if start >= end { + return Err(AsyncValidateError::new( + "endtime can't lie before starttime", + )); + } + + Ok(()) +}