feat: validate availabilit changeset

This commit is contained in:
Max Hohlfeld 2025-06-22 22:13:18 +02:00
parent b2969b988d
commit b65b4c7a00
8 changed files with 186 additions and 73 deletions

View File

@ -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"
}

View File

@ -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<String>,
}
fn find_adjacend_availability<'a>(
changeset: &AvailabilityChangeset,
availability_id_to_be_updated: Option<i32>,
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;
}

View File

@ -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<PgPool>,
form: web::Form<AvailabilityForm>,
) -> Result<impl Responder, ApplicationError> {
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;
}

View File

@ -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> =
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;
}

View File

@ -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<i32>,
) -> Result<Option<Availability>> {
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",

View File

@ -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<String>,
}
pub struct AvailabilityContext {
pub existing_availabilities: Vec<Availability>,
pub struct AvailabilityContext<'a> {
pub pool: &'a PgPool,
pub user_id: i32,
pub availability_to_get_edited: Option<i32>,
}
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<Availability>,
) -> 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",
));
}

View File

@ -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> {

View File

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