1use std::collections::HashMap;
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use sqlx::PgPool;
14use uuid::Uuid;
15
16use crate::{
17 auth::Actor,
18 domain::{self, BaseVersion, FileSyncState, ServerFile},
19 error::{AppError, AppResult},
20 models::Revision,
21 services::{access, files, projects, revisions},
22};
23
24#[derive(Clone, Debug, Default, Deserialize)]
26pub struct ClientFile {
27 pub filename: String,
28 #[serde(default)]
30 pub sha256: Option<String>,
31 #[serde(default)]
33 pub base_version: Option<i32>,
34 #[serde(default)]
36 pub base_sha256: Option<String>,
37}
38
39impl ClientFile {
40 fn base(&self) -> AppResult<Option<BaseVersion>> {
41 if let Some(sha) = self.base_sha256.as_deref().filter(|s| !s.trim().is_empty()) {
42 return match BaseVersion::parse(sha) {
43 Some(b @ BaseVersion::Sha256(_)) => Ok(Some(b)),
44 _ => Err(AppError::BadRequest(format!(
45 "{}: base_sha256 must be a SHA-256 in hex.",
46 self.filename
47 ))),
48 };
49 }
50 match self.base_version {
51 Some(v) if v < 0 => Err(AppError::BadRequest(format!(
52 "{}: base_version must not be negative.",
53 self.filename
54 ))),
55 Some(v) => Ok(Some(BaseVersion::Version(v))),
56 None => Ok(None),
57 }
58 }
59}
60
61#[derive(Clone, Debug, Serialize)]
64pub struct FileStatus {
65 pub filename: String,
66 pub file_id: Option<Uuid>,
68 pub version: Option<i32>,
69 pub sha256: Option<String>,
70 pub size_bytes: Option<i64>,
71 pub folder: Option<String>,
73 pub uploaded_by_name: Option<String>,
74 pub uploaded_at: Option<DateTime<Utc>>,
75 pub local_sha256: Option<String>,
77 pub local_version: Option<i32>,
79 pub state: Option<FileSyncState>,
82}
83
84#[derive(Clone, Debug, Serialize)]
86pub struct RevisionStatus {
87 pub revision_id: Uuid,
88 pub item_number: String,
89 pub code: String,
90 pub state: String,
91 pub locked_by: Option<Uuid>,
92 pub locked_by_name: Option<String>,
93 pub locked_at: Option<DateTime<Utc>>,
94 pub locked_by_me: bool,
95 pub locked_by_other: bool,
98 pub released: Option<ReleasedCheckout>,
103 pub files: Vec<FileStatus>,
104}
105
106#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
108pub struct ReleasedCheckout {
109 pub by_name: String,
111 pub at: DateTime<Utc>,
112 pub reason: Option<String>,
114 pub message: String,
116}
117
118pub fn released_message(by_name: &str) -> String {
122 format!("Released by {by_name}; your local changes are kept and need a new check-out.")
123}
124
125const LOCK_ACTIONS: [&str; 4] = [
128 "revision.checkout",
129 "revision.checkin",
130 "revision.checkout_cancel",
131 "revision.checkout_forced_release",
132];
133
134pub async fn released_from(
140 pool: &PgPool,
141 actor: &Actor,
142 rev: &Revision,
143) -> AppResult<Option<ReleasedCheckout>> {
144 if rev.checked_out_by.is_some() {
145 return Ok(None);
146 }
147 let row: Option<(String, DateTime<Utc>, String, serde_json::Value)> = sqlx::query_as(
148 "SELECT a.action, a.at, COALESCE(u.display_name, a.username), a.details
149 FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
150 WHERE a.entity_id = $1 AND a.entity_type = 'revision' AND a.action = ANY($2)
151 ORDER BY a.at DESC, a.id DESC
152 LIMIT 1",
153 )
154 .bind(rev.id)
155 .bind(&LOCK_ACTIONS[..])
156 .fetch_optional(pool)
157 .await?;
158 let Some((action, at, by_name, details)) = row else {
159 return Ok(None);
160 };
161 let holder = details["holder"]
162 .as_str()
163 .and_then(|h| Uuid::parse_str(h).ok());
164 if action != "revision.checkout_forced_release" || holder != Some(actor.id) {
165 return Ok(None);
166 }
167 Ok(Some(ReleasedCheckout {
168 message: released_message(&by_name),
169 by_name,
170 at,
171 reason: details["reason"].as_str().map(str::to_string),
172 }))
173}
174
175pub async fn file_status(
178 pool: &PgPool,
179 actor: &Actor,
180 revision_id: Uuid,
181 client: &[ClientFile],
182) -> AppResult<RevisionStatus> {
183 let rev = revisions::get(pool, actor, revision_id).await?;
184 let (list, versions) = tokio::try_join!(
185 files::list(pool, actor, revision_id),
186 files::versions_of_revision(pool, revision_id)
187 )?;
188 let mut history: HashMap<Uuid, Vec<(i32, String)>> = HashMap::new();
189 for v in versions {
190 history
191 .entry(v.file_id)
192 .or_default()
193 .push((v.version, v.sha256));
194 }
195 let mut reported: HashMap<String, &ClientFile> = HashMap::new();
196 for c in client {
197 reported.insert(files::stored_name(&c.filename).to_lowercase(), c);
198 }
199 let with_client = !client.is_empty();
200 let mut out = Vec::with_capacity(list.len());
201 for f in &list {
202 let key = f.filename.to_lowercase();
203 let local = reported.remove(&key);
204 let hist: Vec<(i32, &str)> = history
205 .get(&f.id)
206 .map(|h| h.iter().map(|(v, s)| (*v, s.as_str())).collect())
207 .unwrap_or_default();
208 let local_sha = local
209 .and_then(|c| c.sha256.as_deref())
210 .map(str::to_ascii_lowercase);
211 let state = if with_client {
212 let base = local.map(ClientFile::base).transpose()?.flatten();
213 Some(domain::sync_state(
214 Some(ServerFile {
215 version: f.version,
216 sha256: &f.sha256,
217 }),
218 &hist,
219 local_sha.as_deref(),
220 base.as_ref(),
221 ))
222 } else {
223 None
224 };
225 let local_version = local_sha.as_deref().and_then(|sha| {
226 hist.iter()
227 .filter(|(_, s)| s.eq_ignore_ascii_case(sha))
228 .map(|(v, _)| *v)
229 .max()
230 });
231 out.push(FileStatus {
232 filename: f.filename.clone(),
233 file_id: Some(f.id),
234 version: Some(f.version),
235 sha256: Some(f.sha256.clone()),
236 size_bytes: Some(f.size_bytes),
237 folder: Some(f.folder.clone()),
238 uploaded_by_name: Some(f.uploaded_by_name.clone()),
239 uploaded_at: Some(f.uploaded_at),
240 local_sha256: local_sha,
241 local_version,
242 state,
243 });
244 }
245 let mut only_local: Vec<&ClientFile> = reported
247 .into_values()
248 .filter(|c| c.sha256.is_some())
249 .collect();
250 only_local.sort_by_key(|c| c.filename.to_lowercase());
251 for c in only_local {
252 let base = c.base()?;
253 out.push(FileStatus {
254 filename: files::stored_name(&c.filename),
255 file_id: None,
256 version: None,
257 sha256: None,
258 size_bytes: None,
259 folder: None,
260 uploaded_by_name: None,
261 uploaded_at: None,
262 local_sha256: c.sha256.as_deref().map(str::to_ascii_lowercase),
263 local_version: None,
264 state: Some(match base {
266 Some(BaseVersion::Version(v)) if v > 0 => FileSyncState::Conflict,
267 Some(BaseVersion::Sha256(_)) => FileSyncState::Conflict,
268 _ => FileSyncState::New,
269 }),
270 });
271 }
272 let released = released_from(pool, actor, &rev).await?;
273 Ok(RevisionStatus {
274 released,
275 revision_id: rev.id,
276 item_number: rev.item_number.clone(),
277 code: rev.code.clone(),
278 state: rev.state.clone(),
279 locked_by: rev.checked_out_by,
280 locked_by_name: rev.checked_out_by_name.clone(),
281 locked_at: rev.checked_out_at,
282 locked_by_me: rev.checked_out_by == Some(actor.id),
283 locked_by_other: rev.checked_out_by.is_some_and(|id| id != actor.id),
284 files: out,
285 })
286}
287
288#[derive(Clone, Debug, Serialize)]
290pub struct CheckInOutcome {
291 pub revision_id: Uuid,
292 pub item_number: String,
293 pub code: String,
294 pub name: String,
295 pub ok: bool,
296 pub error: Option<String>,
298}
299
300impl CheckInOutcome {
301 fn of(rev: &Revision, result: AppResult<()>) -> CheckInOutcome {
302 CheckInOutcome {
303 revision_id: rev.id,
304 item_number: rev.item_number.clone(),
305 code: rev.code.clone(),
306 name: rev.name.clone(),
307 ok: result.is_ok(),
308 error: result.err().map(|e| match e {
309 AppError::Database(_) | AppError::Io(_) | AppError::Other(_) => {
310 tracing::error!(revision = %rev.id, error = %e, "bulk check-in failed");
311 "Internal error – see the server log.".to_string()
312 }
313 e => e.to_string(),
314 }),
315 }
316 }
317}
318
319pub const BULK_COMMENT: &str = "Bulk check-in: lock released without new files";
321
322pub async fn check_in_all(
329 pool: &PgPool,
330 actor: &Actor,
331 only: Option<&[Uuid]>,
332) -> AppResult<Vec<CheckInOutcome>> {
333 actor.require(domain::Role::Editor)?;
334 let mine = revisions::checked_out_by(pool, actor).await?;
335 let mut outcomes = Vec::new();
336 if let Some(ids) = only {
337 for id in ids {
339 if mine.iter().any(|r| r.id == *id) {
340 continue;
341 }
342 if let Ok(rev) = revisions::get(pool, actor, *id).await {
343 outcomes.push(CheckInOutcome::of(
344 &rev,
345 Err(AppError::Conflict(format!(
346 "You have not checked out {}.",
347 revisions::label(&rev)
348 ))),
349 ));
350 }
351 }
352 }
353 for rev in mine {
354 if only.is_some_and(|ids| !ids.contains(&rev.id)) {
355 continue;
356 }
357 let result = files::check_in(pool, actor, rev.id, &[], BULK_COMMENT)
358 .await
359 .map(|_| ());
360 outcomes.push(CheckInOutcome::of(&rev, result));
361 }
362 Ok(outcomes)
363}
364
365pub const MAX_RELEASE: i64 = 1000;
367
368pub async fn release_held(
391 pool: &PgPool,
392 actor: &Actor,
393 holder: Uuid,
394 project: Option<Uuid>,
395) -> AppResult<Vec<CheckInOutcome>> {
396 release_held_because(pool, actor, holder, project, None).await
397}
398
399pub async fn release_held_because(
412 pool: &PgPool,
413 actor: &Actor,
414 holder: Uuid,
415 project: Option<Uuid>,
416 reason: Option<&str>,
417) -> AppResult<Vec<CheckInOutcome>> {
418 actor.require(domain::Role::Editor)?;
419 revisions::check_release_reason(reason)?;
420 let managed: Option<Vec<Uuid>> = if actor.is_admin() {
421 if let Some(p) = project {
422 projects::get_for_management(pool, actor, p).await?;
423 }
424 None
425 } else {
426 if let Some(p) = project {
427 let mut conn = pool.acquire().await?;
428 projects::require_manager(&mut conn, actor, p).await?;
429 }
430 let ids: Vec<Uuid> = sqlx::query_scalar(
431 "SELECT project_id FROM project_members WHERE user_id = $1 AND manager",
432 )
433 .bind(actor.id)
434 .fetch_all(pool)
435 .await?;
436 if ids.is_empty() {
437 return Err(AppError::Forbidden(
438 "Only managers of a project group and administrators can release other users' \
439 check-outs."
440 .into(),
441 ));
442 }
443 Some(ids)
444 };
445 let held = sqlx::query_as::<_, Revision>(&format!(
446 "{} WHERE r.checked_out_by = $1 AND {}
447 AND ($3::uuid IS NULL OR i.project_id = $3)
448 AND ($4::uuid[] IS NULL OR i.project_id = ANY($4))
449 ORDER BY i.number, r.code LIMIT $5",
450 revisions::REVISION_SELECT,
451 access::item_visible("i", 2)
452 ))
453 .bind(holder)
454 .bind(&actor.hidden_projects)
455 .bind(project)
456 .bind(managed.as_deref())
457 .bind(MAX_RELEASE)
458 .fetch_all(pool)
459 .await?;
460 let mut outcomes = Vec::with_capacity(held.len());
461 for rev in held {
462 let result = revisions::cancel_checkout(pool, actor, rev.id, reason)
463 .await
464 .map(|_| ());
465 outcomes.push(CheckInOutcome::of(&rev, result));
466 }
467 Ok(outcomes)
468}
469
470pub async fn held_count(pool: &PgPool, actor: &Actor, holder: Uuid) -> AppResult<i64> {
473 Ok(sqlx::query_scalar::<_, i64>(&format!(
474 "SELECT COUNT(*) FROM revisions r JOIN items i ON i.id = r.item_id
475 WHERE r.checked_out_by = $1 AND {}",
476 access::item_visible("i", 2)
477 ))
478 .bind(holder)
479 .bind(&actor.hidden_projects)
480 .fetch_one(pool)
481 .await?)
482}
483
484pub fn release_summary(outcomes: &[CheckInOutcome]) -> String {
486 let ok = outcomes.iter().filter(|o| o.ok).count();
487 let failed: Vec<String> = outcomes
488 .iter()
489 .filter(|o| !o.ok)
490 .take(5)
491 .map(|o| {
492 format!(
493 "{}/{} ({})",
494 o.item_number,
495 o.code,
496 o.error.as_deref().unwrap_or("refused")
497 )
498 })
499 .collect();
500 let refused = outcomes.len() - ok;
501 let head = match ok {
502 0 if refused == 0 => return "No check-outs to release.".into(),
503 1 => "Released 1 check-out".to_string(),
504 n => format!("Released {n} check-outs"),
505 };
506 if refused == 0 {
507 format!("{head}.")
508 } else {
509 let more = if refused > failed.len() { ", …" } else { "" };
510 format!("{head}; {refused} refused: {}{more}", failed.join(", "))
511 }
512}
513
514pub fn summary(outcomes: &[CheckInOutcome]) -> String {
516 let ok = outcomes.iter().filter(|o| o.ok).count();
517 let failed: Vec<String> = outcomes
518 .iter()
519 .filter(|o| !o.ok)
520 .map(|o| {
521 format!(
522 "{}/{} ({})",
523 o.item_number,
524 o.code,
525 o.error.as_deref().unwrap_or("failed")
526 )
527 })
528 .collect();
529 let head = match ok {
530 0 if failed.is_empty() => return "Nothing was checked out.".into(),
531 1 => "Checked in 1 revision".to_string(),
532 n => format!("Checked in {n} revisions"),
533 };
534 if failed.is_empty() {
535 format!("{head}.")
536 } else {
537 format!("{head}; {} failed: {}", failed.len(), failed.join(", "))
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 fn outcome(number: &str, error: Option<&str>) -> CheckInOutcome {
546 CheckInOutcome {
547 revision_id: Uuid::nil(),
548 item_number: number.into(),
549 code: "A".into(),
550 name: "x".into(),
551 ok: error.is_none(),
552 error: error.map(str::to_string),
553 }
554 }
555
556 #[test]
557 fn summaries() {
558 assert_eq!(summary(&[]), "Nothing was checked out.");
559 assert_eq!(summary(&[outcome("PRT-1", None)]), "Checked in 1 revision.");
560 assert_eq!(
561 summary(&[
562 outcome("PRT-1", None),
563 outcome("PRT-2", None),
564 outcome("PRT-3", Some("no access"))
565 ]),
566 "Checked in 2 revisions; 1 failed: PRT-3/A (no access)"
567 );
568 assert_eq!(
569 summary(&[outcome("PRT-3", Some("no access"))]),
570 "Checked in 0 revisions; 1 failed: PRT-3/A (no access)"
571 );
572 }
573
574 #[test]
575 fn client_bases() {
576 let c = ClientFile {
577 filename: "a.step".into(),
578 base_version: Some(3),
579 ..Default::default()
580 };
581 assert_eq!(c.base().unwrap(), Some(BaseVersion::Version(3)));
582 let c = ClientFile {
583 filename: "a.step".into(),
584 base_version: Some(3),
585 base_sha256: Some("CD".repeat(32)),
586 ..Default::default()
587 };
588 assert_eq!(
589 c.base().unwrap(),
590 Some(BaseVersion::Sha256("cd".repeat(32))),
591 "the hash wins"
592 );
593 let c = ClientFile {
594 filename: "a.step".into(),
595 base_sha256: Some("12".into()),
596 ..Default::default()
597 };
598 assert!(c.base().is_err());
599 assert_eq!(ClientFile::default().base().unwrap(), None);
600 }
601}