fixing the checkin

This commit is contained in:
2026-09-18 21:59:31 -04:00
parent 25a6747bcd
commit 27da4eafe4
18 changed files with 1521 additions and 406 deletions
+554 -104
View File
@@ -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<i32>,
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<NaiveDateTime, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if let Ok(dt) = s.parse::<DateTime<Utc>>() {
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<Option<NaiveDateTime>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<String>::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(s) if s.is_empty() => Ok(None),
Some(s) => {
if let Ok(dt) = s.parse::<DateTime<Utc>>() {
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<NaiveDateTime>,
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<NaiveDateTime>,
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<NaiveDateTime>,
shift: WorkTime,
}
#[derive(Serialize, utoipa::ToSchema)]
struct WeeklyScheduleResponse {
schedules: Vec<WeeklyScheduleItemResponse>,
}
#[derive(Serialize, utoipa::ToSchema)]
struct GeneratedWeeklyScheduleResponse {
schedules: Vec<ScheduleResponse>,
}
@@ -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<NaiveDateTime>,
) -> 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<String, Response> {
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<HashMap<String, String>> {
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<String, String>, 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<WorkTime, Response> {
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<DayOfWeek, Response> {
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, Response> {
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<AppState>,
multipart: Multipart,
) -> Result<Json<Value>, 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<AppState>,
multipart: Multipart,
) -> Result<Json<Value>, 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::<i32>()
.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<AppState>,
Path(id): Path<i32>,
Json(payload): Json<UpdateEmployeeRequest>,
) -> ApiResult<Json<EmployeeResponse>> {
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<AppState>,
Path(id): Path<i32>,
Json(payload): Json<UpdateEmployeeRequest>,
) -> ApiResult<Json<EmployeeResponse>> {
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<AppState>,
Path(company_name): Path<String>,
) -> ApiResult<Json<EmployeeNamesResponse>> {
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<AppState>,
Json(payload): Json<CreateScheduleRequest>,
) -> ApiResult<Json<ScheduleResponse>> {
let schedule = schedule_queries::add_schedule(
) -> Result<Json<ScheduleResponse>, 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<AppState>,
multipart: Multipart,
) -> Result<Json<Value>, 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::<i32>()
.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<AppState>,
) -> ApiResult<Json<WeeklyScheduleResponse>> {
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<AppState>,
Json(payload): Json<GenerateWeeklyScheduleRequest>,
) -> ApiResult<Json<WeeklyScheduleResponse>> {
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<i32, i32> = 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<AppState>,
Path(id): Path<i32>,
) -> ApiResult<Json<ScheduleResponse>> {
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<ClockInRequest>,
) -> Result<Json<ScheduleResponse>, 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<AppState>,
Path(id): Path<i32>,
Json(payload): Json<ClockOutRequest>,
) -> ApiResult<Json<ScheduleResponse>> {
) -> Result<Json<ScheduleResponse>, 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)))
}