use axum::{ Json, Router, extract::{Multipart, Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post, put}, }; 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; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use crate::{ app::{ApiError, ApiResult, AppState, ErrorResponse}, database::{ 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}, }, manager::scheduler as schedule_manager, models::{ company::{requests::CreateCompanyRequest, service as company_service}, schedule as schedule_model, }, }; #[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 UpdateEmployeeRequest { company_id: Option, 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, } 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, #[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, } #[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: 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, } #[derive(Serialize, utoipa::ToSchema)] struct EmployeeNamesResponse { employees: Vec, } #[derive(OpenApi)] #[openapi( 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, clock_out ), components( schemas( Company, CompanyUser, CreateCompanyRequest, HealthResponse, CreateEmployeeRequest, UpdateEmployeeRequest, RegisterPreferenceRequest, WeeklyPreferenceRequest, DayPreferenceRequest, CreateScheduleRequest, ClockInRequest, ClockOutRequest, GenerateWeeklyScheduleRequest, EmployeeResponse, EmployeeNamesResponse, ScheduleResponse, WeeklyScheduleResponse, WeeklyScheduleItemResponse, WeeklyScheduleUserResponse, GeneratedWeeklyScheduleResponse, 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/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}", put(update_employee) .patch(patch_employee) .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/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)) .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 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, shift: schedule.shift, } } 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 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", 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( 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", 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( 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", 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( 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}", 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> { 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 })) } #[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, ) -> 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 .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", 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 _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(weekly_schedule_item).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> { 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: schedules.into_iter().map(weekly_schedule_item).collect(), })) } #[utoipa::path( post, path = "/employees/{id}/clock-in", tag = "schedules", 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, 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))) } #[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, ) -> 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 .map_err(|e| ApiError::from(e).into_response())?; Ok(Json(schedule_response(schedule))) }