Skip to main content

teamcraft/services/
save_as.rs

1//! "Save As" (as in Teamcenter): a new item from an
2//! existing revision – its attributes, optionally its current files and its
3//! BOM. The new item starts with revision A, in work and checked out to the
4//! author, exactly like a freshly created item.
5
6use serde_json::json;
7use sqlx::PgPool;
8use uuid::Uuid;
9
10use crate::{
11    auth::Actor,
12    domain::{self, ItemType},
13    error::{AppError, AppResult},
14    models::Revision,
15    services::{audit, items, revisions},
16};
17
18pub struct SaveAs {
19    /// Empty: a number is generated (`PRT-000043`).
20    pub number: Option<String>,
21    /// Empty: the name of the source revision.
22    pub name: String,
23    /// `None`: the project group of the source item.
24    pub project_id: Option<Uuid>,
25    pub with_files: bool,
26    pub with_bom: bool,
27}
28
29/// Creates the new item from `source_revision_id` (see the module
30/// documentation).
31///
32/// # Invariants
33///
34/// * **Permission:** the source revision is visible to the actor; creating
35///   the item needs the CAD editor role and membership of the target group;
36///   copying out of a restricted group into another one needs a manager of
37///   the source group (`items::ensure_may_take_out`).
38/// * **Locks:** the target group (`FOR SHARE`, a visibility switch waits)
39///   and the number counter that creating the item steps.
40/// * **Audit:** `item.create` and `item.save_as`.
41/// * **Notifications:** none; the author becomes a watcher of the new item.
42/// * **Webhooks:** none.
43/// * **Atomic:** item, revision, files and BOM lines in one transaction – a
44///   failure leaves no item and no counter step (`tests/atomic_processes.rs`).
45pub async fn save_as(
46    pool: &PgPool,
47    actor: &Actor,
48    source_revision_id: Uuid,
49    input: SaveAs,
50) -> AppResult<Revision> {
51    let src = revisions::get(pool, actor, source_revision_id).await?;
52    let item_type = ItemType::parse(&src.item_type)
53        .ok_or_else(|| AppError::Other(anyhow::anyhow!("unknown item type {}", src.item_type)))?;
54    let name = match input.name.trim() {
55        "" => src.name.clone(),
56        name => name.to_string(),
57    };
58    let mut tx = pool.begin().await?;
59    // Copying out of a restricted group declassifies its content: managers
60    // of the source group (and admins) only.
61    let target = input.project_id.or(src.project_id);
62    if let Some(source) = src.project_id {
63        if target != Some(source) {
64            items::ensure_may_take_out(&mut tx, actor, source, "Copying").await?;
65        }
66    }
67    // creation checks the role and the membership in the target project group
68    let created = items::create_tx(
69        &mut tx,
70        actor,
71        items::NewItem {
72            number: input.number.filter(|n| !n.trim().is_empty()),
73            item_type,
74            unit: src.unit.clone(),
75            project_id: input.project_id.or(src.project_id),
76            name,
77            description: src.description.clone(),
78            material: src.material.clone(),
79            mass_kg: src.mass_kg,
80            unit_cost: src.unit_cost,
81            classification: domain::Classification::parse(&src.classification).unwrap_or_default(),
82            attributes: Vec::new(),
83        },
84    )
85    .await?;
86    let new_rev = created.revision_id;
87    // The custom attribute values are copied as they are (like revise does),
88    // including those of attributes that were deactivated meanwhile.
89    sqlx::query("UPDATE revisions SET attributes = $2 WHERE id = $1")
90        .bind(new_rev)
91        .bind(&src.attributes)
92        .execute(&mut *tx)
93        .await?;
94    let label = format!("{}/{}", src.item_number, src.code);
95    // A copy out of a restricted group is declassified, the source's
96    // identity is not: the file versions do not name it (the audit entry
97    // does, redacted for readers who may not see the source).
98    let source_restricted =
99        match src.project_id {
100            Some(source) if target != Some(source) => sqlx::query_scalar::<_, bool>(
101                "SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1 AND visibility = 'restricted')",
102            )
103            .bind(source)
104            .fetch_one(&mut *tx)
105            .await?,
106            _ => false,
107        };
108    let copied_from = if source_restricted {
109        "a restricted item".to_string()
110    } else {
111        label.clone()
112    };
113
114    let mut files = 0;
115    if input.with_files {
116        files = sqlx::query(
117            "INSERT INTO files (id, revision_id, filename, content_type, size_bytes, sha256,
118                                uploaded_by, uploaded_at, format, folder, retention)
119             SELECT gen_random_uuid(), $2, filename, content_type, size_bytes, sha256, $3, now(), format,
120                    folder, retention
121             FROM files WHERE revision_id = $1",
122        )
123        .bind(src.id)
124        .bind(new_rev)
125        .bind(actor.id)
126        .execute(&mut *tx)
127        .await?
128        .rows_affected();
129        // every copied file starts as v1, with the searchable text and the
130        // CAD references of the source version
131        sqlx::query(
132            "INSERT INTO file_versions (id, file_id, version, sha256, size_bytes, content_type, comment,
133                                        uploaded_by, uploaded_at, content_text, references_scanned,
134                                        export_name, export_name_scanned, solver_summary)
135             SELECT gen_random_uuid(), f.id, 1, f.sha256, f.size_bytes, f.content_type,
136                    'Copied from ' || $3, $4, now(),
137                    COALESCE(ov.content_text, ''), COALESCE(ov.references_scanned, FALSE),
138                    ov.export_name, COALESCE(ov.export_name_scanned, FALSE), ov.solver_summary
139             FROM files f
140             LEFT JOIN files ofl ON ofl.revision_id = $2 AND ofl.filename = f.filename
141             LEFT JOIN file_versions ov ON ov.file_id = ofl.id AND ov.version = ofl.current_version
142             WHERE f.revision_id = $1",
143        )
144        .bind(new_rev)
145        .bind(src.id)
146        .bind(&copied_from)
147        .bind(actor.id)
148        .execute(&mut *tx)
149        .await?;
150        sqlx::query(
151            "INSERT INTO file_references (file_version_id, name, instances)
152             SELECT nv.id, fr.name, fr.instances
153             FROM files nf
154             JOIN file_versions nv ON nv.file_id = nf.id
155             JOIN files ofl ON ofl.revision_id = $2 AND ofl.filename = nf.filename
156             JOIN file_versions ov ON ov.file_id = ofl.id AND ov.version = ofl.current_version
157             JOIN file_references fr ON fr.file_version_id = ov.id
158             WHERE nf.revision_id = $1",
159        )
160        .bind(new_rev)
161        .bind(src.id)
162        .execute(&mut *tx)
163        .await?;
164    }
165    let mut lines = 0;
166    if input.with_bom {
167        lines = sqlx::query(
168            "INSERT INTO bom_lines (id, parent_revision_id, child_item_id, find_number, quantity, notes)
169             SELECT gen_random_uuid(), $2, child_item_id, find_number, quantity, notes
170             FROM bom_lines WHERE parent_revision_id = $1",
171        )
172        .bind(src.id)
173        .bind(new_rev)
174        .execute(&mut *tx)
175        .await?
176        .rows_affected();
177    }
178    audit::record_related(
179        &mut tx,
180        Some(actor),
181        "item.save_as",
182        "item",
183        Some(created.item_id),
184        Some(src.item_id),
185        format!("Saved {label} as a new item"),
186        json!({ "from_revision": src.id, "files": files, "bom_lines": lines }),
187    )
188    .await?;
189    tx.commit().await?;
190    revisions::get(pool, actor, new_rev).await
191}