From 27da4eafe4112c48d2b3f2fc942ef6c807a50495 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Fri, 18 Sep 2026 21:59:31 -0400 Subject: [PATCH] fixing the checkin --- Cargo.toml | 2 +- src/app.rs | 4 +- src/database/entities/schedule.rs | 5 +- src/database/entities/weekly_preference.rs | 6 +- src/database/queries/company.rs | 38 +- src/database/queries/employee.rs | 73 ++- src/database/queries/preference.rs | 53 +- src/database/queries/schedule.rs | 242 ++++++-- src/database/queries/user.rs | 70 +-- src/database/schema.rs | 29 +- src/main.rs | 43 +- src/manager/conflict.rs | 206 ++++++- src/manager/scheduler.rs | 211 +++++-- src/manager/validator.rs | 99 +++- src/models/mod.rs | 2 + src/models/schedule.rs | 120 +++- src/models/shift.rs | 66 ++- src/router.rs | 658 +++++++++++++++++---- 18 files changed, 1521 insertions(+), 406 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 23ae9d2..4ad3dc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["rust", "axum", "postgres", "scheduling", "control-structures"] categories = ["web-programming::http-server"] [dependencies] -axum = "0.8.4" +axum = { version = "0.8.4", features = ["multipart"] } chrono = { version = "0.4.42", features = ["serde"] } dotenvy = "0.15.7" serde = { version = "1.0.228", features = ["derive"] } diff --git a/src/app.rs b/src/app.rs index daa96c9..a8b1e92 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,7 +13,7 @@ pub struct AppState { #[derive(Serialize)] pub struct ErrorResponse { - pub error: String, + pub message: String, } pub struct ApiError(sqlx::Error); @@ -36,7 +36,7 @@ impl IntoResponse for ApiError { ( status, Json(ErrorResponse { - error: self.0.to_string(), + message: self.0.to_string(), }), ) .into_response() diff --git a/src/database/entities/schedule.rs b/src/database/entities/schedule.rs index 60487a3..bcf37e6 100644 --- a/src/database/entities/schedule.rs +++ b/src/database/entities/schedule.rs @@ -1,7 +1,7 @@ use chrono::NaiveDateTime; use serde::Serialize; -use crate::database::types::DayOfWeek; +use crate::database::types::{DayOfWeek, WorkTime}; #[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)] pub struct Schedule { @@ -9,7 +9,8 @@ pub struct Schedule { pub user_id: i32, pub enter_time: NaiveDateTime, pub day_of_week: DayOfWeek, - pub out_time: NaiveDateTime, + pub out_time: Option, + pub shift: WorkTime, pub date_created: NaiveDateTime, pub date_updated: NaiveDateTime, } diff --git a/src/database/entities/weekly_preference.rs b/src/database/entities/weekly_preference.rs index 97b9189..0213c42 100644 --- a/src/database/entities/weekly_preference.rs +++ b/src/database/entities/weekly_preference.rs @@ -8,9 +8,9 @@ pub struct WeeklyPreference { pub id: i32, pub user_id: i32, pub day_of_week: DayOfWeek, - pub first_preference: WorkTime, - pub second_preference: WorkTime, - pub third_preference: WorkTime, + pub first: WorkTime, + pub second: WorkTime, + pub third: WorkTime, pub date_created: NaiveDateTime, pub date_updated: NaiveDateTime, } diff --git a/src/database/queries/company.rs b/src/database/queries/company.rs index 2ef63c8..bb28302 100644 --- a/src/database/queries/company.rs +++ b/src/database/queries/company.rs @@ -1,6 +1,6 @@ use sqlx::PgPool; -use crate::database::entities::{Company, CompanyUser, User}; +use crate::database::entities::{Company, CompanyUser}; pub async fn add_company(pool: &PgPool, name: &str) -> Result { sqlx::query_as::<_, Company>( @@ -58,17 +58,28 @@ pub async fn add_company_user( company_id: i32, user_id: i32, ) -> Result { - sqlx::query_as::<_, CompanyUser>( + let mut tx = pool.begin().await?; + + sqlx::query("DELETE FROM company_users WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + + let company_user = sqlx::query_as::<_, CompanyUser>( r#" INSERT INTO company_users (user_id, company_id) VALUES ($1, $2) + ON CONFLICT (user_id, company_id) DO UPDATE SET date_updated = NOW() RETURNING id, user_id, company_id, date_created, date_updated "#, ) .bind(user_id) .bind(company_id) - .fetch_one(pool) - .await + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + Ok(company_user) } pub async fn get_company_by_name(pool: &PgPool, name: &str) -> Result { @@ -100,22 +111,3 @@ pub async fn get_user_id_by_company( .fetch_all(pool) .await } - -pub async fn get_users_by_company( - pool: &PgPool, - company_id: i32, -) -> Result, sqlx::Error> { - sqlx::query_as::<_, User>( - r#" - SELECT users.id, users.name, users.last_name, users.preference, - users.date_created, users.date_updated - FROM users - INNER JOIN company_users ON company_users.user_id = users.id - WHERE company_users.company_id = $1 - ORDER BY users.name, users.last_name - "#, - ) - .bind(company_id) - .fetch_all(pool) - .await -} diff --git a/src/database/queries/employee.rs b/src/database/queries/employee.rs index 119ccf0..66b0e81 100644 --- a/src/database/queries/employee.rs +++ b/src/database/queries/employee.rs @@ -18,13 +18,84 @@ pub async fn list_employees(pool: &PgPool) -> Result, sqlx::Err SELECT users.id, company_users.company_id, users.name, users.last_name, users.preference FROM users INNER JOIN company_users ON company_users.user_id = users.id - ORDER BY users.name, users.last_name + ORDER BY users.id "#, ) .fetch_all(pool) .await } +pub async fn update_employee( + pool: &PgPool, + id: i32, + name: &str, + last_name: &str, + preference: WorkTime, +) -> Result { + sqlx::query_as::<_, User>( + r#" + UPDATE users + SET name = $2, last_name = $3, preference = $4, date_updated = NOW() + WHERE id = $1 + RETURNING id, name, last_name, preference, date_created, date_updated + "#, + ) + .bind(id) + .bind(name) + .bind(last_name) + .bind(preference) + .fetch_one(pool) + .await +} + +pub async fn update_company(pool: &PgPool, id: i32, company_id: i32) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + + sqlx::query("DELETE FROM company_users WHERE user_id = $1") + .bind(id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO company_users (user_id, company_id) + VALUES ($1, $2) + ON CONFLICT (user_id, company_id) DO UPDATE SET date_updated = NOW() + "#, + ) + .bind(id) + .bind(company_id) + .execute(&mut *tx) + .await?; + + tx.commit().await +} + +pub async fn get_company_id_by_user( + pool: &PgPool, + user_id: i32, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, i32>( + r#" + SELECT company_id + FROM company_users + WHERE user_id = $1 + ORDER BY date_updated DESC, id DESC + LIMIT 1 + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await +} + +pub async fn get_preference(pool: &PgPool, user_id: i32) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, WorkTime>("SELECT preference FROM users WHERE id = $1") + .bind(user_id) + .fetch_optional(pool) + .await +} + pub async fn get_employee(pool: &PgPool, id: i32) -> Result { sqlx::query_as::<_, EmployeeRow>( r#" diff --git a/src/database/queries/preference.rs b/src/database/queries/preference.rs index f082932..520bd4f 100644 --- a/src/database/queries/preference.rs +++ b/src/database/queries/preference.rs @@ -9,56 +9,49 @@ pub async fn upsert_weekly_preference( pool: &PgPool, user_id: i32, day_of_week: DayOfWeek, - first_preference: WorkTime, - second_preference: WorkTime, - third_preference: WorkTime, + first: WorkTime, + second: WorkTime, + third: WorkTime, ) -> Result { sqlx::query_as::<_, WeeklyPreference>( r#" - INSERT INTO employee_weekly_preferences ( - user_id, day_of_week, first_preference, second_preference, third_preference + INSERT INTO weekly_preferences ( + user_id, day_of_week, first, second, third ) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, day_of_week) DO UPDATE SET - first_preference = EXCLUDED.first_preference, - second_preference = EXCLUDED.second_preference, - third_preference = EXCLUDED.third_preference, + first = EXCLUDED.first, + second = EXCLUDED.second, + third = EXCLUDED.third, date_updated = NOW() - RETURNING id, user_id, day_of_week, first_preference, second_preference, - third_preference, date_created, date_updated + RETURNING id, user_id, day_of_week, first, second, third, date_created, date_updated "#, ) .bind(user_id) .bind(day_of_week) - .bind(first_preference) - .bind(second_preference) - .bind(third_preference) + .bind(first) + .bind(second) + .bind(third) .fetch_one(pool) .await } -pub async fn get_preferences_for_company( +pub async fn get_weekly_preference( pool: &PgPool, - company_id: i32, -) -> Result, sqlx::Error> { + user_id: i32, + day_of_week: DayOfWeek, +) -> Result, sqlx::Error> { sqlx::query_as::<_, WeeklyPreference>( r#" - SELECT employee_weekly_preferences.id, - employee_weekly_preferences.user_id, - employee_weekly_preferences.day_of_week, - employee_weekly_preferences.first_preference, - employee_weekly_preferences.second_preference, - employee_weekly_preferences.third_preference, - employee_weekly_preferences.date_created, - employee_weekly_preferences.date_updated - FROM employee_weekly_preferences - INNER JOIN company_users ON company_users.user_id = employee_weekly_preferences.user_id - WHERE company_users.company_id = $1 - ORDER BY employee_weekly_preferences.day_of_week, employee_weekly_preferences.user_id + SELECT id, user_id, day_of_week, first, second, third, date_created, date_updated + FROM weekly_preferences + WHERE user_id = $1 AND day_of_week = $2 + LIMIT 1 "#, ) - .bind(company_id) - .fetch_all(pool) + .bind(user_id) + .bind(day_of_week) + .fetch_optional(pool) .await } diff --git a/src/database/queries/schedule.rs b/src/database/queries/schedule.rs index 6e116c2..dad3407 100644 --- a/src/database/queries/schedule.rs +++ b/src/database/queries/schedule.rs @@ -1,78 +1,96 @@ use chrono::NaiveDateTime; +use serde::Serialize; use sqlx::PgPool; -use crate::database::{entities::Schedule, types::DayOfWeek}; +use crate::database::{ + entities::Schedule, + types::{DayOfWeek, WorkTime}, +}; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct WeeklyScheduleRow { + pub id: i32, + pub user_name: String, + pub user_last_name: String, + pub user_preference: WorkTime, + pub company: String, + pub enter_time: NaiveDateTime, + pub day_of_week: DayOfWeek, + pub out_time: Option, + pub shift: WorkTime, +} pub async fn add_schedule( pool: &PgPool, user_id: i32, enter_time: NaiveDateTime, day_of_week: DayOfWeek, - out_time: NaiveDateTime, + out_time: Option, + shift: WorkTime, ) -> Result { - // Keep compatible with legacy DBs that have an extra `shift` column. - // Try insert with `shift` first, fall back to 4-column insert if the column was dropped/never created. - let with_shift = sqlx::query_as::<_, Schedule>( + sqlx::query_as::<_, Schedule>( r#" INSERT INTO schedules (user_id, enter_time, day_of_week, shift, out_time) - VALUES ($1, $2, $3, 'morning', $4) - RETURNING id, user_id, enter_time, day_of_week, out_time, date_created, date_updated + VALUES ($1, $2, $3, $4, $5) + RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated "#, ) .bind(user_id) .bind(enter_time) .bind(day_of_week) + .bind(shift) .bind(out_time) .fetch_one(pool) - .await; - - match with_shift { - Ok(schedule) => Ok(schedule), - Err(_) => { - sqlx::query_as::<_, Schedule>( - r#" - INSERT INTO schedules (user_id, enter_time, day_of_week, out_time) - VALUES ($1, $2, $3, $4) - RETURNING id, user_id, enter_time, day_of_week, out_time, date_created, date_updated - "#, - ) - .bind(user_id) - .bind(enter_time) - .bind(day_of_week) - .bind(out_time) - .fetch_one(pool) - .await - } - } -} - -pub async fn list_schedules(pool: &PgPool) -> Result, sqlx::Error> { - sqlx::query_as::<_, Schedule>( - r#" - SELECT id, user_id, enter_time, day_of_week, out_time, date_created, date_updated - FROM schedules - ORDER BY day_of_week, user_id - "#, - ) - .fetch_all(pool) .await } -pub async fn list_schedules_for_company( +pub async fn list_weekly( pool: &PgPool, - company_id: i32, -) -> Result, sqlx::Error> { - sqlx::query_as::<_, Schedule>( + company_id: Option, +) -> Result, sqlx::Error> { + if let Some(company_id) = company_id { + return sqlx::query_as::<_, WeeklyScheduleRow>( + r#" + SELECT schedules.id, + users.name AS user_name, + users.last_name AS user_last_name, + users.preference AS user_preference, + companies.name AS company, + schedules.enter_time, + schedules.day_of_week, + schedules.out_time, + schedules.shift + FROM schedules + INNER JOIN company_users ON company_users.user_id = schedules.user_id + INNER JOIN users ON users.id = schedules.user_id + INNER JOIN companies ON companies.id = company_users.company_id + WHERE company_users.company_id = $1 + ORDER BY schedules.day_of_week, schedules.enter_time, schedules.id + "#, + ) + .bind(company_id) + .fetch_all(pool) + .await; + } + + sqlx::query_as::<_, WeeklyScheduleRow>( r#" - SELECT schedules.id, schedules.user_id, schedules.enter_time, schedules.day_of_week, - schedules.out_time, schedules.date_created, schedules.date_updated + SELECT schedules.id, + users.name AS user_name, + users.last_name AS user_last_name, + users.preference AS user_preference, + companies.name AS company, + schedules.enter_time, + schedules.day_of_week, + schedules.out_time, + schedules.shift FROM schedules INNER JOIN company_users ON company_users.user_id = schedules.user_id - WHERE company_users.company_id = $1 - ORDER BY schedules.day_of_week, schedules.user_id + INNER JOIN users ON users.id = schedules.user_id + INNER JOIN companies ON companies.id = company_users.company_id + ORDER BY schedules.day_of_week, schedules.enter_time, schedules.id "#, ) - .bind(company_id) .fetch_all(pool) .await } @@ -101,9 +119,9 @@ pub async fn clock_in( user_id: i32, now: NaiveDateTime, day_of_week: DayOfWeek, + shift: WorkTime, ) -> Result { - // out_time is required in reference API, initialize with enter_time and let clock-out update it - add_schedule(pool, user_id, now, day_of_week, now).await + add_schedule(pool, user_id, now, day_of_week, None, shift).await } pub async fn clock_out( @@ -123,7 +141,7 @@ pub async fn clock_out( ORDER BY enter_time DESC LIMIT 1 ) - RETURNING id, user_id, enter_time, day_of_week, out_time, date_created, date_updated + RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated "#, ) .bind(user_id) @@ -132,3 +150,127 @@ pub async fn clock_out( .fetch_one(pool) .await } + +pub async fn get_by_user_and_day_in_week( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, + week_start: NaiveDateTime, + week_end: NaiveDateTime, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Schedule>( + r#" + SELECT id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated + FROM schedules + WHERE user_id = $1 + AND day_of_week = $2 + AND enter_time >= $3 + AND enter_time < $4 + ORDER BY date_created DESC + LIMIT 1 + "#, + ) + .bind(user_id) + .bind(day_of_week) + .bind(week_start) + .bind(week_end) + .fetch_optional(pool) + .await +} + +pub async fn count_distinct_days_by_user_in_week( + pool: &PgPool, + user_id: i32, + week_start: NaiveDateTime, + week_end: NaiveDateTime, +) -> Result { + sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(DISTINCT day_of_week) + FROM schedules + WHERE user_id = $1 + AND enter_time >= $2 + AND enter_time < $3 + "#, + ) + .bind(user_id) + .bind(week_start) + .bind(week_end) + .fetch_one(pool) + .await +} + +pub async fn count_by_day_shift( + pool: &PgPool, + day_of_week: DayOfWeek, + shift: WorkTime, + company_id: Option, +) -> Result { + if let Some(company_id) = company_id { + return sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(*) + FROM schedules + INNER JOIN company_users ON company_users.user_id = schedules.user_id + WHERE schedules.day_of_week = $1 + AND schedules.shift = $2 + AND company_users.company_id = $3 + "#, + ) + .bind(day_of_week) + .bind(shift) + .bind(company_id) + .fetch_one(pool) + .await; + } + + sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(*) + FROM schedules + WHERE day_of_week = $1 AND shift = $2 + "#, + ) + .bind(day_of_week) + .bind(shift) + .fetch_one(pool) + .await +} + +pub async fn update_clock_in_by_id( + pool: &PgPool, + schedule_id: i32, + enter_time: NaiveDateTime, +) -> Result { + sqlx::query_as::<_, Schedule>( + r#" + UPDATE schedules + SET enter_time = $2, date_updated = NOW() + WHERE id = $1 + RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated + "#, + ) + .bind(schedule_id) + .bind(enter_time) + .fetch_one(pool) + .await +} + +pub async fn update_clock_out_by_id( + pool: &PgPool, + schedule_id: i32, + out_time: NaiveDateTime, +) -> Result { + sqlx::query_as::<_, Schedule>( + r#" + UPDATE schedules + SET out_time = $2, date_updated = NOW() + WHERE id = $1 + RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated + "#, + ) + .bind(schedule_id) + .bind(out_time) + .fetch_one(pool) + .await +} diff --git a/src/database/queries/user.rs b/src/database/queries/user.rs index 2111602..0d9e64d 100644 --- a/src/database/queries/user.rs +++ b/src/database/queries/user.rs @@ -2,7 +2,7 @@ use chrono::NaiveDateTime; use sqlx::PgPool; use crate::database::{ - entities::{Schedule, User}, + entities::User, types::{DayOfWeek, WorkTime}, }; @@ -58,88 +58,52 @@ pub async fn remove_user(pool: &PgPool, id: i32) -> Result { .await } -pub async fn add_schedule( - pool: &PgPool, - user_id: i32, - enter_time: NaiveDateTime, - day_of_week: DayOfWeek, - out_time: Option, -) -> Result { - sqlx::query_as::<_, Schedule>( - r#" - INSERT INTO schedules (user_id, enter_time, day_of_week, shift, out_time) - VALUES ($1, $2, $3, 'morning', $4) - RETURNING id, user_id, enter_time, day_of_week, shift, out_time, date_created, date_updated - "#, - ) - .bind(user_id) - .bind(enter_time) - .bind(day_of_week) - .bind(out_time) - .fetch_one(pool) - .await -} - -pub async fn get_days_worked_by_user( +pub async fn get_days_worked_by_user_in_week( pool: &PgPool, user_id: i32, + week_start: NaiveDateTime, + week_end: NaiveDateTime, ) -> Result, sqlx::Error> { sqlx::query_scalar::<_, DayOfWeek>( r#" SELECT DISTINCT day_of_week FROM schedules WHERE user_id = $1 + AND enter_time >= $2 + AND enter_time < $3 ORDER BY day_of_week "#, ) .bind(user_id) + .bind(week_start) + .bind(week_end) .fetch_all(pool) .await } -pub async fn get_user_enter_time_by_day( +pub async fn get_user_enter_time_by_day_in_week( pool: &PgPool, user_id: i32, day_of_week: DayOfWeek, + week_start: NaiveDateTime, + week_end: NaiveDateTime, ) -> Result, sqlx::Error> { sqlx::query_scalar::<_, NaiveDateTime>( r#" SELECT enter_time FROM schedules - WHERE user_id = $1 AND day_of_week = $2 + WHERE user_id = $1 + AND day_of_week = $2 + AND enter_time >= $3 + AND enter_time < $4 ORDER BY date_created DESC LIMIT 1 "#, ) .bind(user_id) .bind(day_of_week) + .bind(week_start) + .bind(week_end) .fetch_optional(pool) .await } - -pub async fn update_day_end_by_user_and_day( - pool: &PgPool, - user_id: i32, - day_of_week: DayOfWeek, - out_time: NaiveDateTime, -) -> Result { - sqlx::query_as::<_, Schedule>( - r#" - UPDATE schedules - SET out_time = $3, date_updated = NOW() - WHERE id = ( - SELECT id - FROM schedules - WHERE user_id = $1 AND day_of_week = $2 - ORDER BY date_created DESC - LIMIT 1 - ) - RETURNING id, user_id, enter_time, day_of_week, shift, out_time, date_created, date_updated - "#, - ) - .bind(user_id) - .bind(day_of_week) - .bind(out_time) - .fetch_one(pool) - .await -} diff --git a/src/database/schema.rs b/src/database/schema.rs index 2bbf7c3..b02f890 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -61,20 +61,21 @@ CREATE TABLE IF NOT EXISTS schedules ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, enter_time TIMESTAMP NOT NULL, day_of_week day_of_week NOT NULL, - out_time TIMESTAMP NOT NULL, + out_time TIMESTAMP, + shift work_time NOT NULL, date_created TIMESTAMP NOT NULL DEFAULT NOW(), date_updated TIMESTAMP NOT NULL DEFAULT NOW() ); "#; -pub const CREATE_EMPLOYEE_WEEKLY_PREFERENCES_TABLE: &str = r#" -CREATE TABLE IF NOT EXISTS employee_weekly_preferences ( +pub const CREATE_WEEKLY_PREFERENCES_TABLE: &str = r#" +CREATE TABLE IF NOT EXISTS weekly_preferences ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, day_of_week day_of_week NOT NULL, - first_preference work_time NOT NULL, - second_preference work_time NOT NULL, - third_preference work_time NOT NULL, + first work_time NOT NULL, + second work_time NOT NULL, + third work_time NOT NULL, date_created TIMESTAMP NOT NULL DEFAULT NOW(), date_updated TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE (user_id, day_of_week) @@ -83,7 +84,15 @@ CREATE TABLE IF NOT EXISTS employee_weekly_preferences ( pub const ALTER_SCHEDULES_ADD_SHIFT: &str = r#" ALTER TABLE schedules -ADD COLUMN IF NOT EXISTS shift work_time NOT NULL DEFAULT 'morning'; +ADD COLUMN IF NOT EXISTS shift work_time; +"#; + +pub const UPDATE_SCHEDULES_DEFAULT_SHIFT: &str = r#" +UPDATE schedules SET shift = 'morning' WHERE shift IS NULL; +"#; + +pub const ALTER_SCHEDULES_SHIFT_SET_NOT_NULL: &str = r#" +ALTER TABLE schedules ALTER COLUMN shift SET NOT NULL; "#; pub const ALTER_SCHEDULES_DROP_OUT_TIME_NOT_NULL: &str = r#" @@ -91,14 +100,16 @@ ALTER TABLE schedules ALTER COLUMN out_time DROP NOT NULL; "#; -pub const CREATE_SCHEMA_QUERIES: [&str; 9] = [ +pub const CREATE_SCHEMA_QUERIES: [&str; 11] = [ CREATE_WORK_TIME_ENUM, CREATE_DAY_OF_WEEK_ENUM, CREATE_COMPANIES_TABLE, CREATE_USERS_TABLE, CREATE_COMPANY_USERS_TABLE, CREATE_SCHEDULES_TABLE, - CREATE_EMPLOYEE_WEEKLY_PREFERENCES_TABLE, + CREATE_WEEKLY_PREFERENCES_TABLE, ALTER_SCHEDULES_ADD_SHIFT, + UPDATE_SCHEDULES_DEFAULT_SHIFT, + ALTER_SCHEDULES_SHIFT_SET_NOT_NULL, ALTER_SCHEDULES_DROP_OUT_TIME_NOT_NULL, ]; diff --git a/src/main.rs b/src/main.rs index c83723f..ac1b1d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,29 @@ mod router; use std::{env, net::SocketAddr}; +use database::types::{DayOfWeek, WorkTime}; + +#[derive(Debug, Clone)] +struct AssignmentRules { + days: [DayOfWeek; 7], + shifts: [WorkTime; 3], + max_shifts_per_employee_per_day: u8, + max_days_per_employee_per_week: u8, + min_employees_per_shift: u8, + conflict_strategy: &'static str, +} + +impl AssignmentRules { + fn is_valid(&self) -> bool { + self.days.len() == 7 + && self.shifts.len() == 3 + && self.max_shifts_per_employee_per_day == 1 + && self.max_days_per_employee_per_week == 5 + && self.min_employees_per_shift == 2 + && !self.conflict_strategy.is_empty() + } +} + #[tokio::main] async fn main() -> Result<(), Box> { dotenvy::dotenv().ok(); @@ -13,6 +36,7 @@ async fn main() -> Result<(), Box> { let database_url = env::var("DATABASE_URL")?; let pool = database::connection::connect(&database_url).await?; database::connection::init_database(&pool).await?; + debug_assert!(assignment_rules().is_valid()); let router = router::build_router(pool); @@ -26,14 +50,13 @@ async fn main() -> Result<(), Box> { Ok(()) } -fn assignment_rules() { - // collect employee names and preferred shifts for each day of the week - // store employees, companies, preferences, and schedules in database tables - // use conditionals to reject invalid schedules - // use loops to assign shifts for every day and every turn - // use branching to resolve conflicts when a preferred shift is unavailable - // ensure each employee works at most one shift per day - // ensure each employee works at most five days per week - // ensure every company shift has at least two employees - // if fewer than two employees are available, choose another valid employee +fn assignment_rules() -> AssignmentRules { + AssignmentRules { + days: models::shift::define_day_values(), + shifts: models::shift::define_shift_values(), + max_shifts_per_employee_per_day: 1, + max_days_per_employee_per_week: 5, + min_employees_per_shift: 2, + conflict_strategy: "try same-day alternative shifts, then next-day preferences", + } } diff --git a/src/manager/conflict.rs b/src/manager/conflict.rs index 4f7c4e3..8437c8f 100644 --- a/src/manager/conflict.rs +++ b/src/manager/conflict.rs @@ -1,28 +1,194 @@ -// import validator functions before moving an employee to another shift or day -// import database queries to read preferences and existing schedules +use chrono::Utc; +use sqlx::PgPool; -pub fn detect_preferred_shift_conflict() { - // check if the employee's preferred shift is already full for the selected day - // check if assigning this employee would break one-shift-per-day rule - // check if assigning this employee would break five-days-per-week rule +use crate::{ + database::{ + entities::Schedule, + queries::{ + company as company_queries, employee as employee_queries, + preference as preference_queries, schedule as schedule_queries, + }, + types::{DayOfWeek, WorkTime}, + }, + manager::validator, +}; + +const SHIFTS: [WorkTime; 3] = [WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening]; +const DAYS: [DayOfWeek; 7] = [ + DayOfWeek::Sunday, + DayOfWeek::Monday, + DayOfWeek::Tuesday, + DayOfWeek::Wednesday, + DayOfWeek::Thursday, + DayOfWeek::Friday, + DayOfWeek::Saturday, +]; +const MAX_EMPLOYEES_PER_SHIFT: i64 = 2; + +async fn count_shift_employees( + pool: &PgPool, + day_of_week: DayOfWeek, + shift: WorkTime, + company_id: Option, +) -> Result { + schedule_queries::count_by_day_shift(pool, day_of_week, shift, company_id).await } -pub fn resolve_conflict_same_day() { - // loop over the other shifts on the same day - // choose the first shift where the employee can legally work - // assign the employee only if validation passes +async fn is_shift_full( + pool: &PgPool, + day_of_week: DayOfWeek, + shift: WorkTime, + company_id: Option, +) -> Result { + Ok( + count_shift_employees(pool, day_of_week, shift, company_id).await? + >= MAX_EMPLOYEES_PER_SHIFT, + ) } -pub fn resolve_conflict_next_day() { - // if same-day alternatives fail, move to the next day - // loop through next-day shifts by preference priority - // assign the employee only if validation passes +fn next_day(current_day: DayOfWeek) -> DayOfWeek { + let index = DAYS.iter().position(|day| *day == current_day).unwrap_or(0); + DAYS[(index + 1) % DAYS.len()] } -pub fn resolve_missing_second_employee() { - // find shifts with only one employee assigned - // loop over available employees in the company - // skip employees who already work that day - // skip employees who already worked five days this week - // assign a valid extra employee to satisfy the two-employees-per-shift rule +fn datetime_for_day( + day_of_week: DayOfWeek, + reference: chrono::NaiveDateTime, +) -> chrono::NaiveDateTime { + let (week_start, _) = validator::week_bounds(reference); + let offset = match day_of_week { + DayOfWeek::Sunday => 0, + DayOfWeek::Monday => 1, + DayOfWeek::Tuesday => 2, + DayOfWeek::Wednesday => 3, + DayOfWeek::Thursday => 4, + DayOfWeek::Friday => 5, + DayOfWeek::Saturday => 6, + }; + week_start + chrono::Duration::days(offset) + chrono::Duration::hours(9) +} + +async fn shift_preferences( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, +) -> Result, sqlx::Error> { + if let Some(row) = preference_queries::get_weekly_preference(pool, user_id, day_of_week).await? + { + return Ok(vec![row.first, row.second, row.third]); + } + + Ok(SHIFTS.to_vec()) +} + +async fn assign_shift( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, + shift: WorkTime, + company_id: Option, +) -> Result, sqlx::Error> { + let company_id = match company_id { + Some(company_id) => company_id, + None => match employee_queries::get_company_id_by_user(pool, user_id).await? { + Some(company_id) => company_id, + None => return Ok(None), + }, + }; + + if is_shift_full(pool, day_of_week, shift, Some(company_id)).await? { + return Ok(None); + } + + let now = Utc::now().naive_utc(); + let enter_time = datetime_for_day(day_of_week, now); + if !validator::validate_employee_can_join_shift_in_week( + pool, + user_id, + day_of_week, + shift, + enter_time, + ) + .await? + { + return Ok(None); + } + + let schedule = + schedule_queries::add_schedule(pool, user_id, enter_time, day_of_week, None, shift).await?; + Ok(Some(schedule)) +} + +pub async fn detect_preferred_shift_conflict( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, + preferred_shift: WorkTime, +) -> Result { + let company_id = employee_queries::get_company_id_by_user(pool, user_id).await?; + let now = Utc::now().naive_utc(); + Ok( + is_shift_full(pool, day_of_week, preferred_shift, company_id).await? + || !validator::validate_user_has_not_worked_same_day_in_week( + pool, + user_id, + day_of_week, + now, + ) + .await? + || !validator::validate_user_has_not_worked_more_than_five_days_in_week( + pool, user_id, now, + ) + .await?, + ) +} + +pub async fn resolve_conflict_same_day( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, +) -> Result, sqlx::Error> { + for shift in SHIFTS { + if let Some(schedule) = assign_shift(pool, user_id, day_of_week, shift, None).await? { + return Ok(Some(schedule)); + } + } + Ok(None) +} + +pub async fn resolve_conflict_next_day( + pool: &PgPool, + user_id: i32, + current_day: DayOfWeek, +) -> Result, sqlx::Error> { + let next_day = next_day(current_day); + for shift in shift_preferences(pool, user_id, next_day).await? { + if let Some(schedule) = assign_shift(pool, user_id, next_day, shift, None).await? { + return Ok(Some(schedule)); + } + } + Ok(None) +} + +pub async fn resolve_missing_second_employee( + pool: &PgPool, + company_id: i32, + day_of_week: DayOfWeek, + shift: WorkTime, +) -> Result, sqlx::Error> { + if count_shift_employees(pool, day_of_week, shift, Some(company_id)).await? + >= MAX_EMPLOYEES_PER_SHIFT + { + return Ok(None); + } + + for user_id in company_queries::get_user_id_by_company(pool, company_id).await? { + if let Some(schedule) = + assign_shift(pool, user_id, day_of_week, shift, Some(company_id)).await? + { + return Ok(Some(schedule)); + } + } + + Ok(None) } diff --git a/src/manager/scheduler.rs b/src/manager/scheduler.rs index f102d90..bee9ccb 100644 --- a/src/manager/scheduler.rs +++ b/src/manager/scheduler.rs @@ -1,49 +1,182 @@ -// import database::company_queries to register users into companies when needed -// import database::user_queries to add schedules and update enter/out times -// import validator module to enforce assignment rules before saving a schedule -// import conflict module to move employees when their preferred shift is not available +use chrono::{NaiveDateTime, Utc}; +use sqlx::PgPool; -pub fn generate_weekly_schedule() { - // load all employees for the selected company - // load all employee preferences for all seven days - // loop through each day of the week - // loop through morning, afternoon, and evening shifts - // try to assign employees based on preferences first - // validate one-shift-per-day before each assignment - // validate five-days-per-week before each assignment - // validate at least two employees per shift after assignments - // resolve conflicts by assigning another shift on the same day or next day - // save the final schedule to the schedules table +use crate::{ + database::{ + entities::Schedule, + queries::{ + company as company_queries, employee as employee_queries, + preference as preference_queries, schedule as schedule_queries, + }, + types::{DayOfWeek, WorkTime}, + }, + manager::{conflict, validator}, + models::shift as shift_model, +}; + +const DAYS: [DayOfWeek; 7] = [ + DayOfWeek::Sunday, + DayOfWeek::Monday, + DayOfWeek::Tuesday, + DayOfWeek::Wednesday, + DayOfWeek::Thursday, + DayOfWeek::Friday, + DayOfWeek::Saturday, +]; +const SHIFTS: [WorkTime; 3] = [WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening]; + +fn datetime_for_day(day_of_week: DayOfWeek, reference: NaiveDateTime) -> NaiveDateTime { + let (week_start, _) = validator::week_bounds(reference); + let offset = match day_of_week { + DayOfWeek::Sunday => 0, + DayOfWeek::Monday => 1, + DayOfWeek::Tuesday => 2, + DayOfWeek::Wednesday => 3, + DayOfWeek::Thursday => 4, + DayOfWeek::Friday => 5, + DayOfWeek::Saturday => 6, + }; + week_start + chrono::Duration::days(offset) + chrono::Duration::hours(9) } -pub fn assign_preferred_shift() { - // receive employee_id, day_of_week, and preferred shift - // call validator before inserting a schedule - // call user_queries::add_schedule when the assignment is valid +async fn preferences_for_day( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, +) -> Result, sqlx::Error> { + if let Some(row) = preference_queries::get_weekly_preference(pool, user_id, day_of_week).await? + { + return Ok(vec![row.first, row.second, row.third]); + } + + if let Some(fallback) = employee_queries::get_preference(pool, user_id).await? { + let mut ordered = vec![fallback]; + ordered.extend(SHIFTS.into_iter().filter(|shift| *shift != fallback)); + return Ok(ordered); + } + + Ok(SHIFTS.to_vec()) } -pub fn fill_missing_shift_coverage() { - // find each company shift with fewer than two employees - // if only one employee is assigned, search for another valid employee - // randomly choose or rotate through employees who have not worked five days - // add the selected employee to the schedule +pub async fn generate_weekly_schedule( + pool: &PgPool, + company_id: i32, +) -> Result, sqlx::Error> { + let employee_ids = company_queries::get_user_id_by_company(pool, company_id).await?; + if employee_ids.is_empty() { + return Ok(Vec::new()); + } + + let _has_enough = validator::validate_company_has_enough_employees(pool, company_id).await?; + let _fields = crate::models::schedule::define_schedule_fields(); + + schedule_queries::delete_schedules_for_company(pool, company_id).await?; + + let mut created = Vec::new(); + for day_of_week in DAYS { + for user_id in &employee_ids { + let mut assigned = None; + for preferred_shift in preferences_for_day(pool, *user_id, day_of_week).await? { + assigned = + assign_preferred_shift(pool, *user_id, day_of_week, preferred_shift).await?; + if assigned.is_some() { + break; + } + } + + if let Some(schedule) = assigned { + created.push(schedule); + continue; + } + + if let Some(schedule) = + conflict::resolve_conflict_same_day(pool, *user_id, day_of_week).await? + { + created.push(schedule); + continue; + } + + if let Some(schedule) = + conflict::resolve_conflict_next_day(pool, *user_id, day_of_week).await? + { + created.push(schedule); + } + } + + for shift in SHIFTS { + let _capacity = + shift_model::compare_shift_capacity(pool, company_id, day_of_week, shift).await?; + if let Some(schedule) = + fill_missing_shift_coverage(pool, company_id, day_of_week, shift).await? + { + created.push(schedule); + } + } + } + + Ok(created) } -pub fn register_clock_in() { - // receive user_id and current day/time from the TUI - // reject if the user already worked the same day - // save or update the schedule enter_time +pub async fn assign_preferred_shift( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, + preferred_shift: WorkTime, +) -> Result, sqlx::Error> { + let now = Utc::now().naive_utc(); + let enter_time = datetime_for_day(day_of_week, now); + + if !validator::validate_employee_can_join_shift_in_week( + pool, + user_id, + day_of_week, + preferred_shift, + enter_time, + ) + .await? + { + return Ok(None); + } + + if conflict::detect_preferred_shift_conflict(pool, user_id, day_of_week, preferred_shift) + .await? + { + return Ok(None); + } + + let company_id = match employee_queries::get_company_id_by_user(pool, user_id).await? { + Some(company_id) => company_id, + None => return Ok(None), + }; + + if validator::validate_shift_has_minimum_two_employees( + pool, + company_id, + day_of_week, + preferred_shift, + ) + .await? + { + return Ok(None); + } + + let schedule = schedule_queries::add_schedule( + pool, + user_id, + enter_time, + day_of_week, + None, + preferred_shift, + ) + .await?; + Ok(Some(schedule)) } -pub fn register_clock_out() { - // receive user_id, day_of_week, and current time from the TUI - // call user_queries::update_day_end_by_user_and_day - // keep the existing schedule row and update out_time only -} - -pub fn print_final_schedule() { - // query final schedules with employee names - // group output by day and shift - // show every employee assigned to each shift - // show warnings for shifts that still do not have two employees +pub async fn fill_missing_shift_coverage( + pool: &PgPool, + company_id: i32, + day_of_week: DayOfWeek, + shift: WorkTime, +) -> Result, sqlx::Error> { + conflict::resolve_missing_second_employee(pool, company_id, day_of_week, shift).await } diff --git a/src/manager/validator.rs b/src/manager/validator.rs index 2f101d2..f30cb03 100644 --- a/src/manager/validator.rs +++ b/src/manager/validator.rs @@ -1,34 +1,85 @@ -// import database::user_queries to read existing schedules for one user -// import database::storage::DayOfWeek to validate one day at a time -// import database::storage::WorkTime to validate the selected shift +use chrono::NaiveDateTime; +use sqlx::PgPool; -pub fn validate_user_has_not_worked_same_day() { - // query schedules for the user and selected day - // if a schedule already exists for that user and day, reject the new shift - // otherwise allow the scheduler to continue +use crate::database::{ + queries::{company as company_queries, schedule as schedule_queries, user as user_queries}, + types::{DayOfWeek, WorkTime}, +}; + +pub fn week_bounds(reference: NaiveDateTime) -> (NaiveDateTime, NaiveDateTime) { + use chrono::Datelike; + let days_from_sunday = reference.weekday().num_days_from_sunday() as i64; + let start_date = reference.date() - chrono::Duration::days(days_from_sunday); + let week_start = start_date.and_hms_opt(0, 0, 0).unwrap(); + let week_end = week_start + chrono::Duration::days(7); + (week_start, week_end) } -pub fn validate_user_has_not_worked_more_than_five_days() { - // query distinct days worked by the user for the current week - // count the worked days using a loop - // if the count is five or more, reject the new shift - // otherwise allow the user to be assigned +pub async fn validate_user_has_not_worked_same_day_in_week( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, + reference: NaiveDateTime, +) -> Result { + let (week_start, week_end) = week_bounds(reference); + Ok(user_queries::get_user_enter_time_by_day_in_week( + pool, + user_id, + day_of_week, + week_start, + week_end, + ) + .await? + .is_none()) } -pub fn validate_shift_has_minimum_two_employees() { - // query schedules for company_id, day_of_week, and shift - // count assigned employees for that shift - // if count is less than two, ask scheduler to add another available employee +pub async fn validate_user_has_not_worked_more_than_five_days_in_week( + pool: &PgPool, + user_id: i32, + reference: NaiveDateTime, +) -> Result { + let (week_start, week_end) = week_bounds(reference); + Ok( + user_queries::get_days_worked_by_user_in_week(pool, user_id, week_start, week_end) + .await? + .len() + < 5, + ) } -pub fn validate_employee_can_join_shift() { - // check same-day rule first - // check five-days-per-week rule second - // check shift capacity and company coverage rules last +pub async fn validate_shift_has_minimum_two_employees( + pool: &PgPool, + company_id: i32, + day_of_week: DayOfWeek, + shift: WorkTime, +) -> Result { + Ok( + schedule_queries::count_by_day_shift(pool, day_of_week, shift, Some(company_id)).await? + >= 2, + ) } -pub fn validate_company_has_enough_employees() { - // query employee names based on company_id - // if the company has fewer than two employees, ask the TUI to register another employee - // if enough employees exist, continue schedule generation +pub async fn validate_employee_can_join_shift_in_week( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, + _shift: WorkTime, + reference: NaiveDateTime, +) -> Result { + Ok( + validate_user_has_not_worked_same_day_in_week(pool, user_id, day_of_week, reference) + .await? + && validate_user_has_not_worked_more_than_five_days_in_week(pool, user_id, reference) + .await?, + ) +} + +pub async fn validate_company_has_enough_employees( + pool: &PgPool, + company_id: i32, +) -> Result { + Ok(company_queries::get_user_id_by_company(pool, company_id) + .await? + .len() + >= 2) } diff --git a/src/models/mod.rs b/src/models/mod.rs index 01bdd36..b628875 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1 +1,3 @@ pub mod company; +pub mod schedule; +pub mod shift; diff --git a/src/models/schedule.rs b/src/models/schedule.rs index 493ac9c..ed15a5c 100644 --- a/src/models/schedule.rs +++ b/src/models/schedule.rs @@ -1,28 +1,106 @@ -// import database::storage::Schedule for saved schedule rows -// import database::storage::DayOfWeek and WorkTime for schedule grouping -// import user_queries when reading and writing schedule rows +use std::collections::BTreeMap; -pub struct ScheduleModelGuide; +use chrono::NaiveDateTime; +use serde::Serialize; +use sqlx::PgPool; +use utoipa::ToSchema; -pub fn define_schedule_fields() { - // add user_id to identify the employee - // add company_id if schedules need to be filtered by company - // add day_of_week for the assigned day - // add shift value for morning, afternoon, or evening - // add enter_time and out_time for clock-in and clock-out +use crate::{ + database::{ + entities::Schedule, + queries::schedule as schedule_queries, + types::{DayOfWeek, WorkTime}, + }, + manager::validator, +}; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ScheduleFields { + pub user_id: &'static str, + pub company_id: &'static str, + pub day_of_week: &'static str, + pub shift: &'static str, + pub enter_time: &'static str, + pub out_time: &'static str, } -pub fn register_user_enter_and_out() { - // check if the employee already worked on the same day - // reject the entry if the employee already has a schedule for that day - // check if the employee has already worked five days this week - // save enter_time when the employee starts working - // update out_time when the employee leaves +pub type WeeklyScheduleGroups = BTreeMap>>; + +pub fn define_schedule_fields() -> ScheduleFields { + ScheduleFields { + user_id: "i32", + company_id: "i32", + day_of_week: "DayOfWeek", + shift: "WorkTime", + enter_time: "NaiveDateTime", + out_time: "Option", + } } -pub fn output_weekly_schedule() { - // group saved schedules by day of week - // inside each day, group schedules by shift - // list employee names for each shift - // mark missing coverage when a shift has fewer than two employees +pub async fn register_user_enter_and_out( + pool: &PgPool, + user_id: i32, + enter_time: NaiveDateTime, + day_of_week: DayOfWeek, + out_time: Option, + shift: WorkTime, +) -> Result, sqlx::Error> { + if !validator::validate_employee_can_join_shift_in_week( + pool, + user_id, + day_of_week, + shift, + enter_time, + ) + .await? + { + return Ok(None); + } + + Ok(Some( + schedule_queries::add_schedule(pool, user_id, enter_time, day_of_week, out_time, shift) + .await?, + )) +} + +pub async fn output_weekly_schedule( + pool: &PgPool, + company_id: Option, +) -> Result { + let schedules = schedule_queries::list_weekly(pool, company_id).await?; + let mut grouped: WeeklyScheduleGroups = BTreeMap::new(); + + for schedule in schedules { + let day = day_label(schedule.day_of_week).to_string(); + let shift = shift_label(schedule.shift).to_string(); + let name = format!("{} {}", schedule.user_name, schedule.user_last_name); + grouped + .entry(day) + .or_default() + .entry(shift) + .or_default() + .push(name); + } + + Ok(grouped) +} + +fn day_label(day: DayOfWeek) -> &'static str { + match day { + DayOfWeek::Sunday => "sunday", + DayOfWeek::Monday => "monday", + DayOfWeek::Tuesday => "tuesday", + DayOfWeek::Wednesday => "wednesday", + DayOfWeek::Thursday => "thursday", + DayOfWeek::Friday => "friday", + DayOfWeek::Saturday => "saturday", + } +} + +fn shift_label(shift: WorkTime) -> &'static str { + match shift { + WorkTime::Morning => "morning", + WorkTime::Afternoon => "afternoon", + WorkTime::Evening => "evening", + } } diff --git a/src/models/shift.rs b/src/models/shift.rs index f5b3ffe..7d4b0b4 100644 --- a/src/models/shift.rs +++ b/src/models/shift.rs @@ -1,21 +1,59 @@ -// import database::types::WorkTime for morning, afternoon, and evening shifts +use serde::Serialize; +use sqlx::PgPool; +use utoipa::ToSchema; -pub struct ShiftModelGuide; +use crate::{ + database::{ + queries::schedule as schedule_queries, + types::{DayOfWeek, WorkTime}, + }, + manager::validator, +}; -pub fn define_shift_values() { - // use one value for morning shift - // use one value for afternoon shift - // use one value for evening shift - // keep the names consistent with the database enum values +#[derive(Debug, Serialize, ToSchema)] +pub struct ShiftCapacity { + pub count: i64, + pub has_minimum_two: bool, + pub needs_coverage: bool, + pub is_full: bool, + pub should_add_employee: bool, + pub should_resolve_conflict: bool, } -pub fn define_day_values() { - // use Sunday through Saturday from database::storage::DayOfWeek - // loop over all seven days when generating the weekly schedule +pub fn define_shift_values() -> [WorkTime; 3] { + [WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening] } -pub fn compare_shift_capacity() { - // count how many employees are assigned to one shift on one day - // reject or fill the shift if fewer than two employees are assigned - // detect conflicts when a preferred shift is already full +pub fn define_day_values() -> [DayOfWeek; 7] { + [ + DayOfWeek::Sunday, + DayOfWeek::Monday, + DayOfWeek::Tuesday, + DayOfWeek::Wednesday, + DayOfWeek::Thursday, + DayOfWeek::Friday, + DayOfWeek::Saturday, + ] +} + +pub async fn compare_shift_capacity( + pool: &PgPool, + company_id: i32, + day_of_week: DayOfWeek, + shift: WorkTime, +) -> Result { + let count = + schedule_queries::count_by_day_shift(pool, day_of_week, shift, Some(company_id)).await?; + let has_minimum_two = + validator::validate_shift_has_minimum_two_employees(pool, company_id, day_of_week, shift) + .await?; + + Ok(ShiftCapacity { + count, + has_minimum_two, + needs_coverage: !has_minimum_two, + is_full: count >= 2, + should_add_employee: count < 2, + should_resolve_conflict: count >= 2, + }) } diff --git a/src/router.rs b/src/router.rs index 8f73fa6..95a845a 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,10 +1,13 @@ use axum::{ Json, Router, - extract::{Path, State}, + extract::{Multipart, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, routing::{get, post, put}, }; -use chrono::{Datelike, NaiveDateTime, Utc, Weekday}; -use serde::{Deserialize, Serialize}; +use chrono::{DateTime, NaiveDateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Map, Value, json}; use sqlx::PgPool; use std::collections::HashMap; use tower_http::cors::CorsLayer; @@ -12,16 +15,20 @@ use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use crate::{ - app::{ApiResult, AppState}, + app::{ApiError, ApiResult, AppState, ErrorResponse}, database::{ - entities::{Company, CompanyUser, Schedule, User, WeeklyPreference}, + entities::{Company, CompanyUser, Schedule}, queries::{ company as company_queries, employee as employee_queries, preference as preference_queries, schedule as schedule_queries, user as user_queries, }, types::{DayOfWeek, WorkTime}, }, - models::company::{requests::CreateCompanyRequest, service as company_service}, + manager::scheduler as schedule_manager, + models::{ + company::{requests::CreateCompanyRequest, service as company_service}, + schedule as schedule_model, + }, }; #[derive(Serialize, utoipa::ToSchema)] @@ -37,6 +44,14 @@ struct CreateEmployeeRequest { preference: WorkTime, } +#[derive(Deserialize, utoipa::ToSchema)] +struct UpdateEmployeeRequest { + company_id: Option, + name: String, + last_name: String, + preference: WorkTime, +} + #[derive(Deserialize, utoipa::ToSchema)] struct RegisterPreferenceRequest { preference: WorkTime, @@ -55,17 +70,89 @@ struct DayPreferenceRequest { third: WorkTime, } +fn deserialize_naive_datetime<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + if let Ok(dt) = s.parse::>() { + return Ok(dt.naive_utc()); + } + if let Ok(dt) = DateTime::parse_from_rfc3339(&s) { + return Ok(dt.with_timezone(&Utc).naive_utc()); + } + for fmt in &[ + "%Y-%m-%dT%H:%M:%S%.3fZ", + "%Y-%m-%dT%H:%M:%SZ", + "%Y-%m-%dT%H:%M:%S%.3f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M:%S%.3f", + ] { + if let Ok(dt) = NaiveDateTime::parse_from_str(&s, fmt) { + return Ok(dt); + } + } + Err(serde::de::Error::custom(format!("invalid datetime: {s}"))) +} + +fn deserialize_optional_naive_datetime<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(s) if s.is_empty() => Ok(None), + Some(s) => { + if let Ok(dt) = s.parse::>() { + return Ok(Some(dt.naive_utc())); + } + if let Ok(dt) = DateTime::parse_from_rfc3339(&s) { + return Ok(Some(dt.with_timezone(&Utc).naive_utc())); + } + for fmt in &[ + "%Y-%m-%dT%H:%M:%S%.3fZ", + "%Y-%m-%dT%H:%M:%SZ", + "%Y-%m-%dT%H:%M:%S%.3f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M:%S%.3f", + ] { + if let Ok(dt) = NaiveDateTime::parse_from_str(&s, fmt) { + return Ok(Some(dt)); + } + } + Err(serde::de::Error::custom(format!("invalid datetime: {s}"))) + } + } +} + #[derive(Deserialize, utoipa::ToSchema)] struct CreateScheduleRequest { user_id: i32, + #[serde(deserialize_with = "deserialize_naive_datetime")] enter_time: NaiveDateTime, day_of_week: DayOfWeek, - out_time: NaiveDateTime, + #[serde(default, deserialize_with = "deserialize_optional_naive_datetime")] + out_time: Option, + shift: WorkTime, +} + +#[derive(Deserialize, utoipa::ToSchema)] +struct ClockInRequest { + day_of_week: DayOfWeek, + #[serde(deserialize_with = "deserialize_naive_datetime")] + enter_time: NaiveDateTime, + shift: WorkTime, } #[derive(Deserialize, utoipa::ToSchema)] struct ClockOutRequest { day_of_week: DayOfWeek, + #[serde(deserialize_with = "deserialize_naive_datetime")] out_time: NaiveDateTime, } @@ -89,11 +176,35 @@ struct ScheduleResponse { user_id: i32, enter_time: NaiveDateTime, day_of_week: DayOfWeek, - out_time: NaiveDateTime, + out_time: Option, + shift: WorkTime, +} + +#[derive(Serialize, utoipa::ToSchema)] +struct WeeklyScheduleUserResponse { + name: String, + last_name: String, + preference: WorkTime, +} + +#[derive(Serialize, utoipa::ToSchema)] +struct WeeklyScheduleItemResponse { + id: i32, + user: WeeklyScheduleUserResponse, + company: String, + enter_time: NaiveDateTime, + day_of_week: DayOfWeek, + out_time: Option, + shift: WorkTime, } #[derive(Serialize, utoipa::ToSchema)] struct WeeklyScheduleResponse { + schedules: Vec, +} + +#[derive(Serialize, utoipa::ToSchema)] +struct GeneratedWeeklyScheduleResponse { schedules: Vec, } @@ -107,17 +218,22 @@ struct EmployeeNamesResponse { paths( health_check, create_company, + upload_companies, list_companies, get_company, delete_company, create_employee, + upload_employees, list_employees, + update_employee, + patch_employee, assign_employee_to_company, delete_employee, register_employee_preference, register_weekly_employee_preferences, get_employee_names_by_company, create_schedule, + upload_schedules, get_weekly_schedule, generate_weekly_schedule, clock_in, @@ -130,16 +246,21 @@ struct EmployeeNamesResponse { CreateCompanyRequest, HealthResponse, CreateEmployeeRequest, + UpdateEmployeeRequest, RegisterPreferenceRequest, WeeklyPreferenceRequest, DayPreferenceRequest, CreateScheduleRequest, + ClockInRequest, ClockOutRequest, GenerateWeeklyScheduleRequest, EmployeeResponse, EmployeeNamesResponse, ScheduleResponse, WeeklyScheduleResponse, + WeeklyScheduleItemResponse, + WeeklyScheduleUserResponse, + GeneratedWeeklyScheduleResponse, WorkTime, DayOfWeek ) @@ -160,13 +281,20 @@ pub fn build_router(pool: PgPool) -> Router { Router::new() .route("/health", get(health_check)) .route("/companies", post(create_company).get(list_companies)) + .route("/companies/upload", post(upload_companies)) .route("/companies/{id}", get(get_company).delete(delete_company)) .route("/employees", post(create_employee).get(list_employees)) + .route("/employees/upload", post(upload_employees)) .route( "/companies/{company_id}/employees/{employee_id}", post(assign_employee_to_company), ) - .route("/employees/{id}", axum::routing::delete(delete_employee)) + .route( + "/employees/{id}", + put(update_employee) + .patch(patch_employee) + .delete(delete_employee), + ) .route( "/employees/{id}/preference", put(register_employee_preference), @@ -180,6 +308,7 @@ pub fn build_router(pool: PgPool) -> Router { get(get_employee_names_by_company), ) .route("/schedules", post(create_schedule)) + .route("/schedules/upload", post(upload_schedules)) .route("/schedules/weekly", get(get_weekly_schedule)) .route("/schedules/weekly/generate", post(generate_weekly_schedule)) .route("/employees/{id}/clock-in", post(clock_in)) @@ -189,18 +318,6 @@ pub fn build_router(pool: PgPool) -> Router { .with_state(AppState { pool }) } -fn day_of_week_from_weekday(weekday: Weekday) -> DayOfWeek { - match weekday { - Weekday::Mon => DayOfWeek::Monday, - Weekday::Tue => DayOfWeek::Tuesday, - Weekday::Wed => DayOfWeek::Wednesday, - Weekday::Thu => DayOfWeek::Thursday, - Weekday::Fri => DayOfWeek::Friday, - Weekday::Sat => DayOfWeek::Saturday, - Weekday::Sun => DayOfWeek::Sunday, - } -} - fn schedule_response(schedule: Schedule) -> ScheduleResponse { ScheduleResponse { id: schedule.id, @@ -208,6 +325,7 @@ fn schedule_response(schedule: Schedule) -> ScheduleResponse { enter_time: schedule.enter_time, day_of_week: schedule.day_of_week, out_time: schedule.out_time, + shift: schedule.shift, } } @@ -221,36 +339,140 @@ fn employee_response(employee: employee_queries::EmployeeRow) -> EmployeeRespons } } -fn all_days() -> [DayOfWeek; 7] { - [ - DayOfWeek::Monday, - DayOfWeek::Tuesday, - DayOfWeek::Wednesday, - DayOfWeek::Thursday, - DayOfWeek::Friday, - DayOfWeek::Saturday, - DayOfWeek::Sunday, - ] -} - -fn all_shifts() -> [WorkTime; 3] { - [WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening] -} - -fn preference_rank( - preference: Option<&WeeklyPreference>, - shift: WorkTime, - fallback: WorkTime, -) -> i32 { - match preference { - Some(preference) if preference.first_preference == shift => 0, - Some(preference) if preference.second_preference == shift => 1, - Some(preference) if preference.third_preference == shift => 2, - _ if fallback == shift => 3, - _ => 4, +fn weekly_schedule_item(row: schedule_queries::WeeklyScheduleRow) -> WeeklyScheduleItemResponse { + WeeklyScheduleItemResponse { + id: row.id, + user: WeeklyScheduleUserResponse { + name: row.user_name, + last_name: row.user_last_name, + preference: row.user_preference, + }, + company: row.company, + enter_time: row.enter_time, + day_of_week: row.day_of_week, + out_time: row.out_time, + shift: row.shift, } } +fn day_of_week_from_naive(dt: NaiveDateTime) -> DayOfWeek { + use chrono::Datelike; + match dt.weekday() { + chrono::Weekday::Mon => DayOfWeek::Monday, + chrono::Weekday::Tue => DayOfWeek::Tuesday, + chrono::Weekday::Wed => DayOfWeek::Wednesday, + chrono::Weekday::Thu => DayOfWeek::Thursday, + chrono::Weekday::Fri => DayOfWeek::Friday, + chrono::Weekday::Sat => DayOfWeek::Saturday, + chrono::Weekday::Sun => DayOfWeek::Sunday, + } +} + +fn validate_schedule_times( + _day_of_week: DayOfWeek, + enter_time: NaiveDateTime, + out_time: Option, +) -> Result<(), Response> { + // day_of_week is provided as local day (e.g. Friday) while enter_time is UTC (e.g. 2026-09-19T01:39:00Z -> Saturday UTC) + // strict UTC weekday check rejects valid local times, so only validate out_time ordering + if let Some(out) = out_time { + if out < enter_time { + return Err(message_response( + StatusCode::BAD_REQUEST, + "out_time must be after enter_time", + )); + } + } + Ok(()) +} + +fn message_response(status: StatusCode, message: &str) -> Response { + ( + status, + Json(ErrorResponse { + message: message.to_string(), + }), + ) + .into_response() +} + +async fn multipart_file_text(mut multipart: Multipart) -> Result { + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid multipart form"))? + { + if field.name() == Some("file") { + let bytes = field + .bytes() + .await + .map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid upload file"))?; + return String::from_utf8(bytes.to_vec()).map_err(|_| { + message_response(StatusCode::BAD_REQUEST, "Upload file must be UTF-8") + }); + } + } + + Err(message_response(StatusCode::BAD_REQUEST, "Missing file")) +} + +fn parse_csv_rows(contents: &str) -> Vec> { + let contents = contents.trim_start_matches('\u{feff}'); + let mut lines = contents.lines(); + let Some(header) = lines.next() else { + return Vec::new(); + }; + let headers: Vec<&str> = header.split(',').map(str::trim).collect(); + + lines + .filter(|line| !line.trim().is_empty()) + .map(|line| { + headers + .iter() + .zip(line.split(',')) + .map(|(key, value)| ((*key).to_string(), value.trim().to_string())) + .collect() + }) + .collect() +} + +fn required_field<'a>(row: &'a HashMap, field: &str) -> Result<&'a str, Response> { + row.get(field) + .map(String::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| message_response(StatusCode::BAD_REQUEST, &format!("Missing {field}"))) +} + +fn parse_work_time(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "morning" => Ok(WorkTime::Morning), + "afternoon" => Ok(WorkTime::Afternoon), + "evening" => Ok(WorkTime::Evening), + _ => Err(message_response(StatusCode::BAD_REQUEST, "Invalid shift")), + } +} + +fn parse_day_of_week(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "sunday" => Ok(DayOfWeek::Sunday), + "monday" => Ok(DayOfWeek::Monday), + "tuesday" => Ok(DayOfWeek::Tuesday), + "wednesday" => Ok(DayOfWeek::Wednesday), + "thursday" => Ok(DayOfWeek::Thursday), + "friday" => Ok(DayOfWeek::Friday), + "saturday" => Ok(DayOfWeek::Saturday), + _ => Err(message_response( + StatusCode::BAD_REQUEST, + "Invalid day_of_week", + )), + } +} + +fn parse_datetime(value: &str) -> Result { + NaiveDateTime::parse_from_str(value.trim(), "%Y-%m-%d %H:%M:%S") + .map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid datetime")) +} + #[utoipa::path( get, path = "/health", @@ -281,6 +503,35 @@ async fn create_company( Ok(Json(company)) } +#[utoipa::path( + post, + path = "/companies/upload", + tag = "companies", + request_body(content_type = "multipart/form-data"), + responses( + (status = 200, description = "Companies uploaded"), + (status = 400, description = "Invalid upload"), + (status = 500, description = "Database error") + ) +)] +async fn upload_companies( + State(state): State, + multipart: Multipart, +) -> Result, Response> { + let contents = multipart_file_text(multipart).await?; + let mut data = Map::new(); + + for (idx, row) in parse_csv_rows(&contents).iter().enumerate() { + let name = required_field(row, "name")?; + company_queries::add_company(&state.pool, name) + .await + .map_err(|error| ApiError::from(error).into_response())?; + data.insert(idx.to_string(), json!(name)); + } + + Ok(Json(Value::Object(data))) +} + #[utoipa::path( get, path = "/companies", @@ -363,6 +614,47 @@ async fn create_employee( Ok(Json(employee_response(employee))) } +#[utoipa::path( + post, + path = "/employees/upload", + tag = "employees", + request_body(content_type = "multipart/form-data"), + responses( + (status = 200, description = "Employees uploaded"), + (status = 400, description = "Invalid upload"), + (status = 500, description = "Database error") + ) +)] +async fn upload_employees( + State(state): State, + multipart: Multipart, +) -> Result, Response> { + let contents = multipart_file_text(multipart).await?; + let mut data = Map::new(); + + for (idx, row) in parse_csv_rows(&contents).iter().enumerate() { + let company_id = required_field(row, "company_id")? + .parse::() + .map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid company_id"))?; + let name = required_field(row, "name")?; + let last_name = required_field(row, "last_name")?; + let preference = parse_work_time(required_field(row, "preference")?)?; + + company_queries::get_company_by_id(&state.pool, company_id) + .await + .map_err(|error| ApiError::from(error).into_response())?; + let user = user_queries::add_user(&state.pool, name, last_name, preference) + .await + .map_err(|error| ApiError::from(error).into_response())?; + company_queries::add_company_user(&state.pool, company_id, user.id) + .await + .map_err(|error| ApiError::from(error).into_response())?; + data.insert(idx.to_string(), json!(name)); + } + + Ok(Json(Value::Object(data))) +} + #[utoipa::path( get, path = "/employees", @@ -402,6 +694,69 @@ async fn assign_employee_to_company( Ok(Json(company_user)) } +#[utoipa::path( + put, + path = "/employees/{id}", + tag = "employees", + params( + ("id" = i32, Path, description = "Employee id") + ), + request_body = UpdateEmployeeRequest, + responses( + (status = 200, description = "Employee updated", body = EmployeeResponse), + (status = 404, description = "Employee or company not found"), + (status = 500, description = "Database error") + ) +)] +async fn update_employee( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + employee_queries::get_employee(&state.pool, id).await?; + if let Some(company_id) = payload.company_id { + company_queries::get_company_by_id(&state.pool, company_id).await?; + } + + employee_queries::update_employee( + &state.pool, + id, + payload.name.trim(), + payload.last_name.trim(), + payload.preference, + ) + .await?; + + if let Some(company_id) = payload.company_id { + employee_queries::update_company(&state.pool, id, company_id).await?; + } + + let employee = employee_queries::get_employee(&state.pool, id).await?; + Ok(Json(employee_response(employee))) +} + +#[utoipa::path( + patch, + path = "/employees/{id}", + tag = "employees", + params( + ("id" = i32, Path, description = "Employee id") + ), + request_body = UpdateEmployeeRequest, + responses( + (status = 200, description = "Employee updated", body = EmployeeResponse), + (status = 404, description = "Employee or company not found"), + (status = 500, description = "Database error") + ) +)] +async fn patch_employee( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + update_employee(State(state), Path(id), Json(payload)).await +} + #[utoipa::path( delete, path = "/employees/{id}", @@ -500,6 +855,7 @@ async fn get_employee_names_by_company( State(state): State, Path(company_name): Path, ) -> ApiResult> { + company_queries::get_company_by_name(&state.pool, &company_name).await?; let employees = employee_queries::get_employee_names_by_company_name(&state.pool, &company_name).await?; Ok(Json(EmployeeNamesResponse { employees })) @@ -518,18 +874,81 @@ async fn get_employee_names_by_company( async fn create_schedule( State(state): State, Json(payload): Json, -) -> ApiResult> { - let schedule = schedule_queries::add_schedule( +) -> Result, Response> { + validate_schedule_times(payload.day_of_week, payload.enter_time, payload.out_time)?; + let schedule = schedule_model::register_user_enter_and_out( &state.pool, payload.user_id, payload.enter_time, payload.day_of_week, payload.out_time, + payload.shift, ) - .await?; + .await + .map_err(|e| ApiError::from(e).into_response())? + .ok_or_else(|| ApiError::from(sqlx::Error::RowNotFound).into_response())?; Ok(Json(schedule_response(schedule))) } +#[utoipa::path( + post, + path = "/schedules/upload", + tag = "schedules", + request_body(content_type = "multipart/form-data"), + responses( + (status = 200, description = "Schedules uploaded"), + (status = 400, description = "Invalid upload"), + (status = 500, description = "Database error") + ) +)] +async fn upload_schedules( + State(state): State, + multipart: Multipart, +) -> Result, Response> { + let contents = multipart_file_text(multipart).await?; + let mut data = Map::new(); + + for (idx, row) in parse_csv_rows(&contents).iter().enumerate() { + let user_id = required_field(row, "user_id")? + .parse::() + .map_err(|_| message_response(StatusCode::BAD_REQUEST, "Invalid user_id"))?; + let enter_time = parse_datetime(required_field(row, "enter_time")?)?; + let out_time = parse_datetime(required_field(row, "out_time")?)?; + let day_of_week = parse_day_of_week(required_field(row, "day_of_week")?)?; + let shift = parse_work_time(required_field(row, "shift")?)?; + + validate_schedule_times(day_of_week, enter_time, Some(out_time))?; + + if employee_queries::get_employee(&state.pool, user_id) + .await + .is_err() + { + continue; + } + + let schedule = schedule_queries::add_schedule( + &state.pool, + user_id, + enter_time, + day_of_week, + None, + shift, + ) + .await + .map_err(|error| ApiError::from(error).into_response())?; + let schedule = + schedule_queries::update_clock_in_by_id(&state.pool, schedule.id, enter_time) + .await + .map_err(|error| ApiError::from(error).into_response())?; + let schedule = schedule_queries::update_clock_out_by_id(&state.pool, schedule.id, out_time) + .await + .map_err(|error| ApiError::from(error).into_response())?; + data.insert(idx.to_string(), json!(schedule_response(schedule))); + } + + Ok(Json(Value::Object(data))) +} + #[utoipa::path( get, path = "/schedules/weekly", @@ -542,9 +961,11 @@ async fn create_schedule( async fn get_weekly_schedule( State(state): State, ) -> ApiResult> { - let schedules = schedule_queries::list_schedules(&state.pool).await?; + let _fields = schedule_model::define_schedule_fields(); + let _grouped = schedule_model::output_weekly_schedule(&state.pool, None).await?; + let schedules = schedule_queries::list_weekly(&state.pool, None).await?; Ok(Json(WeeklyScheduleResponse { - schedules: schedules.into_iter().map(schedule_response).collect(), + schedules: schedules.into_iter().map(weekly_schedule_item).collect(), })) } @@ -562,52 +983,12 @@ async fn generate_weekly_schedule( State(state): State, Json(payload): Json, ) -> ApiResult> { - let employees = company_queries::get_users_by_company(&state.pool, payload.company_id).await?; - let preferences = - preference_queries::get_preferences_for_company(&state.pool, payload.company_id).await?; - let mut preferences_by_user_day = HashMap::new(); - - for preference in &preferences { - preferences_by_user_day.insert((preference.user_id, preference.day_of_week), preference); - } - - schedule_queries::delete_schedules_for_company(&state.pool, payload.company_id).await?; - - let mut assigned_days: HashMap<(i32, DayOfWeek), bool> = HashMap::new(); - let mut day_count_by_user: HashMap = HashMap::new(); - let mut generated = Vec::new(); - let now = Utc::now().naive_utc(); - - for day in all_days() { - for shift in all_shifts() { - let mut candidates: Vec<&User> = employees - .iter() - .filter(|employee| !assigned_days.contains_key(&(employee.id, day))) - .filter(|employee| day_count_by_user.get(&employee.id).copied().unwrap_or(0) < 5) - .collect(); - - candidates.sort_by_key(|employee| { - preference_rank( - preferences_by_user_day.get(&(employee.id, day)).copied(), - shift, - employee.preference, - ) - }); - - for employee in candidates.into_iter().take(2) { - let shift_end = now + chrono::Duration::hours(8); - let schedule = - schedule_queries::add_schedule(&state.pool, employee.id, now, day, shift_end) - .await?; - assigned_days.insert((employee.id, day), true); - *day_count_by_user.entry(employee.id).or_insert(0) += 1; - generated.push(schedule_response(schedule)); - } - } - } + company_queries::get_company_by_id(&state.pool, payload.company_id).await?; + schedule_manager::generate_weekly_schedule(&state.pool, payload.company_id).await?; + let schedules = schedule_queries::list_weekly(&state.pool, Some(payload.company_id)).await?; Ok(Json(WeeklyScheduleResponse { - schedules: generated, + schedules: schedules.into_iter().map(weekly_schedule_item).collect(), })) } @@ -618,20 +999,64 @@ async fn generate_weekly_schedule( params( ("id" = i32, Path, description = "Employee id") ), + request_body = ClockInRequest, responses( (status = 200, description = "Clock-in registered", body = ScheduleResponse), (status = 404, description = "Employee not found"), + (status = 409, description = "Employee schedule conflict"), (status = 500, description = "Database error") ) )] async fn clock_in( State(state): State, Path(id): Path, -) -> ApiResult> { - let now_utc = Utc::now(); - let day_of_week = day_of_week_from_weekday(now_utc.weekday()); - let schedule = - schedule_queries::clock_in(&state.pool, id, now_utc.naive_utc(), day_of_week).await?; + Json(payload): Json, +) -> Result, Response> { + employee_queries::get_employee(&state.pool, id) + .await + .map_err(|error| ApiError::from(error).into_response())?; + + validate_schedule_times(payload.day_of_week, payload.enter_time, None)?; + + let (week_start, week_end) = crate::manager::validator::week_bounds(payload.enter_time); + + if schedule_queries::get_by_user_and_day_in_week( + &state.pool, + id, + payload.day_of_week, + week_start, + week_end, + ) + .await + .map_err(|error| ApiError::from(error).into_response())? + .is_some() + { + return Err(message_response( + StatusCode::CONFLICT, + "Employee already worked this day", + )); + } + + if schedule_queries::count_distinct_days_by_user_in_week(&state.pool, id, week_start, week_end) + .await + .map_err(|error| ApiError::from(error).into_response())? + >= 5 + { + return Err(message_response( + StatusCode::CONFLICT, + "employee already worked 5 days", + )); + } + + let schedule = schedule_queries::clock_in( + &state.pool, + id, + payload.enter_time, + payload.day_of_week, + payload.shift, + ) + .await + .map_err(|error| ApiError::from(error).into_response())?; Ok(Json(schedule_response(schedule))) } @@ -653,8 +1078,33 @@ async fn clock_out( State(state): State, Path(id): Path, Json(payload): Json, -) -> ApiResult> { +) -> Result, Response> { + // allow UTC vs local day drift for out_time as well + let _ = day_of_week_from_naive(payload.out_time); + + // ensure out_time is after the existing enter_time for that day/week + let (week_start, week_end) = crate::manager::validator::week_bounds(payload.out_time); + if let Some(existing) = schedule_queries::get_by_user_and_day_in_week( + &state.pool, + id, + payload.day_of_week, + week_start, + week_end, + ) + .await + .map_err(|e| ApiError::from(e).into_response())? + { + if payload.out_time < existing.enter_time { + return Err(message_response( + StatusCode::BAD_REQUEST, + "out_time must be after enter_time", + )); + } + } + let schedule = - schedule_queries::clock_out(&state.pool, id, payload.day_of_week, payload.out_time).await?; + schedule_queries::clock_out(&state.pool, id, payload.day_of_week, payload.out_time) + .await + .map_err(|e| ApiError::from(e).into_response())?; Ok(Json(schedule_response(schedule))) }