Skip to main content

teamcraft/services/
impacts.rs

1//! Change propagation to dependents.
2//!
3//! When a revision is **released**, TeamCraft
4//!
5//! 1. freezes the configuration of the released revisions: every BOM line and
6//!    item reference remembers the revision of its target at that moment
7//!    (revision rule *as released*);
8//! 2. resolves the open impacts of the released items themselves – a new
9//!    release was reviewed with the current components (*superseded*);
10//! 3. flags every **dependent** for a check: items whose latest revision uses
11//!    the released item in its BOM, references it (drawing, specification,
12//!    reference), has a CAD file that references one of its files, or is a
13//!    simulation based on another revision of it (or on a file version that
14//!    is no longer current; see `simulations`). Items released in the same
15//!    change are not flagged.
16//!
17//! When the latest revision of an item is made **obsolete**, its dependents
18//! are flagged the same way and its own open impacts are closed.
19//!
20//! An impact stays open until a CAD editor of the dependent's project group
21//! acknowledges it with a note ("form, fit and function unchanged"), a newer
22//! revision of the dependent is released, or the dependent becomes obsolete.
23//! Propagation is one level per event: when the dependent is revised and
24//! released, its own dependents are flagged in turn.
25
26use 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
60/// Latest, not obsolete revision of an item (SQL condition on `pr`).
61const LATEST: &str = "pr.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = pr.item_id)
62                      AND pr.state <> 'obsolete'";
63
64/// Redacts impacts for a reader – every impact that leaves this module goes
65/// through here. Sources the reader may not see lose their identity, and a
66/// releasing change order the reader may not see (it also holds restricted
67/// items, see [`access::change_visible`]) loses its number and id.
68pub 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
102/// An impact as stored, without a read check (the caller checks; the
103/// audit trail names the real source).
104async 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
113/// An impact on an item the actor may read, without redaction.
114async 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
124/// An impact on an item the actor may read; a hidden source is redacted.
125pub 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
131/// Impacts on an item: open ones first, then the 20 most recently resolved.
132/// Sources the actor may not see are redacted.
133pub 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
150/// Open impacts the actor can resolve: on items of their project groups
151/// (all for admins, none for viewers), newest first.
152pub 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
172/// Marks an open impact as checked: the dependent needs no change.
173///
174/// # Invariants
175///
176/// * **Permission:** CAD editor; the impact is on an item the actor sees ("not found"
177///   otherwise) and in a group the actor may edit (403); a note is needed (400); the impact is
178///   open (409).
179/// * **Locks:** the impact row, through the `UPDATE ... WHERE resolved_at IS NULL`; when it was
180///   resolved in the meantime the actor gets a 409.
181/// * **Audit:** `impact.acknowledge`.
182/// * **Notifications / webhooks:** none.
183/// * **Atomic:** one transaction.
184pub 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    // Unredacted: the audit entry names the real source (it is redacted
192    // when read by somebody who may not see it).
193    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(&note)
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/// The result of [`acknowledge_all`].
245#[derive(Clone, Debug, Serialize)]
246pub struct Acknowledged {
247    pub item_number: String,
248    pub revision_code: String,
249    /// The impacts acknowledged now (resolved, with the note).
250    pub acknowledged: Vec<Impact>,
251    /// Requested impacts that were resolved in the meantime (skipped).
252    pub skipped: usize,
253}
254
255/// Acknowledges every open impact on a dependent revision with one note –
256/// or only those in `only` (the ones the user saw, so a check flagged a
257/// moment ago is not acknowledged unseen). Impacts resolved in the meantime
258/// are skipped; if none is left, nothing changes (conflict). Same rules as
259/// [`acknowledge`]; one transaction, one audit entry per impact.
260///
261/// # Invariants
262///
263/// * **Permission:** CAD editor; the revision is visible ("not found" otherwise) and in a group
264///   the actor may edit (403); a note is needed (400); a given list is not empty and names only
265///   impacts on this revision (400); at least one impact is still open (409).
266/// * **Locks:** the open impact rows (`FOR UPDATE`), before they are resolved.
267/// * **Audit:** `impact.acknowledge` per impact.
268/// * **Notifications / webhooks:** none.
269/// * **Atomic:** one transaction; impacts resolved in the meantime are skipped and counted.
270pub 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    // The requested ids, without duplicates; an empty list is a mistake of
292    // the caller, not "resolved in the meantime".
293    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        // Every listed impact has to be one on this revision; otherwise the
307        // answer would count foreign or unknown ids as "resolved meanwhile".
308        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(&note)
346    .execute(&mut *tx)
347    .await?;
348    // Unredacted for the audit entries; the answer is redacted below.
349    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    // by source number; hidden sources last (their place would bound it)
373    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// ---------------------------------------------------------------------------
384// Grouping by dependent (worklists)
385// ---------------------------------------------------------------------------
386
387/// The open impacts on one dependent revision: everything that has to be
388/// checked on one assembly, drawing or simulation.
389#[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    /// When the newest of its impacts was flagged.
400    pub latest_at: DateTime<Utc>,
401    /// The impact ids, comma-separated (the hidden field of the web form
402    /// that acknowledges exactly the impacts shown).
403    pub impact_ids: String,
404    /// The impacts, ordered by source item number.
405    pub impacts: Vec<Impact>,
406}
407
408/// Groups impacts by the dependent revision. Groups keep the order in which
409/// they first appear (newest first for [`open_for`]); inside a group the
410/// sources are ordered by item number and revision.
411pub 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        // hidden sources last (their place among the numbers would bound them)
438        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
455/// Parses the comma-separated impact ids of a web form (see
456/// [`DependentGroup::impact_ids`]); empty means all open impacts.
457pub 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
469/// Flags the dependents of `source_revision` (see the module docs) and
470/// returns their item ids. Items in `exclude` are not flagged.
471async 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    // On conflict the newer event replaces the open impact of the same source.
479    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    // CAD files of dependents that reference a file of the source revision.
486    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    // Drawings, specifications and references to the source item.
509    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    // Simulations based on a revision of the source item: flagged when a
521    // different revision is released, when the simulated file version is no
522    // longer the current one, or when the source becomes obsolete.
523    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    // Assemblies using the source item (last, so the BOM wins if several apply).
542    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
572/// Writes an audit entry that lists the flagged dependents.
573async 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    // One entry per project group of the dependents, each naming only its own
584    // items: a reader who may not see a restricted group's dependents still
585    // reads the entries about the others.
586    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
623/// Tells the creator of each flagged item, the author of its latest revision
624/// and its watchers that it has to be checked.
625async 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        // Recipients who may not see the source learn only that something
694        // restricted changed.
695        .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, &notice).await?;
712    }
713    Ok(())
714}
715
716/// Change propagation after revisions were released together (one change).
717/// Runs inside the releasing transaction, after the state change.
718pub(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    // 1. As-released configuration of the released revisions.
728    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    // 2. The released items were reviewed with the current components.
749    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    // 3. Flag the dependents.
760    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
772/// Change propagation after a revision was made obsolete: if it was the
773/// latest revision of its item, its dependents have to replace it.
774pub(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// ---------------------------------------------------------------------------
803// Impact analysis (before a change is approved)
804// ---------------------------------------------------------------------------
805
806/// An item that depends – directly (level 1) or through other dependents –
807/// on a revision of a change.
808#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
809pub struct Dependent {
810    /// The item of the change it depends on.
811    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    /// `bom`, `drawing`, `specification`, `reference`, `simulation` or `file`.
820    pub relation: String,
821    /// The item it uses or references.
822    pub via_number: String,
823    /// Released together in the same change (not flagged).
824    #[sqlx(default)]
825    pub in_change: bool,
826    /// Whether the reader may see the dependent (hidden ones are dropped).
827    #[sqlx(default)]
828    #[serde(skip)]
829    pub visible: bool,
830}
831
832/// Levels the impact analysis walks up.
833const MAX_ANALYSIS_LEVELS: i32 = 12;
834
835/// The direct dependents of a set of items: whose latest (not obsolete)
836/// revision uses one of them in its BOM, references it or is a simulation
837/// based on it – one query for a whole level of the analysis. `visible`
838/// tells whether the reader (hidden groups bound at `$2`) may see the
839/// dependent.
840fn 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
864/// The dependents of the given revisions that the actor may see, and how
865/// many dependents were left out because they belong to restricted groups
866/// (the walk does not go on through them): multi-level through BOMs and
867/// item references of the latest revisions, plus CAD files that reference
868/// their files.
869///
870/// The walk goes up level by level for all revisions at once (one query per
871/// level, not one per path or per revision): every dependent is reported on
872/// the lowest level it is reached on from each revision's item, with the
873/// relation and the item it depends on there.
874pub 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    // CAD files of other items that reference the files (one level), for
916    // all revisions in one query – run while the walk below goes up. Only
917    // items the actor may see (hidden file-name matches are left out
918    // without a count).
919    #[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    // (root item, item) pairs reached so far; the frontier holds the pairs
950    // of the last level. Roots the actor may not see are not walked.
951    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                        // hidden dependents end the walk on their path
987                        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    // Hidden dependents are dropped and counted.
1007    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    // Checked per revision as before: a dependent found through BOM or
1019    // references keeps that relation.
1020    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    // Numbers, names and latest revisions of everything found, in one query.
1038    #[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    // The same dependent on the same level through the same item, reached
1070    // from several revisions of the change, is listed once (the sort and
1071    // dedup below would drop all but the first anyway) – before any text
1072    // is copied for it.
1073    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        // newest first, as open_for returns them
1142        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}