teamcraft/services/
manager_gaps.rs1use chrono::{DateTime, Utc};
11use serde::Serialize;
12use serde_json::json;
13use sqlx::PgPool;
14use uuid::Uuid;
15
16use crate::{
17 auth::Actor,
18 domain::NotificationKind,
19 error::{AppError, AppResult},
20 services::{
21 access_review, audit,
22 notifications::{self, Notice, Subject},
23 },
24};
25
26pub const STALE_DAYS: i64 = access_review::STALE_DAYS;
29
30#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
32pub struct ManagerGap {
33 pub project_id: Uuid,
34 pub code: String,
35 pub name: String,
36 pub active_managers: i64,
38 pub last_active_at: Option<DateTime<Utc>>,
41}
42
43impl ManagerGap {
44 pub fn kind(&self) -> &'static str {
47 if self.active_managers == 0 {
48 "none"
49 } else {
50 "stale"
51 }
52 }
53}
54
55pub fn to_json(gaps: &[ManagerGap]) -> serde_json::Value {
57 json!(gaps
58 .iter()
59 .map(|g| {
60 let mut v = json!(g);
61 v["kind"] = json!(g.kind());
62 v
63 })
64 .collect::<Vec<_>>())
65}
66
67const GAP_SELECT: &str = "SELECT p.id AS project_id, p.code, p.name,
70 COUNT(u.id) AS active_managers,
71 MAX(GREATEST(u.last_login_at, u.created_at, t.used)) AS last_active_at
72 FROM projects p
73 LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.manager
74 LEFT JOIN users u ON u.id = pm.user_id AND u.active
75 LEFT JOIN (SELECT user_id, MAX(last_used_at) AS used FROM api_tokens GROUP BY user_id) t
76 ON t.user_id = u.id
77 WHERE p.active AND ($2::uuid IS NULL OR p.id = $2)
78 GROUP BY p.id, p.code, p.name
79 HAVING COUNT(u.id) = 0
80 OR MAX(GREATEST(u.last_login_at, u.created_at, t.used))
81 < now() - make_interval(days => $1)
82 ORDER BY p.code";
83
84pub async fn list(pool: &PgPool, actor: &Actor) -> AppResult<Vec<ManagerGap>> {
95 if !actor.is_admin() {
96 return Err(AppError::Forbidden(
97 "Only administrators see the groups without an active manager.".into(),
98 ));
99 }
100 Ok(sqlx::query_as::<_, ManagerGap>(GAP_SELECT)
101 .bind(STALE_DAYS as i32)
102 .bind(None::<Uuid>)
103 .fetch_all(pool)
104 .await?)
105}
106
107pub async fn alert_admins(pool: &PgPool) -> AppResult<u64> {
123 let gaps = sqlx::query_as::<_, ManagerGap>(GAP_SELECT)
124 .bind(STALE_DAYS as i32)
125 .bind(None::<Uuid>)
126 .fetch_all(pool)
127 .await?;
128 let open: Vec<Uuid> = gaps.iter().map(|g| g.project_id).collect();
130 sqlx::query(
131 "UPDATE projects SET manager_gap_alerted_at = NULL
132 WHERE manager_gap_alerted_at IS NOT NULL AND id <> ALL($1)",
133 )
134 .bind(&open)
135 .execute(pool)
136 .await?;
137 let mut sent = 0;
138 for gap in gaps {
139 let mut tx = pool.begin().await?;
140 let unreported: Option<Uuid> = sqlx::query_scalar(
141 "SELECT id FROM projects
142 WHERE id = $1 AND manager_gap_alerted_at IS NULL FOR UPDATE",
143 )
144 .bind(gap.project_id)
145 .fetch_optional(&mut *tx)
146 .await?;
147 if unreported.is_none() {
148 tx.rollback().await?;
149 continue;
150 }
151 let still = sqlx::query_as::<_, ManagerGap>(GAP_SELECT)
153 .bind(STALE_DAYS as i32)
154 .bind(gap.project_id)
155 .fetch_optional(&mut *tx)
156 .await?;
157 let Some(gap) = still else {
158 tx.rollback().await?;
159 continue;
160 };
161 let admins: Vec<Uuid> =
162 sqlx::query_scalar("SELECT id FROM users WHERE role = 'admin' AND active")
163 .fetch_all(&mut *tx)
164 .await?;
165 let code = &gap.code;
166 let (title, body) = if gap.active_managers == 0 {
167 (
168 format!("{code} has no active manager"),
169 format!(
170 "Every manager of {code} has left the group or been deactivated. Until \
171 somebody is made a manager, only administrators can add members, release \
172 the check-outs of people who left or confirm the access review."
173 ),
174 )
175 } else {
176 (
177 format!("The managers of {code} have been inactive for {STALE_DAYS} days"),
178 format!(
179 "No manager of {code} has signed in or used a token for {STALE_DAYS} days. \
180 If they have left, make somebody else a manager of the group."
181 ),
182 )
183 };
184 let notice = Notice {
185 kind: NotificationKind::AccessAlert,
186 title,
187 body,
188 link: format!("/projects/{}", gap.project_id),
189 subject_type: "",
190 subject_ref: String::new(),
191 subject: Subject::Personal,
192 redacted: None,
193 };
194 notifications::notify(&mut tx, None, &admins, ¬ice).await?;
195 sqlx::query("UPDATE projects SET manager_gap_alerted_at = now() WHERE id = $1")
196 .bind(gap.project_id)
197 .execute(&mut *tx)
198 .await?;
199 audit::record(
200 &mut tx,
201 None,
202 "project.manager_gap_alert",
203 "project",
204 Some(gap.project_id),
205 format!(
206 "{code} has no present manager: told {} administrators",
207 admins.len()
208 ),
209 json!({
210 "kind": gap.kind(),
211 "active_managers": gap.active_managers,
212 "recipients": admins.len(),
213 }),
214 )
215 .await?;
216 tx.commit().await?;
217 sent += 1;
218 }
219 Ok(sent)
220}