1use std::collections::{BTreeMap, HashMap};
14
15use serde::Serialize;
16use serde_json::json;
17use sqlx::{PgConnection, PgPool};
18use uuid::Uuid;
19
20use crate::{
21 auth::Actor,
22 cadformat,
23 domain::{self, ReferenceKind},
24 error::{AppError, AppResult},
25 models::{ItemReference, Referrer},
26 services::{access, audit, items, revisions},
27};
28
29const REFERENCE_SELECT: &str = "
30 SELECT ir.id, ir.from_revision_id, ir.to_item_id, i.number AS to_number, i.item_type AS to_type,
31 ir.kind, ir.note, lr.id AS to_latest_revision_id, lr.code AS to_latest_code,
32 lr.state AS to_latest_state, lr.name AS to_name,
33 ir.released_to_revision_id, rr.code AS released_to_code, ir.created_at,
34 i.project_id AS to_project_id
35 FROM item_references ir
36 JOIN items i ON i.id = ir.to_item_id
37 CROSS JOIN LATERAL (
38 SELECT r.id, r.code, r.state, r.name FROM revisions r
39 WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
40 ) lr
41 LEFT JOIN revisions rr ON rr.id = ir.released_to_revision_id";
42
43pub async fn outgoing(
46 pool: &PgPool,
47 actor: &Actor,
48 revision_id: Uuid,
49) -> AppResult<Vec<ItemReference>> {
50 let mut rows = sqlx::query_as::<_, ItemReference>(&format!(
51 "{REFERENCE_SELECT} WHERE ir.from_revision_id = $1 ORDER BY ir.kind, i.number"
52 ))
53 .bind(revision_id)
54 .fetch_all(pool)
55 .await?;
56 for r in &mut rows {
57 if !actor.can_see_project(r.to_project_id) {
58 r.mask();
59 }
60 }
61 rows.sort_by(|a, b| (&a.kind, a.restricted).cmp(&(&b.kind, b.restricted)));
64 Ok(rows)
65}
66
67pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ItemReference> {
69 Ok(masked_for(actor, get_unmasked(pool, actor, id).await?))
70}
71
72async fn get_unmasked(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ItemReference> {
74 let r = sqlx::query_as::<_, ItemReference>(&format!("{REFERENCE_SELECT} WHERE ir.id = $1"))
75 .bind(id)
76 .fetch_optional(pool)
77 .await?
78 .ok_or_else(|| AppError::not_found("Reference not found."))?;
79 revisions::get(pool, actor, r.from_revision_id)
80 .await
81 .map_err(|_| AppError::not_found("Reference not found."))?;
82 Ok(r)
83}
84
85fn masked_for(actor: &Actor, mut r: ItemReference) -> ItemReference {
86 if !actor.can_see_project(r.to_project_id) {
87 r.mask();
88 }
89 r
90}
91
92pub async fn incoming(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<Vec<Referrer>> {
95 Ok(sqlx::query_as::<_, Referrer>(&format!(
96 "SELECT ir.id AS reference_id, ir.kind, ir.note, r.id AS from_revision_id, i.id AS from_item_id,
97 i.number AS from_number, r.code AS from_code, r.name AS from_name, r.state AS from_state,
98 rr.code AS released_to_code
99 FROM item_references ir
100 JOIN revisions r ON r.id = ir.from_revision_id
101 JOIN items i ON i.id = r.item_id
102 LEFT JOIN revisions rr ON rr.id = ir.released_to_revision_id
103 WHERE ir.to_item_id = $1 AND {}
104 ORDER BY i.number, r.seq DESC",
105 access::item_visible("i", 2)
106 ))
107 .bind(item_id)
108 .bind(&actor.hidden_projects)
109 .fetch_all(pool)
110 .await?)
111}
112
113pub async fn incoming_hidden(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<i64> {
115 if actor.hidden_projects.is_empty() {
116 return Ok(0);
117 }
118 Ok(sqlx::query_scalar::<_, i64>(&format!(
119 "SELECT COUNT(*) FROM item_references ir
120 JOIN revisions r ON r.id = ir.from_revision_id
121 JOIN items i ON i.id = r.item_id
122 WHERE ir.to_item_id = $1 AND NOT {}",
123 access::item_visible("i", 2)
124 ))
125 .bind(item_id)
126 .bind(&actor.hidden_projects)
127 .fetch_one(pool)
128 .await?)
129}
130
131#[derive(Clone, Debug)]
132pub struct NewReference {
133 pub to_number: String,
135 pub kind: ReferenceKind,
136 pub note: String,
137}
138
139pub async fn add(
152 pool: &PgPool,
153 actor: &Actor,
154 from_revision_id: Uuid,
155 input: NewReference,
156) -> AppResult<ItemReference> {
157 let note = domain::limit_text("Note", &input.note, 500)?;
158 let target = items::get_by_number(pool, actor, &input.to_number).await?;
159 let mut tx = pool.begin().await?;
160 let rev = revisions::lock(&mut tx, from_revision_id).await?;
161 revisions::ensure_editable(&rev, actor)?;
162 if target.id == rev.item_id {
163 return Err(AppError::BadRequest(
164 "An item cannot reference itself.".into(),
165 ));
166 }
167 let id = Uuid::new_v4();
168 sqlx::query(
169 "INSERT INTO item_references (id, from_revision_id, to_item_id, kind, note, created_by)
170 VALUES ($1, $2, $3, $4, $5, $6)",
171 )
172 .bind(id)
173 .bind(rev.id)
174 .bind(target.id)
175 .bind(input.kind.as_str())
176 .bind(¬e)
177 .bind(actor.id)
178 .execute(&mut *tx)
179 .await
180 .map_err(|e| {
181 AppError::unique_violation(
182 e,
183 format!(
184 "{} already has this reference to {}.",
185 revisions::label(&rev),
186 target.number
187 ),
188 )
189 })?;
190 audit::record_related(
191 &mut tx,
192 Some(actor),
193 "reference.add",
194 "revision",
195 Some(rev.id),
196 Some(target.id),
197 format!(
198 "{}: {} {}",
199 revisions::label(&rev),
200 input.kind.label().to_lowercase(),
201 target.number
202 ),
203 json!({ "item_id": rev.item_id, "reference_id": id, "to_item_id": target.id,
204 "kind": input.kind.as_str() }),
205 )
206 .await?;
207 tx.commit().await?;
208 get(pool, actor, id).await
209}
210
211pub async fn remove(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ItemReference> {
223 let existing = get_unmasked(pool, actor, id).await?;
224 let mut tx = pool.begin().await?;
225 let rev = revisions::lock(&mut tx, existing.from_revision_id).await?;
226 revisions::ensure_editable(&rev, actor)?;
227 sqlx::query("DELETE FROM item_references WHERE id = $1")
228 .bind(id)
229 .execute(&mut *tx)
230 .await?;
231 audit::record_related(
232 &mut tx,
233 Some(actor),
234 "reference.remove",
235 "revision",
236 Some(rev.id),
237 Some(existing.to_item_id),
238 format!(
239 "Removed the reference of {} to {}",
240 revisions::label(&rev),
241 existing.to_number
242 ),
243 json!({ "item_id": rev.item_id, "reference_id": id }),
244 )
245 .await?;
246 tx.commit().await?;
247 Ok(masked_for(actor, existing))
248}
249
250pub(crate) async fn copy_to_revision(
252 conn: &mut PgConnection,
253 from: Uuid,
254 to: Uuid,
255) -> AppResult<u64> {
256 Ok(sqlx::query(
257 "INSERT INTO item_references (id, from_revision_id, to_item_id, kind, note, created_by, created_at)
258 SELECT gen_random_uuid(), $2, to_item_id, kind, note, created_by, created_at
259 FROM item_references WHERE from_revision_id = $1",
260 )
261 .bind(from)
262 .bind(to)
263 .execute(&mut *conn)
264 .await?
265 .rows_affected())
266}
267
268#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
274pub struct FileMatch {
275 #[serde(skip)]
276 pub key: String,
277 pub item_id: Uuid,
278 pub item_number: String,
279 pub item_name: String,
280 pub revision_id: Uuid,
281 pub revision_code: String,
282 pub revision_state: String,
283 pub file_id: Uuid,
284 pub filename: String,
285 pub same_revision: bool,
287 pub export_name: bool,
290}
291
292#[derive(Clone, Debug, Serialize)]
294pub struct ReferencedName {
295 pub name: String,
296 pub instances: Option<i32>,
300 pub matches: Vec<FileMatch>,
301}
302
303#[derive(Clone, Debug, Serialize)]
305pub struct FileWithReferences {
306 pub file_id: Uuid,
307 pub filename: String,
308 pub format_kind: Option<cadformat::FormatKind>,
309 pub references: Vec<ReferencedName>,
310}
311
312pub async fn files_of_revision(
315 pool: &PgPool,
316 actor: &Actor,
317 revision_id: Uuid,
318) -> AppResult<Vec<FileWithReferences>> {
319 #[derive(sqlx::FromRow)]
320 struct Row {
321 file_id: Uuid,
322 filename: String,
323 format: String,
324 name: String,
325 instances: Option<i32>,
326 }
327 let rows = sqlx::query_as::<_, Row>(
328 "SELECT f.id AS file_id, f.filename, COALESCE(f.format, '') AS format, fr.name,
329 fr.instances
330 FROM files f
331 JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
332 JOIN file_references fr ON fr.file_version_id = fv.id
333 WHERE f.revision_id = $1
334 ORDER BY lower(f.filename), lower(fr.name)",
335 )
336 .bind(revision_id)
337 .fetch_all(pool)
338 .await?;
339 if rows.is_empty() {
340 return Ok(Vec::new());
341 }
342 let keys: Vec<String> = rows.iter().map(|r| r.name.to_lowercase()).collect();
343 let scope = format!(
346 "(r.id = $2 OR (r.item_id <> (SELECT item_id FROM revisions WHERE id = $2)
347 AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)))
348 AND {}",
349 access::item_visible("i", 3)
350 );
351 let found = sqlx::query_as::<_, FileMatch>(&format!(
352 "SELECT lower(f.filename) AS key, i.id AS item_id, i.number AS item_number, r.name AS item_name,
353 r.id AS revision_id, r.code AS revision_code, r.state AS revision_state,
354 f.id AS file_id, f.filename, r.id = $2 AS same_revision, FALSE AS export_name
355 FROM files f
356 JOIN revisions r ON r.id = f.revision_id
357 JOIN items i ON i.id = r.item_id
358 WHERE lower(f.filename) = ANY($1) AND {scope}
359 ORDER BY i.number"
360 ))
361 .bind(&keys)
362 .bind(revision_id)
363 .bind(&actor.hidden_projects)
364 .fetch_all(pool)
365 .await?;
366 let mut by_key: HashMap<String, Vec<FileMatch>> = HashMap::new();
367 for m in found {
368 by_key.entry(m.key.clone()).or_default().push(m);
369 }
370 let unmatched: Vec<String> = keys
373 .iter()
374 .filter(|k| !by_key.contains_key(*k))
375 .cloned()
376 .collect::<std::collections::BTreeSet<_>>()
377 .into_iter()
378 .collect();
379 if !unmatched.is_empty() {
380 let weak = sqlx::query_as::<_, FileMatch>(&format!(
381 "SELECT lower(fv.export_name) AS key, i.id AS item_id, i.number AS item_number,
382 r.name AS item_name, r.id AS revision_id, r.code AS revision_code,
383 r.state AS revision_state, f.id AS file_id, f.filename,
384 r.id = $2 AS same_revision, TRUE AS export_name
385 FROM files f
386 JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
387 JOIN revisions r ON r.id = f.revision_id
388 JOIN items i ON i.id = r.item_id
389 WHERE lower(fv.export_name) = ANY($1) AND {scope}
390 ORDER BY i.number, f.filename"
391 ))
392 .bind(&unmatched)
393 .bind(revision_id)
394 .bind(&actor.hidden_projects)
395 .fetch_all(pool)
396 .await?;
397 for m in weak {
398 by_key.entry(m.key.clone()).or_default().push(m);
399 }
400 }
401 let mut files: BTreeMap<(String, Uuid), FileWithReferences> = BTreeMap::new();
402 for row in rows {
403 let entry = files
404 .entry((row.filename.to_lowercase(), row.file_id))
405 .or_insert_with(|| FileWithReferences {
406 file_id: row.file_id,
407 filename: row.filename.clone(),
408 format_kind: cadformat::by_id(&row.format).map(|f| f.kind),
409 references: Vec::new(),
410 });
411 let matches = by_key
413 .get(&row.name.to_lowercase())
414 .map(|ms| {
415 ms.iter()
416 .filter(|m| !(m.export_name && m.file_id == row.file_id))
417 .cloned()
418 .collect()
419 })
420 .unwrap_or_default();
421 entry.references.push(ReferencedName {
422 name: row.name,
423 instances: row.instances,
424 matches,
425 });
426 }
427 Ok(files.into_values().collect())
428}
429
430#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
432pub struct FileReferrer {
433 pub item_id: Uuid,
434 pub item_number: String,
435 pub revision_id: Uuid,
436 pub revision_code: String,
437 pub revision_state: String,
438 pub item_name: String,
439 pub file_id: Uuid,
440 pub filename: String,
441 pub referenced: String,
443 pub export_name: bool,
446}
447
448pub async fn file_referrers(
454 pool: &PgPool,
455 actor: &Actor,
456 revision_id: Uuid,
457) -> AppResult<Vec<FileReferrer>> {
458 let all = file_referrers_unchecked(pool, revision_id).await?;
459 let ids: Vec<Uuid> = all.iter().map(|f| f.item_id).collect();
460 let visible: std::collections::HashSet<Uuid> = sqlx::query_scalar::<_, Uuid>(&format!(
461 "SELECT i.id FROM items i WHERE i.id = ANY($1) AND {}",
462 access::item_visible("i", 2)
463 ))
464 .bind(&ids)
465 .bind(&actor.hidden_projects)
466 .fetch_all(pool)
467 .await?
468 .into_iter()
469 .collect();
470 Ok(all
471 .into_iter()
472 .filter(|f| visible.contains(&f.item_id))
473 .collect())
474}
475
476pub(crate) async fn file_referrers_unchecked(
478 pool: &PgPool,
479 revision_id: Uuid,
480) -> AppResult<Vec<FileReferrer>> {
481 Ok(sqlx::query_as::<_, FileReferrer>(
486 "SELECT DISTINCT ON (item_number, filename, referenced) *
487 FROM (
488 SELECT i.id AS item_id, i.number AS item_number, r.id AS revision_id,
489 r.code AS revision_code, r.state AS revision_state, r.name AS item_name,
490 df.id AS file_id, df.filename, sf.filename AS referenced, FALSE AS export_name
491 FROM files sf
492 JOIN revisions sr ON sr.id = sf.revision_id
493 JOIN file_references fr ON lower(fr.name) = lower(sf.filename)
494 JOIN file_versions dv ON dv.id = fr.file_version_id
495 JOIN files df ON df.id = dv.file_id AND dv.version = df.current_version
496 JOIN revisions r ON r.id = df.revision_id
497 JOIN items i ON i.id = r.item_id
498 WHERE sf.revision_id = $1
499 AND r.item_id <> sr.item_id
500 AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
501 UNION ALL
502 SELECT i.id, i.number, r.id, r.code, r.state, r.name, df.id, df.filename,
503 sf.filename, TRUE
504 FROM files sf
505 JOIN file_versions sv ON sv.file_id = sf.id AND sv.version = sf.current_version
506 JOIN revisions sr ON sr.id = sf.revision_id
507 JOIN file_references fr ON lower(fr.name) = lower(sv.export_name)
508 JOIN file_versions dv ON dv.id = fr.file_version_id
509 JOIN files df ON df.id = dv.file_id AND dv.version = df.current_version
510 JOIN revisions r ON r.id = df.revision_id
511 JOIN items i ON i.id = r.item_id
512 WHERE sf.revision_id = $1
513 AND sv.export_name IS NOT NULL
514 AND r.item_id <> sr.item_id
515 AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
516 AND NOT EXISTS (
517 SELECT 1 FROM files xf JOIN revisions xr ON xr.id = xf.revision_id
518 WHERE lower(xf.filename) = lower(fr.name)
519 AND (xr.id = r.id
520 OR (xr.item_id <> r.item_id
521 AND xr.seq = (SELECT MAX(y.seq) FROM revisions y
522 WHERE y.item_id = xr.item_id))))
523 ) m
524 ORDER BY item_number, filename, referenced, export_name",
525 )
526 .bind(revision_id)
527 .fetch_all(pool)
528 .await?)
529}
530
531#[derive(Clone, Debug, Default, Serialize)]
535pub struct Suggestions {
536 pub bom: Vec<SuggestedItem>,
537 pub drawing_of: Vec<SuggestedItem>,
538}
539
540#[derive(Clone, Debug, Serialize)]
541pub struct SuggestedItem {
542 pub item_number: String,
543 pub item_name: String,
544 pub via: String,
546}
547
548pub fn suggestions(
549 files: &[FileWithReferences],
550 bom_children: &[String],
551 references: &[ItemReference],
552) -> Suggestions {
553 let mut out = Suggestions::default();
554 let mut seen = std::collections::HashSet::new();
555 for file in files {
556 let kind = file.format_kind;
557 for name in &file.references {
558 for m in name.matches.iter().filter(|m| !m.same_revision) {
559 let via = format!("{} → {}", file.filename, m.filename);
560 let item = SuggestedItem {
561 item_number: m.item_number.clone(),
562 item_name: m.item_name.clone(),
563 via,
564 };
565 match kind {
566 Some(cadformat::FormatKind::Assembly | cadformat::FormatKind::Model)
567 if !bom_children.contains(&m.item_number)
568 && seen.insert(("bom", m.item_number.clone())) =>
569 {
570 out.bom.push(item)
571 }
572 Some(cadformat::FormatKind::Drawing)
573 if !references.iter().any(|r| {
574 r.to_number == m.item_number
575 && r.kind == ReferenceKind::Drawing.as_str()
576 }) && seen.insert(("drawing", m.item_number.clone())) =>
577 {
578 out.drawing_of.push(item)
579 }
580 _ => {}
581 }
582 }
583 }
584 }
585 out
586}
587
588pub(crate) async fn store_export_name(
590 conn: &mut PgConnection,
591 file_version_id: Uuid,
592 export_name: Option<&str>,
593) -> AppResult<()> {
594 sqlx::query(
595 "UPDATE file_versions SET export_name = $2, export_name_scanned = TRUE WHERE id = $1",
596 )
597 .bind(file_version_id)
598 .bind(export_name)
599 .execute(&mut *conn)
600 .await?;
601 Ok(())
602}
603
604pub(crate) async fn store_file_references(
607 conn: &mut PgConnection,
608 file_version_id: Uuid,
609 refs: &[crate::cadrefs::FileRef],
610) -> AppResult<()> {
611 if !refs.is_empty() {
612 let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
613 let instances: Vec<Option<i32>> = refs
614 .iter()
615 .map(|r| r.instances.map(|n| i32::try_from(n).unwrap_or(i32::MAX)))
616 .collect();
617 sqlx::query(
618 "INSERT INTO file_references (file_version_id, name, instances)
619 SELECT $1, n, i FROM unnest($2::text[], $3::int4[]) AS t (n, i)
620 ON CONFLICT DO NOTHING",
621 )
622 .bind(file_version_id)
623 .bind(&names)
624 .bind(&instances)
625 .execute(&mut *conn)
626 .await?;
627 }
628 sqlx::query("UPDATE file_versions SET references_scanned = TRUE WHERE id = $1")
629 .bind(file_version_id)
630 .execute(&mut *conn)
631 .await?;
632 Ok(())
633}