1use serde::Serialize;
20use serde_json::json;
21use sqlx::{PgConnection, PgPool};
22use uuid::Uuid;
23
24use crate::{
25 auth::Actor,
26 domain::Role,
27 error::{AppError, AppResult},
28 services::{
29 audit,
30 checkouts::{self, CheckInOutcome},
31 ownership::{self, HandoverReport},
32 },
33};
34
35#[derive(Clone, Debug, Default)]
37pub struct Offboarding {
38 pub release_checkouts: bool,
41 pub hand_over_to: Option<Uuid>,
43 pub revoke_tokens: bool,
45 pub confirm_no_manager: bool,
47}
48
49#[derive(Clone, Debug, Default, Serialize)]
51pub struct DeactivationReport {
52 pub username: String,
53 pub was_active: bool,
56 pub handover: Option<HandoverReport>,
57 pub managers_moved: Vec<String>,
59 pub left_without_manager: Vec<String>,
61 pub tokens_revoked: u64,
62 pub releases: Vec<CheckInOutcome>,
64}
65
66impl DeactivationReport {
67 pub fn summary(&self) -> String {
69 let head = if self.was_active {
70 format!("Deactivated {}", self.username)
71 } else {
72 format!("{} was already deactivated", self.username)
73 };
74 let mut parts = vec![head];
75 if let Some(h) = &self.handover {
76 parts.push(format!(
77 "handed over to {}: {} items, {} changes, {} requests, {} reviews{}",
78 h.to_name,
79 h.count("item"),
80 h.count("change"),
81 h.count("request"),
82 h.count("review"),
83 if h.skipped.is_empty() {
84 String::new()
85 } else {
86 format!(" ({} stayed)", h.skipped.len())
87 }
88 ));
89 }
90 if !self.managers_moved.is_empty() {
91 parts.push(format!("manager of {}", self.managers_moved.join(", ")));
92 }
93 if !self.left_without_manager.is_empty() {
94 parts.push(format!(
95 "no active manager left in {}",
96 self.left_without_manager.join(", ")
97 ));
98 }
99 if self.tokens_revoked > 0 {
100 parts.push(format!("{} API tokens revoked", self.tokens_revoked));
101 }
102 if !self.releases.is_empty() {
103 parts.push(checkouts::release_summary(&self.releases));
104 }
105 let mut text = parts.join("; ");
106 if !text.ends_with('.') {
107 text.push('.');
108 }
109 text
110 }
111}
112
113#[derive(sqlx::FromRow)]
114struct Leaver {
115 username: String,
116 active: bool,
117}
118
119async fn sole_manager_of(conn: &mut PgConnection, user: Uuid) -> AppResult<Vec<(Uuid, String)>> {
121 Ok(sqlx::query_as::<_, (Uuid, String)>(
122 "SELECT p.id, p.code FROM project_members pm JOIN projects p ON p.id = pm.project_id
123 WHERE pm.user_id = $1 AND pm.manager
124 AND NOT EXISTS (SELECT 1 FROM project_members o JOIN users ou ON ou.id = o.user_id
125 WHERE o.project_id = pm.project_id AND o.manager AND ou.active
126 AND o.user_id <> $1)
127 ORDER BY p.code",
128 )
129 .bind(user)
130 .fetch_all(&mut *conn)
131 .await?)
132}
133
134pub async fn sole_manager_codes(
137 pool: &PgPool,
138 actor: &Actor,
139 user: Uuid,
140) -> AppResult<Vec<String>> {
141 actor.require(Role::Admin)?;
142 let mut conn = pool.acquire().await?;
143 Ok(sole_manager_of(&mut conn, user)
144 .await?
145 .into_iter()
146 .map(|(_, code)| code)
147 .collect())
148}
149
150async fn hand_over_manager_roles(
154 conn: &mut PgConnection,
155 actor: &Actor,
156 from: Uuid,
157 to: Uuid,
158 from_name: &str,
159) -> AppResult<Vec<String>> {
160 let groups = sqlx::query_as::<_, (Uuid, String)>(
161 "SELECT p.id, p.code FROM project_members pm JOIN projects p ON p.id = pm.project_id
162 WHERE pm.user_id = $1 AND pm.manager ORDER BY p.code FOR UPDATE OF pm",
163 )
164 .bind(from)
165 .fetch_all(&mut *conn)
166 .await?;
167 let to_name = sqlx::query_scalar::<_, String>("SELECT username FROM users WHERE id = $1")
168 .bind(to)
169 .fetch_one(&mut *conn)
170 .await?;
171 let mut moved = Vec::new();
172 for (project_id, code) in groups {
173 sqlx::query(
174 "INSERT INTO project_members (project_id, user_id, source, manager)
175 VALUES ($1, $2, 'manual', true)
176 ON CONFLICT (project_id, user_id) DO UPDATE SET manager = true",
177 )
178 .bind(project_id)
179 .bind(to)
180 .execute(&mut *conn)
181 .await?;
182 sqlx::query(
183 "UPDATE project_members SET manager = false WHERE project_id = $1 AND user_id = $2",
184 )
185 .bind(project_id)
186 .bind(from)
187 .execute(&mut *conn)
188 .await?;
189 audit::record(
190 &mut *conn,
191 Some(actor),
192 "project.manager_handover",
193 "project",
194 Some(project_id),
195 format!("{to_name} manages {code} in place of {from_name}"),
196 json!({ "from": from, "to": to }),
197 )
198 .await?;
199 moved.push(code);
200 }
201 Ok(moved)
202}
203
204pub async fn deactivate(
227 pool: &PgPool,
228 actor: &Actor,
229 user_id: Uuid,
230 opts: &Offboarding,
231) -> AppResult<DeactivationReport> {
232 actor.require(Role::Admin)?;
233 if user_id == actor.id {
234 return Err(AppError::BadRequest(
235 "You cannot offboard yourself; another administrator does it.".into(),
236 ));
237 }
238 let mut tx = pool.begin().await?;
239 let admins: Vec<Uuid> = sqlx::query_scalar(
240 "SELECT id FROM users WHERE role = 'admin' AND active ORDER BY id FOR UPDATE",
241 )
242 .fetch_all(&mut *tx)
243 .await?;
244 let leaver =
245 sqlx::query_as::<_, Leaver>("SELECT username, active FROM users WHERE id = $1 FOR UPDATE")
246 .bind(user_id)
247 .fetch_optional(&mut *tx)
248 .await?
249 .ok_or_else(|| AppError::not_found("User not found."))?;
250 if admins.contains(&user_id) && admins.len() == 1 {
251 return Err(AppError::Conflict(
252 "At least one active administrator must remain.".into(),
253 ));
254 }
255 let mut report = DeactivationReport {
256 username: leaver.username.clone(),
257 was_active: leaver.active,
258 ..DeactivationReport::default()
259 };
260
261 let sole = sole_manager_of(&mut tx, user_id).await?;
262 match opts.hand_over_to {
263 Some(to) => {
264 report.handover = Some(ownership::hand_over_in(&mut tx, actor, user_id, to).await?);
265 report.managers_moved =
266 hand_over_manager_roles(&mut tx, actor, user_id, to, &leaver.username).await?;
267 }
268 None if !sole.is_empty() && !opts.confirm_no_manager => {
269 let codes: Vec<&str> = sole.iter().map(|(_, c)| c.as_str()).collect();
270 return Err(AppError::Conflict(format!(
271 "{} is the only active manager of {}. Name a successor, or confirm that the \
272 group is left without an active manager.",
273 leaver.username,
274 codes.join(", ")
275 )));
276 }
277 None => {
278 report.left_without_manager = sole.into_iter().map(|(_, c)| c).collect();
279 }
280 }
281
282 if opts.revoke_tokens {
283 report.tokens_revoked = sqlx::query("DELETE FROM api_tokens WHERE user_id = $1")
284 .bind(user_id)
285 .execute(&mut *tx)
286 .await?
287 .rows_affected();
288 if report.tokens_revoked > 0 {
289 audit::record(
290 &mut tx,
291 Some(actor),
292 "token.revoke_all",
293 "user",
294 Some(user_id),
295 format!(
296 "Revoked all {} API tokens of {}",
297 report.tokens_revoked, leaver.username
298 ),
299 json!({ "count": report.tokens_revoked }),
300 )
301 .await?;
302 }
303 }
304
305 if leaver.active {
306 sqlx::query("UPDATE users SET active = false WHERE id = $1")
307 .bind(user_id)
308 .execute(&mut *tx)
309 .await?;
310 sqlx::query("DELETE FROM sessions WHERE user_id = $1")
311 .bind(user_id)
312 .execute(&mut *tx)
313 .await?;
314 audit::record(
315 &mut tx,
316 Some(actor),
317 "user.update",
318 "user",
319 Some(user_id),
320 format!("Deactivated user '{}'", leaver.username),
321 json!({ "active": false }),
322 )
323 .await?;
324 }
325 audit::record(
326 &mut tx,
327 Some(actor),
328 "user.offboard",
329 "user",
330 Some(user_id),
331 format!("Offboarded {}", leaver.username),
332 json!({
333 "hand_over_to": opts.hand_over_to,
334 "managers_moved": report.managers_moved,
335 "left_without_manager": report.left_without_manager,
336 "tokens_revoked": report.tokens_revoked,
337 "release_checkouts": opts.release_checkouts,
338 }),
339 )
340 .await?;
341 tx.commit().await?;
342
343 if opts.release_checkouts {
344 report.releases = checkouts::release_held(pool, actor, user_id, None).await?;
345 }
346 Ok(report)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn the_summary_names_every_step() {
355 let mut r = DeactivationReport {
356 username: "lena".into(),
357 was_active: true,
358 managers_moved: vec!["CHASSIS".into()],
359 tokens_revoked: 2,
360 ..DeactivationReport::default()
361 };
362 let s = r.summary();
363 assert!(s.starts_with("Deactivated lena"), "{s}");
364 assert!(s.contains("manager of CHASSIS") && s.contains("2 API tokens revoked"));
365 assert!(s.ends_with('.'));
366 r.was_active = false;
367 r.managers_moved.clear();
368 r.left_without_manager = vec!["AERO".into()];
369 let s = r.summary();
370 assert!(s.contains("already deactivated") && s.contains("no active manager left in AERO"));
371 }
372}