From 29c8d0f27df006b11f6c760a126edd8db79c1f1f Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Fri, 18 Sep 2026 12:45:11 -0400 Subject: [PATCH] adding endpoints --- .gitignore | 16 + Cargo.toml | 16 + LICENSE | 18 + README.md | 2 + src/app.rs | 44 ++ src/database/connection.rs | 18 + src/database/entities/company.rs | 10 + src/database/entities/company_user.rs | 11 + src/database/entities/mod.rs | 11 + src/database/entities/schedule.rs | 15 + src/database/entities/user.rs | 14 + src/database/entities/weekly_preference.rs | 16 + src/database/mod.rs | 5 + src/database/queries/company.rs | 121 ++++ src/database/queries/employee.rs | 72 +++ src/database/queries/mod.rs | 5 + src/database/queries/preference.rs | 64 ++ src/database/queries/schedule.rs | 134 +++++ src/database/queries/user.rs | 145 +++++ src/database/schema.rs | 104 ++++ src/database/types.rs | 47 ++ src/main.rs | 39 ++ src/manager/conflict.rs | 28 + src/manager/mod.rs | 3 + src/manager/scheduler.rs | 49 ++ src/manager/validator.rs | 34 ++ src/models/company/mod.rs | 2 + src/models/company/requests.rs | 6 + src/models/company/service.rs | 25 + src/models/employee.rs | 58 ++ src/models/mod.rs | 1 + src/models/schedule.rs | 28 + src/models/shift.rs | 21 + src/router.rs | 660 +++++++++++++++++++++ 34 files changed, 1842 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/database/connection.rs create mode 100644 src/database/entities/company.rs create mode 100644 src/database/entities/company_user.rs create mode 100644 src/database/entities/mod.rs create mode 100644 src/database/entities/schedule.rs create mode 100644 src/database/entities/user.rs create mode 100644 src/database/entities/weekly_preference.rs create mode 100644 src/database/mod.rs create mode 100644 src/database/queries/company.rs create mode 100644 src/database/queries/employee.rs create mode 100644 src/database/queries/mod.rs create mode 100644 src/database/queries/preference.rs create mode 100644 src/database/queries/schedule.rs create mode 100644 src/database/queries/user.rs create mode 100644 src/database/schema.rs create mode 100644 src/database/types.rs create mode 100644 src/main.rs create mode 100644 src/manager/conflict.rs create mode 100644 src/manager/mod.rs create mode 100644 src/manager/scheduler.rs create mode 100644 src/manager/validator.rs create mode 100644 src/models/company/mod.rs create mode 100644 src/models/company/requests.rs create mode 100644 src/models/company/service.rs create mode 100644 src/models/employee.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/schedule.rs create mode 100644 src/models/shift.rs create mode 100644 src/router.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6bcf8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ +/target +.env +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..91c02b7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "manager_employees_rust" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.4" +chrono = { version = "0.4.42", features = ["serde"] } +dotenvy = "0.15.7" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sqlx = { version = "0.9.0", features = ["chrono", "derive", "postgres", "runtime-tokio"] } +tokio = { version = "1.53.1", features = ["full"] } +tower-http = { version = "0.6.6", features = ["cors"] } +utoipa = { version = "5", features = ["axum_extras", "chrono"] } +utoipa-swagger-ui = { version = "9", features = ["axum"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6bddeeb --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 CarGDev + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..91555d6 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# manager_employees_rust + diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..daa96c9 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,44 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; +use sqlx::PgPool; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +pub struct ApiError(sqlx::Error); + +pub type ApiResult = Result; + +impl From for ApiError { + fn from(error: sqlx::Error) -> Self { + Self(error) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = match self.0 { + sqlx::Error::RowNotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + ( + status, + Json(ErrorResponse { + error: self.0.to_string(), + }), + ) + .into_response() + } +} diff --git a/src/database/connection.rs b/src/database/connection.rs new file mode 100644 index 0000000..1b08c98 --- /dev/null +++ b/src/database/connection.rs @@ -0,0 +1,18 @@ +use sqlx::{PgPool, postgres::PgPoolOptions}; + +use crate::database::schema; + +pub async fn connect(database_url: &str) -> Result { + PgPoolOptions::new() + .max_connections(5) + .connect(database_url) + .await +} + +pub async fn init_database(pool: &PgPool) -> Result<(), sqlx::Error> { + for query in schema::CREATE_SCHEMA_QUERIES { + sqlx::query(query).execute(pool).await?; + } + + Ok(()) +} diff --git a/src/database/entities/company.rs b/src/database/entities/company.rs new file mode 100644 index 0000000..634ec49 --- /dev/null +++ b/src/database/entities/company.rs @@ -0,0 +1,10 @@ +use chrono::NaiveDateTime; +use serde::Serialize; + +#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct Company { + pub id: i32, + pub name: String, + pub date_created: NaiveDateTime, + pub date_updated: NaiveDateTime, +} diff --git a/src/database/entities/company_user.rs b/src/database/entities/company_user.rs new file mode 100644 index 0000000..6c1a2af --- /dev/null +++ b/src/database/entities/company_user.rs @@ -0,0 +1,11 @@ +use chrono::NaiveDateTime; +use serde::Serialize; + +#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct CompanyUser { + pub id: i32, + pub user_id: i32, + pub company_id: i32, + pub date_created: NaiveDateTime, + pub date_updated: NaiveDateTime, +} diff --git a/src/database/entities/mod.rs b/src/database/entities/mod.rs new file mode 100644 index 0000000..d22b8d3 --- /dev/null +++ b/src/database/entities/mod.rs @@ -0,0 +1,11 @@ +pub mod company; +pub mod company_user; +pub mod schedule; +pub mod user; +pub mod weekly_preference; + +pub use company::Company; +pub use company_user::CompanyUser; +pub use schedule::Schedule; +pub use user::User; +pub use weekly_preference::WeeklyPreference; diff --git a/src/database/entities/schedule.rs b/src/database/entities/schedule.rs new file mode 100644 index 0000000..60487a3 --- /dev/null +++ b/src/database/entities/schedule.rs @@ -0,0 +1,15 @@ +use chrono::NaiveDateTime; +use serde::Serialize; + +use crate::database::types::DayOfWeek; + +#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct Schedule { + pub id: i32, + pub user_id: i32, + pub enter_time: NaiveDateTime, + pub day_of_week: DayOfWeek, + pub out_time: NaiveDateTime, + pub date_created: NaiveDateTime, + pub date_updated: NaiveDateTime, +} diff --git a/src/database/entities/user.rs b/src/database/entities/user.rs new file mode 100644 index 0000000..6708d13 --- /dev/null +++ b/src/database/entities/user.rs @@ -0,0 +1,14 @@ +use chrono::NaiveDateTime; +use serde::Serialize; + +use crate::database::types::WorkTime; + +#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct User { + pub id: i32, + pub name: String, + pub last_name: String, + pub preference: 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 new file mode 100644 index 0000000..97b9189 --- /dev/null +++ b/src/database/entities/weekly_preference.rs @@ -0,0 +1,16 @@ +use chrono::NaiveDateTime; +use serde::Serialize; + +use crate::database::types::{DayOfWeek, WorkTime}; + +#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)] +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 date_created: NaiveDateTime, + pub date_updated: NaiveDateTime, +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 0000000..23b9ac6 --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,5 @@ +pub mod connection; +pub mod entities; +pub mod queries; +pub mod schema; +pub mod types; diff --git a/src/database/queries/company.rs b/src/database/queries/company.rs new file mode 100644 index 0000000..2ef63c8 --- /dev/null +++ b/src/database/queries/company.rs @@ -0,0 +1,121 @@ +use sqlx::PgPool; + +use crate::database::entities::{Company, CompanyUser, User}; + +pub async fn add_company(pool: &PgPool, name: &str) -> Result { + sqlx::query_as::<_, Company>( + r#" + INSERT INTO companies (name) + VALUES ($1) + RETURNING id, name, date_created, date_updated + "#, + ) + .bind(name) + .fetch_one(pool) + .await +} + +pub async fn get_company_by_id(pool: &PgPool, id: i32) -> Result { + sqlx::query_as::<_, Company>( + r#" + SELECT id, name, date_created, date_updated + FROM companies + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_one(pool) + .await +} + +pub async fn list_companies(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, Company>( + r#" + SELECT id, name, date_created, date_updated + FROM companies + ORDER BY name + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn remove_company(pool: &PgPool, id: i32) -> Result { + sqlx::query_as::<_, Company>( + r#" + DELETE FROM companies + WHERE id = $1 + RETURNING id, name, date_created, date_updated + "#, + ) + .bind(id) + .fetch_one(pool) + .await +} + +pub async fn add_company_user( + pool: &PgPool, + company_id: i32, + user_id: i32, +) -> Result { + sqlx::query_as::<_, CompanyUser>( + r#" + INSERT INTO company_users (user_id, company_id) + VALUES ($1, $2) + RETURNING id, user_id, company_id, date_created, date_updated + "#, + ) + .bind(user_id) + .bind(company_id) + .fetch_one(pool) + .await +} + +pub async fn get_company_by_name(pool: &PgPool, name: &str) -> Result { + sqlx::query_as::<_, Company>( + r#" + SELECT id, name, date_created, date_updated + FROM companies + WHERE name = $1 + "#, + ) + .bind(name) + .fetch_one(pool) + .await +} + +pub async fn get_user_id_by_company( + pool: &PgPool, + company_id: i32, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, i32>( + r#" + SELECT user_id + FROM company_users + WHERE company_id = $1 + ORDER BY user_id + "#, + ) + .bind(company_id) + .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 new file mode 100644 index 0000000..119ccf0 --- /dev/null +++ b/src/database/queries/employee.rs @@ -0,0 +1,72 @@ +use serde::Serialize; +use sqlx::PgPool; + +use crate::database::{entities::User, types::WorkTime}; + +#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct EmployeeRow { + pub id: i32, + pub company_id: i32, + pub name: String, + pub last_name: String, + pub preference: WorkTime, +} + +pub async fn list_employees(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, EmployeeRow>( + r#" + 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 + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn get_employee(pool: &PgPool, id: i32) -> Result { + sqlx::query_as::<_, EmployeeRow>( + r#" + 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 + WHERE users.id = $1 + "#, + ) + .bind(id) + .fetch_one(pool) + .await +} + +pub async fn get_employee_user(pool: &PgPool, id: i32) -> Result { + sqlx::query_as::<_, User>( + r#" + SELECT id, name, last_name, preference, date_created, date_updated + FROM users + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_one(pool) + .await +} + +pub async fn get_employee_names_by_company_name( + pool: &PgPool, + company_name: &str, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, String>( + r#" + SELECT users.name || ' ' || users.last_name + FROM users + INNER JOIN company_users ON company_users.user_id = users.id + INNER JOIN companies ON companies.id = company_users.company_id + WHERE companies.name = $1 + ORDER BY users.name, users.last_name + "#, + ) + .bind(company_name) + .fetch_all(pool) + .await +} diff --git a/src/database/queries/mod.rs b/src/database/queries/mod.rs new file mode 100644 index 0000000..acd6c38 --- /dev/null +++ b/src/database/queries/mod.rs @@ -0,0 +1,5 @@ +pub mod company; +pub mod employee; +pub mod preference; +pub mod schedule; +pub mod user; diff --git a/src/database/queries/preference.rs b/src/database/queries/preference.rs new file mode 100644 index 0000000..f082932 --- /dev/null +++ b/src/database/queries/preference.rs @@ -0,0 +1,64 @@ +use sqlx::PgPool; + +use crate::database::{ + entities::WeeklyPreference, + types::{DayOfWeek, WorkTime}, +}; + +pub async fn upsert_weekly_preference( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, + first_preference: WorkTime, + second_preference: WorkTime, + third_preference: WorkTime, +) -> Result { + sqlx::query_as::<_, WeeklyPreference>( + r#" + INSERT INTO employee_weekly_preferences ( + user_id, day_of_week, first_preference, second_preference, third_preference + ) + 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, + date_updated = NOW() + RETURNING id, user_id, day_of_week, first_preference, second_preference, + third_preference, date_created, date_updated + "#, + ) + .bind(user_id) + .bind(day_of_week) + .bind(first_preference) + .bind(second_preference) + .bind(third_preference) + .fetch_one(pool) + .await +} + +pub async fn get_preferences_for_company( + pool: &PgPool, + company_id: i32, +) -> 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 + "#, + ) + .bind(company_id) + .fetch_all(pool) + .await +} diff --git a/src/database/queries/schedule.rs b/src/database/queries/schedule.rs new file mode 100644 index 0000000..6e116c2 --- /dev/null +++ b/src/database/queries/schedule.rs @@ -0,0 +1,134 @@ +use chrono::NaiveDateTime; +use sqlx::PgPool; + +use crate::database::{entities::Schedule, types::DayOfWeek}; + +pub async fn add_schedule( + pool: &PgPool, + user_id: i32, + enter_time: NaiveDateTime, + day_of_week: DayOfWeek, + out_time: NaiveDateTime, +) -> 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>( + 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 + "#, + ) + .bind(user_id) + .bind(enter_time) + .bind(day_of_week) + .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( + pool: &PgPool, + company_id: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Schedule>( + r#" + SELECT schedules.id, schedules.user_id, schedules.enter_time, schedules.day_of_week, + schedules.out_time, schedules.date_created, schedules.date_updated + 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 + "#, + ) + .bind(company_id) + .fetch_all(pool) + .await +} + +pub async fn delete_schedules_for_company( + pool: &PgPool, + company_id: i32, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + DELETE FROM schedules + USING company_users + WHERE company_users.user_id = schedules.user_id + AND company_users.company_id = $1 + "#, + ) + .bind(company_id) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn clock_in( + pool: &PgPool, + user_id: i32, + now: NaiveDateTime, + day_of_week: DayOfWeek, +) -> 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 +} + +pub async fn clock_out( + 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 enter_time DESC + LIMIT 1 + ) + RETURNING id, user_id, enter_time, day_of_week, 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/queries/user.rs b/src/database/queries/user.rs new file mode 100644 index 0000000..2111602 --- /dev/null +++ b/src/database/queries/user.rs @@ -0,0 +1,145 @@ +use chrono::NaiveDateTime; +use sqlx::PgPool; + +use crate::database::{ + entities::{Schedule, User}, + types::{DayOfWeek, WorkTime}, +}; + +pub async fn add_user( + pool: &PgPool, + name: &str, + last_name: &str, + preference: WorkTime, +) -> Result { + sqlx::query_as::<_, User>( + r#" + INSERT INTO users (name, last_name, preference) + VALUES ($1, $2, $3) + RETURNING id, name, last_name, preference, date_created, date_updated + "#, + ) + .bind(name) + .bind(last_name) + .bind(preference) + .fetch_one(pool) + .await +} + +pub async fn update_preference( + pool: &PgPool, + user_id: i32, + preference: WorkTime, +) -> Result { + sqlx::query_as::<_, User>( + r#" + UPDATE users + SET preference = $2, date_updated = NOW() + WHERE id = $1 + RETURNING id, name, last_name, preference, date_created, date_updated + "#, + ) + .bind(user_id) + .bind(preference) + .fetch_one(pool) + .await +} + +pub async fn remove_user(pool: &PgPool, id: i32) -> Result { + sqlx::query_as::<_, User>( + r#" + DELETE FROM users + WHERE id = $1 + RETURNING id, name, last_name, preference, date_created, date_updated + "#, + ) + .bind(id) + .fetch_one(pool) + .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( + pool: &PgPool, + user_id: i32, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, DayOfWeek>( + r#" + SELECT DISTINCT day_of_week + FROM schedules + WHERE user_id = $1 + ORDER BY day_of_week + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +pub async fn get_user_enter_time_by_day( + pool: &PgPool, + user_id: i32, + day_of_week: DayOfWeek, +) -> Result, sqlx::Error> { + sqlx::query_scalar::<_, NaiveDateTime>( + r#" + SELECT enter_time + FROM schedules + WHERE user_id = $1 AND day_of_week = $2 + ORDER BY date_created DESC + LIMIT 1 + "#, + ) + .bind(user_id) + .bind(day_of_week) + .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 new file mode 100644 index 0000000..2bbf7c3 --- /dev/null +++ b/src/database/schema.rs @@ -0,0 +1,104 @@ +pub const CREATE_WORK_TIME_ENUM: &str = r#" +DO $$ +BEGIN + CREATE TYPE work_time AS ENUM ('morning', 'afternoon', 'evening'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +"#; + +pub const CREATE_DAY_OF_WEEK_ENUM: &str = r#" +DO $$ +BEGIN + CREATE TYPE day_of_week AS ENUM ( + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +"#; + +pub const CREATE_COMPANIES_TABLE: &str = r#" +CREATE TABLE IF NOT EXISTS companies ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW() +); +"#; + +pub const CREATE_USERS_TABLE: &str = r#" +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + last_name TEXT NOT NULL, + preference work_time NOT NULL, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW() +); +"#; + +pub const CREATE_COMPANY_USERS_TABLE: &str = r#" +CREATE TABLE IF NOT EXISTS company_users ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (user_id, company_id) +); +"#; + +pub const CREATE_SCHEDULES_TABLE: &str = r#" +CREATE TABLE IF NOT EXISTS schedules ( + id SERIAL PRIMARY KEY, + 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, + 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 ( + 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, + date_created TIMESTAMP NOT NULL DEFAULT NOW(), + date_updated TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (user_id, day_of_week) +); +"#; + +pub const ALTER_SCHEDULES_ADD_SHIFT: &str = r#" +ALTER TABLE schedules +ADD COLUMN IF NOT EXISTS shift work_time NOT NULL DEFAULT 'morning'; +"#; + +pub const ALTER_SCHEDULES_DROP_OUT_TIME_NOT_NULL: &str = r#" +ALTER TABLE schedules +ALTER COLUMN out_time DROP NOT NULL; +"#; + +pub const CREATE_SCHEMA_QUERIES: [&str; 9] = [ + 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, + ALTER_SCHEDULES_ADD_SHIFT, + ALTER_SCHEDULES_DROP_OUT_TIME_NOT_NULL, +]; diff --git a/src/database/types.rs b/src/database/types.rs new file mode 100644 index 0000000..d6070a9 --- /dev/null +++ b/src/database/types.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + std::hash::Hash, + Deserialize, + Serialize, + sqlx::Type, + utoipa::ToSchema, +)] +#[sqlx(type_name = "work_time", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +#[schema(rename_all = "snake_case")] +pub enum WorkTime { + Morning, + Afternoon, + Evening, +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + std::hash::Hash, + Deserialize, + Serialize, + sqlx::Type, + utoipa::ToSchema, +)] +#[sqlx(type_name = "day_of_week", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +#[schema(rename_all = "snake_case")] +pub enum DayOfWeek { + Sunday, + Monday, + Tuesday, + Wednesday, + Thursday, + Friday, + Saturday, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c83723f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,39 @@ +mod app; +mod database; +mod manager; +mod models; +mod router; + +use std::{env, net::SocketAddr}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenvy::dotenv().ok(); + + let database_url = env::var("DATABASE_URL")?; + let pool = database::connection::connect(&database_url).await?; + database::connection::init_database(&pool).await?; + + let router = router::build_router(pool); + + let address = env::var("APP_ADDRESS") + .unwrap_or_else(|_| "127.0.0.1:5005".to_string()) + .parse::()?; + + let listener = tokio::net::TcpListener::bind(address).await?; + axum::serve(listener, router).await?; + + 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 +} diff --git a/src/manager/conflict.rs b/src/manager/conflict.rs new file mode 100644 index 0000000..4f7c4e3 --- /dev/null +++ b/src/manager/conflict.rs @@ -0,0 +1,28 @@ +// import validator functions before moving an employee to another shift or day +// import database queries to read preferences and existing schedules + +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 +} + +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 +} + +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 +} + +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 +} diff --git a/src/manager/mod.rs b/src/manager/mod.rs new file mode 100644 index 0000000..297a0ee --- /dev/null +++ b/src/manager/mod.rs @@ -0,0 +1,3 @@ +pub mod conflict; +pub mod scheduler; +pub mod validator; diff --git a/src/manager/scheduler.rs b/src/manager/scheduler.rs new file mode 100644 index 0000000..f102d90 --- /dev/null +++ b/src/manager/scheduler.rs @@ -0,0 +1,49 @@ +// 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 + +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 +} + +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 +} + +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 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 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 +} diff --git a/src/manager/validator.rs b/src/manager/validator.rs new file mode 100644 index 0000000..2f101d2 --- /dev/null +++ b/src/manager/validator.rs @@ -0,0 +1,34 @@ +// 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 + +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 +} + +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 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 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 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 +} diff --git a/src/models/company/mod.rs b/src/models/company/mod.rs new file mode 100644 index 0000000..668849b --- /dev/null +++ b/src/models/company/mod.rs @@ -0,0 +1,2 @@ +pub mod requests; +pub mod service; diff --git a/src/models/company/requests.rs b/src/models/company/requests.rs new file mode 100644 index 0000000..9cd4543 --- /dev/null +++ b/src/models/company/requests.rs @@ -0,0 +1,6 @@ +use serde::Deserialize; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateCompanyRequest { + pub name: String, +} diff --git a/src/models/company/service.rs b/src/models/company/service.rs new file mode 100644 index 0000000..e698f32 --- /dev/null +++ b/src/models/company/service.rs @@ -0,0 +1,25 @@ +use sqlx::PgPool; + +use crate::{ + database::{entities::Company, queries::company as company_queries}, + models::company::requests::CreateCompanyRequest, +}; + +pub async fn create_company( + pool: &PgPool, + request: CreateCompanyRequest, +) -> Result { + company_queries::add_company(pool, request.name.trim()).await +} + +pub async fn get_company(pool: &PgPool, id: i32) -> Result { + company_queries::get_company_by_id(pool, id).await +} + +pub async fn list_companies(pool: &PgPool) -> Result, sqlx::Error> { + company_queries::list_companies(pool).await +} + +pub async fn delete_company(pool: &PgPool, id: i32) -> Result { + company_queries::remove_company(pool, id).await +} diff --git a/src/models/employee.rs b/src/models/employee.rs new file mode 100644 index 0000000..2201673 --- /dev/null +++ b/src/models/employee.rs @@ -0,0 +1,58 @@ +use sqlx::PgPool; + +use crate::database::{ + company_queries, + storage::{User, WorkTime}, + user_queries, +}; + +#[derive(Deserialize)] +pub struct CreateEmployeeRequest { + pub company_id: i32, + pub name: String, + pub last_name: String, + pub preference: WorkTime, +} + +pub struct EmployeeModelGuide; + +pub fn create_employee( + pool: &PgPool, + Json(payload): Json, +) -> ApiResult> { + let user = user_queries::add_user( + &state.pool, + &payload.name, + &payload.last_name, + payload.preference, + ) + .await?; + company_queries::add_company_user(pool, payload.company_id, user.id).await?; + Ok(Json(user)) +} + +pub async fn register_employee_preference( + pool: &PgPool, + user_id: i32, + preference: WorkTime, +) -> Result { + user_queries::update_preference(pool, user_id, preference).await +} + +pub async fn get_employee_names_by_company( + pool: &PgPool, + company_name: &str, +) -> Result, sqlx::Error> { + let company = company_queries::get_company_by_name(pool, company_name).await?; + let users = company_queries::get_users_by_company(pool, company.id).await?; + + Ok(users + .into_iter() + .map(|user| format!("{} {}", user.name, user.last_name)) + .collect()) +} + +pub fn register_user_into_company(user_id: i32, company: &string) { + let company = company_queries::get_company_by_name(pool, company_name).await?; + company_queries::add_company_user(pool, company.id, user_id); +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..01bdd36 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1 @@ +pub mod company; diff --git a/src/models/schedule.rs b/src/models/schedule.rs new file mode 100644 index 0000000..493ac9c --- /dev/null +++ b/src/models/schedule.rs @@ -0,0 +1,28 @@ +// 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 + +pub struct ScheduleModelGuide; + +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 +} + +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 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 +} diff --git a/src/models/shift.rs b/src/models/shift.rs new file mode 100644 index 0000000..f5b3ffe --- /dev/null +++ b/src/models/shift.rs @@ -0,0 +1,21 @@ +// import database::types::WorkTime for morning, afternoon, and evening shifts + +pub struct ShiftModelGuide; + +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 +} + +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 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 +} diff --git a/src/router.rs b/src/router.rs new file mode 100644 index 0000000..8f73fa6 --- /dev/null +++ b/src/router.rs @@ -0,0 +1,660 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + routing::{get, post, put}, +}; +use chrono::{Datelike, NaiveDateTime, Utc, Weekday}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::collections::HashMap; +use tower_http::cors::CorsLayer; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; + +use crate::{ + app::{ApiResult, AppState}, + database::{ + entities::{Company, CompanyUser, Schedule, User, WeeklyPreference}, + 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}, +}; + +#[derive(Serialize, utoipa::ToSchema)] +struct HealthResponse { + status: &'static str, +} + +#[derive(Deserialize, utoipa::ToSchema)] +struct CreateEmployeeRequest { + company_id: i32, + name: String, + last_name: String, + preference: WorkTime, +} + +#[derive(Deserialize, utoipa::ToSchema)] +struct RegisterPreferenceRequest { + preference: WorkTime, +} + +#[derive(Deserialize, utoipa::ToSchema)] +struct WeeklyPreferenceRequest { + preferences: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +struct DayPreferenceRequest { + day_of_week: DayOfWeek, + first: WorkTime, + second: WorkTime, + third: WorkTime, +} + +#[derive(Deserialize, utoipa::ToSchema)] +struct CreateScheduleRequest { + user_id: i32, + enter_time: NaiveDateTime, + day_of_week: DayOfWeek, + out_time: NaiveDateTime, +} + +#[derive(Deserialize, utoipa::ToSchema)] +struct ClockOutRequest { + day_of_week: DayOfWeek, + out_time: NaiveDateTime, +} + +#[derive(Deserialize, utoipa::ToSchema)] +struct GenerateWeeklyScheduleRequest { + company_id: i32, +} + +#[derive(Serialize, utoipa::ToSchema)] +struct EmployeeResponse { + id: i32, + company_id: i32, + name: String, + last_name: String, + preference: WorkTime, +} + +#[derive(Serialize, utoipa::ToSchema)] +struct ScheduleResponse { + id: i32, + user_id: i32, + enter_time: NaiveDateTime, + day_of_week: DayOfWeek, + out_time: NaiveDateTime, +} + +#[derive(Serialize, utoipa::ToSchema)] +struct WeeklyScheduleResponse { + schedules: Vec, +} + +#[derive(Serialize, utoipa::ToSchema)] +struct EmployeeNamesResponse { + employees: Vec, +} + +#[derive(OpenApi)] +#[openapi( + paths( + health_check, + create_company, + list_companies, + get_company, + delete_company, + create_employee, + list_employees, + assign_employee_to_company, + delete_employee, + register_employee_preference, + register_weekly_employee_preferences, + get_employee_names_by_company, + create_schedule, + get_weekly_schedule, + generate_weekly_schedule, + clock_in, + clock_out + ), + components( + schemas( + Company, + CompanyUser, + CreateCompanyRequest, + HealthResponse, + CreateEmployeeRequest, + RegisterPreferenceRequest, + WeeklyPreferenceRequest, + DayPreferenceRequest, + CreateScheduleRequest, + ClockOutRequest, + GenerateWeeklyScheduleRequest, + EmployeeResponse, + EmployeeNamesResponse, + ScheduleResponse, + WeeklyScheduleResponse, + WorkTime, + DayOfWeek + ) + ), + tags( + (name = "health", description = "Application health endpoints"), + (name = "companies", description = "Company management endpoints"), + (name = "employees", description = "Employee endpoints to implement"), + (name = "schedules", description = "Schedule endpoints to implement") + ), + servers( + (url = "{{rust_url}}", description = "Rust API base URL") + ) +)] +struct ApiDoc; + +pub fn build_router(pool: PgPool) -> Router { + Router::new() + .route("/health", get(health_check)) + .route("/companies", post(create_company).get(list_companies)) + .route("/companies/{id}", get(get_company).delete(delete_company)) + .route("/employees", post(create_employee).get(list_employees)) + .route( + "/companies/{company_id}/employees/{employee_id}", + post(assign_employee_to_company), + ) + .route("/employees/{id}", axum::routing::delete(delete_employee)) + .route( + "/employees/{id}/preference", + put(register_employee_preference), + ) + .route( + "/employees/{id}/weekly-preferences", + put(register_weekly_employee_preferences), + ) + .route( + "/companies/{company_name}/employees/names", + get(get_employee_names_by_company), + ) + .route("/schedules", post(create_schedule)) + .route("/schedules/weekly", get(get_weekly_schedule)) + .route("/schedules/weekly/generate", post(generate_weekly_schedule)) + .route("/employees/{id}/clock-in", post(clock_in)) + .route("/employees/{id}/clock-out", put(clock_out)) + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi())) + .layer(CorsLayer::permissive()) + .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, + user_id: schedule.user_id, + enter_time: schedule.enter_time, + day_of_week: schedule.day_of_week, + out_time: schedule.out_time, + } +} + +fn employee_response(employee: employee_queries::EmployeeRow) -> EmployeeResponse { + EmployeeResponse { + id: employee.id, + company_id: employee.company_id, + name: employee.name, + last_name: employee.last_name, + preference: employee.preference, + } +} + +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, + } +} + +#[utoipa::path( + get, + path = "/health", + tag = "health", + responses( + (status = 200, description = "Application is healthy", body = HealthResponse) + ) +)] +async fn health_check() -> Json { + Json(HealthResponse { status: "ok" }) +} + +#[utoipa::path( + post, + path = "/companies", + tag = "companies", + request_body = CreateCompanyRequest, + responses( + (status = 200, description = "Company created", body = Company), + (status = 500, description = "Database error") + ) +)] +async fn create_company( + State(state): State, + Json(payload): Json, +) -> ApiResult> { + let company = company_service::create_company(&state.pool, payload).await?; + Ok(Json(company)) +} + +#[utoipa::path( + get, + path = "/companies", + tag = "companies", + responses( + (status = 200, description = "Companies found", body = [Company]), + (status = 500, description = "Database error") + ) +)] +async fn list_companies(State(state): State) -> ApiResult>> { + let companies = company_service::list_companies(&state.pool).await?; + Ok(Json(companies)) +} + +#[utoipa::path( + get, + path = "/companies/{id}", + tag = "companies", + params( + ("id" = i32, Path, description = "Company id") + ), + responses( + (status = 200, description = "Company found", body = Company), + (status = 404, description = "Company not found"), + (status = 500, description = "Database error") + ) +)] +async fn get_company( + State(state): State, + Path(id): Path, +) -> ApiResult> { + let company = company_service::get_company(&state.pool, id).await?; + Ok(Json(company)) +} + +#[utoipa::path( + delete, + path = "/companies/{id}", + tag = "companies", + params( + ("id" = i32, Path, description = "Company id") + ), + responses( + (status = 200, description = "Company deleted", body = Company), + (status = 404, description = "Company not found"), + (status = 500, description = "Database error") + ) +)] +async fn delete_company( + State(state): State, + Path(id): Path, +) -> ApiResult> { + let company = company_service::delete_company(&state.pool, id).await?; + Ok(Json(company)) +} + +#[utoipa::path( + post, + path = "/employees", + tag = "employees", + request_body = CreateEmployeeRequest, + responses( + (status = 200, description = "Employee created", body = EmployeeResponse), + (status = 500, description = "Database error") + ) +)] +async fn create_employee( + State(state): State, + Json(payload): Json, +) -> ApiResult> { + let user = user_queries::add_user( + &state.pool, + payload.name.trim(), + payload.last_name.trim(), + payload.preference, + ) + .await?; + company_queries::add_company_user(&state.pool, payload.company_id, user.id).await?; + let employee = employee_queries::get_employee(&state.pool, user.id).await?; + Ok(Json(employee_response(employee))) +} + +#[utoipa::path( + get, + path = "/employees", + tag = "employees", + responses( + (status = 200, description = "Employees found", body = [EmployeeResponse]), + (status = 500, description = "Database error") + ) +)] +async fn list_employees(State(state): State) -> ApiResult>> { + let employees = employee_queries::list_employees(&state.pool).await?; + Ok(Json(employees.into_iter().map(employee_response).collect())) +} + +#[utoipa::path( + post, + path = "/companies/{company_id}/employees/{employee_id}", + tag = "employees", + params( + ("company_id" = i32, Path, description = "Company id"), + ("employee_id" = i32, Path, description = "Employee id") + ), + responses( + (status = 200, description = "Employee assigned to company", body = CompanyUser), + (status = 404, description = "Company or employee not found"), + (status = 500, description = "Database error") + ) +)] +async fn assign_employee_to_company( + State(state): State, + Path((company_id, employee_id)): Path<(i32, i32)>, +) -> ApiResult> { + company_queries::get_company_by_id(&state.pool, company_id).await?; + employee_queries::get_employee_user(&state.pool, employee_id).await?; + let company_user = + company_queries::add_company_user(&state.pool, company_id, employee_id).await?; + Ok(Json(company_user)) +} + +#[utoipa::path( + delete, + path = "/employees/{id}", + tag = "employees", + params( + ("id" = i32, Path, description = "Employee id") + ), + responses( + (status = 200, description = "Employee deleted", body = EmployeeResponse), + (status = 404, description = "Employee not found"), + (status = 500, description = "Database error") + ) +)] +async fn delete_employee( + State(state): State, + Path(id): Path, +) -> ApiResult> { + let employee = employee_queries::get_employee(&state.pool, id).await?; + user_queries::remove_user(&state.pool, id).await?; + Ok(Json(employee_response(employee))) +} + +#[utoipa::path( + put, + path = "/employees/{id}/preference", + tag = "employees", + params( + ("id" = i32, Path, description = "Employee id") + ), + request_body = RegisterPreferenceRequest, + responses( + (status = 200, description = "Employee preference saved", body = EmployeeResponse), + (status = 404, description = "Employee not found"), + (status = 500, description = "Database error") + ) +)] +async fn register_employee_preference( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + user_queries::update_preference(&state.pool, id, payload.preference).await?; + let employee = employee_queries::get_employee(&state.pool, id).await?; + Ok(Json(employee_response(employee))) +} + +#[utoipa::path( + put, + path = "/employees/{id}/weekly-preferences", + tag = "employees", + params( + ("id" = i32, Path, description = "Employee id") + ), + request_body = WeeklyPreferenceRequest, + responses( + (status = 200, description = "Weekly preferences saved", body = EmployeeResponse), + (status = 404, description = "Employee not found"), + (status = 500, description = "Database error") + ) +)] +async fn register_weekly_employee_preferences( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + employee_queries::get_employee_user(&state.pool, id).await?; + for preference in payload.preferences { + preference_queries::upsert_weekly_preference( + &state.pool, + id, + preference.day_of_week, + preference.first, + preference.second, + preference.third, + ) + .await?; + } + + let employee = employee_queries::get_employee(&state.pool, id).await?; + Ok(Json(employee_response(employee))) +} + +#[utoipa::path( + get, + path = "/companies/{company_name}/employees/names", + tag = "employees", + params( + ("company_name" = String, Path, description = "Company name") + ), + responses( + (status = 200, description = "Employee names for company", body = EmployeeNamesResponse), + (status = 500, description = "Database error") + ) +)] +async fn get_employee_names_by_company( + State(state): State, + Path(company_name): Path, +) -> ApiResult> { + let employees = + employee_queries::get_employee_names_by_company_name(&state.pool, &company_name).await?; + Ok(Json(EmployeeNamesResponse { employees })) +} + +#[utoipa::path( + post, + path = "/schedules", + tag = "schedules", + request_body = CreateScheduleRequest, + responses( + (status = 200, description = "Schedule created", body = ScheduleResponse), + (status = 500, description = "Database error") + ) +)] +async fn create_schedule( + State(state): State, + Json(payload): Json, +) -> ApiResult> { + let schedule = schedule_queries::add_schedule( + &state.pool, + payload.user_id, + payload.enter_time, + payload.day_of_week, + payload.out_time, + ) + .await?; + Ok(Json(schedule_response(schedule))) +} + +#[utoipa::path( + get, + path = "/schedules/weekly", + tag = "schedules", + responses( + (status = 200, description = "Weekly schedule found", body = WeeklyScheduleResponse), + (status = 500, description = "Database error") + ) +)] +async fn get_weekly_schedule( + State(state): State, +) -> ApiResult> { + let schedules = schedule_queries::list_schedules(&state.pool).await?; + Ok(Json(WeeklyScheduleResponse { + schedules: schedules.into_iter().map(schedule_response).collect(), + })) +} + +#[utoipa::path( + post, + path = "/schedules/weekly/generate", + tag = "schedules", + request_body = GenerateWeeklyScheduleRequest, + responses( + (status = 200, description = "Weekly schedule generated", body = WeeklyScheduleResponse), + (status = 500, description = "Database error") + ) +)] +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)); + } + } + } + + Ok(Json(WeeklyScheduleResponse { + schedules: generated, + })) +} + +#[utoipa::path( + post, + path = "/employees/{id}/clock-in", + tag = "schedules", + params( + ("id" = i32, Path, description = "Employee id") + ), + responses( + (status = 200, description = "Clock-in registered", body = ScheduleResponse), + (status = 404, description = "Employee not found"), + (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?; + Ok(Json(schedule_response(schedule))) +} + +#[utoipa::path( + put, + path = "/employees/{id}/clock-out", + tag = "schedules", + params( + ("id" = i32, Path, description = "Employee id") + ), + request_body = ClockOutRequest, + responses( + (status = 200, description = "Clock-out registered", body = ScheduleResponse), + (status = 404, description = "Open clock-in record not found"), + (status = 500, description = "Database error") + ) +)] +async fn clock_out( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> ApiResult> { + let schedule = + schedule_queries::clock_out(&state.pool, id, payload.day_of_week, payload.out_time).await?; + Ok(Json(schedule_response(schedule))) +}