Skip to main content

teamcraft/services/
season.rs

1//! Season (term) handover of a project group (issue #173, step 8).
2//!
3//! Student teams replace many members at once. The managers of a group (and
4//! administrators) see every member with their last activity, check-outs,
5//! manager flag and "member until" date ([`members`]), choose members, and
6//! run one plan ([`run`]): make the new lead a manager, release the chosen
7//! members' check-outs (with a reason, so active members' locks too), set or
8//! clear "member until", remove members and demote the old managers. Each
9//! step goes through the service that owns it, so every object is checked
10//! and audited on its own, and one refusal does not stop the others.
11
12use chrono::{DateTime, NaiveDate, Utc};
13use serde::Serialize;
14use sqlx::PgPool;
15use uuid::Uuid;
16
17use crate::{
18    auth::Actor,
19    error::{AppError, AppResult},
20    services::{
21        checkouts::{self, CheckInOutcome},
22        projects, revisions,
23    },
24};
25
26/// Most members one plan may touch.
27pub const MAX_MEMBERS: usize = 500;
28
29/// A member of the group as the season page shows them.
30#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
31pub struct SeasonMember {
32    pub user_id: Uuid,
33    pub username: String,
34    pub display_name: String,
35    pub role: String,
36    pub active: bool,
37    /// `manual` or `oidc`.
38    pub source: String,
39    pub manager: bool,
40    /// Revisions of the group the member has checked out.
41    pub checkouts: i64,
42    /// Latest sign-in, use of an API or desktop token, or the account's
43    /// creation.
44    pub last_active_at: Option<DateTime<Utc>>,
45    /// "Member until"; only a flag.
46    pub expires_at: Option<NaiveDate>,
47    /// `expires_at` has passed.
48    pub expired: bool,
49}
50
51/// The members of a group for the season handover, managers first.
52///
53/// # Invariants
54///
55/// * **Permission:** the group is visible to the actor ("not found"
56///   otherwise) and the actor manages it (or is an administrator; 403).
57/// * **Data:** read-only; the clock of the database decides what has expired.
58pub async fn members(
59    pool: &PgPool,
60    actor: &Actor,
61    project_id: Uuid,
62) -> AppResult<Vec<SeasonMember>> {
63    projects::get_for_management(pool, actor, project_id).await?;
64    let mut conn = pool.acquire().await?;
65    projects::require_manager(&mut conn, actor, project_id).await?;
66    Ok(sqlx::query_as::<_, SeasonMember>(
67        "SELECT u.id AS user_id, u.username, u.display_name, u.role, u.active, pm.source,
68                pm.manager,
69                (SELECT COUNT(*) FROM revisions r JOIN items i ON i.id = r.item_id
70                 WHERE i.project_id = pm.project_id AND r.checked_out_by = u.id) AS checkouts,
71                GREATEST(u.last_login_at, u.created_at,
72                         (SELECT MAX(t.last_used_at) FROM api_tokens t WHERE t.user_id = u.id))
73                    AS last_active_at,
74                pm.expires_at,
75                COALESCE(pm.expires_at < CURRENT_DATE, false) AS expired
76         FROM project_members pm JOIN users u ON u.id = pm.user_id
77         WHERE pm.project_id = $1 ORDER BY pm.manager DESC, u.display_name",
78    )
79    .bind(project_id)
80    .fetch_all(&mut *conn)
81    .await?)
82}
83
84/// What [`run`] does, in this order.
85#[derive(Clone, Debug, Default)]
86pub struct SeasonPlan {
87    /// Made a manager first (username; added as a member if need be).
88    pub new_lead: Option<String>,
89    /// Members whose check-outs in the group are released.
90    pub release: Vec<Uuid>,
91    /// The reason told to the holders and audited; needed for the
92    /// check-outs of active members (10 to 500 characters).
93    pub reason: Option<String>,
94    /// Managers lowered to plain members.
95    pub demote: Vec<Uuid>,
96    /// Members whose "member until" is set to `until` (or cleared).
97    pub expire: Vec<Uuid>,
98    pub until: Option<NaiveDate>,
99    /// Members removed from the group, last.
100    pub remove: Vec<Uuid>,
101}
102
103impl SeasonPlan {
104    fn is_empty(&self) -> bool {
105        self.new_lead.as_deref().map_or("", str::trim).is_empty()
106            && self.release.is_empty()
107            && self.demote.is_empty()
108            && self.expire.is_empty()
109            && self.remove.is_empty()
110    }
111}
112
113/// One step of the plan for one member.
114#[derive(Clone, Debug, Serialize)]
115pub struct Step {
116    pub user_id: Option<Uuid>,
117    pub username: String,
118    pub ok: bool,
119    /// Why it was refused (`ok` = false).
120    pub error: Option<String>,
121}
122
123/// What [`run`] did.
124#[derive(Clone, Debug, Default, Serialize)]
125pub struct SeasonReport {
126    pub lead: Option<Step>,
127    pub released: Vec<CheckInOutcome>,
128    pub demoted: Vec<Step>,
129    pub expiry: Vec<Step>,
130    pub removed: Vec<Step>,
131}
132
133impl SeasonReport {
134    /// One line for a flash message.
135    pub fn summary(&self) -> String {
136        fn count(steps: &[Step]) -> (usize, usize) {
137            let ok = steps.iter().filter(|s| s.ok).count();
138            (ok, steps.len() - ok)
139        }
140        let mut parts = Vec::new();
141        if let Some(l) = &self.lead {
142            parts.push(if l.ok {
143                format!("{} is a manager", l.username)
144            } else {
145                format!(
146                    "{} not made a manager: {}",
147                    l.username,
148                    l.error.as_deref().unwrap_or("refused")
149                )
150            });
151        }
152        let released = self.released.iter().filter(|o| o.ok).count();
153        if !self.released.is_empty() {
154            parts.push(format!(
155                "{released} check-outs released{}",
156                refused(self.released.len() - released)
157            ));
158        }
159        for (what, steps) in [
160            ("demoted", &self.demoted),
161            ("member-until dates set", &self.expiry),
162            ("removed", &self.removed),
163        ] {
164            if !steps.is_empty() {
165                let (ok, no) = count(steps);
166                parts.push(format!("{ok} {what}{}", refused(no)));
167            }
168        }
169        if parts.is_empty() {
170            "Nothing to do.".into()
171        } else {
172            format!("Season handover: {}.", parts.join(", "))
173        }
174    }
175
176    /// Whether any step was refused.
177    pub fn any_refused(&self) -> bool {
178        let lead = self.lead.as_ref().is_some_and(|l| !l.ok);
179        let released = self.released.iter().any(|o| !o.ok);
180        let mut steps = self.demoted.iter().chain(&self.expiry).chain(&self.removed);
181        lead || released || steps.any(|s| !s.ok)
182    }
183}
184
185fn refused(n: usize) -> String {
186    if n == 0 {
187        String::new()
188    } else {
189        format!(" ({n} refused)")
190    }
191}
192
193fn step(user_id: Option<Uuid>, username: &str, result: AppResult<()>) -> Step {
194    Step {
195        user_id,
196        username: username.to_string(),
197        ok: result.is_ok(),
198        error: result.err().map(|e| match e {
199            AppError::Database(_) | AppError::Io(_) | AppError::Other(_) => {
200                tracing::error!(error = %e, "season handover step failed");
201                "Internal error – see the server log.".to_string()
202            }
203            e => e.to_string(),
204        }),
205    }
206}
207
208/// Runs a season handover plan on one group.
209///
210/// # Invariants
211///
212/// * **Permission:** the group is visible to the actor ("not found"
213///   otherwise) and the actor manages it (or is an administrator; 403);
214///   every step is checked again by its own service
215///   (`projects::add_member`, `checkouts::release_held_because`,
216///   `projects::demote_manager`, `projects::set_member_expiry`,
217///   `projects::remove_member`), so need-to-know rules (an administrator
218///   cannot add themselves) and "at least one active manager" hold.
219/// * **Data:** the chosen people must be members of the group (400 before
220///   anything changes); at most [`MAX_MEMBERS`] per list (400); an empty plan
221///   is refused (400); a reason is checked before anything changes (400).
222/// * **Order:** new lead → releases → "member until" → removals → demotions,
223///   so the lead is a manager before the old ones step down, locks are
224///   released while their holders are still members, and a manager who
225///   demotes themselves keeps the right to run the other steps.
226/// * **Audit / notifications:** those of each step, per object.
227/// * **Atomic:** one transaction per step; refused steps are reported and do
228///   not stop the others.
229pub async fn run(
230    pool: &PgPool,
231    actor: &Actor,
232    project_id: Uuid,
233    plan: &SeasonPlan,
234) -> AppResult<SeasonReport> {
235    if plan.is_empty() {
236        return Err(AppError::BadRequest(
237            "Choose members and at least one action.".into(),
238        ));
239    }
240    let lists = [&plan.release, &plan.demote, &plan.expire, &plan.remove];
241    if lists.iter().any(|l| l.len() > MAX_MEMBERS) {
242        return Err(AppError::BadRequest(format!(
243            "At most {MAX_MEMBERS} members per action."
244        )));
245    }
246    // trimmed and checked once; an empty reason is none
247    let reason = plan.reason.as_deref();
248    revisions::check_release_reason(reason)?;
249    let current = members(pool, actor, project_id).await?;
250    let name_of = |id: &Uuid| {
251        current
252            .iter()
253            .find(|m| m.user_id == *id)
254            .map(|m| m.username.clone())
255    };
256    for id in lists.iter().copied().flatten() {
257        if name_of(id).is_none() {
258            return Err(AppError::BadRequest(
259                "A chosen user is not a member of the group.".into(),
260            ));
261        }
262    }
263    let mut report = SeasonReport::default();
264    let lead = plan.new_lead.as_deref().map_or("", str::trim);
265    if !lead.is_empty() {
266        let result = projects::add_member(pool, actor, project_id, lead, true).await;
267        let id = result.as_ref().ok().map(|m| m.user_id);
268        report.lead = Some(step(id, lead, result.map(|_| ())));
269    }
270    for id in &plan.release {
271        let project = Some(project_id);
272        let mut outcomes =
273            checkouts::release_held_because(pool, actor, *id, project, reason).await?;
274        report.released.append(&mut outcomes);
275    }
276    for id in &plan.expire {
277        let name = name_of(id).unwrap_or_default();
278        let result = projects::set_member_expiry(pool, actor, project_id, *id, plan.until).await;
279        report.expiry.push(step(Some(*id), &name, result));
280    }
281    for id in &plan.remove {
282        let name = name_of(id).unwrap_or_default();
283        let result = projects::remove_member(pool, actor, project_id, *id)
284            .await
285            .map(|_| ());
286        report.removed.push(step(Some(*id), &name, result));
287    }
288    // last: a manager who steps down keeps the right to run the steps above
289    for id in &plan.demote {
290        let name = name_of(id).unwrap_or_default();
291        let result = projects::demote_manager(pool, actor, project_id, *id).await;
292        report.demoted.push(step(Some(*id), &name, result));
293    }
294    Ok(report)
295}