1use serde::Serialize;
20use sqlx::PgConnection;
21use uuid::Uuid;
22
23use crate::error::AppResult;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Problem {
29 Unreleased,
31 Obsolete,
33}
34
35#[derive(Clone, Debug, Serialize)]
37pub struct Dependency {
38 pub revision_id: Uuid,
40 pub revision_label: String,
41 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 pub other_change: Option<String>,
51 pub problem: Problem,
52 pub blocking: bool,
54 pub addable: bool,
56 pub restricted: bool,
59 #[serde(skip)]
61 pub revision_restricted: bool,
62 #[serde(skip)]
65 pub project_id: Option<Uuid>,
66 #[serde(skip)]
67 pub revision_project_id: Option<Uuid>,
68 #[serde(skip)]
70 pub other_change_access: Option<ChangeAccess>,
71}
72
73#[derive(Clone, Debug, Default)]
77pub struct ChangeAccess {
78 pub created_by: Uuid,
79 pub reviewers: Vec<Uuid>,
80 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
94pub const OTHER_CHANGE: &str = "another open change order";
96
97impl Dependency {
98 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 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
130fn placeholders_last(rows: &mut [Dependency]) {
135 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 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#[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 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 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 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
269fn 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
306async 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
340pub 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 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 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 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}