1111 lines
34 KiB
Rust
1111 lines
34 KiB
Rust
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<i32>,
|
|
name: String,
|
|
last_name: String,
|
|
preference: WorkTime,
|
|
}
|
|
|
|
#[derive(Deserialize, utoipa::ToSchema)]
|
|
struct RegisterPreferenceRequest {
|
|
preference: WorkTime,
|
|
}
|
|
|
|
#[derive(Deserialize, utoipa::ToSchema)]
|
|
struct WeeklyPreferenceRequest {
|
|
preferences: Vec<DayPreferenceRequest>,
|
|
}
|
|
|
|
#[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<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,
|
|
#[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,
|
|
}
|
|
|
|
#[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<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>,
|
|
}
|
|
|
|
#[derive(Serialize, utoipa::ToSchema)]
|
|
struct EmployeeNamesResponse {
|
|
employees: Vec<String>,
|
|
}
|
|
|
|
#[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<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",
|
|
tag = "health",
|
|
responses(
|
|
(status = 200, description = "Application is healthy", body = HealthResponse)
|
|
)
|
|
)]
|
|
async fn health_check() -> Json<HealthResponse> {
|
|
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<AppState>,
|
|
Json(payload): Json<CreateCompanyRequest>,
|
|
) -> ApiResult<Json<Company>> {
|
|
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<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",
|
|
tag = "companies",
|
|
responses(
|
|
(status = 200, description = "Companies found", body = [Company]),
|
|
(status = 500, description = "Database error")
|
|
)
|
|
)]
|
|
async fn list_companies(State(state): State<AppState>) -> ApiResult<Json<Vec<Company>>> {
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
) -> ApiResult<Json<Company>> {
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
) -> ApiResult<Json<Company>> {
|
|
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<AppState>,
|
|
Json(payload): Json<CreateEmployeeRequest>,
|
|
) -> ApiResult<Json<EmployeeResponse>> {
|
|
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<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",
|
|
tag = "employees",
|
|
responses(
|
|
(status = 200, description = "Employees found", body = [EmployeeResponse]),
|
|
(status = 500, description = "Database error")
|
|
)
|
|
)]
|
|
async fn list_employees(State(state): State<AppState>) -> ApiResult<Json<Vec<EmployeeResponse>>> {
|
|
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<AppState>,
|
|
Path((company_id, employee_id)): Path<(i32, i32)>,
|
|
) -> ApiResult<Json<CompanyUser>> {
|
|
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<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}",
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
) -> ApiResult<Json<EmployeeResponse>> {
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
Json(payload): Json<RegisterPreferenceRequest>,
|
|
) -> ApiResult<Json<EmployeeResponse>> {
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
Json(payload): Json<WeeklyPreferenceRequest>,
|
|
) -> ApiResult<Json<EmployeeResponse>> {
|
|
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<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 }))
|
|
}
|
|
|
|
#[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<AppState>,
|
|
Json(payload): Json<CreateScheduleRequest>,
|
|
) -> 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
|
|
.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",
|
|
tag = "schedules",
|
|
responses(
|
|
(status = 200, description = "Weekly schedule found", body = WeeklyScheduleResponse),
|
|
(status = 500, description = "Database error")
|
|
)
|
|
)]
|
|
async fn get_weekly_schedule(
|
|
State(state): State<AppState>,
|
|
) -> ApiResult<Json<WeeklyScheduleResponse>> {
|
|
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<AppState>,
|
|
Json(payload): Json<GenerateWeeklyScheduleRequest>,
|
|
) -> ApiResult<Json<WeeklyScheduleResponse>> {
|
|
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<AppState>,
|
|
Path(id): Path<i32>,
|
|
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)))
|
|
}
|
|
|
|
#[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<AppState>,
|
|
Path(id): Path<i32>,
|
|
Json(payload): Json<ClockOutRequest>,
|
|
) -> 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
|
|
.map_err(|e| ApiError::from(e).into_response())?;
|
|
Ok(Json(schedule_response(schedule)))
|
|
}
|