1use axum::{
9 Json,
10 extract::{Path, State},
11 http::StatusCode,
12 response::IntoResponse,
13};
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18use crate::{app::AppState, error::ApiError, login::CurrentUser, model::UserRole};
19
20#[derive(Debug, Serialize, sqlx::FromRow)]
22pub struct DepartmentRow {
23 pub id: Uuid,
24 pub name: String,
25 pub manager_id: Option<Uuid>,
26 pub manager: Option<String>,
28 pub members: i64,
30 pub created_at: DateTime<Utc>,
31}
32
33#[derive(Debug, Deserialize)]
34pub struct NewDepartment {
35 pub name: String,
36 pub manager_id: Option<Uuid>,
37}
38
39#[derive(Debug, Deserialize)]
42pub struct DepartmentPatch {
43 pub name: Option<String>,
44 #[serde(default, deserialize_with = "double_option")]
45 pub manager_id: Option<Option<Uuid>>,
46}
47
48fn double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
54where
55 D: serde::Deserializer<'de>,
56 T: serde::Deserialize<'de>,
57{
58 serde::Deserialize::deserialize(deserializer).map(Some)
59}
60
61#[derive(Debug, Deserialize)]
62pub struct Assignment {
63 pub department_id: Option<Uuid>,
65}
66
67pub async fn list(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
69 if user.role == UserRole::Employee {
70 return Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed"));
71 }
72
73 let departments: Vec<DepartmentRow> = sqlx::query_as(
77 "SELECT d.id, d.name, d.manager_id, m.display_name AS manager,
78 (SELECT count(*) FROM users u WHERE u.department_id = d.id) AS members,
79 d.created_at
80 FROM departments d
81 LEFT JOIN users m ON m.id = d.manager_id
82 ORDER BY d.name",
83 )
84 .fetch_all(&state.pool)
85 .await?;
86
87 Ok(Json(departments))
88}
89
90pub async fn create(State(state): State<AppState>, user: CurrentUser, Json(new): Json<NewDepartment>) -> Result<impl IntoResponse, ApiError> {
92 user.require_admin()?;
93
94 let name = new.name.trim();
95 if name.is_empty() {
96 return Err(ApiError::bad_request("a department needs a name"));
97 }
98 if let Some(manager_id) = new.manager_id {
99 check_can_manage(&state.pool, manager_id).await?;
100 }
101
102 let created: Result<Uuid, sqlx::Error> = sqlx::query_scalar("INSERT INTO departments (name, manager_id) VALUES ($1, $2) RETURNING id")
103 .bind(name)
104 .bind(new.manager_id)
105 .fetch_one(&state.pool)
106 .await;
107
108 let id = match created {
109 Ok(id) => id,
110 Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
111 return Err(ApiError::new(StatusCode::CONFLICT, "a department with that name already exists"));
112 }
113 Err(error) => return Err(error.into()),
114 };
115
116 tracing::info!(%id, by = %user.user_id, "created a department");
117 Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))))
118}
119
120pub async fn update(
122 State(state): State<AppState>,
123 user: CurrentUser,
124 Path(target): Path<Uuid>,
125 Json(patch): Json<DepartmentPatch>,
126) -> Result<impl IntoResponse, ApiError> {
127 user.require_admin()?;
128
129 if let Some(Some(manager_id)) = patch.manager_id {
130 check_can_manage(&state.pool, manager_id).await?;
131 }
132
133 let name = patch.name.as_deref().map(str::trim).filter(|name| !name.is_empty());
134 let (set_manager, manager_id) = match patch.manager_id {
138 None => (false, None),
139 Some(value) => (true, value),
140 };
141
142 let updated = sqlx::query(
143 "UPDATE departments SET
144 name = coalesce($2, name),
145 manager_id = CASE WHEN $3 THEN $4 ELSE manager_id END
146 WHERE id = $1",
147 )
148 .bind(target)
149 .bind(name)
150 .bind(set_manager)
151 .bind(manager_id)
152 .execute(&state.pool)
153 .await;
154
155 let updated = match updated {
156 Ok(result) => result.rows_affected(),
157 Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
158 return Err(ApiError::new(StatusCode::CONFLICT, "a department with that name already exists"));
159 }
160 Err(error) => return Err(error.into()),
161 };
162
163 if updated == 0 {
164 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such department"));
165 }
166
167 tracing::info!(%target, by = %user.user_id, "updated a department");
168 Ok(StatusCode::NO_CONTENT)
169}
170
171pub async fn delete(State(state): State<AppState>, user: CurrentUser, Path(target): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
173 user.require_admin()?;
174
175 let deleted = sqlx::query("DELETE FROM departments WHERE id = $1")
179 .bind(target)
180 .execute(&state.pool)
181 .await?
182 .rows_affected();
183
184 if deleted == 0 {
185 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such department"));
186 }
187
188 tracing::info!(%target, by = %user.user_id, "deleted a department");
189 Ok(StatusCode::NO_CONTENT)
190}
191
192pub async fn assign(
194 State(state): State<AppState>,
195 user: CurrentUser,
196 Path(target): Path<Uuid>,
197 Json(assignment): Json<Assignment>,
198) -> Result<impl IntoResponse, ApiError> {
199 user.require_admin()?;
200
201 let updated = sqlx::query("UPDATE users SET department_id = $2 WHERE id = $1")
202 .bind(target)
203 .bind(assignment.department_id);
204
205 let updated = match updated.execute(&state.pool).await {
206 Ok(result) => result.rows_affected(),
207 Err(sqlx::Error::Database(error)) if error.is_foreign_key_violation() => {
210 return Err(ApiError::bad_request("no such department"));
211 }
212 Err(error) => return Err(error.into()),
213 };
214
215 if updated == 0 {
216 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
217 }
218
219 tracing::info!(%target, department = ?assignment.department_id, by = %user.user_id, "assigned a department");
220 Ok(StatusCode::NO_CONTENT)
221}
222
223async fn check_can_manage(pool: &sqlx::PgPool, manager_id: Uuid) -> Result<(), ApiError> {
229 let role: Option<(UserRole, bool)> = sqlx::query_as("SELECT role, active FROM users WHERE id = $1")
230 .bind(manager_id)
231 .fetch_optional(pool)
232 .await?;
233
234 match role {
235 None => Err(ApiError::bad_request("no such user")),
236 Some((_, false)) => Err(ApiError::bad_request("that account is deactivated")),
237 Some((UserRole::Employee, _)) => Err(ApiError::bad_request("an employee cannot run a department; make them a manager first")),
238 Some((UserRole::Manager | UserRole::Admin, true)) => Ok(()),
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn an_absent_manager_and_an_explicit_null_are_different_requests() {
248 let absent: DepartmentPatch = serde_json::from_value(serde_json::json!({"name": "Sales"})).unwrap();
252 assert!(absent.manager_id.is_none(), "an omitted field must leave the manager alone");
253
254 let cleared: DepartmentPatch = serde_json::from_value(serde_json::json!({"manager_id": null})).unwrap();
255 assert_eq!(cleared.manager_id, Some(None), "an explicit null must clear it");
256
257 let set: DepartmentPatch = serde_json::from_value(serde_json::json!({"manager_id": "00000000-0000-0000-0000-000000000001"})).unwrap();
258 assert!(matches!(set.manager_id, Some(Some(_))));
259 }
260
261 #[test]
262 fn an_assignment_can_carry_nothing_which_means_unfiled() {
263 let removed: Assignment = serde_json::from_value(serde_json::json!({"department_id": null})).unwrap();
264 assert!(removed.department_id.is_none());
265 }
266}