1use chrono::{DateTime, Utc};
27use serde::Serialize;
28use serde_json::json;
29use sqlx::{PgConnection, PgPool};
30use uuid::Uuid;
31
32use crate::{
33 auth::Actor,
34 domain::{self, NotificationKind, Role},
35 error::{AppError, AppResult},
36 models::{Impact, Revision},
37 services::{
38 access, audit,
39 notifications::{self, Notice},
40 revisions,
41 },
42};
43
44const IMPACT_SELECT: &str = "
45 SELECT im.id, im.item_id, i.number AS item_number, i.project_id, p.code AS project_code,
46 r.id AS revision_id, r.code AS revision_code, r.state AS revision_state, r.name AS item_name,
47 im.source_item_id, si.number AS source_number, sr.id AS source_revision_id,
48 sr.code AS source_code, sr.name AS source_name, im.event, im.relation, im.detail,
49 im.change_id, c.number AS change_number, im.created_at, im.resolved_at, im.resolution,
50 u.display_name AS resolved_by_name, im.note, si.project_id AS source_project_id
51 FROM impacts im
52 JOIN items i ON i.id = im.item_id
53 LEFT JOIN projects p ON p.id = i.project_id
54 JOIN revisions r ON r.id = im.revision_id
55 JOIN items si ON si.id = im.source_item_id
56 JOIN revisions sr ON sr.id = im.source_revision_id
57 LEFT JOIN changes c ON c.id = im.change_id
58 LEFT JOIN users u ON u.id = im.resolved_by";
59
60const LATEST: &str = "pr.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = pr.item_id)
62 AND pr.state <> 'obsolete'";
63
64pub async fn redact_all(
69 conn: &mut PgConnection,
70 actor: &Actor,
71 rows: Vec<Impact>,
72) -> AppResult<Vec<Impact>> {
73 let mut changes: Vec<Uuid> = rows.iter().filter_map(|i| i.change_id).collect();
74 changes.sort_unstable();
75 changes.dedup();
76 let visible: Vec<Uuid> =
77 if changes.is_empty() || (actor.is_admin() && actor.hidden_projects.is_empty()) {
78 changes
79 } else {
80 sqlx::query_scalar(&format!(
81 "SELECT c.id FROM changes c WHERE c.id = ANY($1) AND {}",
82 access::change_visible("c", 2, 3)
83 ))
84 .bind(&changes)
85 .bind(actor.id)
86 .bind(&actor.hidden_projects)
87 .fetch_all(&mut *conn)
88 .await?
89 };
90 Ok(rows
91 .into_iter()
92 .map(|i| {
93 let mut i = i.redact(actor);
94 if i.change_id.is_some_and(|c| !visible.contains(&c)) {
95 i.hide_change();
96 }
97 i
98 })
99 .collect())
100}
101
102async fn fetch(conn: &mut PgConnection, id: Uuid) -> AppResult<Impact> {
105 let impact = sqlx::query_as::<_, Impact>(&format!("{IMPACT_SELECT} WHERE im.id = $1"))
106 .bind(id)
107 .fetch_optional(&mut *conn)
108 .await?
109 .ok_or_else(|| AppError::not_found("Impact not found."))?;
110 Ok(impact)
111}
112
113async fn fetch_visible(conn: &mut PgConnection, actor: &Actor, id: Uuid) -> AppResult<Impact> {
115 let impact = fetch(conn, id).await?;
116 access::ensure_visible(
117 actor,
118 impact.project_id,
119 AppError::not_found("Impact not found."),
120 )?;
121 Ok(impact)
122}
123
124pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Impact> {
126 let mut conn = pool.acquire().await?;
127 let impact = fetch_visible(&mut conn, actor, id).await?;
128 Ok(redact_all(&mut conn, actor, vec![impact]).await?.remove(0))
129}
130
131pub async fn for_item(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<Vec<Impact>> {
134 let mut conn = pool.acquire().await?;
135 let rows = sqlx::query_as::<_, Impact>(&format!(
136 "({IMPACT_SELECT} WHERE im.item_id = $1 AND im.resolved_at IS NULL ORDER BY im.created_at DESC)
137 UNION ALL
138 ({IMPACT_SELECT} WHERE im.item_id = $1 AND im.resolved_at IS NOT NULL
139 ORDER BY im.resolved_at DESC LIMIT 20)"
140 ))
141 .bind(item_id)
142 .fetch_all(&mut *conn)
143 .await?;
144 if let Some(first) = rows.first() {
145 access::ensure_visible(actor, first.project_id, access::not_found_item())?;
146 }
147 redact_all(&mut conn, actor, rows).await
148}
149
150pub async fn open_for(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Impact>> {
153 if !actor.can_edit() {
154 return Ok(Vec::new());
155 }
156 let mut conn = pool.acquire().await?;
157 let projects: Vec<Uuid> = actor.projects.to_vec();
158 let rows = sqlx::query_as::<_, Impact>(&format!(
159 "{IMPACT_SELECT} WHERE im.resolved_at IS NULL AND ($1 OR i.project_id = ANY($2))
160 AND {}
161 ORDER BY im.created_at DESC LIMIT 200",
162 access::item_visible("i", 3)
163 ))
164 .bind(actor.is_admin())
165 .bind(&projects)
166 .bind(&actor.hidden_projects)
167 .fetch_all(&mut *conn)
168 .await?;
169 redact_all(&mut conn, actor, rows).await
170}
171
172pub async fn acknowledge(pool: &PgPool, actor: &Actor, id: Uuid, note: &str) -> AppResult<Impact> {
185 actor.require(Role::Editor)?;
186 let note = domain::require_text("Note", note, 2000).map_err(|_| {
187 AppError::BadRequest(
188 "Say why the dependent needs no change (e.g. form, fit and function unchanged).".into(),
189 )
190 })?;
191 let impact = {
194 let mut conn = pool.acquire().await?;
195 fetch_visible(&mut conn, actor, id).await?
196 };
197 if impact.resolved_at.is_some() {
198 return Err(AppError::Conflict(format!(
199 "This impact on {} is already resolved.",
200 impact.item_number
201 )));
202 }
203 if !actor.can_edit_project(impact.project_id) {
204 return Err(AppError::Forbidden(format!(
205 "Only CAD editors of the project group {} (or admins) can acknowledge impacts on {}.",
206 impact.project_code.as_deref().unwrap_or("—"),
207 impact.item_number
208 )));
209 }
210 let mut tx = pool.begin().await?;
211 let updated = sqlx::query(
212 "UPDATE impacts SET resolved_at = now(), resolved_by = $2, resolution = 'acknowledged', note = $3
213 WHERE id = $1 AND resolved_at IS NULL",
214 )
215 .bind(id)
216 .bind(actor.id)
217 .bind(¬e)
218 .execute(&mut *tx)
219 .await?
220 .rows_affected();
221 if updated == 0 {
222 return Err(AppError::Conflict(
223 "The impact was resolved in the meantime.".into(),
224 ));
225 }
226 audit::record_related(
227 &mut tx,
228 Some(actor),
229 "impact.acknowledge",
230 "item",
231 Some(impact.item_id),
232 Some(impact.source_item_id),
233 format!(
234 "{} checked against {}/{} ({}): {note}",
235 impact.item_number, impact.source_number, impact.source_code, impact.event
236 ),
237 json!({ "impact_id": id, "source_revision_id": impact.source_revision_id }),
238 )
239 .await?;
240 tx.commit().await?;
241 get(pool, actor, id).await
242}
243
244#[derive(Clone, Debug, Serialize)]
246pub struct Acknowledged {
247 pub item_number: String,
248 pub revision_code: String,
249 pub acknowledged: Vec<Impact>,
251 pub skipped: usize,
253}
254
255pub async fn acknowledge_all(
271 pool: &PgPool,
272 actor: &Actor,
273 revision_id: Uuid,
274 only: Option<&[Uuid]>,
275 note: &str,
276) -> AppResult<Acknowledged> {
277 actor.require(Role::Editor)?;
278 let note = domain::require_text("Note", note, 2000).map_err(|_| {
279 AppError::BadRequest(
280 "Say why the dependent needs no change (e.g. form, fit and function unchanged).".into(),
281 )
282 })?;
283 let rev = revisions::get(pool, actor, revision_id).await?;
284 if !actor.can_edit_project(rev.project_id) {
285 return Err(AppError::Forbidden(format!(
286 "Only CAD editors of the project group {} (or admins) can acknowledge impacts on {}.",
287 rev.project_code.as_deref().unwrap_or("—"),
288 rev.item_number
289 )));
290 }
291 let wanted = only.map(|o| {
294 let mut wanted = o.to_vec();
295 wanted.sort_unstable();
296 wanted.dedup();
297 wanted
298 });
299 if wanted.as_ref().is_some_and(Vec::is_empty) {
300 return Err(AppError::BadRequest(
301 "List the impacts to acknowledge (or leave the list out for all).".into(),
302 ));
303 }
304 let mut tx = pool.begin().await?;
305 if let Some(wanted) = &wanted {
306 let known = sqlx::query_scalar::<_, i64>(
309 "SELECT count(*) FROM impacts WHERE revision_id = $1 AND id = ANY($2)",
310 )
311 .bind(revision_id)
312 .bind(wanted)
313 .fetch_one(&mut *tx)
314 .await?;
315 if known != wanted.len() as i64 {
316 return Err(AppError::BadRequest(format!(
317 "Only impacts on {} can be acknowledged here.",
318 revisions::label(&rev)
319 )));
320 }
321 }
322 let ids = sqlx::query_scalar::<_, Uuid>(
323 "SELECT id FROM impacts
324 WHERE revision_id = $1 AND resolved_at IS NULL AND ($2::uuid[] IS NULL OR id = ANY($2))
325 ORDER BY created_at
326 FOR UPDATE",
327 )
328 .bind(revision_id)
329 .bind(wanted.as_deref())
330 .fetch_all(&mut *tx)
331 .await?;
332 if ids.is_empty() {
333 return Err(AppError::Conflict(format!(
334 "The checks on {} were resolved in the meantime.",
335 revisions::label(&rev)
336 )));
337 }
338 let skipped = wanted.map_or(0, |w| w.len().saturating_sub(ids.len()));
339 sqlx::query(
340 "UPDATE impacts SET resolved_at = now(), resolved_by = $2, resolution = 'acknowledged', note = $3
341 WHERE id = ANY($1) AND resolved_at IS NULL",
342 )
343 .bind(&ids)
344 .bind(actor.id)
345 .bind(¬e)
346 .execute(&mut *tx)
347 .await?;
348 let acknowledged = sqlx::query_as::<_, Impact>(&format!(
350 "{IMPACT_SELECT} WHERE im.id = ANY($1) ORDER BY si.number, im.id"
351 ))
352 .bind(&ids)
353 .fetch_all(&mut *tx)
354 .await?;
355 for impact in &acknowledged {
356 audit::record_related(
357 &mut tx,
358 Some(actor),
359 "impact.acknowledge",
360 "item",
361 Some(impact.item_id),
362 Some(impact.source_item_id),
363 format!(
364 "{} checked against {}/{} ({}): {note}",
365 impact.item_number, impact.source_number, impact.source_code, impact.event
366 ),
367 json!({ "impact_id": impact.id, "source_revision_id": impact.source_revision_id }),
368 )
369 .await?;
370 }
371 let mut acknowledged = redact_all(&mut tx, actor, acknowledged).await?;
372 acknowledged.sort_by_key(|i| i.source_restricted);
374 tx.commit().await?;
375 Ok(Acknowledged {
376 item_number: rev.item_number,
377 revision_code: rev.code,
378 acknowledged,
379 skipped,
380 })
381}
382
383#[derive(Clone, Debug, Serialize)]
390pub struct DependentGroup {
391 pub item_id: Uuid,
392 pub item_number: String,
393 pub item_name: String,
394 pub project_id: Option<Uuid>,
395 pub project_code: Option<String>,
396 pub revision_id: Uuid,
397 pub revision_code: String,
398 pub revision_state: String,
399 pub latest_at: DateTime<Utc>,
401 pub impact_ids: String,
404 pub impacts: Vec<Impact>,
406}
407
408pub fn group_by_dependent(impacts: Vec<Impact>) -> Vec<DependentGroup> {
412 let mut groups: Vec<DependentGroup> = Vec::new();
413 for impact in impacts {
414 if let Some(g) = groups
415 .iter_mut()
416 .find(|g| g.revision_id == impact.revision_id)
417 {
418 g.latest_at = g.latest_at.max(impact.created_at);
419 g.impacts.push(impact);
420 continue;
421 }
422 groups.push(DependentGroup {
423 item_id: impact.item_id,
424 item_number: impact.item_number.clone(),
425 item_name: impact.item_name.clone(),
426 project_id: impact.project_id,
427 project_code: impact.project_code.clone(),
428 revision_id: impact.revision_id,
429 revision_code: impact.revision_code.clone(),
430 revision_state: impact.revision_state.clone(),
431 latest_at: impact.created_at,
432 impact_ids: String::new(),
433 impacts: vec![impact],
434 });
435 }
436 for g in &mut groups {
437 g.impacts.sort_by(|a, b| {
439 (a.source_restricted, &a.source_number, &a.source_code).cmp(&(
440 b.source_restricted,
441 &b.source_number,
442 &b.source_code,
443 ))
444 });
445 g.impact_ids = g
446 .impacts
447 .iter()
448 .map(|i| i.id.to_string())
449 .collect::<Vec<_>>()
450 .join(",");
451 }
452 groups
453}
454
455pub fn parse_ids(text: &str) -> AppResult<Option<Vec<Uuid>>> {
458 let ids = text
459 .split(',')
460 .map(str::trim)
461 .filter(|s| !s.is_empty())
462 .map(|s| {
463 Uuid::parse_str(s).map_err(|_| AppError::BadRequest("Invalid impact list.".into()))
464 })
465 .collect::<AppResult<Vec<_>>>()?;
466 Ok((!ids.is_empty()).then_some(ids))
467}
468
469async fn flag_dependents(
472 conn: &mut PgConnection,
473 source: &Revision,
474 event: &str,
475 change_id: Option<Uuid>,
476 exclude: &[Uuid],
477) -> AppResult<Vec<Uuid>> {
478 const UPSERT: &str = "
480 ON CONFLICT (item_id, source_item_id) WHERE resolved_at IS NULL
481 DO UPDATE SET revision_id = EXCLUDED.revision_id, source_revision_id = EXCLUDED.source_revision_id,
482 event = EXCLUDED.event, relation = EXCLUDED.relation, detail = EXCLUDED.detail,
483 change_id = EXCLUDED.change_id, created_at = now()
484 RETURNING item_id";
485 let by_file = format!(
487 "INSERT INTO impacts (id, item_id, revision_id, source_item_id, source_revision_id, event,
488 relation, detail, change_id)
489 SELECT DISTINCT ON (pr.item_id) gen_random_uuid(), pr.item_id, pr.id, $1, $2, $3, 'file',
490 df.filename || ' references ' || sf.filename, $4
491 FROM files sf
492 JOIN file_references fr ON lower(fr.name) = lower(sf.filename)
493 JOIN file_versions dv ON dv.id = fr.file_version_id
494 JOIN files df ON df.id = dv.file_id AND dv.version = df.current_version
495 JOIN revisions pr ON pr.id = df.revision_id
496 JOIN items di ON di.id = pr.item_id
497 WHERE sf.revision_id = $2 AND pr.item_id <> $1 AND {LATEST} AND NOT (pr.item_id = ANY($5))
498 -- a file-name match alone never crosses the boundary of a
499 -- restricted project group (it would tell one side about the other)
500 AND (di.project_id IS NOT DISTINCT FROM (SELECT si.project_id FROM items si WHERE si.id = $1)
501 OR NOT EXISTS (SELECT 1 FROM projects xp
502 WHERE xp.visibility = 'restricted'
503 AND (xp.id = di.project_id
504 OR xp.id = (SELECT si.project_id FROM items si WHERE si.id = $1))))
505 ORDER BY pr.item_id, df.filename
506 {UPSERT}"
507 );
508 let by_reference = format!(
510 "INSERT INTO impacts (id, item_id, revision_id, source_item_id, source_revision_id, event,
511 relation, detail, change_id)
512 SELECT DISTINCT ON (pr.item_id) gen_random_uuid(), pr.item_id, pr.id, $1, $2, $3, ir.kind,
513 ir.note, $4
514 FROM item_references ir
515 JOIN revisions pr ON pr.id = ir.from_revision_id
516 WHERE ir.to_item_id = $1 AND {LATEST} AND NOT (pr.item_id = ANY($5))
517 ORDER BY pr.item_id, ir.kind
518 {UPSERT}"
519 );
520 let by_simulation = format!(
524 "INSERT INTO impacts (id, item_id, revision_id, source_item_id, source_revision_id, event,
525 relation, detail, change_id)
526 SELECT DISTINCT ON (pr.item_id) gen_random_uuid(), pr.item_id, pr.id, $1, $2, $3, 'simulation',
527 'based on ' || br.code ||
528 CASE WHEN sb.file_name <> '' THEN ', ' || sb.file_name || ' v' || sb.file_version ELSE '' END,
529 $4
530 FROM simulation_bases sb
531 JOIN revisions br ON br.id = sb.basis_revision_id
532 JOIN revisions pr ON pr.id = sb.revision_id
533 LEFT JOIN file_versions fv ON fv.id = sb.file_version_id
534 LEFT JOIN files f ON f.id = fv.file_id
535 WHERE br.item_id = $1 AND {LATEST} AND NOT (pr.item_id = ANY($5))
536 AND ($3 = 'obsolete' OR br.id <> $2
537 OR (sb.file_name <> '' AND (fv.id IS NULL OR fv.version <> f.current_version)))
538 ORDER BY pr.item_id, sb.role
539 {UPSERT}"
540 );
541 let by_bom = format!(
543 "INSERT INTO impacts (id, item_id, revision_id, source_item_id, source_revision_id, event,
544 relation, detail, change_id)
545 SELECT DISTINCT ON (pr.item_id) gen_random_uuid(), pr.item_id, pr.id, $1, $2, $3, 'bom',
546 'find no. ' || bl.find_number, $4
547 FROM bom_lines bl
548 JOIN revisions pr ON pr.id = bl.parent_revision_id
549 WHERE bl.child_item_id = $1 AND {LATEST} AND NOT (pr.item_id = ANY($5))
550 ORDER BY pr.item_id, bl.find_number
551 {UPSERT}"
552 );
553 let mut flagged = Vec::new();
554 for sql in [by_file, by_simulation, by_reference, by_bom] {
555 let ids = sqlx::query_scalar::<_, Uuid>(&sql)
556 .bind(source.item_id)
557 .bind(source.id)
558 .bind(event)
559 .bind(change_id)
560 .bind(exclude)
561 .fetch_all(&mut *conn)
562 .await?;
563 for id in ids {
564 if !flagged.contains(&id) {
565 flagged.push(id);
566 }
567 }
568 }
569 Ok(flagged)
570}
571
572async fn record_flags(
574 conn: &mut PgConnection,
575 actor: &Actor,
576 source: &Revision,
577 event: &str,
578 flagged: &[Uuid],
579) -> AppResult<()> {
580 if flagged.is_empty() {
581 return Ok(());
582 }
583 let rows = sqlx::query_as::<_, (Option<Uuid>, Uuid, String)>(
587 "SELECT project_id, id, number FROM items WHERE id = ANY($1)
588 ORDER BY project_id NULLS FIRST, number",
589 )
590 .bind(flagged)
591 .fetch_all(&mut *conn)
592 .await?;
593 let mut groups: Vec<(Option<Uuid>, Vec<Uuid>, Vec<String>)> = Vec::new();
594 for (project, id, number) in rows {
595 match groups.last_mut() {
596 Some((p, ids, numbers)) if *p == project => {
597 ids.push(id);
598 numbers.push(number);
599 }
600 _ => groups.push((project, vec![id], vec![number])),
601 }
602 }
603 for (_, ids, numbers) in groups {
604 audit::record_mentioning(
605 conn,
606 Some(actor),
607 "impact.flag",
608 "revision",
609 Some(source.id),
610 &audit::Mentions::items(ids.iter().copied()),
611 format!(
612 "{} {event}: check {}",
613 revisions::label(source),
614 numbers.join(", ")
615 ),
616 json!({ "item_id": source.item_id, "dependents": ids }),
617 )
618 .await?;
619 }
620 notify_flagged(conn, actor, source, flagged).await
621}
622
623async fn notify_flagged(
626 conn: &mut PgConnection,
627 actor: &Actor,
628 source: &Revision,
629 flagged: &[Uuid],
630) -> AppResult<()> {
631 #[derive(sqlx::FromRow)]
632 struct Row {
633 item_id: Uuid,
634 number: String,
635 item_creator: Uuid,
636 revision_id: Uuid,
637 code: String,
638 revision_creator: Uuid,
639 event: String,
640 relation: String,
641 detail: String,
642 }
643 let rows = sqlx::query_as::<_, Row>(
644 "SELECT i.id AS item_id, i.number, i.created_by AS item_creator, lr.id AS revision_id,
645 lr.code, lr.created_by AS revision_creator, im.event, im.relation, im.detail
646 FROM impacts im
647 JOIN items i ON i.id = im.item_id
648 CROSS JOIN LATERAL (SELECT r.id, r.code, r.created_by FROM revisions r
649 WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1) lr
650 WHERE im.source_item_id = $1 AND im.item_id = ANY($2) AND im.resolved_at IS NULL",
651 )
652 .bind(source.item_id)
653 .bind(flagged)
654 .fetch_all(&mut *conn)
655 .await?;
656 for row in rows {
657 let mut recipients = notifications::watchers(conn, "item", &[row.item_id]).await?;
658 recipients.extend([row.item_creator, row.revision_creator]);
659 let how = match row.relation.as_str() {
660 "bom" => "uses it in its BOM",
661 "drawing" => "is a drawing of it",
662 "specification" => "is a specification for it",
663 "reference" => "references it",
664 "simulation" => "is a simulation based on it",
665 _ => "has a CAD file that references its files",
666 };
667 let notice = Notice::revision_ref(
668 NotificationKind::Impact,
669 row.revision_id,
670 row.item_id,
671 &row.number,
672 &row.code,
673 format!(
674 "Check needed: {} – {} {}",
675 row.number,
676 revisions::label(source),
677 if row.event == "released" {
678 "was released"
679 } else {
680 "is obsolete"
681 }
682 ),
683 format!(
684 "{} {how}{}. Acknowledge with a note if it needs no change, or revise it.",
685 row.number,
686 if row.detail.is_empty() {
687 String::new()
688 } else {
689 format!(" ({})", row.detail)
690 }
691 ),
692 )
693 .redacted_for(
696 source.item_id,
697 format!(
698 "Check needed: {} – a restricted item this depends on {}",
699 row.number,
700 if row.event == "released" {
701 "was released"
702 } else {
703 "is obsolete"
704 }
705 ),
706 format!(
707 "{} {how}. Acknowledge with a note if it needs no change, or revise it.",
708 row.number
709 ),
710 );
711 notifications::notify(conn, Some(actor), &recipients, ¬ice).await?;
712 }
713 Ok(())
714}
715
716pub(crate) async fn on_release(
719 conn: &mut PgConnection,
720 actor: &Actor,
721 change_id: Option<Uuid>,
722 released: &[Uuid],
723) -> AppResult<()> {
724 if released.is_empty() {
725 return Ok(());
726 }
727 sqlx::query(
729 "UPDATE bom_lines bl SET released_child_revision_id = (
730 SELECT r.id FROM revisions r
731 WHERE r.item_id = bl.child_item_id AND r.state = 'released'
732 ORDER BY r.seq DESC LIMIT 1)
733 WHERE bl.parent_revision_id = ANY($1)",
734 )
735 .bind(released)
736 .execute(&mut *conn)
737 .await?;
738 sqlx::query(
739 "UPDATE item_references ir SET released_to_revision_id = (
740 SELECT r.id FROM revisions r
741 WHERE r.item_id = ir.to_item_id AND r.state = 'released'
742 ORDER BY r.seq DESC LIMIT 1)
743 WHERE ir.from_revision_id = ANY($1)",
744 )
745 .bind(released)
746 .execute(&mut *conn)
747 .await?;
748 sqlx::query(
750 "UPDATE impacts im SET resolved_at = now(), resolved_by = $2, resolution = 'superseded',
751 note = 'Released as ' || r.code
752 FROM revisions r
753 WHERE r.id = ANY($1) AND im.item_id = r.item_id AND im.resolved_at IS NULL",
754 )
755 .bind(released)
756 .bind(actor.id)
757 .execute(&mut *conn)
758 .await?;
759 let mut revs = Vec::with_capacity(released.len());
761 for id in released {
762 revs.push(revisions::lock(conn, *id).await?);
763 }
764 let batch: Vec<Uuid> = revs.iter().map(|r| r.item_id).collect();
765 for rev in &revs {
766 let flagged = flag_dependents(conn, rev, "released", change_id, &batch).await?;
767 record_flags(conn, actor, rev, "released", &flagged).await?;
768 }
769 Ok(())
770}
771
772pub(crate) async fn on_obsolete(
775 conn: &mut PgConnection,
776 actor: &Actor,
777 rev: &Revision,
778) -> AppResult<()> {
779 let is_latest = sqlx::query_scalar::<_, bool>(
780 "SELECT $2 = (SELECT MAX(seq) FROM revisions WHERE item_id = $1)",
781 )
782 .bind(rev.item_id)
783 .bind(rev.seq)
784 .fetch_one(&mut *conn)
785 .await?;
786 if !is_latest {
787 return Ok(());
788 }
789 sqlx::query(
790 "UPDATE impacts SET resolved_at = now(), resolved_by = $2, resolution = 'obsolete',
791 note = 'The item is obsolete'
792 WHERE item_id = $1 AND resolved_at IS NULL",
793 )
794 .bind(rev.item_id)
795 .bind(actor.id)
796 .execute(&mut *conn)
797 .await?;
798 let flagged = flag_dependents(conn, rev, "obsolete", None, &[rev.item_id]).await?;
799 record_flags(conn, actor, rev, "is obsolete", &flagged).await
800}
801
802#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
809pub struct Dependent {
810 pub root_item_id: Uuid,
812 pub level: i32,
813 pub item_id: Uuid,
814 pub item_number: String,
815 pub item_name: String,
816 pub revision_id: Uuid,
817 pub revision_code: String,
818 pub revision_state: String,
819 pub relation: String,
821 pub via_number: String,
823 #[sqlx(default)]
825 pub in_change: bool,
826 #[sqlx(default)]
828 #[serde(skip)]
829 pub visible: bool,
830}
831
832const MAX_ANALYSIS_LEVELS: i32 = 12;
834
835fn dependents_sql() -> String {
841 format!(
842 "SELECT e.target, e.dependent, e.relation, {} AS visible
843 FROM (
844 SELECT bl.child_item_id AS target, pr.item_id AS dependent, 'bom'::text AS relation
845 FROM bom_lines bl JOIN revisions pr ON pr.id = bl.parent_revision_id
846 WHERE bl.child_item_id = ANY($1) AND {LATEST}
847 UNION
848 SELECT ir.to_item_id, pr.item_id, ir.kind
849 FROM item_references ir JOIN revisions pr ON pr.id = ir.from_revision_id
850 WHERE ir.to_item_id = ANY($1) AND {LATEST}
851 UNION
852 SELECT br.item_id, pr.item_id, 'simulation'::text
853 FROM revisions br
854 JOIN simulation_bases sb ON sb.basis_revision_id = br.id
855 JOIN revisions pr ON pr.id = sb.revision_id
856 WHERE br.item_id = ANY($1) AND {LATEST}
857 ) e
858 JOIN items di ON di.id = e.dependent
859 ORDER BY 1, 3, 2",
860 access::item_visible("di", 2)
861 )
862}
863
864pub async fn analysis(
875 pool: &PgPool,
876 actor: &Actor,
877 revision_ids: &[Uuid],
878) -> AppResult<(Vec<Dependent>, i64)> {
879 use std::collections::{HashMap, HashSet};
880 if revision_ids.is_empty() {
881 return Ok((Vec::new(), 0));
882 }
883 #[derive(sqlx::FromRow)]
884 struct Root {
885 revision_id: Uuid,
886 item_id: Uuid,
887 visible: bool,
888 }
889 let roots = sqlx::query_as::<_, Root>(&format!(
890 "SELECT r.id AS revision_id, r.item_id, {} AS visible
891 FROM revisions r JOIN items i ON i.id = r.item_id
892 WHERE r.id = ANY($1)",
893 access::item_visible("i", 2)
894 ))
895 .bind(revision_ids)
896 .bind(&actor.hidden_projects)
897 .fetch_all(pool)
898 .await?;
899
900 #[derive(sqlx::FromRow)]
901 struct Edge {
902 target: Uuid,
903 dependent: Uuid,
904 relation: String,
905 visible: bool,
906 }
907 struct Found {
908 root: Uuid,
909 item: Uuid,
910 level: i32,
911 relation: String,
912 via: Uuid,
913 visible: bool,
914 }
915 #[derive(sqlx::FromRow)]
920 struct FileReferrer {
921 source_revision_id: Uuid,
922 item_id: Uuid,
923 }
924 let file_referrers = async {
925 Ok::<_, AppError>(
926 sqlx::query_as::<_, FileReferrer>(&format!(
927 "SELECT DISTINCT ON (sf.revision_id, i.number) sf.revision_id AS source_revision_id,
928 i.id AS item_id
929 FROM files sf
930 JOIN revisions sr ON sr.id = sf.revision_id
931 JOIN file_references fr ON lower(fr.name) = lower(sf.filename)
932 JOIN file_versions dv ON dv.id = fr.file_version_id
933 JOIN files df ON df.id = dv.file_id AND dv.version = df.current_version
934 JOIN revisions r ON r.id = df.revision_id
935 JOIN items i ON i.id = r.item_id
936 WHERE sf.revision_id = ANY($1)
937 AND r.item_id <> sr.item_id
938 AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
939 AND {}
940 ORDER BY sf.revision_id, i.number",
941 access::item_visible("i", 2)
942 ))
943 .bind(revision_ids)
944 .bind(&actor.hidden_projects)
945 .fetch_all(pool)
946 .await?,
947 )
948 };
949 let walk = async {
952 let sql = dependents_sql();
953 let mut reached: HashSet<(Uuid, Uuid)> =
954 roots.iter().map(|r| (r.item_id, r.item_id)).collect();
955 let mut frontier: Vec<(Uuid, Uuid)> = roots
956 .iter()
957 .filter(|r| r.visible)
958 .map(|r| (r.item_id, r.item_id))
959 .collect::<HashSet<_>>()
960 .into_iter()
961 .collect();
962 let mut found: Vec<Found> = Vec::new();
963 for level in 1..=MAX_ANALYSIS_LEVELS {
964 if frontier.is_empty() {
965 break;
966 }
967 let targets: Vec<Uuid> = frontier
968 .iter()
969 .map(|(_, item)| *item)
970 .collect::<HashSet<_>>()
971 .into_iter()
972 .collect();
973 let edges = sqlx::query_as::<_, Edge>(&sql)
974 .bind(&targets)
975 .bind(&actor.hidden_projects)
976 .fetch_all(pool)
977 .await?;
978 let mut by_target: HashMap<Uuid, Vec<&Edge>> = HashMap::new();
979 for e in &edges {
980 by_target.entry(e.target).or_default().push(e);
981 }
982 let mut next = Vec::new();
983 for (root, item) in frontier {
984 for e in by_target.get(&item).into_iter().flatten() {
985 if reached.insert((root, e.dependent)) {
986 if e.visible {
988 next.push((root, e.dependent));
989 }
990 found.push(Found {
991 root,
992 item: e.dependent,
993 level,
994 relation: e.relation.clone(),
995 via: item,
996 visible: e.visible,
997 });
998 }
999 }
1000 }
1001 frontier = next;
1002 }
1003 Ok::<_, AppError>(found)
1004 };
1005 let (found, file_referrers) = tokio::try_join!(walk, file_referrers)?;
1006 let hidden_items: HashSet<Uuid> = found
1008 .iter()
1009 .filter(|f| !f.visible)
1010 .map(|f| f.item)
1011 .collect();
1012 let mut found: Vec<Found> = found.into_iter().filter(|f| f.visible).collect();
1013 let root_of: HashMap<Uuid, Uuid> = roots
1014 .iter()
1015 .filter(|r| r.visible)
1016 .map(|r| (r.revision_id, r.item_id))
1017 .collect();
1018 let mut by_pair: HashSet<(Uuid, Uuid)> = found.iter().map(|f| (f.root, f.item)).collect();
1021 for f in file_referrers {
1022 let Some(&root) = root_of.get(&f.source_revision_id) else {
1023 continue;
1024 };
1025 if by_pair.insert((root, f.item_id)) {
1026 found.push(Found {
1027 root,
1028 item: f.item_id,
1029 level: 1,
1030 relation: "file".into(),
1031 via: root,
1032 visible: true,
1033 });
1034 }
1035 }
1036
1037 #[derive(sqlx::FromRow)]
1039 struct Latest {
1040 item_id: Uuid,
1041 number: String,
1042 revision_id: Uuid,
1043 code: String,
1044 state: String,
1045 name: String,
1046 }
1047 let ids: Vec<Uuid> = found
1048 .iter()
1049 .flat_map(|f| [f.item, f.via])
1050 .collect::<HashSet<_>>()
1051 .into_iter()
1052 .collect();
1053 let latest: HashMap<Uuid, Latest> = sqlx::query_as::<_, Latest>(
1054 "SELECT i.id AS item_id, i.number, lr.id AS revision_id, lr.code, lr.state, lr.name
1055 FROM items i
1056 CROSS JOIN LATERAL (
1057 SELECT r.id, r.code, r.state, r.name FROM revisions r
1058 WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
1059 ) lr
1060 WHERE i.id = ANY($1)",
1061 )
1062 .bind(&ids)
1063 .fetch_all(pool)
1064 .await?
1065 .into_iter()
1066 .map(|l| (l.item_id, l))
1067 .collect();
1068 let in_change: HashSet<Uuid> = roots.iter().map(|r| r.item_id).collect();
1069 let mut listed: HashSet<(i32, Uuid, Uuid)> = HashSet::new();
1074 let mut out: Vec<Dependent> = found
1075 .into_iter()
1076 .filter(|f| listed.insert((f.level, f.item, f.via)))
1077 .filter_map(|f| {
1078 let item = latest.get(&f.item)?;
1079 let via = latest.get(&f.via)?;
1080 Some(Dependent {
1081 root_item_id: f.root,
1082 level: f.level,
1083 item_id: f.item,
1084 item_number: item.number.clone(),
1085 item_name: item.name.clone(),
1086 revision_id: item.revision_id,
1087 revision_code: item.code.clone(),
1088 revision_state: item.state.clone(),
1089 relation: f.relation,
1090 via_number: via.number.clone(),
1091 in_change: in_change.contains(&f.item),
1092 visible: true,
1093 })
1094 })
1095 .collect();
1096 out.sort_by(|a, b| {
1097 (a.level, &a.item_number, &a.via_number).cmp(&(b.level, &b.item_number, &b.via_number))
1098 });
1099 out.dedup_by(|a, b| a.item_id == b.item_id && a.via_number == b.via_number);
1100 Ok((out, hidden_items.len() as i64))
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 use super::*;
1106 use chrono::TimeZone;
1107
1108 fn impact(n: u128, dependent: u128, source: &str, minute: u32) -> Impact {
1109 Impact {
1110 id: Uuid::from_u128(n),
1111 item_id: Uuid::from_u128(1000 + dependent),
1112 item_number: format!("ASM-{dependent:06}"),
1113 project_id: None,
1114 project_code: Some("ROVER".into()),
1115 revision_id: Uuid::from_u128(2000 + dependent),
1116 revision_code: "A".into(),
1117 revision_state: "released".into(),
1118 item_name: "Enclosure".into(),
1119 source_item_id: Uuid::from_u128(3000 + n),
1120 source_number: source.into(),
1121 source_revision_id: Uuid::from_u128(4000 + n),
1122 source_code: "A".into(),
1123 source_name: "Screw".into(),
1124 event: "released".into(),
1125 relation: "bom".into(),
1126 detail: String::new(),
1127 change_id: None,
1128 change_number: None,
1129 created_at: Utc.with_ymd_and_hms(2026, 11, 5, 9, minute, 0).unwrap(),
1130 resolved_at: None,
1131 resolution: None,
1132 resolved_by_name: None,
1133 note: String::new(),
1134 source_project_id: None,
1135 source_restricted: false,
1136 }
1137 }
1138
1139 #[test]
1140 fn groups_by_dependent_revision_in_first_seen_order() {
1141 let groups = group_by_dependent(vec![
1143 impact(1, 2, "PRT-000008", 50),
1144 impact(2, 1, "PRT-000008", 40),
1145 impact(3, 2, "PRT-000005", 30),
1146 impact(4, 2, "PRT-000006", 20),
1147 impact(5, 1, "PRT-000006", 10),
1148 ]);
1149 let shape: Vec<(&str, Vec<&str>)> = groups
1150 .iter()
1151 .map(|g| {
1152 (
1153 g.item_number.as_str(),
1154 g.impacts.iter().map(|i| i.source_number.as_str()).collect(),
1155 )
1156 })
1157 .collect();
1158 assert_eq!(
1159 shape,
1160 vec![
1161 ("ASM-000002", vec!["PRT-000005", "PRT-000006", "PRT-000008"]),
1162 ("ASM-000001", vec!["PRT-000006", "PRT-000008"]),
1163 ]
1164 );
1165 assert_eq!(groups[0].latest_at.format("%M").to_string(), "50");
1166 assert_eq!(groups[0].revision_id, Uuid::from_u128(2002));
1167 assert_eq!(
1168 groups[1].impact_ids,
1169 format!("{},{}", Uuid::from_u128(5), Uuid::from_u128(2))
1170 );
1171 assert_eq!(parse_ids(&groups[1].impact_ids).unwrap().unwrap().len(), 2);
1172 }
1173
1174 #[test]
1175 fn revisions_of_the_same_item_are_separate_groups() {
1176 let mut older = impact(1, 1, "PRT-000001", 1);
1177 older.revision_id = Uuid::from_u128(99);
1178 older.revision_code = "A".into();
1179 let mut newer = impact(2, 1, "PRT-000002", 2);
1180 newer.revision_code = "B".into();
1181 let groups = group_by_dependent(vec![newer, older]);
1182 assert_eq!(groups.len(), 2);
1183 assert_eq!(groups[0].revision_code, "B");
1184 assert!(group_by_dependent(Vec::new()).is_empty());
1185 }
1186
1187 #[test]
1188 fn parses_impact_id_lists() {
1189 assert_eq!(parse_ids("").unwrap(), None);
1190 assert_eq!(parse_ids(" , ").unwrap(), None);
1191 let id = Uuid::from_u128(7);
1192 assert_eq!(parse_ids(&format!(" {id} ,")).unwrap(), Some(vec![id]));
1193 assert!(parse_ids("nope").is_err());
1194 }
1195}