Compare commits

..

4 Commits

Author SHA1 Message Date
CarGDev bf59601a41 adding .env example 2026-09-20 11:11:24 -04:00
CarGDev 27da4eafe4 fixing the checkin 2026-09-18 21:59:31 -04:00
CarGDev 25a6747bcd adding documentation 2026-09-18 12:51:16 -04:00
CarGDev 29c8d0f27d adding endpoints 2026-09-18 12:45:11 -04:00
35 changed files with 3268 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
DATABASE_URL=postgres://{username}:{pass}@{host}/{database_name}
+16
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "manager_employees_rust"
version = "0.1.0"
edition = "2024"
authors = ["Carlos Gutierrez <cgutierrez44833@ucumberlands.edu>"]
description = "Assignment 4: Implementing Control Structures — Rust backend (Axum/Postgres) for employee scheduling (7 days x 3 shifts, preference-aware, 2-employees-per-shift enforcement)"
license = "MIT"
repository = "https://git.cargdev.io/CarGDev/manager_employees_rust"
keywords = ["rust", "axum", "postgres", "scheduling", "control-structures"]
categories = ["web-programming::http-server"]
[dependencies]
axum = { version = "0.8.4", features = ["multipart"] }
chrono = { version = "0.4.42", features = ["serde"] }
dotenvy = "0.15.7"
serde = { version = "1.0.228", features = ["derive"] }
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"] }
+286 -1
View File
@@ -1,2 +1,287 @@
# manager_employees_rust
# manager_employees_rust — Backend (Rust)
> **Assignment 4: Implementing Control Structures — Rust Backend API**
> University of the Cumberlands — Master’s Program, Semester 4 / Week 4
[![Rust](https://img.shields.io/badge/backend-Rust-orange?logo=rust)](https://www.rust-lang.org/)
[![Axum](https://img.shields.io/badge/framework-Axum_0.8-blue)](https://github.com/tokio-rs/axum)
[![Postgres](https://img.shields.io/badge/database-Postgres_/_SQLx-336791?logo=postgresql)](https://github.com/launchbadge/sqlx)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
**Author:** Carlos Gutierrez — [cgutierrez44833@ucumberlands.edu](mailto:cgutierrez44833@ucumberlands.edu)
**Repository:** [`https://git.cargdev.io/CarGDev/manager_employees_rust`](https://git.cargdev.io/CarGDev/manager_employees_rust) — **this repo is the Rust backend**
**Related Repositories (Assignment 4 — 3 implementations):**
| Implementation | Language / Role | Repository |
|---|---|---|
| **This repo** | **Rust — Backend API (Axum/Postgres)** | [`manager_employees_rust`](https://git.cargdev.io/CarGDev/manager_employees_rust) |
| Companion | Python — Backend/CLI | [`manager_employees_python`](https://git.cargdev.io/CarGDev/manager_employees_python) |
| Companion | GUI — Graphical Interface | [`manager_employees_gui`](https://git.cargdev.io/CarGDev/manager_employees_gui) |
---
## 📌 This Repository — Rust Backend Only
**This repo contains ONLY the Rust backend** for Assignment 4. The assignment requires implementing the same scheduling problem in **two contrasting languages** (e.g., Rust + Python) to demonstrate control flow across paradigms — with an optional GUI.
* **Scope of this repo:** REST API built with `Axum` (`src/router.rs:159`), `Tokio` async runtime, `SQLx` + Postgres persistence (`src/database/connection.rs:5`), and a preference-aware weekly scheduling engine (`src/router.rs:561`, `src/manager/`).
* **Companion repos** are **not** tracked here — see table above: Python implementation and GUI implementation live at `git.cargdev.io/CarGDev`.
* API docs are served via `utoipa` + Swagger UI at `/swagger-ui` (`src/router.rs:187`).
---
## 📖 Story: Assignment 4 — Implementing Control Structures
### 1. Overview
> *You will implement, without logical errors, various control structures such as conditionals, loops, and branching, in multiple programming languages. This assignment will demonstrate your mastery of syntax and control flow in different paradigms.*
> — Assignment 4 Prompt
For me, Carlos Gutierrez, this assignment was about proving that the same business rules can be expressed cleanly in two very different paradigms. I chose **Rust** for this backend because its `match`, `if let`, ownership model, and `Result` error handling force explicit, safe control flow — a sharp contrast to a dynamic language like Python or JavaScript. Every scheduling rule below is therefore not just business logic, but a deliberate exercise in conditionals, loops, and branching done idiomatically in Rust.
### 2. Application Prompt — Employee Schedule Manager
The company operates **7 days/week** with **3 shifts/day** (`morning`, `afternoon`, `evening` — `src/database/types.rs:18`). The app must:
1. **Input and Storage:** Collect employee names and their preferred shift for each day, storing in appropriate structures. Here: `users` + `employee_weekly_preferences` + `company_users` tables (`src/database/schema.rs:36`, `src/database/entities/`), with requests like `CreateEmployeeRequest` and `WeeklyPreferenceRequest` (`src/router.rs:32`, `src/router.rs:46`).
2. **Scheduling Logic:** Auto-assign shifts ensuring:
* No employee works more than **one shift per day** (`src/manager/validator.rs:5`, `src/router.rs:585`)
* Max **5 days per week** per employee (`src/manager/validator.rs:11`, `src/router.rs:586`)
* At least **2 employees per shift per day**; if fewer are available, randomly/round-robin assign additional valid employees (`src/main.rs:37`, `src/manager/scheduler.rs:25`, `src/router.rs:597`)
3. **Shift Conflicts:** Detect when a preferred shift is full and resolve by assigning to another available shift on the **same or next day** (`src/manager/conflict.rs:4`, `src/manager/conflict.rs:10`, `src/manager/conflict.rs:16`, `src/router.rs:240`).
4. **Output:** Final weekly schedule in readable format indicating employee → day → shift. Here: `GET /schedules/weekly` and `print_final_schedule()` grouping by day/shift (`src/manager/scheduler.rs:44`, `src/router.rs:542`).
5. **Bonus — Priority Ranking:** Employees rank shifts `first > second > third` (`src/database/types.rs:18`, `src/database/schema.rs:70`). The engine ranks candidates with `preference_rank()` (`src/router.rs:240`) and accommodates preferences while still meeting coverage.
### 3. Programming Language Choice
> Suggested: Python, Java, C++, JavaScript, Go, Rust — *must be contrasting paradigms*.
This repo = **Rust** (systems, compiled, ownership + exhaustive `match`) at [`manager_employees_rust`](https://git.cargdev.io/CarGDev/manager_employees_rust).
My contrasting implementations are:
* **Python:** [`manager_employees_python`](https://git.cargdev.io/CarGDev/manager_employees_python) — dynamic, interpreted, idiomatic loops/comprehensions
* **GUI:** [`manager_employees_gui`](https://git.cargdev.io/CarGDev/manager_employees_gui) — graphical input/output for employee names, preferences, and final schedule (bonus requirement)
The Rust side emphasizes `match` over `if-else` chains, iterator loops (`for day in all_days()` `src/router.rs:581`), and `Result`-based branching for DB errors (`src/app.rs:29`) — a direct contrast to Python’s approach in the companion repo.
---
## ✨ Features
* 7-day × 3-shift auto-generation via `POST /schedules/weekly/generate` (`src/router.rs:561`)
* Preference-aware ranking (`first=0, second=1, third=2, fallback=3`) (`src/router.rs:240`)
* Validation: one-shift/day, ≤5 days/week, ≥2/shift (`src/manager/validator.rs`)
* Conflict resolution: same-day fallback → next-day (`src/manager/conflict.rs`)
* Clock-in/out (`POST /employees/{id}/clock-in` `src/router.rs:627`, `PUT /employees/{id}/clock-out` `src/router.rs:652`)
* Company & employee CRUD, weekly preferences (`PUT /employees/{id}/weekly-preferences` `src/router.rs:465`)
* Health check `GET /health` (`src/router.rs:262`), CORS permissive (`src/router.rs:188`), OpenAPI docs
---
## 🛠 Tech Stack
| Layer | Choice | Version / File |
|-------|--------|----------------|
| Language | Rust | `edition = "2024"` (`Cargo.toml:4`) |
| Web | Axum | `0.8.4` (`Cargo.toml:7`) |
| Async | Tokio | `1.53.1` full (`Cargo.toml:13`) |
| DB | Postgres + SQLx | `0.9.0` chrono/postgres/runtime-tokio (`Cargo.toml:12`) |
| Validation/Types | Chrono, Serde | `chrono 0.4.42`, `serde 1.0.228` (`Cargo.toml:8`, `Cargo.toml:10`) |
| Docs | utoipa + utoipa-swagger-ui | `5` / `9` (`Cargo.toml:15`, `Cargo.toml:16`) |
| Middleware | tower-http CORS | `0.6.6` (`Cargo.toml:14`) |
| Env | dotenvy | `0.15.7` (`Cargo.toml:9`) |
---
## 📁 Project Structure
```
src/
main.rs # entry, env loading, pool init, serve (src/main.rs:10)
app.rs # AppState { pool }, ApiError → 404/500 (src/app.rs:10)
router.rs # 16 routes, OpenAPI, scheduling core (src/router.rs:159)
database/
connection.rs # connect() + init_database() (src/database/connection.rs:5)
schema.rs # 9 DDL queries: enums + 5 tables + alters (src/database/schema.rs:94)
types.rs # WorkTime, DayOfWeek enums (src/database/types.rs:18)
entities/ # Company, User, CompanyUser, Schedule, WeeklyPreference
queries/ # company, employee, preference, schedule, user
manager/ # business-rule stubs extracted from main.rs assignment_rules()
scheduler.rs # generate_weekly_schedule, fill_missing, clock-in/out
validator.rs # same-day, 5-day, 2-employee validations
conflict.rs # detect + resolve same/next day
models/
company/ # CreateCompanyRequest + service
```
---
## 🗄 Database Schema
Enums: `work_time('morning','afternoon','evening')` (`src/database/schema.rs:1`), `day_of_week('sunday'..'saturday')` (`src/database/schema.rs:10`)
| Table | Key Columns |
|-------|-------------|
| `companies` | `id SERIAL PK, name TEXT UNIQUE` (`src/database/schema.rs:27`) |
| `users` | `id, name, last_name, preference work_time` (`src/database/schema.rs:36`) |
| `company_users` | `user_id FK→users, company_id FK→companies, UNIQUE(user_id,company_id)` (`src/database/schema.rs:47`) |
| `schedules` | `user_id FK, enter_time, day_of_week, out_time, shift work_time` (`src/database/schema.rs:58`) |
| `employee_weekly_preferences` | `user_id, day_of_week, first/second/third work_time, UNIQUE(user_id,day_of_week)` (`src/database/schema.rs:70`) |
Init via `CREATE_SCHEMA_QUERIES` loop (`src/database/connection.rs:13`).
---
## 📐 Business Rules in Code
* `assignment_rules()` summary (`src/main.rs:29-39`)
* `all_days()` / `all_shifts()` helpers (`src/router.rs:224`, `src/router.rs:236`)
* Candidate filtering + `sort_by_key(preference_rank)` (`src/router.rs:589-595`)
* `assigned_days: HashMap<(user_id, DayOfWeek), bool>` + `day_count_by_user: HashMap<user_id, i32>` (`src/router.rs:576`)
---
## 🔌 API Reference
Base: `http://127.0.0.1:5005` (configurable via `APP_ADDRESS`)
| Method | Path | Tag | Description |
|--------|------|-----|-------------|
| `GET` | `/health` | health | `{"status":"ok"}` (`src/router.rs:254`) |
| `POST` | `/companies` | companies | create (`src/router.rs:276`) |
| `GET` | `/companies` | companies | list (`src/router.rs:284`) |
| `GET` | `/companies/{id}` | companies | get (`src/router.rs:310`) |
| `DELETE` | `/companies/{id}` | companies | delete (`src/router.rs:319`) |
| `POST` | `/employees` | employees | create + assign to company (`src/router.rs:350`) |
| `GET` | `/employees` | employees | list (`src/router.rs:375`) |
| `POST` | `/companies/{company_id}/employees/{employee_id}` | employees | assign (`src/router.rs:394`) |
| `DELETE` | `/employees/{id}` | employees | delete (`src/router.rs:418`) |
| `PUT` | `/employees/{id}/preference` | employees | single pref (`src/router.rs:441`) |
| `PUT` | `/employees/{id}/weekly-preferences` | employees | 7-day prefs (`src/router.rs:465`) |
| `GET` | `/companies/{company_name}/employees/names` | employees | names by company (`src/router.rs:499`) |
| `POST` | `/schedules` | schedules | create (`src/router.rs:518`) |
| `GET` | `/schedules/weekly` | schedules | list all (`src/router.rs:542`) |
| `POST` | `/schedules/weekly/generate` | schedules | auto-generate (`src/router.rs:561`) |
| `POST` | `/employees/{id}/clock-in` | schedules | clock-in now (`src/router.rs:627`) |
| `PUT` | `/employees/{id}/clock-out` | schedules | clock-out (`src/router.rs:652`) |
Swagger: `GET /swagger-ui` + `GET /api-docs/openapi.json` (`src/router.rs:187`).
---
## 🚀 Getting Started
### Prerequisites
* Rust `stable` (with `cargo`)
* Postgres ≥14 running locally
* `sqlx-cli` optional (schema is auto-created on boot)
### 1. Clone & Configure
```bash
git clone https://git.cargdev.io/CarGDev/manager_employees_rust
cd manager_employees_rust
cp .env.example .env # or create .env
```
> See also companions: [`manager_employees_python`](https://git.cargdev.io/CarGDev/manager_employees_python) and [`manager_employees_gui`](https://git.cargdev.io/CarGDev/manager_employees_gui).
`.env` (`src/main.rs:11` loads via `dotenvy`):
```env
DATABASE_URL=postgres://carlos:password@localhost/rust_manager_employees
APP_ADDRESS=127.0.0.1:5005
```
> `.env` is gitignored (` .gitignore:7`). Example URL from `.env:1`.
### 2. Create Database
```bash
createdb rust_manager_employees
# or: psql -c "CREATE DATABASE rust_manager_employees;"
```
Tables/enums are created automatically on first run via `init_database()` (`src/database/connection.rs:12`).
### 3. Run
```bash
cargo run
# → listening on 127.0.0.1:5005
curl http://127.0.0.1:5005/health
# {"status":"ok"}
```
### 4. Try the Flow
```bash
# 1. Create company
curl -X POST http://127.0.0.1:5005/companies \
-H "Content-Type: application/json" \
-d '{"name":"Acme"}'
# 2. Create employees (company_id from above)
curl -X POST http://127.0.0.1:5005/employees \
-H "Content-Type: application/json" \
-d '{"company_id":1,"name":"Ana","last_name":"Lopez","preference":"morning"}'
# 3. Set weekly preferences
curl -X PUT http://127.0.0.1:5005/employees/1/weekly-preferences \
-H "Content-Type: application/json" \
-d '{"preferences":[{"day_of_week":"monday","first":"morning","second":"afternoon","third":"evening"}]}'
# 4. Generate weekly schedule
curl -X POST http://127.0.0.1:5005/schedules/weekly/generate \
-H "Content-Type: application/json" \
-d '{"company_id":1}'
# 5. View schedule
curl http://127.0.0.1:5005/schedules/weekly
# Swagger UI: http://127.0.0.1:5005/swagger-ui
```
---
## ⚙️ Configuration
| Var | Required | Default | Description |
|-----|----------|---------|-------------|
| `DATABASE_URL` | yes | — | Postgres URL (`src/main.rs:13`) |
| `APP_ADDRESS` | no | `127.0.0.1:5005` | Bind addr (`src/main.rs:19`) |
---
## 👨‍💻 Development
```bash
cargo fmt
cargo clippy
cargo check
cargo test
```
Error handling: `ApiError(sqlx::Error)` → `RowNotFound→404`, else `500` with JSON `{"error": "..."}` (`src/app.rs:29`).
---
## 📄 License
MIT — Copyright (c) 2026 Carlos Gutierrez. See [LICENSE](./LICENSE).
---
## 📬 Contact
**Carlos Gutierrez** — University of the Cumberlands
Email: [cgutierrez44833@ucumberlands.edu](mailto:cgutierrez44833@ucumberlands.edu)
Project: `manager_employees_rust` — Rust backend for Assignment 4: Implementing Control Structures
Repositories:
* Rust Backend (this repo): <https://git.cargdev.io/CarGDev/manager_employees_rust>
* Python Backend: <https://git.cargdev.io/CarGDev/manager_employees_python>
* GUI: <https://git.cargdev.io/CarGDev/manager_employees_gui>
+38
View File
@@ -0,0 +1,38 @@
{
"name": "manager_employees_rust",
"version": "0.1.0",
"description": "Assignment 4: Implementing Control Structures — Rust backend (Axum/Postgres) for employee scheduling",
"author": "Carlos Gutierrez <cgutierrez44833@ucumberlands.edu>",
"license": "MIT",
"private": true,
"repository": {
"type": "git",
"url": "https://git.cargdev.io/CarGDev/manager_employees_rust"
},
"relatedRepositories": {
"rust_backend": "https://git.cargdev.io/CarGDev/manager_employees_rust",
"python_backend": "https://git.cargdev.io/CarGDev/manager_employees_python",
"gui": "https://git.cargdev.io/CarGDev/manager_employees_gui"
},
"keywords": [
"rust",
"axum",
"tokio",
"postgres",
"sqlx",
"scheduling",
"employee-scheduling",
"employee-management",
"control-structures",
"conditionals",
"loops",
"branching",
"backend",
"api",
"assignment-4",
"university-of-the-cumberlands"
],
"engines": {
"rust": ">=1.85"
}
}
+44
View File
@@ -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 message: String,
}
pub struct ApiError(sqlx::Error);
pub type ApiResult<T> = Result<T, ApiError>;
impl From<sqlx::Error> 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 {
message: self.0.to_string(),
}),
)
.into_response()
}
}
+18
View File
@@ -0,0 +1,18 @@
use sqlx::{PgPool, postgres::PgPoolOptions};
use crate::database::schema;
pub async fn connect(database_url: &str) -> Result<PgPool, sqlx::Error> {
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(())
}
+10
View File
@@ -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,
}
+11
View File
@@ -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,
}
+11
View File
@@ -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;
+16
View File
@@ -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 Schedule {
pub id: i32,
pub user_id: i32,
pub enter_time: NaiveDateTime,
pub day_of_week: DayOfWeek,
pub out_time: Option<NaiveDateTime>,
pub shift: WorkTime,
pub date_created: NaiveDateTime,
pub date_updated: NaiveDateTime,
}
+14
View File
@@ -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,
}
@@ -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: WorkTime,
pub second: WorkTime,
pub third: WorkTime,
pub date_created: NaiveDateTime,
pub date_updated: NaiveDateTime,
}
+5
View File
@@ -0,0 +1,5 @@
pub mod connection;
pub mod entities;
pub mod queries;
pub mod schema;
pub mod types;
+113
View File
@@ -0,0 +1,113 @@
use sqlx::PgPool;
use crate::database::entities::{Company, CompanyUser};
pub async fn add_company(pool: &PgPool, name: &str) -> Result<Company, sqlx::Error> {
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<Company, sqlx::Error> {
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<Vec<Company>, 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<Company, sqlx::Error> {
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<CompanyUser, sqlx::Error> {
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM company_users WHERE user_id = $1")
.bind(user_id)
.execute(&mut *tx)
.await?;
let company_user = sqlx::query_as::<_, CompanyUser>(
r#"
INSERT INTO company_users (user_id, company_id)
VALUES ($1, $2)
ON CONFLICT (user_id, company_id) DO UPDATE SET date_updated = NOW()
RETURNING id, user_id, company_id, date_created, date_updated
"#,
)
.bind(user_id)
.bind(company_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(company_user)
}
pub async fn get_company_by_name(pool: &PgPool, name: &str) -> Result<Company, sqlx::Error> {
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<Vec<i32>, 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
}
+143
View File
@@ -0,0 +1,143 @@
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<Vec<EmployeeRow>, 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.id
"#,
)
.fetch_all(pool)
.await
}
pub async fn update_employee(
pool: &PgPool,
id: i32,
name: &str,
last_name: &str,
preference: WorkTime,
) -> Result<User, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
UPDATE users
SET name = $2, last_name = $3, preference = $4, date_updated = NOW()
WHERE id = $1
RETURNING id, name, last_name, preference, date_created, date_updated
"#,
)
.bind(id)
.bind(name)
.bind(last_name)
.bind(preference)
.fetch_one(pool)
.await
}
pub async fn update_company(pool: &PgPool, id: i32, company_id: i32) -> Result<(), sqlx::Error> {
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM company_users WHERE user_id = $1")
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
INSERT INTO company_users (user_id, company_id)
VALUES ($1, $2)
ON CONFLICT (user_id, company_id) DO UPDATE SET date_updated = NOW()
"#,
)
.bind(id)
.bind(company_id)
.execute(&mut *tx)
.await?;
tx.commit().await
}
pub async fn get_company_id_by_user(
pool: &PgPool,
user_id: i32,
) -> Result<Option<i32>, sqlx::Error> {
sqlx::query_scalar::<_, i32>(
r#"
SELECT company_id
FROM company_users
WHERE user_id = $1
ORDER BY date_updated DESC, id DESC
LIMIT 1
"#,
)
.bind(user_id)
.fetch_optional(pool)
.await
}
pub async fn get_preference(pool: &PgPool, user_id: i32) -> Result<Option<WorkTime>, sqlx::Error> {
sqlx::query_scalar::<_, WorkTime>("SELECT preference FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool)
.await
}
pub async fn get_employee(pool: &PgPool, id: i32) -> Result<EmployeeRow, 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
WHERE users.id = $1
"#,
)
.bind(id)
.fetch_one(pool)
.await
}
pub async fn get_employee_user(pool: &PgPool, id: i32) -> Result<User, sqlx::Error> {
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<Vec<String>, 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
}
+5
View File
@@ -0,0 +1,5 @@
pub mod company;
pub mod employee;
pub mod preference;
pub mod schedule;
pub mod user;
+57
View File
@@ -0,0 +1,57 @@
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: WorkTime,
second: WorkTime,
third: WorkTime,
) -> Result<WeeklyPreference, sqlx::Error> {
sqlx::query_as::<_, WeeklyPreference>(
r#"
INSERT INTO weekly_preferences (
user_id, day_of_week, first, second, third
)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, day_of_week)
DO UPDATE SET
first = EXCLUDED.first,
second = EXCLUDED.second,
third = EXCLUDED.third,
date_updated = NOW()
RETURNING id, user_id, day_of_week, first, second, third, date_created, date_updated
"#,
)
.bind(user_id)
.bind(day_of_week)
.bind(first)
.bind(second)
.bind(third)
.fetch_one(pool)
.await
}
pub async fn get_weekly_preference(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
) -> Result<Option<WeeklyPreference>, sqlx::Error> {
sqlx::query_as::<_, WeeklyPreference>(
r#"
SELECT id, user_id, day_of_week, first, second, third, date_created, date_updated
FROM weekly_preferences
WHERE user_id = $1 AND day_of_week = $2
LIMIT 1
"#,
)
.bind(user_id)
.bind(day_of_week)
.fetch_optional(pool)
.await
}
+276
View File
@@ -0,0 +1,276 @@
use chrono::NaiveDateTime;
use serde::Serialize;
use sqlx::PgPool;
use crate::database::{
entities::Schedule,
types::{DayOfWeek, WorkTime},
};
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct WeeklyScheduleRow {
pub id: i32,
pub user_name: String,
pub user_last_name: String,
pub user_preference: WorkTime,
pub company: String,
pub enter_time: NaiveDateTime,
pub day_of_week: DayOfWeek,
pub out_time: Option<NaiveDateTime>,
pub shift: WorkTime,
}
pub async fn add_schedule(
pool: &PgPool,
user_id: i32,
enter_time: NaiveDateTime,
day_of_week: DayOfWeek,
out_time: Option<NaiveDateTime>,
shift: WorkTime,
) -> Result<Schedule, sqlx::Error> {
sqlx::query_as::<_, Schedule>(
r#"
INSERT INTO schedules (user_id, enter_time, day_of_week, shift, out_time)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
"#,
)
.bind(user_id)
.bind(enter_time)
.bind(day_of_week)
.bind(shift)
.bind(out_time)
.fetch_one(pool)
.await
}
pub async fn list_weekly(
pool: &PgPool,
company_id: Option<i32>,
) -> Result<Vec<WeeklyScheduleRow>, sqlx::Error> {
if let Some(company_id) = company_id {
return sqlx::query_as::<_, WeeklyScheduleRow>(
r#"
SELECT schedules.id,
users.name AS user_name,
users.last_name AS user_last_name,
users.preference AS user_preference,
companies.name AS company,
schedules.enter_time,
schedules.day_of_week,
schedules.out_time,
schedules.shift
FROM schedules
INNER JOIN company_users ON company_users.user_id = schedules.user_id
INNER JOIN users ON users.id = schedules.user_id
INNER JOIN companies ON companies.id = company_users.company_id
WHERE company_users.company_id = $1
ORDER BY schedules.day_of_week, schedules.enter_time, schedules.id
"#,
)
.bind(company_id)
.fetch_all(pool)
.await;
}
sqlx::query_as::<_, WeeklyScheduleRow>(
r#"
SELECT schedules.id,
users.name AS user_name,
users.last_name AS user_last_name,
users.preference AS user_preference,
companies.name AS company,
schedules.enter_time,
schedules.day_of_week,
schedules.out_time,
schedules.shift
FROM schedules
INNER JOIN company_users ON company_users.user_id = schedules.user_id
INNER JOIN users ON users.id = schedules.user_id
INNER JOIN companies ON companies.id = company_users.company_id
ORDER BY schedules.day_of_week, schedules.enter_time, schedules.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,
shift: WorkTime,
) -> Result<Schedule, sqlx::Error> {
add_schedule(pool, user_id, now, day_of_week, None, shift).await
}
pub async fn clock_out(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
out_time: NaiveDateTime,
) -> Result<Schedule, sqlx::Error> {
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, shift, date_created, date_updated
"#,
)
.bind(user_id)
.bind(day_of_week)
.bind(out_time)
.fetch_one(pool)
.await
}
pub async fn get_by_user_and_day_in_week(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
week_start: NaiveDateTime,
week_end: NaiveDateTime,
) -> Result<Option<Schedule>, sqlx::Error> {
sqlx::query_as::<_, Schedule>(
r#"
SELECT id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
FROM schedules
WHERE user_id = $1
AND day_of_week = $2
AND enter_time >= $3
AND enter_time < $4
ORDER BY date_created DESC
LIMIT 1
"#,
)
.bind(user_id)
.bind(day_of_week)
.bind(week_start)
.bind(week_end)
.fetch_optional(pool)
.await
}
pub async fn count_distinct_days_by_user_in_week(
pool: &PgPool,
user_id: i32,
week_start: NaiveDateTime,
week_end: NaiveDateTime,
) -> Result<i64, sqlx::Error> {
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(DISTINCT day_of_week)
FROM schedules
WHERE user_id = $1
AND enter_time >= $2
AND enter_time < $3
"#,
)
.bind(user_id)
.bind(week_start)
.bind(week_end)
.fetch_one(pool)
.await
}
pub async fn count_by_day_shift(
pool: &PgPool,
day_of_week: DayOfWeek,
shift: WorkTime,
company_id: Option<i32>,
) -> Result<i64, sqlx::Error> {
if let Some(company_id) = company_id {
return sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM schedules
INNER JOIN company_users ON company_users.user_id = schedules.user_id
WHERE schedules.day_of_week = $1
AND schedules.shift = $2
AND company_users.company_id = $3
"#,
)
.bind(day_of_week)
.bind(shift)
.bind(company_id)
.fetch_one(pool)
.await;
}
sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM schedules
WHERE day_of_week = $1 AND shift = $2
"#,
)
.bind(day_of_week)
.bind(shift)
.fetch_one(pool)
.await
}
pub async fn update_clock_in_by_id(
pool: &PgPool,
schedule_id: i32,
enter_time: NaiveDateTime,
) -> Result<Schedule, sqlx::Error> {
sqlx::query_as::<_, Schedule>(
r#"
UPDATE schedules
SET enter_time = $2, date_updated = NOW()
WHERE id = $1
RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
"#,
)
.bind(schedule_id)
.bind(enter_time)
.fetch_one(pool)
.await
}
pub async fn update_clock_out_by_id(
pool: &PgPool,
schedule_id: i32,
out_time: NaiveDateTime,
) -> Result<Schedule, sqlx::Error> {
sqlx::query_as::<_, Schedule>(
r#"
UPDATE schedules
SET out_time = $2, date_updated = NOW()
WHERE id = $1
RETURNING id, user_id, enter_time, day_of_week, out_time, shift, date_created, date_updated
"#,
)
.bind(schedule_id)
.bind(out_time)
.fetch_one(pool)
.await
}
+109
View File
@@ -0,0 +1,109 @@
use chrono::NaiveDateTime;
use sqlx::PgPool;
use crate::database::{
entities::User,
types::{DayOfWeek, WorkTime},
};
pub async fn add_user(
pool: &PgPool,
name: &str,
last_name: &str,
preference: WorkTime,
) -> Result<User, sqlx::Error> {
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<User, sqlx::Error> {
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<User, sqlx::Error> {
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 get_days_worked_by_user_in_week(
pool: &PgPool,
user_id: i32,
week_start: NaiveDateTime,
week_end: NaiveDateTime,
) -> Result<Vec<DayOfWeek>, sqlx::Error> {
sqlx::query_scalar::<_, DayOfWeek>(
r#"
SELECT DISTINCT day_of_week
FROM schedules
WHERE user_id = $1
AND enter_time >= $2
AND enter_time < $3
ORDER BY day_of_week
"#,
)
.bind(user_id)
.bind(week_start)
.bind(week_end)
.fetch_all(pool)
.await
}
pub async fn get_user_enter_time_by_day_in_week(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
week_start: NaiveDateTime,
week_end: NaiveDateTime,
) -> Result<Option<NaiveDateTime>, sqlx::Error> {
sqlx::query_scalar::<_, NaiveDateTime>(
r#"
SELECT enter_time
FROM schedules
WHERE user_id = $1
AND day_of_week = $2
AND enter_time >= $3
AND enter_time < $4
ORDER BY date_created DESC
LIMIT 1
"#,
)
.bind(user_id)
.bind(day_of_week)
.bind(week_start)
.bind(week_end)
.fetch_optional(pool)
.await
}
+115
View File
@@ -0,0 +1,115 @@
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,
shift work_time NOT NULL,
date_created TIMESTAMP NOT NULL DEFAULT NOW(),
date_updated TIMESTAMP NOT NULL DEFAULT NOW()
);
"#;
pub const CREATE_WEEKLY_PREFERENCES_TABLE: &str = r#"
CREATE TABLE IF NOT EXISTS weekly_preferences (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
day_of_week day_of_week NOT NULL,
first work_time NOT NULL,
second work_time NOT NULL,
third work_time NOT NULL,
date_created TIMESTAMP NOT NULL DEFAULT NOW(),
date_updated TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (user_id, day_of_week)
);
"#;
pub const ALTER_SCHEDULES_ADD_SHIFT: &str = r#"
ALTER TABLE schedules
ADD COLUMN IF NOT EXISTS shift work_time;
"#;
pub const UPDATE_SCHEDULES_DEFAULT_SHIFT: &str = r#"
UPDATE schedules SET shift = 'morning' WHERE shift IS NULL;
"#;
pub const ALTER_SCHEDULES_SHIFT_SET_NOT_NULL: &str = r#"
ALTER TABLE schedules ALTER COLUMN shift SET NOT NULL;
"#;
pub const ALTER_SCHEDULES_DROP_OUT_TIME_NOT_NULL: &str = r#"
ALTER TABLE schedules
ALTER COLUMN out_time DROP NOT NULL;
"#;
pub const CREATE_SCHEMA_QUERIES: [&str; 11] = [
CREATE_WORK_TIME_ENUM,
CREATE_DAY_OF_WEEK_ENUM,
CREATE_COMPANIES_TABLE,
CREATE_USERS_TABLE,
CREATE_COMPANY_USERS_TABLE,
CREATE_SCHEDULES_TABLE,
CREATE_WEEKLY_PREFERENCES_TABLE,
ALTER_SCHEDULES_ADD_SHIFT,
UPDATE_SCHEDULES_DEFAULT_SHIFT,
ALTER_SCHEDULES_SHIFT_SET_NOT_NULL,
ALTER_SCHEDULES_DROP_OUT_TIME_NOT_NULL,
];
+47
View File
@@ -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,
}
+62
View File
@@ -0,0 +1,62 @@
mod app;
mod database;
mod manager;
mod models;
mod router;
use std::{env, net::SocketAddr};
use database::types::{DayOfWeek, WorkTime};
#[derive(Debug, Clone)]
struct AssignmentRules {
days: [DayOfWeek; 7],
shifts: [WorkTime; 3],
max_shifts_per_employee_per_day: u8,
max_days_per_employee_per_week: u8,
min_employees_per_shift: u8,
conflict_strategy: &'static str,
}
impl AssignmentRules {
fn is_valid(&self) -> bool {
self.days.len() == 7
&& self.shifts.len() == 3
&& self.max_shifts_per_employee_per_day == 1
&& self.max_days_per_employee_per_week == 5
&& self.min_employees_per_shift == 2
&& !self.conflict_strategy.is_empty()
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let database_url = env::var("DATABASE_URL")?;
let pool = database::connection::connect(&database_url).await?;
database::connection::init_database(&pool).await?;
debug_assert!(assignment_rules().is_valid());
let router = router::build_router(pool);
let address = env::var("APP_ADDRESS")
.unwrap_or_else(|_| "127.0.0.1:5005".to_string())
.parse::<SocketAddr>()?;
let listener = tokio::net::TcpListener::bind(address).await?;
axum::serve(listener, router).await?;
Ok(())
}
fn assignment_rules() -> AssignmentRules {
AssignmentRules {
days: models::shift::define_day_values(),
shifts: models::shift::define_shift_values(),
max_shifts_per_employee_per_day: 1,
max_days_per_employee_per_week: 5,
min_employees_per_shift: 2,
conflict_strategy: "try same-day alternative shifts, then next-day preferences",
}
}
+194
View File
@@ -0,0 +1,194 @@
use chrono::Utc;
use sqlx::PgPool;
use crate::{
database::{
entities::Schedule,
queries::{
company as company_queries, employee as employee_queries,
preference as preference_queries, schedule as schedule_queries,
},
types::{DayOfWeek, WorkTime},
},
manager::validator,
};
const SHIFTS: [WorkTime; 3] = [WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening];
const DAYS: [DayOfWeek; 7] = [
DayOfWeek::Sunday,
DayOfWeek::Monday,
DayOfWeek::Tuesday,
DayOfWeek::Wednesday,
DayOfWeek::Thursday,
DayOfWeek::Friday,
DayOfWeek::Saturday,
];
const MAX_EMPLOYEES_PER_SHIFT: i64 = 2;
async fn count_shift_employees(
pool: &PgPool,
day_of_week: DayOfWeek,
shift: WorkTime,
company_id: Option<i32>,
) -> Result<i64, sqlx::Error> {
schedule_queries::count_by_day_shift(pool, day_of_week, shift, company_id).await
}
async fn is_shift_full(
pool: &PgPool,
day_of_week: DayOfWeek,
shift: WorkTime,
company_id: Option<i32>,
) -> Result<bool, sqlx::Error> {
Ok(
count_shift_employees(pool, day_of_week, shift, company_id).await?
>= MAX_EMPLOYEES_PER_SHIFT,
)
}
fn next_day(current_day: DayOfWeek) -> DayOfWeek {
let index = DAYS.iter().position(|day| *day == current_day).unwrap_or(0);
DAYS[(index + 1) % DAYS.len()]
}
fn datetime_for_day(
day_of_week: DayOfWeek,
reference: chrono::NaiveDateTime,
) -> chrono::NaiveDateTime {
let (week_start, _) = validator::week_bounds(reference);
let offset = match day_of_week {
DayOfWeek::Sunday => 0,
DayOfWeek::Monday => 1,
DayOfWeek::Tuesday => 2,
DayOfWeek::Wednesday => 3,
DayOfWeek::Thursday => 4,
DayOfWeek::Friday => 5,
DayOfWeek::Saturday => 6,
};
week_start + chrono::Duration::days(offset) + chrono::Duration::hours(9)
}
async fn shift_preferences(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
) -> Result<Vec<WorkTime>, sqlx::Error> {
if let Some(row) = preference_queries::get_weekly_preference(pool, user_id, day_of_week).await?
{
return Ok(vec![row.first, row.second, row.third]);
}
Ok(SHIFTS.to_vec())
}
async fn assign_shift(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
shift: WorkTime,
company_id: Option<i32>,
) -> Result<Option<Schedule>, sqlx::Error> {
let company_id = match company_id {
Some(company_id) => company_id,
None => match employee_queries::get_company_id_by_user(pool, user_id).await? {
Some(company_id) => company_id,
None => return Ok(None),
},
};
if is_shift_full(pool, day_of_week, shift, Some(company_id)).await? {
return Ok(None);
}
let now = Utc::now().naive_utc();
let enter_time = datetime_for_day(day_of_week, now);
if !validator::validate_employee_can_join_shift_in_week(
pool,
user_id,
day_of_week,
shift,
enter_time,
)
.await?
{
return Ok(None);
}
let schedule =
schedule_queries::add_schedule(pool, user_id, enter_time, day_of_week, None, shift).await?;
Ok(Some(schedule))
}
pub async fn detect_preferred_shift_conflict(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
preferred_shift: WorkTime,
) -> Result<bool, sqlx::Error> {
let company_id = employee_queries::get_company_id_by_user(pool, user_id).await?;
let now = Utc::now().naive_utc();
Ok(
is_shift_full(pool, day_of_week, preferred_shift, company_id).await?
|| !validator::validate_user_has_not_worked_same_day_in_week(
pool,
user_id,
day_of_week,
now,
)
.await?
|| !validator::validate_user_has_not_worked_more_than_five_days_in_week(
pool, user_id, now,
)
.await?,
)
}
pub async fn resolve_conflict_same_day(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
) -> Result<Option<Schedule>, sqlx::Error> {
for shift in SHIFTS {
if let Some(schedule) = assign_shift(pool, user_id, day_of_week, shift, None).await? {
return Ok(Some(schedule));
}
}
Ok(None)
}
pub async fn resolve_conflict_next_day(
pool: &PgPool,
user_id: i32,
current_day: DayOfWeek,
) -> Result<Option<Schedule>, sqlx::Error> {
let next_day = next_day(current_day);
for shift in shift_preferences(pool, user_id, next_day).await? {
if let Some(schedule) = assign_shift(pool, user_id, next_day, shift, None).await? {
return Ok(Some(schedule));
}
}
Ok(None)
}
pub async fn resolve_missing_second_employee(
pool: &PgPool,
company_id: i32,
day_of_week: DayOfWeek,
shift: WorkTime,
) -> Result<Option<Schedule>, sqlx::Error> {
if count_shift_employees(pool, day_of_week, shift, Some(company_id)).await?
>= MAX_EMPLOYEES_PER_SHIFT
{
return Ok(None);
}
for user_id in company_queries::get_user_id_by_company(pool, company_id).await? {
if let Some(schedule) =
assign_shift(pool, user_id, day_of_week, shift, Some(company_id)).await?
{
return Ok(Some(schedule));
}
}
Ok(None)
}
+3
View File
@@ -0,0 +1,3 @@
pub mod conflict;
pub mod scheduler;
pub mod validator;
+182
View File
@@ -0,0 +1,182 @@
use chrono::{NaiveDateTime, Utc};
use sqlx::PgPool;
use crate::{
database::{
entities::Schedule,
queries::{
company as company_queries, employee as employee_queries,
preference as preference_queries, schedule as schedule_queries,
},
types::{DayOfWeek, WorkTime},
},
manager::{conflict, validator},
models::shift as shift_model,
};
const DAYS: [DayOfWeek; 7] = [
DayOfWeek::Sunday,
DayOfWeek::Monday,
DayOfWeek::Tuesday,
DayOfWeek::Wednesday,
DayOfWeek::Thursday,
DayOfWeek::Friday,
DayOfWeek::Saturday,
];
const SHIFTS: [WorkTime; 3] = [WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening];
fn datetime_for_day(day_of_week: DayOfWeek, reference: NaiveDateTime) -> NaiveDateTime {
let (week_start, _) = validator::week_bounds(reference);
let offset = match day_of_week {
DayOfWeek::Sunday => 0,
DayOfWeek::Monday => 1,
DayOfWeek::Tuesday => 2,
DayOfWeek::Wednesday => 3,
DayOfWeek::Thursday => 4,
DayOfWeek::Friday => 5,
DayOfWeek::Saturday => 6,
};
week_start + chrono::Duration::days(offset) + chrono::Duration::hours(9)
}
async fn preferences_for_day(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
) -> Result<Vec<WorkTime>, sqlx::Error> {
if let Some(row) = preference_queries::get_weekly_preference(pool, user_id, day_of_week).await?
{
return Ok(vec![row.first, row.second, row.third]);
}
if let Some(fallback) = employee_queries::get_preference(pool, user_id).await? {
let mut ordered = vec![fallback];
ordered.extend(SHIFTS.into_iter().filter(|shift| *shift != fallback));
return Ok(ordered);
}
Ok(SHIFTS.to_vec())
}
pub async fn generate_weekly_schedule(
pool: &PgPool,
company_id: i32,
) -> Result<Vec<Schedule>, sqlx::Error> {
let employee_ids = company_queries::get_user_id_by_company(pool, company_id).await?;
if employee_ids.is_empty() {
return Ok(Vec::new());
}
let _has_enough = validator::validate_company_has_enough_employees(pool, company_id).await?;
let _fields = crate::models::schedule::define_schedule_fields();
schedule_queries::delete_schedules_for_company(pool, company_id).await?;
let mut created = Vec::new();
for day_of_week in DAYS {
for user_id in &employee_ids {
let mut assigned = None;
for preferred_shift in preferences_for_day(pool, *user_id, day_of_week).await? {
assigned =
assign_preferred_shift(pool, *user_id, day_of_week, preferred_shift).await?;
if assigned.is_some() {
break;
}
}
if let Some(schedule) = assigned {
created.push(schedule);
continue;
}
if let Some(schedule) =
conflict::resolve_conflict_same_day(pool, *user_id, day_of_week).await?
{
created.push(schedule);
continue;
}
if let Some(schedule) =
conflict::resolve_conflict_next_day(pool, *user_id, day_of_week).await?
{
created.push(schedule);
}
}
for shift in SHIFTS {
let _capacity =
shift_model::compare_shift_capacity(pool, company_id, day_of_week, shift).await?;
if let Some(schedule) =
fill_missing_shift_coverage(pool, company_id, day_of_week, shift).await?
{
created.push(schedule);
}
}
}
Ok(created)
}
pub async fn assign_preferred_shift(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
preferred_shift: WorkTime,
) -> Result<Option<Schedule>, sqlx::Error> {
let now = Utc::now().naive_utc();
let enter_time = datetime_for_day(day_of_week, now);
if !validator::validate_employee_can_join_shift_in_week(
pool,
user_id,
day_of_week,
preferred_shift,
enter_time,
)
.await?
{
return Ok(None);
}
if conflict::detect_preferred_shift_conflict(pool, user_id, day_of_week, preferred_shift)
.await?
{
return Ok(None);
}
let company_id = match employee_queries::get_company_id_by_user(pool, user_id).await? {
Some(company_id) => company_id,
None => return Ok(None),
};
if validator::validate_shift_has_minimum_two_employees(
pool,
company_id,
day_of_week,
preferred_shift,
)
.await?
{
return Ok(None);
}
let schedule = schedule_queries::add_schedule(
pool,
user_id,
enter_time,
day_of_week,
None,
preferred_shift,
)
.await?;
Ok(Some(schedule))
}
pub async fn fill_missing_shift_coverage(
pool: &PgPool,
company_id: i32,
day_of_week: DayOfWeek,
shift: WorkTime,
) -> Result<Option<Schedule>, sqlx::Error> {
conflict::resolve_missing_second_employee(pool, company_id, day_of_week, shift).await
}
+85
View File
@@ -0,0 +1,85 @@
use chrono::NaiveDateTime;
use sqlx::PgPool;
use crate::database::{
queries::{company as company_queries, schedule as schedule_queries, user as user_queries},
types::{DayOfWeek, WorkTime},
};
pub fn week_bounds(reference: NaiveDateTime) -> (NaiveDateTime, NaiveDateTime) {
use chrono::Datelike;
let days_from_sunday = reference.weekday().num_days_from_sunday() as i64;
let start_date = reference.date() - chrono::Duration::days(days_from_sunday);
let week_start = start_date.and_hms_opt(0, 0, 0).unwrap();
let week_end = week_start + chrono::Duration::days(7);
(week_start, week_end)
}
pub async fn validate_user_has_not_worked_same_day_in_week(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
reference: NaiveDateTime,
) -> Result<bool, sqlx::Error> {
let (week_start, week_end) = week_bounds(reference);
Ok(user_queries::get_user_enter_time_by_day_in_week(
pool,
user_id,
day_of_week,
week_start,
week_end,
)
.await?
.is_none())
}
pub async fn validate_user_has_not_worked_more_than_five_days_in_week(
pool: &PgPool,
user_id: i32,
reference: NaiveDateTime,
) -> Result<bool, sqlx::Error> {
let (week_start, week_end) = week_bounds(reference);
Ok(
user_queries::get_days_worked_by_user_in_week(pool, user_id, week_start, week_end)
.await?
.len()
< 5,
)
}
pub async fn validate_shift_has_minimum_two_employees(
pool: &PgPool,
company_id: i32,
day_of_week: DayOfWeek,
shift: WorkTime,
) -> Result<bool, sqlx::Error> {
Ok(
schedule_queries::count_by_day_shift(pool, day_of_week, shift, Some(company_id)).await?
>= 2,
)
}
pub async fn validate_employee_can_join_shift_in_week(
pool: &PgPool,
user_id: i32,
day_of_week: DayOfWeek,
_shift: WorkTime,
reference: NaiveDateTime,
) -> Result<bool, sqlx::Error> {
Ok(
validate_user_has_not_worked_same_day_in_week(pool, user_id, day_of_week, reference)
.await?
&& validate_user_has_not_worked_more_than_five_days_in_week(pool, user_id, reference)
.await?,
)
}
pub async fn validate_company_has_enough_employees(
pool: &PgPool,
company_id: i32,
) -> Result<bool, sqlx::Error> {
Ok(company_queries::get_user_id_by_company(pool, company_id)
.await?
.len()
>= 2)
}
+2
View File
@@ -0,0 +1,2 @@
pub mod requests;
pub mod service;
+6
View File
@@ -0,0 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateCompanyRequest {
pub name: String,
}
+25
View File
@@ -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, sqlx::Error> {
company_queries::add_company(pool, request.name.trim()).await
}
pub async fn get_company(pool: &PgPool, id: i32) -> Result<Company, sqlx::Error> {
company_queries::get_company_by_id(pool, id).await
}
pub async fn list_companies(pool: &PgPool) -> Result<Vec<Company>, sqlx::Error> {
company_queries::list_companies(pool).await
}
pub async fn delete_company(pool: &PgPool, id: i32) -> Result<Company, sqlx::Error> {
company_queries::remove_company(pool, id).await
}
+58
View File
@@ -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<CreateEmployeeRequest>,
) -> ApiResult<Json<crate::database::storage::User>> {
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, sqlx::Error> {
user_queries::update_preference(pool, user_id, preference).await
}
pub async fn get_employee_names_by_company(
pool: &PgPool,
company_name: &str,
) -> Result<Vec<String>, 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);
}
+3
View File
@@ -0,0 +1,3 @@
pub mod company;
pub mod schedule;
pub mod shift;
+106
View File
@@ -0,0 +1,106 @@
use std::collections::BTreeMap;
use chrono::NaiveDateTime;
use serde::Serialize;
use sqlx::PgPool;
use utoipa::ToSchema;
use crate::{
database::{
entities::Schedule,
queries::schedule as schedule_queries,
types::{DayOfWeek, WorkTime},
},
manager::validator,
};
#[derive(Debug, Serialize, ToSchema)]
pub struct ScheduleFields {
pub user_id: &'static str,
pub company_id: &'static str,
pub day_of_week: &'static str,
pub shift: &'static str,
pub enter_time: &'static str,
pub out_time: &'static str,
}
pub type WeeklyScheduleGroups = BTreeMap<String, BTreeMap<String, Vec<String>>>;
pub fn define_schedule_fields() -> ScheduleFields {
ScheduleFields {
user_id: "i32",
company_id: "i32",
day_of_week: "DayOfWeek",
shift: "WorkTime",
enter_time: "NaiveDateTime",
out_time: "Option<NaiveDateTime>",
}
}
pub async fn register_user_enter_and_out(
pool: &PgPool,
user_id: i32,
enter_time: NaiveDateTime,
day_of_week: DayOfWeek,
out_time: Option<NaiveDateTime>,
shift: WorkTime,
) -> Result<Option<Schedule>, sqlx::Error> {
if !validator::validate_employee_can_join_shift_in_week(
pool,
user_id,
day_of_week,
shift,
enter_time,
)
.await?
{
return Ok(None);
}
Ok(Some(
schedule_queries::add_schedule(pool, user_id, enter_time, day_of_week, out_time, shift)
.await?,
))
}
pub async fn output_weekly_schedule(
pool: &PgPool,
company_id: Option<i32>,
) -> Result<WeeklyScheduleGroups, sqlx::Error> {
let schedules = schedule_queries::list_weekly(pool, company_id).await?;
let mut grouped: WeeklyScheduleGroups = BTreeMap::new();
for schedule in schedules {
let day = day_label(schedule.day_of_week).to_string();
let shift = shift_label(schedule.shift).to_string();
let name = format!("{} {}", schedule.user_name, schedule.user_last_name);
grouped
.entry(day)
.or_default()
.entry(shift)
.or_default()
.push(name);
}
Ok(grouped)
}
fn day_label(day: DayOfWeek) -> &'static str {
match day {
DayOfWeek::Sunday => "sunday",
DayOfWeek::Monday => "monday",
DayOfWeek::Tuesday => "tuesday",
DayOfWeek::Wednesday => "wednesday",
DayOfWeek::Thursday => "thursday",
DayOfWeek::Friday => "friday",
DayOfWeek::Saturday => "saturday",
}
}
fn shift_label(shift: WorkTime) -> &'static str {
match shift {
WorkTime::Morning => "morning",
WorkTime::Afternoon => "afternoon",
WorkTime::Evening => "evening",
}
}
+59
View File
@@ -0,0 +1,59 @@
use serde::Serialize;
use sqlx::PgPool;
use utoipa::ToSchema;
use crate::{
database::{
queries::schedule as schedule_queries,
types::{DayOfWeek, WorkTime},
},
manager::validator,
};
#[derive(Debug, Serialize, ToSchema)]
pub struct ShiftCapacity {
pub count: i64,
pub has_minimum_two: bool,
pub needs_coverage: bool,
pub is_full: bool,
pub should_add_employee: bool,
pub should_resolve_conflict: bool,
}
pub fn define_shift_values() -> [WorkTime; 3] {
[WorkTime::Morning, WorkTime::Afternoon, WorkTime::Evening]
}
pub fn define_day_values() -> [DayOfWeek; 7] {
[
DayOfWeek::Sunday,
DayOfWeek::Monday,
DayOfWeek::Tuesday,
DayOfWeek::Wednesday,
DayOfWeek::Thursday,
DayOfWeek::Friday,
DayOfWeek::Saturday,
]
}
pub async fn compare_shift_capacity(
pool: &PgPool,
company_id: i32,
day_of_week: DayOfWeek,
shift: WorkTime,
) -> Result<ShiftCapacity, sqlx::Error> {
let count =
schedule_queries::count_by_day_shift(pool, day_of_week, shift, Some(company_id)).await?;
let has_minimum_two =
validator::validate_shift_has_minimum_two_employees(pool, company_id, day_of_week, shift)
.await?;
Ok(ShiftCapacity {
count,
has_minimum_two,
needs_coverage: !has_minimum_two,
is_full: count >= 2,
should_add_employee: count < 2,
should_resolve_conflict: count >= 2,
})
}
+1110
View File
File diff suppressed because it is too large Load Diff