Skip to main content

teamcraft/services/
revisions.rs

1//! Revisions: attributes, check-out/check-in, revise and obsolete.
2
3use serde_json::json;
4use sqlx::{PgConnection, PgPool};
5use uuid::Uuid;
6
7use crate::{
8    auth::Actor,
9    domain::{self, LifecycleState, NotificationKind, Role, WebhookEvent},
10    error::{AppError, AppResult},
11    models::Revision,
12    services::{
13        access, as_built, attributes, audit, impacts, mass_properties,
14        notifications::{self, Notice},
15        project_policy, references, simulations, webhooks,
16    },
17};
18
19pub(crate) const REVISION_SELECT: &str = "
20    SELECT r.id, r.item_id, i.number AS item_number, i.item_type, i.unit,
21           i.project_id, rp.code AS project_code,
22           r.seq, r.code, r.state, r.name, r.description, r.material,
23           r.mass_kg, r.unit_cost, r.attributes, i.classification,
24           r.checked_out_by, co.display_name AS checked_out_by_name, r.checked_out_at,
25           r.created_by, cb.display_name AS created_by_name,
26           r.created_at, r.updated_at, r.released_at
27    FROM revisions r
28    JOIN items i ON i.id = r.item_id
29    JOIN users cb ON cb.id = r.created_by
30    LEFT JOIN users co ON co.id = r.checked_out_by
31    LEFT JOIN projects rp ON rp.id = i.project_id";
32
33pub fn state_of(rev: &Revision) -> LifecycleState {
34    LifecycleState::parse(&rev.state).unwrap_or(LifecycleState::Obsolete)
35}
36
37/// Human readable label such as `PRT-000042/B`.
38pub fn label(rev: &Revision) -> String {
39    format!("{}/{}", rev.item_number, rev.code)
40}
41
42pub(crate) async fn get_unchecked(pool: &PgPool, id: Uuid) -> AppResult<Revision> {
43    sqlx::query_as::<_, Revision>(&format!("{REVISION_SELECT} WHERE r.id = $1"))
44        .bind(id)
45        .fetch_optional(pool)
46        .await?
47        .ok_or_else(|| AppError::not_found("Revision not found."))
48}
49
50pub(crate) async fn get_by_code_unchecked(
51    pool: &PgPool,
52    item_id: Uuid,
53    code: &str,
54) -> AppResult<Revision> {
55    sqlx::query_as::<_, Revision>(&format!(
56        "{REVISION_SELECT} WHERE r.item_id = $1 AND r.code = $2"
57    ))
58    .bind(item_id)
59    .bind(code.trim().to_ascii_uppercase())
60    .fetch_optional(pool)
61    .await?
62    .ok_or_else(|| AppError::not_found(format!("Revision '{code}' not found.")))
63}
64
65/// Loads a revision and locks its row until the transaction ends.
66pub(crate) async fn lock(conn: &mut PgConnection, id: Uuid) -> AppResult<Revision> {
67    sqlx::query_as::<_, Revision>(&format!(
68        "{REVISION_SELECT} WHERE r.id = $1 FOR UPDATE OF r"
69    ))
70    .bind(id)
71    .fetch_optional(&mut *conn)
72    .await?
73    .ok_or_else(|| AppError::not_found("Revision not found."))
74}
75
76/// A revision the actor may read; hidden ones answer like missing ones.
77pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Revision> {
78    let rev = get_unchecked(pool, id).await?;
79    access::ensure_visible(actor, rev.project_id, access::not_found_revision())?;
80    Ok(rev)
81}
82
83/// A revision of an item by its code (`A`, `B`, …), if the actor may read it.
84pub async fn get_by_code(
85    pool: &PgPool,
86    actor: &Actor,
87    item_id: Uuid,
88    code: &str,
89) -> AppResult<Revision> {
90    let rev = get_by_code_unchecked(pool, item_id, code).await?;
91    access::ensure_visible(
92        actor,
93        rev.project_id,
94        AppError::not_found(format!("Revision '{code}' not found.")),
95    )?;
96    Ok(rev)
97}
98
99/// Revisions currently checked out by the actor (only those it may still
100/// read: a user who left a restricted group asks a manager of the group or
101/// an admin to cancel them, see [`cancel_checkout`]).
102pub async fn checked_out_by(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Revision>> {
103    Ok(sqlx::query_as::<_, Revision>(&format!(
104        "{REVISION_SELECT} WHERE r.checked_out_by = $1 AND {}
105         ORDER BY r.checked_out_at DESC",
106        access::item_visible("i", 2)
107    ))
108    .bind(actor.id)
109    .bind(&actor.hidden_projects)
110    .fetch_all(pool)
111    .await?)
112}
113
114/// Fails unless `actor` may change data of the revision's project:
115/// CAD editor (or admin) and member of the item's project group. A revision
116/// the actor may not even read answers "not found", so the message below
117/// (which names the group) never reaches outsiders of restricted groups.
118pub fn ensure_project_access(rev: &Revision, actor: &Actor) -> AppResult<()> {
119    access::ensure_visible(actor, rev.project_id, access::not_found_revision())?;
120    actor.require(Role::Editor)?;
121    if actor.can_edit_project(rev.project_id) {
122        Ok(())
123    } else {
124        Err(AppError::Forbidden(format!(
125            "Only members of the project group {} can modify {}.",
126            rev.project_code.as_deref().unwrap_or("?"),
127            label(rev)
128        )))
129    }
130}
131
132/// Fails unless `actor` could work on `rev`: project access, revision in work
133/// and not checked out by somebody else (the precondition for checking out).
134pub fn ensure_checkout_possible(rev: &Revision, actor: &Actor) -> AppResult<()> {
135    ensure_project_access(rev, actor)?;
136    let state = state_of(rev);
137    if !state.is_editable() {
138        return Err(AppError::Conflict(format!(
139            "{} is {} and cannot be modified. Create a new revision instead.",
140            label(rev),
141            state.label()
142        )));
143    }
144    if let Some(owner) = rev.checked_out_by {
145        if owner != actor.id {
146            return Err(AppError::Conflict(format!(
147                "{} is checked out by {}.",
148                label(rev),
149                rev.checked_out_by_name.as_deref().unwrap_or("another user")
150            )));
151        }
152    }
153    Ok(())
154}
155
156/// Fails unless `actor` may modify `rev` right now: everything of
157/// [`ensure_checkout_possible`] **and** the revision is checked out by the
158/// actor. Every change to attributes, files and BOM requires the lock.
159pub fn ensure_editable(rev: &Revision, actor: &Actor) -> AppResult<()> {
160    ensure_checkout_possible(rev, actor)?;
161    if rev.checked_out_by != Some(actor.id) {
162        return Err(AppError::Conflict(format!(
163            "Check out {} before changing it.",
164            label(rev)
165        )));
166    }
167    Ok(())
168}
169
170#[derive(Clone, Debug)]
171pub struct RevisionUpdate {
172    pub name: String,
173    pub description: String,
174    pub material: String,
175    pub mass_kg: Option<f64>,
176    pub unit_cost: Option<f64>,
177    /// Custom attribute changes, `key → value as text`; an empty text removes
178    /// the value, keys not listed keep theirs (see `services::attributes`).
179    pub attributes: Vec<(String, String)>,
180}
181
182impl RevisionUpdate {
183    pub(crate) fn validated(self) -> AppResult<RevisionUpdate> {
184        Ok(RevisionUpdate {
185            name: domain::require_text("Name", &self.name, 200)?,
186            description: domain::limit_text("Description", &self.description, 10_000)?,
187            material: domain::limit_text("Material", &self.material, 200)?,
188            mass_kg: domain::validate_non_negative("Mass", self.mass_kg)?,
189            unit_cost: domain::validate_non_negative("Unit cost", self.unit_cost)?,
190            attributes: self.attributes,
191        })
192    }
193}
194
195/// Changes name, description, material, mass, cost and custom attributes of a revision.
196///
197/// # Invariants
198///
199/// * **Permission:** the revision is visible ("not found" otherwise), checked out by the actor
200///   (409 otherwise) and editable (`revisions::ensure_editable`: in work, CAD editor of its
201///   group).
202/// * **Locks:** the revision row (`revisions::lock`).
203/// * **Audit:** `revision.update`.
204/// * **Notifications / webhooks:** none.
205/// * **Atomic:** one transaction.
206pub async fn update(
207    pool: &PgPool,
208    actor: &Actor,
209    id: Uuid,
210    upd: RevisionUpdate,
211) -> AppResult<Revision> {
212    let upd = upd.validated()?;
213    let mut tx = pool.begin().await?;
214    let rev = lock(&mut tx, id).await?;
215    ensure_editable(&rev, actor)?;
216    let attributes =
217        attributes::apply_changes(&mut tx, &rev.item_type, &rev.attributes, &upd.attributes)
218            .await?;
219    sqlx::query(
220        "UPDATE revisions SET name = $2, description = $3, material = $4, mass_kg = $5,
221                              unit_cost = $6, attributes = $7, updated_at = now()
222         WHERE id = $1",
223    )
224    .bind(id)
225    .bind(&upd.name)
226    .bind(&upd.description)
227    .bind(&upd.material)
228    .bind(upd.mass_kg)
229    .bind(upd.unit_cost)
230    .bind(&attributes)
231    .execute(&mut *tx)
232    .await?;
233    audit::record(
234        &mut tx,
235        Some(actor),
236        "revision.update",
237        "revision",
238        Some(id),
239        format!("Updated attributes of {}", label(&rev)),
240        json!({
241            "item_id": rev.item_id,
242            "name": upd.name,
243            "material": upd.material,
244            "mass_kg": upd.mass_kg,
245            "unit_cost": upd.unit_cost,
246            "attributes": attributes,
247        }),
248    )
249    .await?;
250    tx.commit().await?;
251    get(pool, actor, id).await
252}
253
254/// Takes the exclusive edit lock of an in-work revision.
255///
256/// # Invariants
257///
258/// * **Permission:** CAD editor; the revision is visible ("not found" otherwise), in a group the
259///   actor may edit (403), in work and not checked out by someone else (409).
260/// * **Locks:** the revision row (`revisions::lock`); two check-outs at once give one winner and
261///   a 409.
262/// * **Audit:** `revision.checkout`; nothing when the actor already holds the check-out.
263/// * **Notifications / webhooks:** none.
264/// * **Atomic:** one transaction.
265pub async fn checkout(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Revision> {
266    actor.require(Role::Editor)?;
267    let mut tx = pool.begin().await?;
268    let rev = lock(&mut tx, id).await?;
269    ensure_checkout_possible(&rev, actor)?;
270    if rev.checked_out_by == Some(actor.id) {
271        return Ok(rev);
272    }
273    sqlx::query("UPDATE revisions SET checked_out_by = $2, checked_out_at = now() WHERE id = $1")
274        .bind(id)
275        .bind(actor.id)
276        .execute(&mut *tx)
277        .await?;
278    audit::record(
279        &mut tx,
280        Some(actor),
281        "revision.checkout",
282        "revision",
283        Some(id),
284        format!("Checked out {}", label(&rev)),
285        json!({ "item_id": rev.item_id }),
286    )
287    .await?;
288    tx.commit().await?;
289    get(pool, actor, id).await
290}
291
292/// Releases the edit lock without uploading anything ("cancel check-out").
293/// Administrators may release locks of other users, managers of the item's
294/// group those of users who are gone: no longer members (who cannot release
295/// them themselves once a restricted group hides the revision) or
296/// deactivated. A check-in with new file versions is `files::check_in`;
297/// the bulk release for one holder is `checkouts::release_held`.
298///
299/// # Invariants
300///
301/// * **Permission:** CAD editor; the revision is visible ("not found" otherwise). Nothing happens
302///   when it is not checked out. Another user's check-out may be released by an administrator,
303///   by a manager of the group when that user is no longer a member or is deactivated, or by a
304///   manager of the group for an active member with a `reason` (issue #173, step 6); 403
305///   otherwise (a manager without a reason is told to give one).
306/// * **Data:** a reason has [`RELEASE_REASON_MIN`] to [`RELEASE_REASON_MAX`] characters (400).
307/// * **Locks:** the revision row (`revisions::lock`).
308/// * **Audit:** `revision.checkout_cancel`; `revision.checkout_forced_release` when the check-out
309///   was held by someone else, with the reason.
310/// * **Notifications:** on a forced release the former holder, with the reason; otherwise none.
311/// * **Webhooks:** none.
312/// * **Atomic:** one transaction.
313pub async fn cancel_checkout(
314    pool: &PgPool,
315    actor: &Actor,
316    id: Uuid,
317    reason: Option<&str>,
318) -> AppResult<Revision> {
319    actor.require(Role::Editor)?;
320    let reason = release_reason(reason)?;
321    let mut tx = pool.begin().await?;
322    let rev = lock(&mut tx, id).await?;
323    access::ensure_visible(actor, rev.project_id, access::not_found_revision())?;
324    let Some(owner) = rev.checked_out_by else {
325        return Ok(rev);
326    };
327    if owner != actor.id && !actor.is_admin() && !orphaned_lock(&mut tx, actor, &rev, owner).await?
328    {
329        let holder = rev.checked_out_by_name.as_deref().unwrap_or("another user");
330        let manager = match rev.project_id {
331            Some(p) => crate::services::projects::can_manage(&mut tx, actor, p).await?,
332            None => false,
333        };
334        if !manager {
335            return Err(AppError::Forbidden(format!(
336                "{} is checked out by {holder}; only they or an administrator can check it in.",
337                label(&rev)
338            )));
339        }
340        if reason.is_none() {
341            return Err(AppError::Forbidden(format!(
342                "{} is checked out by {holder}, who is an active member: as a manager of the \
343                 group you release it only with a reason ({RELEASE_REASON_MIN} to \
344                 {RELEASE_REASON_MAX} characters), which {holder} is told.",
345                label(&rev)
346            )));
347        }
348    }
349    sqlx::query("UPDATE revisions SET checked_out_by = NULL, checked_out_at = NULL WHERE id = $1")
350        .bind(id)
351        .execute(&mut *tx)
352        .await?;
353    let forced = owner != actor.id;
354    audit::record(
355        &mut tx,
356        Some(actor),
357        if forced {
358            "revision.checkout_forced_release"
359        } else {
360            "revision.checkout_cancel"
361        },
362        "revision",
363        Some(id),
364        if forced {
365            format!(
366                "Released check-out of {} held by {}",
367                label(&rev),
368                rev.checked_out_by_name.as_deref().unwrap_or("?")
369            )
370        } else {
371            format!("Cancelled the check-out of {}", label(&rev))
372        },
373        // `holder`: whose check-out it was – the former holder's working
374        // folder reads it back as "released by …" (`checkouts::released_from`)
375        json!({ "item_id": rev.item_id, "reason": reason, "holder": owner }),
376    )
377    .await?;
378    if forced {
379        let keep = "Files you changed in your workspace stay there; check the revision out again \
380                    to continue.";
381        let notice = Notice::revision(
382            NotificationKind::Unlock,
383            &rev,
384            format!(
385                "{} released your check-out of {}",
386                actor.display_name,
387                label(&rev)
388            ),
389            match &reason {
390                Some(r) => format!("Reason: {r}\n\n{keep}"),
391                None => keep.to_string(),
392            },
393        );
394        notifications::notify(&mut tx, Some(actor), &[owner], &notice).await?;
395    }
396    tx.commit().await?;
397    get(pool, actor, id).await
398}
399
400/// Shortest reason for releasing an active member's check-out.
401pub const RELEASE_REASON_MIN: usize = 10;
402/// Longest reason for releasing a check-out.
403pub const RELEASE_REASON_MAX: usize = 500;
404
405/// Checks a release reason as [`cancel_checkout`] would (400), for callers
406/// that release many check-outs with one reason.
407pub fn check_release_reason(raw: Option<&str>) -> AppResult<()> {
408    release_reason(raw).map(|_| ())
409}
410
411/// A release reason as given: trimmed, empty = none; its length is checked.
412fn release_reason(raw: Option<&str>) -> AppResult<Option<String>> {
413    let Some(text) = raw.map(str::trim).filter(|t| !t.is_empty()) else {
414        return Ok(None);
415    };
416    let n = text.chars().count();
417    if !(RELEASE_REASON_MIN..=RELEASE_REASON_MAX).contains(&n) {
418        return Err(AppError::BadRequest(format!(
419            "Give a reason of {RELEASE_REASON_MIN} to {RELEASE_REASON_MAX} characters."
420        )));
421    }
422    Ok(Some(text.to_string()))
423}
424
425/// How [`cancel_checkout`] would treat the actor and the revision's lock.
426#[derive(Clone, Copy, Debug, PartialEq, Eq)]
427pub enum Release {
428    /// Not checked out, or the actor may not release it.
429    No,
430    /// The actor may release it at once.
431    Yes,
432    /// A manager releasing an active member's lock: only with a reason.
433    WithReason,
434}
435
436/// For pages: whether and how [`cancel_checkout`] would release the
437/// revision's lock.
438pub async fn release_rule(pool: &PgPool, actor: &Actor, rev: &Revision) -> AppResult<Release> {
439    let Some(owner) = rev.checked_out_by else {
440        return Ok(Release::No);
441    };
442    if !actor.can_edit() {
443        return Ok(Release::No);
444    }
445    if owner == actor.id || actor.is_admin() {
446        return Ok(Release::Yes);
447    }
448    let mut conn = pool.acquire().await?;
449    if orphaned_lock(&mut conn, actor, rev, owner).await? {
450        return Ok(Release::Yes);
451    }
452    let manager = match rev.project_id {
453        Some(p) => crate::services::projects::can_manage(&mut conn, actor, p).await?,
454        None => false,
455    };
456    Ok(if manager {
457        Release::WithReason
458    } else {
459        Release::No
460    })
461}
462
463/// For pages: whether [`cancel_checkout`] would release the revision's lock
464/// without a reason.
465pub async fn may_cancel_checkout(pool: &PgPool, actor: &Actor, rev: &Revision) -> AppResult<bool> {
466    Ok(release_rule(pool, actor, rev).await? == Release::Yes)
467}
468
469/// A lock that a manager of the revision's group may release: its owner is
470/// gone – no longer a member of the group (removed, or never one before the
471/// group was restricted), so they can neither see nor check in the revision
472/// of a restricted group nor edit it in an internal one, or deactivated, so
473/// they cannot sign in to release it at all (issue #173: a deactivated member
474/// keeps their memberships, IdP-synchronised ones included).
475async fn orphaned_lock(
476    conn: &mut PgConnection,
477    actor: &Actor,
478    rev: &Revision,
479    owner: Uuid,
480) -> AppResult<bool> {
481    let Some(project_id) = rev.project_id else {
482        return Ok(false);
483    };
484    if !crate::services::projects::can_manage(conn, actor, project_id).await? {
485        return Ok(false);
486    }
487    let owner_is_active_member = sqlx::query_scalar::<_, bool>(
488        "SELECT EXISTS(SELECT 1 FROM project_members pm JOIN users u ON u.id = pm.user_id
489                       WHERE pm.project_id = $1 AND pm.user_id = $2 AND u.active)",
490    )
491    .bind(project_id)
492    .bind(owner)
493    .fetch_one(&mut *conn)
494    .await?;
495    Ok(!owner_is_active_member)
496}
497
498/// Tells the watchers of an item about something that happened to a revision.
499pub(crate) async fn tell_watchers(
500    conn: &mut PgConnection,
501    actor: &Actor,
502    rev: &Revision,
503    title: String,
504    body: String,
505) -> AppResult<()> {
506    let watchers = notifications::watchers(conn, "item", &[rev.item_id]).await?;
507    let notice = Notice::revision(NotificationKind::ItemUpdate, rev, title, body);
508    notifications::notify(conn, Some(actor), &watchers, &notice).await?;
509    Ok(())
510}
511
512/// Creates the next revision of an item, copying attributes, files and BOM
513/// lines from revision `id`. Only possible when the item has no open revision.
514///
515/// # Invariants
516///
517/// * **Permission:** CAD editor; the source revision is visible ("not found" otherwise) and in a
518///   group the actor may edit (403); the item has no revision in work or in review (409).
519/// * **Locks:** the source revision row (`revisions::lock`), then the item row (`FOR UPDATE`),
520///   which serialises concurrent revisions of the same item.
521/// * **Audit:** `revision.revise`.
522/// * **Notifications:** the watchers of the item.
523/// * **Webhooks:** none.
524/// * **Data:** the new revision is checked out by the actor and takes over attributes, files
525///   (as version 1), references, simulations and BOM lines.
526/// * **Atomic:** one transaction.
527pub async fn revise(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Revision> {
528    actor.require(Role::Editor)?;
529    let mut tx = pool.begin().await?;
530    let src = lock(&mut tx, id).await?;
531    ensure_project_access(&src, actor)?;
532    // Serialise concurrent revise operations on the same item.
533    sqlx::query("SELECT id FROM items WHERE id = $1 FOR UPDATE")
534        .bind(src.item_id)
535        .execute(&mut *tx)
536        .await?;
537    let open = sqlx::query_scalar::<_, String>(
538        "SELECT code FROM revisions WHERE item_id = $1 AND state IN ('in_work', 'in_review')",
539    )
540    .bind(src.item_id)
541    .fetch_optional(&mut *tx)
542    .await?;
543    if let Some(code) = open {
544        return Err(AppError::Conflict(format!(
545            "{} already has the open revision {code}; release or finish it first.",
546            src.item_number
547        )));
548    }
549    let max_seq = sqlx::query_scalar::<_, i32>(
550        "SELECT COALESCE(MAX(seq), 0) FROM revisions WHERE item_id = $1",
551    )
552    .bind(src.item_id)
553    .fetch_one(&mut *tx)
554    .await?;
555    let seq = max_seq + 1;
556    let code = domain::revision_code(seq);
557    let new_id = Uuid::new_v4();
558    sqlx::query(
559        "INSERT INTO revisions (id, item_id, seq, code, state, name, description, material,
560                                mass_kg, unit_cost, attributes, created_by, checked_out_by, checked_out_at)
561         SELECT $2, item_id, $3, $4, 'in_work', name, description, material, mass_kg, unit_cost, attributes, $5, $5, now()
562         FROM revisions WHERE id = $1",
563    )
564    .bind(src.id)
565    .bind(new_id)
566    .bind(seq)
567    .bind(&code)
568    .bind(actor.id)
569    .execute(&mut *tx)
570    .await?;
571    // The new revision starts with the current version of every file as v1.
572    let files = sqlx::query(
573        "INSERT INTO files (id, revision_id, filename, content_type, size_bytes, sha256, uploaded_by, uploaded_at,
574                            format, folder, retention)
575         SELECT gen_random_uuid(), $2, filename, content_type, size_bytes, sha256, uploaded_by, uploaded_at, format,
576                folder, retention
577         FROM files WHERE revision_id = $1",
578    )
579    .bind(src.id)
580    .bind(new_id)
581    .execute(&mut *tx)
582    .await?
583    .rows_affected();
584    sqlx::query(
585        "INSERT INTO file_versions (id, file_id, version, sha256, size_bytes, content_type, comment,
586                                    uploaded_by, uploaded_at, content_text, references_scanned,
587                                    export_name, export_name_scanned, solver_summary)
588         SELECT gen_random_uuid(), f.id, 1, f.sha256, f.size_bytes, f.content_type,
589                'Taken over from revision ' || $2, f.uploaded_by, f.uploaded_at,
590                COALESCE(ov.content_text, ''), COALESCE(ov.references_scanned, FALSE), ov.export_name,
591                COALESCE(ov.export_name_scanned, FALSE), ov.solver_summary
592         FROM files f
593         LEFT JOIN files ofl ON ofl.revision_id = $3 AND ofl.filename = f.filename
594         LEFT JOIN file_versions ov ON ov.file_id = ofl.id AND ov.version = ofl.current_version
595         WHERE f.revision_id = $1",
596    )
597    .bind(new_id)
598    .bind(&src.code)
599    .bind(src.id)
600    .execute(&mut *tx)
601    .await?;
602    // The CAD file references of the current versions come along.
603    sqlx::query(
604        "INSERT INTO file_references (file_version_id, name, instances)
605         SELECT nv.id, fr.name, fr.instances
606         FROM files nf
607         JOIN file_versions nv ON nv.file_id = nf.id
608         JOIN files ofl ON ofl.revision_id = $2 AND ofl.filename = nf.filename
609         JOIN file_versions ov ON ov.file_id = ofl.id AND ov.version = ofl.current_version
610         JOIN file_references fr ON fr.file_version_id = ov.id
611         WHERE nf.revision_id = $1",
612    )
613    .bind(new_id)
614    .bind(src.id)
615    .execute(&mut *tx)
616    .await?;
617    let references = references::copy_to_revision(&mut tx, src.id, new_id).await?;
618    let (simulation_bases, simulation_results) =
619        simulations::copy_to_revision(&mut tx, src.id, new_id).await?;
620    let (as_built_records, as_built_deviations) =
621        as_built::copy_to_revision(&mut tx, src.id, new_id).await?;
622    let mass_settings = mass_properties::copy_to_revision(&mut tx, src.id, new_id).await?;
623    let lines = sqlx::query(
624        "INSERT INTO bom_lines (id, parent_revision_id, child_item_id, find_number, quantity, notes)
625         SELECT gen_random_uuid(), $2, child_item_id, find_number, quantity, notes
626         FROM bom_lines WHERE parent_revision_id = $1",
627    )
628    .bind(src.id)
629    .bind(new_id)
630    .execute(&mut *tx)
631    .await?
632    .rows_affected();
633    audit::record(
634        &mut tx,
635        Some(actor),
636        "revision.revise",
637        "revision",
638        Some(new_id),
639        format!(
640            "Created revision {}/{code} from {}",
641            src.item_number, src.code
642        ),
643        json!({ "item_id": src.item_id, "from": src.code, "files": files, "bom_lines": lines,
644                "references": references, "simulation_bases": simulation_bases,
645                "simulation_results": simulation_results,
646                "as_built_records": as_built_records, "as_built_deviations": as_built_deviations,
647                "mass_settings": mass_settings }),
648    )
649    .await?;
650    let new_rev = lock(&mut tx, new_id).await?;
651    tell_watchers(
652        &mut tx,
653        actor,
654        &new_rev,
655        format!("New revision {}", label(&new_rev)),
656        format!(
657            "{} started revision {code} of {} ({}) from {}.",
658            actor.display_name, src.item_number, src.name, src.code
659        ),
660    )
661    .await?;
662    tx.commit().await?;
663    get(pool, actor, new_id).await
664}
665
666/// Marks a released revision as obsolete.
667///
668/// # Invariants
669///
670/// * **Permission:** CAD editor; the revision is visible ("not found" otherwise) and in a group
671///   the actor may edit (403); it is released (409 otherwise), and its group does not require a
672///   change order for it (`obsolete_requires_change`, 409).
673/// * **Locks:** the revision row (`revisions::lock`).
674/// * **Audit:** `revision.obsolete`; when it is the latest revision of its item, also
675///   `impact.flag` for its dependents (`impacts::on_obsolete`).
676/// * **Notifications:** the watchers of the item; when the latest revision became obsolete also
677///   the people responsible for the flagged dependents.
678/// * **Webhooks:** `revision.obsoleted`.
679/// * **Data:** when it is the latest revision, the open impacts on the item are resolved and
680///   its dependents are flagged.
681/// * **Atomic:** one transaction.
682pub async fn obsolete(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Revision> {
683    actor.require(Role::Editor)?;
684    let mut tx = pool.begin().await?;
685    let rev = lock(&mut tx, id).await?;
686    ensure_project_access(&rev, actor)?;
687    if project_policy::obsolete_requires_change(&mut tx, rev.project_id).await? {
688        return Err(AppError::Conflict(format!(
689            "{} is in project group {}, which requires a change order to obsolete a revision: add an obsolete line to a change order.",
690            label(&rev),
691            rev.project_code.as_deref().unwrap_or("?")
692        )));
693    }
694    obsolete_in(&mut tx, actor, &rev).await?;
695    tx.commit().await?;
696    get(pool, actor, id).await
697}
698
699/// The obsoleting itself, in the caller's transaction: used by [`obsolete`] and when a
700/// change order with an obsolete line is released. The revision row must be locked and the
701/// permission checked by the caller.
702pub(crate) async fn obsolete_in(
703    tx: &mut PgConnection,
704    actor: &Actor,
705    rev: &Revision,
706) -> AppResult<()> {
707    let id = rev.id;
708    let rev = rev.clone();
709    transition(&mut *tx, &rev, LifecycleState::Obsolete).await?;
710    // Dependents of an item without a current revision have to replace it.
711    impacts::on_obsolete(tx, actor, &rev).await?;
712    audit::record(
713        tx,
714        Some(actor),
715        "revision.obsolete",
716        "revision",
717        Some(id),
718        format!("Marked {} as obsolete", label(&rev)),
719        json!({ "item_id": rev.item_id }),
720    )
721    .await?;
722    tell_watchers(
723        tx,
724        actor,
725        &rev,
726        format!("{} is obsolete", label(&rev)),
727        format!("{} marked {} as obsolete.", actor.display_name, rev.name),
728    )
729    .await?;
730    webhooks::emit(
731        tx,
732        Some(actor),
733        WebhookEvent::RevisionObsoleted,
734        &[rev.project_id],
735        json!({ "revision": webhooks::revision_object(&rev, LifecycleState::Obsolete.as_str()) }),
736    )
737    .await?;
738    Ok(())
739}
740
741/// Moves a (locked) revision to another lifecycle state after validating
742/// the transition. Entering review clears any check-out.
743pub(crate) async fn transition(
744    conn: &mut PgConnection,
745    rev: &Revision,
746    to: LifecycleState,
747) -> AppResult<()> {
748    let from = state_of(rev);
749    if !from.can_transition_to(to) {
750        return Err(AppError::Conflict(format!(
751            "{} cannot go from {} to {}.",
752            label(rev),
753            from.label(),
754            to.label()
755        )));
756    }
757    sqlx::query(
758        "UPDATE revisions SET state = $2, updated_at = now(),
759             released_at = CASE WHEN $2 = 'released' THEN now() ELSE released_at END,
760             checked_out_by = CASE WHEN $2 = 'in_work' THEN checked_out_by ELSE NULL END,
761             checked_out_at = CASE WHEN $2 = 'in_work' THEN checked_out_at ELSE NULL END
762         WHERE id = $1",
763    )
764    .bind(rev.id)
765    .bind(to.as_str())
766    .execute(&mut *conn)
767    .await?;
768    Ok(())
769}