Skip to main content

teamcraft/services/
checkouts.rs

1//! Concurrency management around check-outs: the sync state of the files of
2//! a revision as a client's workspace sees them, and the bulk check-in of
3//! everything a user has checked out.
4//!
5//! The lock itself (`revisions::checkout` / `cancel_checkout`) and the
6//! optimistic base-version guard of uploads (`files::store_uploads`) live in
7//! their own services; this module combines them for the workspace views.
8
9use 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/// A file of the client's workspace, as the client reports it.
25#[derive(Clone, Debug, Default, Deserialize)]
26pub struct ClientFile {
27    pub filename: String,
28    /// SHA-256 of the local content; `None` = not in the workspace.
29    #[serde(default)]
30    pub sha256: Option<String>,
31    /// The version the local file was checked out as (number) …
32    #[serde(default)]
33    pub base_version: Option<i32>,
34    /// … or its content hash at check-out time.
35    #[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/// One file of a revision with its server state and – if the client
62/// reported its workspace – the sync state of the local copy.
63#[derive(Clone, Debug, Serialize)]
64pub struct FileStatus {
65    pub filename: String,
66    /// `None` for files that exist only in the client's workspace.
67    pub file_id: Option<Uuid>,
68    pub version: Option<i32>,
69    pub sha256: Option<String>,
70    pub size_bytes: Option<i64>,
71    /// Folder inside the project group (`None` for files only in the workspace).
72    pub folder: Option<String>,
73    pub uploaded_by_name: Option<String>,
74    pub uploaded_at: Option<DateTime<Utc>>,
75    /// The local file as the client reported it.
76    pub local_sha256: Option<String>,
77    /// The server version the local content equals, if any.
78    pub local_version: Option<i32>,
79    /// `up_to_date`, `modified`, `outdated`, `conflict`, `new`, `missing`;
80    /// `None` when the client reported no files.
81    pub state: Option<FileSyncState>,
82}
83
84/// The files of a revision and who holds its lock.
85#[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    /// `true` if somebody else holds the lock: local changes cannot be
96    /// checked in right now.
97    pub locked_by_other: bool,
98    /// Somebody else released the actor's check-out of this revision and
99    /// nobody has checked it out since (issue #173, step 10): the desktop
100    /// app tells the former holder that their local changes stay but need
101    /// a new check-out. `None` otherwise.
102    pub released: Option<ReleasedCheckout>,
103    pub files: Vec<FileStatus>,
104}
105
106/// A forced release of the actor's own check-out (`revision.checkout_forced_release`).
107#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
108pub struct ReleasedCheckout {
109    /// Who released it (display name; the user name if the account is gone).
110    pub by_name: String,
111    pub at: DateTime<Utc>,
112    /// The reason a manager gave, if any.
113    pub reason: Option<String>,
114    /// The sentence to show the former holder ([`released_message`]).
115    pub message: String,
116}
117
118/// What the former holder of a released check-out is told in the working
119/// folder: their local files are not touched, but checking them in needs
120/// the lock again.
121pub 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
125/// The audit actions that take or give back the lock of a revision; the
126/// newest of them says what happened to the lock last.
127const LOCK_ACTIONS: [&str; 4] = [
128    "revision.checkout",
129    "revision.checkin",
130    "revision.checkout_cancel",
131    "revision.checkout_forced_release",
132];
133
134/// The forced release of `actor`'s check-out of `rev`, if that is the last
135/// thing that happened to its lock and it is not checked out now. Read from
136/// the audit log (`details.holder`, written by `revisions::cancel_checkout`),
137/// so it needs no state of its own; releases audited before the holder was
138/// recorded are not reported.
139pub 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
175/// The status of every file of a revision for one workspace. With an empty
176/// `client` list only the server side is returned (`state` = `None`).
177pub 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    // Files only in the workspace.
246    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            // A local file that was checked out from the server but is gone there.
265            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/// Result of the bulk check-in for one revision.
289#[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    /// Why the check-in failed (`ok` = false).
297    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
319/// The comment of every check-in of [`check_in_all`] (shown in the audit log).
320pub const BULK_COMMENT: &str = "Bulk check-in: lock released without new files";
321
322/// Checks in every revision the actor has checked out – or only those of
323/// `only` – **without new files**: the lock is released, the revision keeps
324/// its files. Each revision is checked in in its own transaction with its own
325/// audit record (`revision.checkin`), so one failure does not block the
326/// others; the outcome lists every revision. Clients with a local workspace
327/// (desktop) pass only the revisions without local changes.
328pub 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        // Requested revisions that are not (or no longer) checked out by the actor.
338        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
365/// Most check-outs one [`release_held`] run releases.
366pub const MAX_RELEASE: i64 = 1000;
367
368/// Releases the check-outs `holder` still has – in every group, or only in
369/// `project` – for a leaver (issue #173). Each release goes through
370/// `revisions::cancel_checkout` in its own transaction, so the permission
371/// rule stays in one place, every release is audited
372/// (`revision.checkout_forced_release`) and one refusal does not stop the
373/// others; the outcome lists every revision.
374///
375/// # Invariants
376///
377/// * **Permission:** CAD editor. Administrators release in every group they
378///   can read; anyone else needs to manage `project` ("not found" for a hidden
379///   group, 403 otherwise) or, without `project`, at least one group (403),
380///   and only check-outs in the groups they manage are considered. Per
381///   revision `cancel_checkout` decides: a manager only releases the locks of
382///   users who are no longer members or are deactivated; the rest are listed
383///   as refused (an active member's lock needs a reason, one at a time).
384/// * **Visibility:** revisions the actor may not read are left out, as if
385///   they did not exist (a need-to-know group with `admin_read = none` hides
386///   its revisions from administrators too).
387/// * **Bounds:** at most [`MAX_RELEASE`] revisions per run.
388/// * **Audit / notifications:** those of `cancel_checkout`, per revision.
389/// * **Atomic:** one transaction per revision.
390pub 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
399/// [`release_held`] with a reason for every release: a manager then also
400/// releases the check-outs of an *active* member of their groups (issue #173,
401/// the season handover); the reason is audited and told to the holder.
402///
403/// # Invariants
404///
405/// * **Permission / visibility / bounds / atomic:** as [`release_held`];
406///   per revision `cancel_checkout` decides with the reason.
407/// * **Data:** a reason has `revisions::RELEASE_REASON_MIN` to
408///   `RELEASE_REASON_MAX` characters (400 before anything is released).
409/// * **Audit / notifications:** those of `cancel_checkout`, per revision,
410///   with the reason.
411pub 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
470/// How many revisions `holder` has checked out that the actor may read – the
471/// number [`release_held`] would look at for an administrator.
472pub 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
484/// `"Released 4 check-outs; 1 refused: PRT-000003/A (…)"` for a flash message.
485pub 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
514/// `"Checked in 4 revisions; 1 failed: PRT-000003/A (…)"` for a flash message.
515pub 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}