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, audit, 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    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
129/// Renames a department or changes who runs it.
130pub 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    // `manager_id` is three-valued here: absent leaves it, `Some(None)` clears
144    // it, `Some(Some(id))` sets it. `coalesce` cannot express the middle one,
145    // so the flag decides which branch the statement takes.
146    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
188/// Removes a department. Its people stay, unfiled.
189pub async fn delete(State(state): State<AppState>, user: CurrentUser, Path(target): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
190    user.require_admin()?;
191
192    // The foreign key is ON DELETE SET NULL, so the members survive and become
193    // admin-only until they are filed again. Deleting people along with the
194    // department they happened to be in would be a catastrophe behind a button.
195    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
216/// Files a person into a department, or removes them from one.
217pub 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        // A department id that does not exist. Answered as a bad request
232        // rather than a 500: the caller sent an id, and it was wrong.
233        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    // Who can see whom changes here, which is exactly what an audit reader
245    // wants to reconstruct.
246    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
257/// Refuses to put someone in charge who cannot be in charge.
258///
259/// An employee heading a department would see nothing of it - `GET /users`
260/// admits managers and admins only - so the department would silently have no
261/// working head at all.
262async 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        // The whole reason for the double option: "leave the head alone" and
283        // "this department has no head at the moment" must not be the same
284        // payload.
285        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}