1use 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
26pub const MAX_MEMBERS: usize = 500;
28
29#[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 pub source: String,
39 pub manager: bool,
40 pub checkouts: i64,
42 pub last_active_at: Option<DateTime<Utc>>,
45 pub expires_at: Option<NaiveDate>,
47 pub expired: bool,
49}
50
51pub 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#[derive(Clone, Debug, Default)]
86pub struct SeasonPlan {
87 pub new_lead: Option<String>,
89 pub release: Vec<Uuid>,
91 pub reason: Option<String>,
94 pub demote: Vec<Uuid>,
96 pub expire: Vec<Uuid>,
98 pub until: Option<NaiveDate>,
99 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#[derive(Clone, Debug, Serialize)]
115pub struct Step {
116 pub user_id: Option<Uuid>,
117 pub username: String,
118 pub ok: bool,
119 pub error: Option<String>,
121}
122
123#[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 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 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
208pub 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 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 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}