1use 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
37pub 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
65pub(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
76pub 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
83pub 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
99pub 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
114pub 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
132pub 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
156pub 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 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
195pub 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
254pub 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
292pub 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 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], ¬ice).await?;
395 }
396 tx.commit().await?;
397 get(pool, actor, id).await
398}
399
400pub const RELEASE_REASON_MIN: usize = 10;
402pub const RELEASE_REASON_MAX: usize = 500;
404
405pub fn check_release_reason(raw: Option<&str>) -> AppResult<()> {
408 release_reason(raw).map(|_| ())
409}
410
411fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
427pub enum Release {
428 No,
430 Yes,
432 WithReason,
434}
435
436pub 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
463pub 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
469async 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
498pub(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, ¬ice).await?;
509 Ok(())
510}
511
512pub 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 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 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 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
666pub 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
699pub(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 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
741pub(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}