Skip to main content

teamcraft/services/
readiness.rs

1//! Release readiness of a change order.
2//!
3//! A change order releases its revisions atomically, like a release
4//! candidate that carries a whole product. Before it goes into review, every
5//! revision in it is checked against what it depends on:
6//!
7//! * **BOM children and item references** (drawing, specification,
8//!   reference) must be released already or be part of the same change
9//!   order. Otherwise the released assembly could not be reproduced, so this
10//!   **blocks** the submit.
11//! * **CAD file references** come from a heuristic scan of the files
12//!   (`cadrefs`). An unreleased item found that way is only a **warning**.
13//! * A dependency whose revisions are all **obsolete** is a **warning**: the
14//!   design still uses a part that must not be used any more.
15//!
16//! Dependencies that are released already were checked when they were
17//! released, so one level per revision is enough.
18
19use serde::Serialize;
20use sqlx::PgConnection;
21use uuid::Uuid;
22
23use crate::error::AppResult;
24
25/// Why a dependency is not ready for the release.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Problem {
29    /// No revision is released and none is part of this change order.
30    Unreleased,
31    /// Every revision of the item is obsolete.
32    Obsolete,
33}
34
35/// An item a revision of the change order depends on and that is not ready.
36#[derive(Clone, Debug, Serialize)]
37pub struct Dependency {
38    /// The revision of the change order that depends on the item.
39    pub revision_id: Uuid,
40    pub revision_label: String,
41    /// `bom`, `drawing`, `specification`, `reference` or `file`.
42    pub relation: String,
43    pub item_id: Uuid,
44    pub item_number: String,
45    pub item_name: String,
46    pub latest_revision_id: Uuid,
47    pub latest_code: String,
48    pub latest_state: String,
49    /// Number of the other open change order its latest revision is part of.
50    pub other_change: Option<String>,
51    pub problem: Problem,
52    /// The dependency prevents the submit.
53    pub blocking: bool,
54    /// Its latest revision can be added to this change order.
55    pub addable: bool,
56    /// Placeholder for a dependency the reader may not see: it still
57    /// blocks, but its identity is removed.
58    pub restricted: bool,
59    /// The depending revision is hidden from the reader (never sent).
60    #[serde(skip)]
61    pub revision_restricted: bool,
62    /// Project groups of the dependency and of the depending revision (for
63    /// the read check; never sent).
64    #[serde(skip)]
65    pub project_id: Option<Uuid>,
66    #[serde(skip)]
67    pub revision_project_id: Option<Uuid>,
68    /// Who may see the other open change (for the read check; never sent).
69    #[serde(skip)]
70    pub other_change_access: Option<ChangeAccess>,
71}
72
73/// What decides whether a reader sees a change order (see
74/// `access::change_visible`), loaded for the other open changes named in
75/// the result.
76#[derive(Clone, Debug, Default)]
77pub struct ChangeAccess {
78    pub created_by: Uuid,
79    pub reviewers: Vec<Uuid>,
80    /// The groups of its items (`None` = items without a group).
81    pub projects: Vec<Option<Uuid>>,
82}
83
84impl ChangeAccess {
85    fn visible_to(&self, actor: &crate::auth::Actor) -> bool {
86        (actor.is_admin() && self.projects.iter().all(|p| actor.can_see_project(*p)))
87            || self.created_by == actor.id
88            || self.reviewers.contains(&actor.id)
89            || (!self.projects.is_empty()
90                && self.projects.iter().all(|p| actor.can_see_project(*p)))
91    }
92}
93
94/// Text that stands for an open change order the reader may not see.
95pub const OTHER_CHANGE: &str = "another open change order";
96
97impl Dependency {
98    /// The depending revision of the change order is hidden from the reader.
99    fn mask_revision(&mut self) {
100        self.revision_id = Uuid::nil();
101        self.revision_label = crate::services::access::RESTRICTED_LABEL.into();
102        self.revision_restricted = true;
103    }
104
105    /// The other open change is named only to readers who may see it.
106    fn mask_other_change(&mut self, actor: &crate::auth::Actor) {
107        if self.other_change.is_some()
108            && !self
109                .other_change_access
110                .as_ref()
111                .is_some_and(|a| a.visible_to(actor))
112        {
113            self.other_change = Some(OTHER_CHANGE.into());
114        }
115    }
116
117    fn mask(&mut self) {
118        self.item_id = Uuid::nil();
119        self.item_number = crate::services::access::RESTRICTED_LABEL.into();
120        self.item_name = String::new();
121        self.latest_revision_id = Uuid::nil();
122        self.latest_code = String::new();
123        self.latest_state = String::new();
124        self.other_change = None;
125        self.addable = false;
126        self.restricted = true;
127    }
128}
129
130/// Moves masked rows behind the visible ones (see [`Readiness::redact`]).
131/// The sort is stable, so the visible rows keep their order; rows of a
132/// hidden depending revision and placeholders are ordered by what the
133/// reader sees only, so their order says nothing about hidden numbers.
134fn placeholders_last(rows: &mut [Dependency]) {
135    // the place of each visible depending revision in the checked order
136    let mut first: std::collections::HashMap<Uuid, usize> = std::collections::HashMap::new();
137    for (i, d) in rows.iter().enumerate() {
138        first.entry(d.revision_id).or_insert(i);
139    }
140    rows.sort_by_cached_key(|d| {
141        if d.revision_restricted {
142            (
143                true,
144                0,
145                d.restricted,
146                d.item_number.clone(),
147                d.relation.clone(),
148            )
149        } else {
150            // placeholders by what they show: their relation
151            let relation = if d.restricted {
152                d.relation.clone()
153            } else {
154                String::new()
155            };
156            (
157                false,
158                first[&d.revision_id],
159                d.restricted,
160                String::new(),
161                relation,
162            )
163        }
164    });
165}
166
167/// Result of the readiness check.
168#[derive(Clone, Debug, Default, Serialize)]
169pub struct Readiness {
170    pub blockers: Vec<Dependency>,
171    pub warnings: Vec<Dependency>,
172}
173
174impl Readiness {
175    pub fn is_ready(&self) -> bool {
176        self.blockers.is_empty()
177    }
178
179    /// Removes what the actor may not see: blockers on hidden items become
180    /// placeholders that still block; warnings on hidden items (heuristic
181    /// file-name matches, obsolete items) are dropped. Hidden depending
182    /// revisions lose their label, and another open change the actor may not
183    /// see is not named.
184    ///
185    /// The check sorts by the item numbers of the depending revision and of
186    /// the dependency, so a placeholder left in place would bound the hidden
187    /// number between its visible neighbours. Rows of hidden depending
188    /// revisions therefore go last (ordered by what the reader sees), and
189    /// within each depending revision the hidden dependencies follow the
190    /// visible ones.
191    pub fn redact(mut self, actor: &crate::auth::Actor) -> Self {
192        self.warnings
193            .retain(|d| actor.can_see_project(d.project_id));
194        for d in self.warnings.iter_mut().chain(self.blockers.iter_mut()) {
195            if !actor.can_see_project(d.revision_project_id) {
196                d.mask_revision();
197            }
198            if actor.can_see_project(d.project_id) {
199                d.mask_other_change(actor);
200            } else {
201                d.mask();
202            }
203        }
204        placeholders_last(&mut self.blockers);
205        placeholders_last(&mut self.warnings);
206        self
207    }
208
209    /// Latest revisions of the blocking dependencies that can be added to
210    /// the change order (each revision once).
211    pub fn addable_blockers(&self) -> Vec<Uuid> {
212        let mut ids: Vec<Uuid> = self
213            .blockers
214            .iter()
215            .filter(|d| d.addable)
216            .map(|d| d.latest_revision_id)
217            .collect();
218        ids.sort();
219        ids.dedup();
220        ids
221    }
222
223    /// One sentence for an error message, e.g. when submitting.
224    pub fn blocker_summary(&self) -> String {
225        let names: Vec<String> = self
226            .blockers
227            .iter()
228            .take(5)
229            .map(|d| {
230                if d.restricted {
231                    return format!(
232                        "a restricted dependency ({} of {}) – ask a member of its group or an admin",
233                        relation_label(&d.relation),
234                        d.revision_label
235                    );
236                }
237                let place = match &d.other_change {
238                    Some(c) => format!("in {c}"),
239                    None => d.latest_state.replace('_', " "),
240                };
241                format!(
242                    "{}/{} ({} of {}, {place})",
243                    d.item_number,
244                    d.latest_code,
245                    relation_label(&d.relation),
246                    d.revision_label
247                )
248            })
249            .collect();
250        let more = self.blockers.len().saturating_sub(names.len());
251        let mut text = names.join(", ");
252        if more > 0 {
253            text.push_str(&format!(" and {more} more"));
254        }
255        text
256    }
257}
258
259fn relation_label(relation: &str) -> &'static str {
260    match relation {
261        "bom" => "BOM line",
262        "drawing" => "drawing",
263        "specification" => "specification",
264        "file" => "CAD file reference",
265        _ => "reference",
266    }
267}
268
269/// Classifies a dependency. `None` = it is ready (a revision is released).
270fn classify(
271    relation: &str,
272    has_released: bool,
273    latest_state: &str,
274    other_change: Option<&str>,
275) -> Option<(Problem, bool, bool)> {
276    if has_released {
277        return None;
278    }
279    if latest_state == "obsolete" {
280        return Some((Problem::Obsolete, false, false));
281    }
282    let blocking = relation != "file";
283    let addable = latest_state == "in_work" && other_change.is_none();
284    Some((Problem::Unreleased, blocking, addable))
285}
286
287#[derive(sqlx::FromRow)]
288struct Row {
289    revision_id: Uuid,
290    revision_number: String,
291    revision_code: String,
292    relation: String,
293    item_id: Uuid,
294    item_number: String,
295    item_name: String,
296    latest_revision_id: Uuid,
297    latest_code: String,
298    latest_state: String,
299    has_released: bool,
300    other_change: Option<String>,
301    other_change_id: Option<Uuid>,
302    project_id: Option<Uuid>,
303    revision_project_id: Option<Uuid>,
304}
305
306/// Author, reviewers and item groups of the given changes.
307async fn change_access(
308    conn: &mut PgConnection,
309    ids: &[Uuid],
310) -> AppResult<std::collections::HashMap<Uuid, ChangeAccess>> {
311    if ids.is_empty() {
312        return Ok(Default::default());
313    }
314    let rows = sqlx::query_as::<_, (Uuid, Uuid, Vec<Uuid>, Vec<Option<Uuid>>)>(
315        "SELECT c.id, c.created_by,
316                ARRAY(SELECT cr.reviewer_id FROM change_reviews cr WHERE cr.change_id = c.id),
317                ARRAY(SELECT DISTINCT i.project_id FROM change_items ci
318                      JOIN revisions r ON r.id = ci.revision_id JOIN items i ON i.id = r.item_id
319                      WHERE ci.change_id = c.id)
320         FROM changes c WHERE c.id = ANY($1)",
321    )
322    .bind(ids)
323    .fetch_all(&mut *conn)
324    .await?;
325    Ok(rows
326        .into_iter()
327        .map(|(id, created_by, reviewers, projects)| {
328            (
329                id,
330                ChangeAccess {
331                    created_by,
332                    reviewers,
333                    projects,
334                },
335            )
336        })
337        .collect())
338}
339
340/// Checks the dependencies of all revisions of a change order. One query,
341/// usable inside the transaction that submits the change.
342pub async fn check(conn: &mut PgConnection, change_id: Uuid) -> AppResult<Readiness> {
343    let rows = sqlx::query_as::<_, Row>(
344        "WITH cr AS (
345             SELECT ci.revision_id, r.item_id
346             FROM change_items ci JOIN revisions r ON r.id = ci.revision_id
347             WHERE ci.change_id = $1 AND ci.action = 'release'
348         ),
349         deps AS (
350             SELECT b.parent_revision_id AS revision_id, b.child_item_id AS item_id, 'bom'::text AS relation
351             FROM bom_lines b JOIN cr ON cr.revision_id = b.parent_revision_id
352             UNION
353             SELECT ir.from_revision_id, ir.to_item_id, ir.kind
354             FROM item_references ir JOIN cr ON cr.revision_id = ir.from_revision_id
355             UNION
356             SELECT f.revision_id, r2.item_id, 'file'
357             FROM files f
358             JOIN cr ON cr.revision_id = f.revision_id
359             JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
360             JOIN file_references fr ON fr.file_version_id = fv.id
361             JOIN files f2 ON lower(f2.filename) = lower(fr.name)
362             JOIN revisions r2 ON r2.id = f2.revision_id
363             WHERE r2.item_id <> cr.item_id
364               AND r2.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r2.item_id)
365         ),
366         -- An item found both in the BOM and by the file scan counts once,
367         -- with the stronger relation.
368         one AS (
369             SELECT DISTINCT ON (revision_id, item_id) revision_id, item_id, relation
370             FROM deps ORDER BY revision_id, item_id, (relation = 'file'), relation
371         )
372         SELECT d.revision_id, pi.number AS revision_number, pr.code AS revision_code, d.relation,
373                i.id AS item_id, i.number AS item_number, lr.name AS item_name,
374                i.project_id, pi.project_id AS revision_project_id,
375                lr.id AS latest_revision_id, lr.code AS latest_code, lr.state AS latest_state,
376                EXISTS (SELECT 1 FROM revisions x WHERE x.item_id = i.id AND x.state = 'released')
377                    AS has_released,
378                (SELECT c.number FROM change_items ci
379                   JOIN changes c ON c.id = ci.change_id
380                   JOIN revisions x ON x.id = ci.revision_id
381                 WHERE x.item_id = i.id AND c.id <> $1
382                   AND c.status IN ('draft', 'in_review', 'rejected')
383                 ORDER BY c.number LIMIT 1) AS other_change,
384                (SELECT c.id FROM change_items ci
385                   JOIN changes c ON c.id = ci.change_id
386                   JOIN revisions x ON x.id = ci.revision_id
387                 WHERE x.item_id = i.id AND c.id <> $1
388                   AND c.status IN ('draft', 'in_review', 'rejected')
389                 ORDER BY c.number LIMIT 1) AS other_change_id
390         FROM one d
391         JOIN revisions pr ON pr.id = d.revision_id
392         JOIN items pi ON pi.id = pr.item_id
393         JOIN items i ON i.id = d.item_id
394         CROSS JOIN LATERAL (
395             SELECT r.id, r.code, r.state, r.name FROM revisions r
396             WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
397         ) lr
398         WHERE NOT EXISTS (SELECT 1 FROM cr WHERE cr.item_id = d.item_id)
399         ORDER BY pi.number, pr.code, i.number",
400    )
401    .bind(change_id)
402    .fetch_all(&mut *conn)
403    .await?;
404    let mut others: Vec<Uuid> = rows.iter().filter_map(|r| r.other_change_id).collect();
405    others.sort();
406    others.dedup();
407    let access = change_access(conn, &others).await?;
408
409    let mut result = Readiness::default();
410    for row in rows {
411        let Some((problem, blocking, addable)) = classify(
412            &row.relation,
413            row.has_released,
414            &row.latest_state,
415            row.other_change.as_deref(),
416        ) else {
417            continue;
418        };
419        let dep = Dependency {
420            revision_id: row.revision_id,
421            revision_label: format!("{}/{}", row.revision_number, row.revision_code),
422            relation: row.relation,
423            item_id: row.item_id,
424            item_number: row.item_number,
425            item_name: row.item_name,
426            latest_revision_id: row.latest_revision_id,
427            latest_code: row.latest_code,
428            latest_state: row.latest_state,
429            other_change: row.other_change,
430            problem,
431            blocking,
432            addable,
433            restricted: false,
434            revision_restricted: false,
435            project_id: row.project_id,
436            revision_project_id: row.revision_project_id,
437            other_change_access: row.other_change_id.and_then(|id| access.get(&id).cloned()),
438        };
439        if blocking {
440            result.blockers.push(dep);
441        } else {
442            result.warnings.push(dep);
443        }
444    }
445    Ok(result)
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn released_dependencies_are_ready() {
454        assert_eq!(classify("bom", true, "in_work", None), None);
455        assert_eq!(classify("file", true, "obsolete", None), None);
456    }
457
458    #[test]
459    fn unreleased_bom_and_references_block() {
460        for rel in ["bom", "drawing", "specification", "reference"] {
461            assert_eq!(
462                classify(rel, false, "in_work", None),
463                Some((Problem::Unreleased, true, true)),
464                "{rel}"
465            );
466        }
467        // In review elsewhere: blocks, but cannot be added here.
468        assert_eq!(
469            classify("bom", false, "in_review", Some("CHG-000002")),
470            Some((Problem::Unreleased, true, false))
471        );
472        assert_eq!(
473            classify("bom", false, "in_work", Some("CHG-000002")),
474            Some((Problem::Unreleased, true, false))
475        );
476    }
477
478    #[test]
479    fn file_references_and_obsolete_items_only_warn() {
480        assert_eq!(
481            classify("file", false, "in_work", None),
482            Some((Problem::Unreleased, false, true))
483        );
484        assert_eq!(
485            classify("bom", false, "obsolete", None),
486            Some((Problem::Obsolete, false, false))
487        );
488    }
489
490    fn dep(number: &str, relation: &str, addable: bool, latest: Uuid) -> Dependency {
491        Dependency {
492            revision_id: Uuid::nil(),
493            revision_label: "ASM-000001/A".into(),
494            relation: relation.into(),
495            item_id: Uuid::nil(),
496            item_number: number.into(),
497            item_name: String::new(),
498            latest_revision_id: latest,
499            latest_code: "A".into(),
500            latest_state: "in_work".into(),
501            other_change: None,
502            problem: Problem::Unreleased,
503            blocking: true,
504            addable,
505            restricted: false,
506            revision_restricted: false,
507            project_id: None,
508            revision_project_id: None,
509            other_change_access: None,
510        }
511    }
512
513    #[test]
514    fn redact_masks_hidden_revisions_and_other_changes() {
515        use crate::{auth::Actor, domain::Role};
516        let secret = Uuid::new_v4();
517        let reader = Actor::test(Role::Editor, vec![], vec![secret]);
518        let mut warning = dep("PRT-000002", "file", false, Uuid::new_v4());
519        warning.blocking = false;
520        warning.revision_label = "SEC-000001/A".into();
521        warning.revision_project_id = Some(secret);
522        let mut blocker = dep("PRT-000003", "bom", false, Uuid::new_v4());
523        blocker.other_change = Some("CHG-000061".into());
524        blocker.other_change_access = Some(ChangeAccess {
525            created_by: Uuid::new_v4(),
526            reviewers: vec![],
527            projects: vec![None, Some(secret)],
528        });
529        let mut own = dep("PRT-000004", "bom", false, Uuid::new_v4());
530        own.other_change = Some("CHG-000062".into());
531        own.other_change_access = Some(ChangeAccess {
532            created_by: reader.id,
533            reviewers: vec![],
534            projects: vec![Some(secret)],
535        });
536        let r = Readiness {
537            blockers: vec![blocker, own],
538            warnings: vec![warning],
539        }
540        .redact(&reader);
541        assert_eq!(r.warnings[0].revision_label, "restricted item");
542        assert_eq!(r.warnings[0].revision_id, Uuid::nil());
543        assert_eq!(r.blockers[0].other_change.as_deref(), Some(OTHER_CHANGE));
544        assert_eq!(r.blockers[1].other_change.as_deref(), Some("CHG-000062"));
545    }
546
547    #[test]
548    fn redact_puts_placeholders_last() {
549        use crate::{auth::Actor, domain::Role};
550        let secret = Uuid::new_v4();
551        let reader = Actor::test(Role::Editor, vec![], vec![secret]);
552        let asm = Uuid::new_v4();
553        let other = Uuid::new_v4();
554        // as the check sorts them: by depending item, then dependency number
555        let on = |rev: Uuid, label: &str, number: &str, hidden_dep: bool, hidden_rev: bool| {
556            let mut d = dep(number, "bom", true, Uuid::new_v4());
557            d.revision_id = rev;
558            d.revision_label = label.into();
559            if hidden_dep {
560                d.project_id = Some(secret);
561            }
562            if hidden_rev {
563                d.revision_project_id = Some(secret);
564            }
565            d
566        };
567        let hidden_a = Uuid::new_v4();
568        let hidden_b = Uuid::new_v4();
569        let r = Readiness {
570            blockers: vec![
571                on(asm, "ASM-000001/A", "PRT-000010", false, false),
572                on(asm, "ASM-000001/A", "PRT-000057", true, false),
573                on(asm, "ASM-000001/A", "PRT-000099", false, false),
574                on(hidden_a, "ASM-000005/A", "PRT-000030", false, true),
575                on(hidden_b, "ASM-000006/A", "PRT-000020", false, true),
576                on(other, "ASM-000009/A", "PRT-000011", false, false),
577            ],
578            warnings: vec![],
579        }
580        .redact(&reader);
581        let seen: Vec<(String, String)> = r
582            .blockers
583            .iter()
584            .map(|d| (d.revision_label.clone(), d.item_number.clone()))
585            .collect();
586        let pair = |a: &str, b: &str| (a.to_string(), b.to_string());
587        assert_eq!(
588            seen,
589            vec![
590                pair("ASM-000001/A", "PRT-000010"),
591                pair("ASM-000001/A", "PRT-000099"),
592                pair("ASM-000001/A", "restricted item"),
593                pair("ASM-000009/A", "PRT-000011"),
594                // hidden depending revisions: last, by the visible dependency
595                pair("restricted item", "PRT-000020"),
596                pair("restricted item", "PRT-000030"),
597            ]
598        );
599        assert!(r.blocker_summary().contains("PRT-000099/A"));
600    }
601
602    #[test]
603    fn summary_and_addable_list() {
604        let a = Uuid::new_v4();
605        let b = Uuid::new_v4();
606        let r = Readiness {
607            blockers: vec![
608                dep("PRT-000002", "bom", true, a),
609                dep("PRT-000002", "drawing", true, a),
610                dep("PRT-000003", "bom", false, b),
611            ],
612            warnings: vec![],
613        };
614        assert!(!r.is_ready());
615        assert_eq!(r.addable_blockers(), vec![a]);
616        assert!(r
617            .blocker_summary()
618            .starts_with("PRT-000002/A (BOM line of ASM-000001/A, in work)"));
619        let many = Readiness {
620            blockers: (0..7)
621                .map(|i| dep(&format!("P{i}"), "bom", true, a))
622                .collect(),
623            warnings: vec![],
624        };
625        assert!(many.blocker_summary().ends_with(" and 2 more"));
626    }
627}