Skip to main content

kasl_server/
department.rs

1//! Departments: the boundary a manager's authority is measured in.
2//!
3//! Administered by an administrator, read by anyone who may read the team.
4//! Membership is one department per person (`users.department_id`), and a
5//! department names its own manager - so "who may see whom" is one join rather
6//! than a rule to remember (ADR 0009).
7
8use 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/// A department as the admin screens list it.
21#[derive(Debug, Serialize, sqlx::FromRow)]
22pub struct DepartmentRow {
23    pub id: Uuid,
24    pub name: String,
25    pub manager_id: Option<Uuid>,
26    /// The manager's display name, so a list needs no second request.
27    pub manager: Option<String>,
28    /// How many people are filed here. The number an admin actually looks at.
29    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/// A change to a department. Absent means "leave it alone"; `manager_id: null`
40/// explicitly clears it, which is how a department between heads is recorded.
41#[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
48/// Distinguishes an absent field from an explicit `null`.
49///
50/// Without this a request that omits `manager_id` and one that sets it to null
51/// arrive identically, and the second - "this department has no head at the
52/// moment" - becomes impossible to express.
53fn 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    /// Null removes the person from their department without deleting them.
64    pub department_id: Option<Uuid>,
65}
66
67/// Lists departments. Readable by anyone who may read the team.
68pub 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    // Every department, both roles. A manager knowing the company has a "Sales"
74    // is not a disclosure worth a second query path - the people inside it are
75    // what `GET /users` scopes.
76    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
90/// Creates a department.
91pub 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
120/// Renames a department or changes who runs it.
121pub 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    // `manager_id` is three-valued here: absent leaves it, `Some(None)` clears
135    // it, `Some(Some(id))` sets it. `coalesce` cannot express the middle one,
136    // so the flag decides which branch the statement takes.
137    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
171/// Removes a department. Its people stay, unfiled.
172pub async fn delete(State(state): State<AppState>, user: CurrentUser, Path(target): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
173    user.require_admin()?;
174
175    // The foreign key is ON DELETE SET NULL, so the members survive and become
176    // admin-only until they are filed again. Deleting people along with the
177    // department they happened to be in would be a catastrophe behind a button.
178    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
192/// Files a person into a department, or removes them from one.
193pub 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        // A department id that does not exist. Answered as a bad request
208        // rather than a 500: the caller sent an id, and it was wrong.
209        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
223/// Refuses to put someone in charge who cannot be in charge.
224///
225/// An employee heading a department would see nothing of it - `GET /users`
226/// admits managers and admins only - so the department would silently have no
227/// working head at all.
228async 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        // The whole reason for the double option: "leave the head alone" and
249        // "this department has no head at the moment" must not be the same
250        // payload.
251        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}