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, audit, 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 audit::Entry::new(audit::action::DEPARTMENT_CREATED)
118 .by(user.user_id)
119 .by_email(&user.email)
120 .on(id)
121 .labelled(name)
122 .with(serde_json::json!({"manager_id": new.manager_id}))
123 .record(&state.pool)
124 .await;
125
126 Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))))
127}
128
129pub async fn update(
131 State(state): State<AppState>,
132 user: CurrentUser,
133 Path(target): Path<Uuid>,
134 Json(patch): Json<DepartmentPatch>,
135) -> Result<impl IntoResponse, ApiError> {
136 user.require_admin()?;
137
138 if let Some(Some(manager_id)) = patch.manager_id {
139 check_can_manage(&state.pool, manager_id).await?;
140 }
141
142 let name = patch.name.as_deref().map(str::trim).filter(|name| !name.is_empty());
143 let (set_manager, manager_id) = match patch.manager_id {
147 None => (false, None),
148 Some(value) => (true, value),
149 };
150
151 let updated = sqlx::query(
152 "UPDATE departments SET
153 name = coalesce($2, name),
154 manager_id = CASE WHEN $3 THEN $4 ELSE manager_id END
155 WHERE id = $1",
156 )
157 .bind(target)
158 .bind(name)
159 .bind(set_manager)
160 .bind(manager_id)
161 .execute(&state.pool)
162 .await;
163
164 let updated = match updated {
165 Ok(result) => result.rows_affected(),
166 Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
167 return Err(ApiError::new(StatusCode::CONFLICT, "a department with that name already exists"));
168 }
169 Err(error) => return Err(error.into()),
170 };
171
172 if updated == 0 {
173 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such department"));
174 }
175
176 tracing::info!(%target, by = %user.user_id, "updated a department");
177 audit::Entry::new(audit::action::DEPARTMENT_UPDATED)
178 .by(user.user_id)
179 .by_email(&user.email)
180 .on(target)
181 .with(serde_json::json!({"renamed_to": name, "manager_set": set_manager, "manager_id": manager_id}))
182 .record(&state.pool)
183 .await;
184
185 Ok(StatusCode::NO_CONTENT)
186}
187
188pub async fn delete(State(state): State<AppState>, user: CurrentUser, Path(target): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
190 user.require_admin()?;
191
192 let deleted = sqlx::query("DELETE FROM departments WHERE id = $1")
196 .bind(target)
197 .execute(&state.pool)
198 .await?
199 .rows_affected();
200
201 if deleted == 0 {
202 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such department"));
203 }
204
205 tracing::info!(%target, by = %user.user_id, "deleted a department");
206 audit::Entry::new(audit::action::DEPARTMENT_DELETED)
207 .by(user.user_id)
208 .by_email(&user.email)
209 .on(target)
210 .record(&state.pool)
211 .await;
212
213 Ok(StatusCode::NO_CONTENT)
214}
215
216pub async fn assign(
218 State(state): State<AppState>,
219 user: CurrentUser,
220 Path(target): Path<Uuid>,
221 Json(assignment): Json<Assignment>,
222) -> Result<impl IntoResponse, ApiError> {
223 user.require_admin()?;
224
225 let updated = sqlx::query("UPDATE users SET department_id = $2 WHERE id = $1")
226 .bind(target)
227 .bind(assignment.department_id);
228
229 let updated = match updated.execute(&state.pool).await {
230 Ok(result) => result.rows_affected(),
231 Err(sqlx::Error::Database(error)) if error.is_foreign_key_violation() => {
234 return Err(ApiError::bad_request("no such department"));
235 }
236 Err(error) => return Err(error.into()),
237 };
238
239 if updated == 0 {
240 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
241 }
242
243 tracing::info!(%target, department = ?assignment.department_id, by = %user.user_id, "assigned a department");
244 audit::Entry::new(audit::action::DEPARTMENT_ASSIGNED)
247 .by(user.user_id)
248 .by_email(&user.email)
249 .on(target)
250 .with(serde_json::json!({"department_id": assignment.department_id}))
251 .record(&state.pool)
252 .await;
253
254 Ok(StatusCode::NO_CONTENT)
255}
256
257async fn check_can_manage(pool: &sqlx::PgPool, manager_id: Uuid) -> Result<(), ApiError> {
263 let role: Option<(UserRole, bool)> = sqlx::query_as("SELECT role, active FROM users WHERE id = $1")
264 .bind(manager_id)
265 .fetch_optional(pool)
266 .await?;
267
268 match role {
269 None => Err(ApiError::bad_request("no such user")),
270 Some((_, false)) => Err(ApiError::bad_request("that account is deactivated")),
271 Some((UserRole::Employee, _)) => Err(ApiError::bad_request("an employee cannot run a department; make them a manager first")),
272 Some((UserRole::Manager | UserRole::Admin, true)) => Ok(()),
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn an_absent_manager_and_an_explicit_null_are_different_requests() {
282 let absent: DepartmentPatch = serde_json::from_value(serde_json::json!({"name": "Sales"})).unwrap();
286 assert!(absent.manager_id.is_none(), "an omitted field must leave the manager alone");
287
288 let cleared: DepartmentPatch = serde_json::from_value(serde_json::json!({"manager_id": null})).unwrap();
289 assert_eq!(cleared.manager_id, Some(None), "an explicit null must clear it");
290
291 let set: DepartmentPatch = serde_json::from_value(serde_json::json!({"manager_id": "00000000-0000-0000-0000-000000000001"})).unwrap();
292 assert!(matches!(set.manager_id, Some(Some(_))));
293 }
294
295 #[test]
296 fn an_assignment_can_carry_nothing_which_means_unfiled() {
297 let removed: Assignment = serde_json::from_value(serde_json::json!({"department_id": null})).unwrap();
298 assert!(removed.department_id.is_none());
299 }
300}