Skip to main content

teamcraft/services/
items.rs

1//! Items (parts, assemblies, documents, software) and their search.
2
3use chrono::Utc;
4use serde_json::json;
5use sqlx::{PgConnection, PgPool};
6use uuid::Uuid;
7
8use crate::{
9    auth::Actor,
10    domain::{self, Classification, ItemType, LifecycleState, ProjectVisibility, Role},
11    error::{AppError, AppResult},
12    models::{Item, ItemSummary, Revision, WhereUsed, WhereUsedLevel},
13    services::{
14        access, attributes, audit, files, notifications, projects, revisions, simulations, Page,
15    },
16};
17
18pub(crate) const ITEM_SELECT: &str = "
19    SELECT i.id, i.number, i.item_type, i.unit, i.project_id, p.code AS project_code,
20           i.created_by, u.display_name AS created_by_name, i.created_at, i.classification,
21           i.owner_id, ou.display_name AS owner_name
22    FROM items i
23    LEFT JOIN projects p ON p.id = i.project_id
24    JOIN users u ON u.id = i.created_by
25    JOIN users ou ON ou.id = i.owner_id";
26
27#[derive(Clone, Debug, Default)]
28pub struct ItemFilter {
29    /// Free text matched against number, name and description.
30    pub query: Option<String>,
31    pub item_type: Option<ItemType>,
32    pub project_id: Option<Uuid>,
33    /// State of the latest revision.
34    pub state: Option<LifecycleState>,
35    /// CAD program (`cadformat::SYSTEMS` id): only items whose latest revision
36    /// has a file of one of its formats.
37    pub system: Option<String>,
38    pub classification: Option<Classification>,
39    /// Custom attribute key: only items whose latest revision has a value
40    /// matching `attribute_value` (see `domain::parse_attribute_condition`).
41    pub attribute: Option<String>,
42    pub attribute_value: Option<String>,
43    /// Simulations whose latest revision has a result value in this state
44    /// (see `domain::ResultFilter`), optionally only for values whose name
45    /// contains `result_name`.
46    pub result: Option<domain::ResultFilter>,
47    pub result_name: Option<String>,
48}
49
50/// Bind values of an attribute condition: key, LIKE pattern, yes/no, min, max.
51type AttributeBinds = (
52    Option<String>,
53    Option<String>,
54    Option<bool>,
55    Option<f64>,
56    Option<f64>,
57);
58
59async fn attribute_binds(pool: &PgPool, filter: &ItemFilter) -> AppResult<AttributeBinds> {
60    let Some(key) = filter.attribute.as_deref().filter(|k| !k.trim().is_empty()) else {
61        return Ok((None, None, None, None, None));
62    };
63    let def = attributes::get_by_key(pool, key)
64        .await
65        .map_err(|_| AppError::BadRequest(format!("Unknown attribute '{}'.", key.trim())))?;
66    let data_type =
67        domain::AttributeType::parse(&def.data_type).unwrap_or(domain::AttributeType::Text);
68    let condition = domain::parse_attribute_condition(
69        &def.label,
70        data_type,
71        filter.attribute_value.as_deref().unwrap_or(""),
72    )?;
73    let key = Some(def.key);
74    Ok(match condition {
75        domain::AttributeCondition::Present => (key, None, None, None, None),
76        domain::AttributeCondition::Contains(text) => {
77            (key, Some(domain::like_pattern(&text)), None, None, None)
78        }
79        domain::AttributeCondition::Bool(b) => (key, None, Some(b), None, None),
80        domain::AttributeCondition::Range(min, max) => (key, None, None, min, max),
81    })
82}
83
84/// Searches items the actor may read; every row carries its latest revision.
85///
86/// The latest revision of an item is looked up per item, so the query never
87/// does that for the whole vault: a search text, a state, a CAD program or
88/// an attribute condition first narrows the items to candidates found
89/// set-based (any revision matches – a superset, the exact conditions on
90/// the latest revision are checked afterwards), and the total comes from the
91/// same query (`COUNT(*) OVER ()`). Without such a filter only the requested
92/// page of items (by number) is looked up, and the total is a plain count.
93pub async fn search(
94    pool: &PgPool,
95    actor: &Actor,
96    filter: &ItemFilter,
97    page: Page,
98) -> AppResult<(Vec<ItemSummary>, i64)> {
99    // $1 = LIKE pattern, $5 = prefix tsquery, $6 = formats the query names
100    // ("SolidWorks", "NX", "STEP"), $7 = formats of the CAD program filter,
101    // $8/$9 = limit/offset, $10 = the actor's hidden project groups,
102    // $11 = classification, $12–$16 = attribute key, LIKE pattern, yes/no,
103    // min, max, $17 = result filter, $18 = LIKE pattern of a result name.
104    // A match is any of: item number, name/description/material or a custom
105    // attribute value (substring or full-text), a file name, the extracted
106    // text of the current version of a file of the latest revision, or a file
107    // of a format the query names.
108    const JOINS: &str = "
109        LEFT JOIN projects p ON p.id = i.project_id
110        CROSS JOIN LATERAL (
111            SELECT r.id, r.code, r.state, r.name, r.description, r.material, r.search_vector,
112                   r.attributes
113            FROM revisions r WHERE r.item_id = i.id ORDER BY r.seq DESC LIMIT 1
114        ) lr";
115    // A file of the latest revision whose name, content or format matches.
116    // In the condition it is checked last (only for items that match
117    // nothing else); the file name is only looked up for the rows of the
118    // page (PostgreSQL computes such sub-selects after ORDER BY / LIMIT).
119    const FILE_MATCH: &str = "
120        FROM files f
121        JOIN file_versions fv ON fv.file_id = f.id AND fv.version = f.current_version
122        WHERE $1::text IS NOT NULL AND f.revision_id = lr.id
123          AND (f.filename ILIKE $1
124               OR ($5::text IS NOT NULL AND fv.content_vector @@ to_tsquery('simple', $5))
125               OR f.format = ANY($6::text[]))";
126    // A result value of the revision: its name and its state against the limits.
127    const RESULT_MATCH: &str = "($18::text IS NULL OR sr.name ILIKE $18)
128          AND ($17::text IS NULL
129               OR ($17 = 'failed' AND (RESULT_FAILED))
130               OR ($17 = 'passed' AND (sr.lower_limit IS NOT NULL OR sr.upper_limit IS NOT NULL)
131                   AND NOT (RESULT_FAILED))
132               OR ($17 = 'above_limit' AND sr.upper_limit IS NOT NULL AND sr.value > sr.upper_limit)
133               OR ($17 = 'below_limit' AND sr.lower_limit IS NOT NULL AND sr.value < sr.lower_limit))";
134    let result_match = RESULT_MATCH.replace("RESULT_FAILED", simulations::RESULT_FAILED_SQL);
135    let visible = access::item_visible("i", 10);
136    // conditions on the item itself (also applied to the page of items)
137    let item_conditions = format!(
138        "($2::text IS NULL OR i.item_type = $2)
139          AND ($3::uuid IS NULL OR i.project_id = $3)
140          AND ($11::text IS NULL OR i.classification = $11)
141          AND {visible}"
142    );
143    let where_ = format!(
144        "($1::text IS NULL
145               OR i.number ILIKE $1 OR lr.name ILIKE $1 OR lr.description ILIKE $1 OR lr.material ILIKE $1
146               OR jsonb_path_query_array(lr.attributes, '$.*')::text ILIKE $1
147               OR ($5::text IS NOT NULL AND lr.search_vector @@ to_tsquery('simple', $5))
148               OR EXISTS (SELECT 1 {FILE_MATCH}))
149          AND {item_conditions}
150          AND ($4::text IS NULL OR lr.state = $4)
151          AND ($7::text[] IS NULL OR EXISTS (
152                SELECT 1 FROM files sf WHERE sf.revision_id = lr.id AND sf.format = ANY($7::text[])))
153          AND ($12::text IS NULL OR (lr.attributes ? $12
154               AND ($13::text IS NULL OR lr.attributes ->> $12 ILIKE $13)
155               AND ($14::boolean IS NULL OR lr.attributes -> $12 = to_jsonb($14::boolean))
156               AND ($15::float8 IS NULL OR CASE WHEN jsonb_typeof(lr.attributes -> $12) = 'number'
157                    THEN (lr.attributes ->> $12)::float8 >= $15 ELSE FALSE END)
158               AND ($16::float8 IS NULL OR CASE WHEN jsonb_typeof(lr.attributes -> $12) = 'number'
159                    THEN (lr.attributes ->> $12)::float8 <= $16 ELSE FALSE END)))
160          AND ($17::text IS NULL AND $18::text IS NULL OR EXISTS (
161                SELECT 1 FROM simulation_results sr WHERE sr.revision_id = lr.id AND {result_match}))"
162    );
163    let columns = format!(
164        "i.id, i.number, i.item_type, i.unit, p.code AS project_code, i.created_at,
165         lr.id AS latest_revision_id, lr.code AS latest_code, lr.state AS latest_state, lr.name,
166         (SELECT rr.code FROM revisions rr WHERE rr.item_id = i.id AND rr.state = 'released'
167          ORDER BY rr.seq DESC LIMIT 1) AS released_code,
168         (SELECT f.filename {FILE_MATCH} ORDER BY f.filename LIMIT 1) AS matched_file,
169         i.classification, lr.attributes"
170    );
171    const ORDER: &str = "ORDER BY CASE WHEN $5::text IS NULL THEN 0
172                       ELSE ts_rank(lr.search_vector, to_tsquery('simple', $5)) END DESC,
173                  i.number";
174    // Items with a revision or file that matches the text (in any revision).
175    const TEXT_CANDIDATES: &str = "
176        SELECT ci.id FROM items ci WHERE ci.number ILIKE $1
177        UNION
178        SELECT cr.item_id FROM revisions cr
179        WHERE cr.name ILIKE $1 OR cr.description ILIKE $1 OR cr.material ILIKE $1
180           OR jsonb_path_query_array(cr.attributes, '$.*')::text ILIKE $1
181           OR ($5::text IS NOT NULL AND cr.search_vector @@ to_tsquery('simple', $5))
182        UNION
183        SELECT cr.item_id FROM files cf JOIN revisions cr ON cr.id = cf.revision_id
184        WHERE cf.filename ILIKE $1 OR cf.format = ANY($6::text[])
185        UNION
186        SELECT cr.item_id FROM file_versions cv
187        JOIN files cf ON cf.id = cv.file_id AND cv.version = cf.current_version
188        JOIN revisions cr ON cr.id = cf.revision_id
189        WHERE $5::text IS NOT NULL AND cv.content_vector @@ to_tsquery('simple', $5)";
190    const STATE_CANDIDATES: &str = "SELECT cr.item_id FROM revisions cr WHERE cr.state = $4";
191    const SYSTEM_CANDIDATES: &str = "
192        SELECT cr.item_id FROM files cf JOIN revisions cr ON cr.id = cf.revision_id
193        WHERE cf.format = ANY($7::text[])";
194    const ATTRIBUTE_CANDIDATES: &str =
195        "SELECT cr.item_id FROM revisions cr WHERE cr.attributes ? $12";
196    let result_candidates = format!(
197        "SELECT cr.item_id FROM simulation_results sr JOIN revisions cr ON cr.id = sr.revision_id
198         WHERE {result_match}"
199    );
200
201    let classification = filter.classification.map(Classification::as_str);
202    let (attr_key, attr_pattern, attr_bool, attr_min, attr_max) =
203        attribute_binds(pool, filter).await?;
204    let query = filter
205        .query
206        .as_deref()
207        .map(str::trim)
208        .filter(|q| !q.is_empty());
209    let pattern = query.map(domain::like_pattern);
210    let tsquery = query.and_then(crate::textindex::prefix_query);
211    let result_state = filter.result.map(domain::ResultFilter::as_str);
212    let result_pattern = filter
213        .result_name
214        .as_deref()
215        .map(str::trim)
216        .filter(|n| !n.is_empty())
217        .map(domain::like_pattern);
218    let item_type = filter.item_type.map(ItemType::as_str);
219    let state = filter.state.map(LifecycleState::as_str);
220    let named_formats = query
221        .map(crate::cadformat::ids_named_by)
222        .unwrap_or_default();
223    let system_formats = filter
224        .system
225        .as_deref()
226        .filter(|s| !s.is_empty())
227        .map(crate::cadformat::ids_of_system);
228
229    let mut narrow: Vec<&str> = Vec::new();
230    if pattern.is_some() {
231        narrow.push(TEXT_CANDIDATES);
232    }
233    if state.is_some() {
234        narrow.push(STATE_CANDIDATES);
235    }
236    if system_formats.is_some() {
237        narrow.push(SYSTEM_CANDIDATES);
238    }
239    if attr_key.is_some() {
240        narrow.push(ATTRIBUTE_CANDIDATES);
241    }
242    if result_state.is_some() || result_pattern.is_some() {
243        narrow.push(&result_candidates);
244    }
245    let candidates: String = narrow
246        .iter()
247        .map(|c| format!(" AND i.id IN ({c})"))
248        .collect();
249
250    #[derive(sqlx::FromRow)]
251    struct Row {
252        #[sqlx(flatten)]
253        item: ItemSummary,
254        total: i64,
255    }
256    let rows_sql = if narrow.is_empty() {
257        // Only the page of (visible) items is looked up; every item has at
258        // least one revision (created together, revisions are never deleted
259        // alone), so the latest-revision conditions cannot drop any item here.
260        format!(
261            "WITH page AS (
262                 SELECT i.id FROM items i
263                 WHERE {item_conditions}
264                 ORDER BY i.number LIMIT $8 OFFSET $9
265             )
266             SELECT {columns}, 0::bigint AS total
267             FROM page JOIN items i ON i.id = page.id {JOINS}
268             WHERE {where_}
269             {ORDER}"
270        )
271    } else {
272        format!(
273            "SELECT {columns}, COUNT(*) OVER () AS total
274             FROM items i {JOINS}
275             WHERE {where_} {candidates}
276             {ORDER}
277             LIMIT $8 OFFSET $9"
278        )
279    };
280    let rows = sqlx::query_as::<_, Row>(&rows_sql)
281        .bind(&pattern)
282        .bind(item_type)
283        .bind(filter.project_id)
284        .bind(state)
285        .bind(&tsquery)
286        .bind(&named_formats)
287        .bind(&system_formats)
288        .bind(page.limit)
289        .bind(page.offset)
290        .bind(&actor.hidden_projects)
291        .bind(classification)
292        .bind(&attr_key)
293        .bind(&attr_pattern)
294        .bind(attr_bool)
295        .bind(attr_min)
296        .bind(attr_max)
297        .bind(result_state)
298        .bind(&result_pattern)
299        .fetch_all(pool)
300        .await?;
301    // The window total is there whenever the page has rows; a page behind
302    // the last one (and the unfiltered listing) needs the count query.
303    let total = match rows.first() {
304        _ if narrow.is_empty() => {
305            sqlx::query_scalar::<_, i64>(&format!(
306                "SELECT COUNT(*) FROM items i
307                 WHERE ($1::text IS NULL OR i.item_type = $1) AND ($2::uuid IS NULL OR i.project_id = $2)
308                   AND ($4::text IS NULL OR i.classification = $4)
309                   AND {}",
310                access::item_visible("i", 3)
311            ))
312            .bind(item_type)
313            .bind(filter.project_id)
314            .bind(&actor.hidden_projects)
315            .bind(classification)
316            .fetch_one(pool)
317            .await?
318        }
319        Some(row) => row.total,
320        None if page.offset == 0 => 0,
321        None => {
322            // same parameter positions as the page query ($8/$9 unused)
323            sqlx::query_scalar::<_, i64>(&format!(
324                "SELECT COUNT(*) FROM items i {JOINS} WHERE {where_} {candidates}
325                   AND $8::bigint IS NOT NULL AND $9::bigint IS NOT NULL"
326            ))
327            .bind(&pattern)
328            .bind(item_type)
329            .bind(filter.project_id)
330            .bind(state)
331            .bind(&tsquery)
332            .bind(&named_formats)
333            .bind(&system_formats)
334            .bind(page.limit)
335            .bind(page.offset)
336            .bind(&actor.hidden_projects)
337            .bind(classification)
338            .bind(&attr_key)
339            .bind(&attr_pattern)
340            .bind(attr_bool)
341            .bind(attr_min)
342            .bind(attr_max)
343        .bind(result_state)
344        .bind(&result_pattern)
345            .fetch_one(pool)
346            .await?
347        }
348    };
349    Ok((rows.into_iter().map(|r| r.item).collect(), total))
350}
351
352pub(crate) async fn get_unchecked(pool: impl sqlx::PgExecutor<'_>, id: Uuid) -> AppResult<Item> {
353    sqlx::query_as::<_, Item>(&format!("{ITEM_SELECT} WHERE i.id = $1"))
354        .bind(id)
355        .fetch_optional(pool)
356        .await?
357        .ok_or_else(|| AppError::not_found("Item not found."))
358}
359
360pub(crate) async fn get_by_number_unchecked(pool: &PgPool, number: &str) -> AppResult<Item> {
361    let number = number.trim().to_ascii_uppercase();
362    sqlx::query_as::<_, Item>(&format!("{ITEM_SELECT} WHERE i.number = $1"))
363        .bind(&number)
364        .fetch_optional(pool)
365        .await?
366        .ok_or_else(|| AppError::not_found(format!("Item '{number}' not found.")))
367}
368
369/// All revisions of an item, newest first.
370pub(crate) async fn revisions_unchecked(pool: &PgPool, item_id: Uuid) -> AppResult<Vec<Revision>> {
371    Ok(sqlx::query_as::<_, Revision>(&format!(
372        "{} WHERE r.item_id = $1 ORDER BY r.seq DESC",
373        revisions::REVISION_SELECT
374    ))
375    .bind(item_id)
376    .fetch_all(pool)
377    .await?)
378}
379
380pub(crate) async fn latest_revision_unchecked(pool: &PgPool, item_id: Uuid) -> AppResult<Revision> {
381    sqlx::query_as::<_, Revision>(&format!(
382        "{} WHERE r.item_id = $1 ORDER BY r.seq DESC LIMIT 1",
383        revisions::REVISION_SELECT
384    ))
385    .bind(item_id)
386    .fetch_optional(pool)
387    .await?
388    // every item has a revision: none means no item, and the message must
389    // be the one of a hidden item (`latest_revision`)
390    .ok_or_else(access::not_found_item)
391}
392
393/// The in-work or in-review revision of an item, if any.
394pub(crate) async fn open_revision_unchecked(
395    pool: &PgPool,
396    item_id: Uuid,
397) -> AppResult<Option<Revision>> {
398    Ok(sqlx::query_as::<_, Revision>(&format!(
399        "{} WHERE r.item_id = $1 AND r.state IN ('in_work', 'in_review')",
400        revisions::REVISION_SELECT
401    ))
402    .bind(item_id)
403    .fetch_optional(pool)
404    .await?)
405}
406
407/// An item the actor may read; hidden items answer like missing ones.
408pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Item> {
409    let item = get_unchecked(pool, id).await?;
410    access::ensure_visible(actor, item.project_id, access::not_found_item())?;
411    Ok(item)
412}
413
414/// An item by number, if the actor may read it. A hidden item gives exactly
415/// the message of an unknown number.
416pub async fn get_by_number(pool: &PgPool, actor: &Actor, number: &str) -> AppResult<Item> {
417    let item = get_by_number_unchecked(pool, number).await?;
418    access::ensure_visible(
419        actor,
420        item.project_id,
421        AppError::not_found(format!(
422            "Item '{}' not found.",
423            number.trim().to_ascii_uppercase()
424        )),
425    )?;
426    Ok(item)
427}
428
429/// All revisions of an item the actor may read, newest first.
430pub async fn revisions(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<Vec<Revision>> {
431    let revs = revisions_unchecked(pool, item_id).await?;
432    if let Some(first) = revs.first() {
433        access::ensure_visible(actor, first.project_id, access::not_found_item())?;
434    }
435    Ok(revs)
436}
437
438/// The latest revision of an item the actor may read.
439pub async fn latest_revision(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<Revision> {
440    let rev = latest_revision_unchecked(pool, item_id).await?;
441    access::ensure_visible(actor, rev.project_id, access::not_found_item())?;
442    Ok(rev)
443}
444
445/// The in-work or in-review revision of an item the actor may read.
446pub async fn open_revision(
447    pool: &PgPool,
448    actor: &Actor,
449    item_id: Uuid,
450) -> AppResult<Option<Revision>> {
451    let rev = open_revision_unchecked(pool, item_id).await?;
452    if let Some(rev) = &rev {
453        access::ensure_visible(actor, rev.project_id, access::not_found_item())?;
454    }
455    Ok(rev)
456}
457
458#[derive(Clone, Debug)]
459pub struct NewItem {
460    /// Leave empty to generate a number such as `PRT-000042`.
461    pub number: Option<String>,
462    pub item_type: ItemType,
463    pub unit: String,
464    pub project_id: Option<Uuid>,
465    pub name: String,
466    pub description: String,
467    pub material: String,
468    pub mass_kg: Option<f64>,
469    pub unit_cost: Option<f64>,
470    /// Make, purchased or standard part.
471    pub classification: Classification,
472    /// Custom attribute values, `key → value as text` (empty ones are skipped).
473    pub attributes: Vec<(String, String)>,
474}
475
476fn validate_unit(unit: &str) -> AppResult<String> {
477    let unit = unit.trim();
478    if unit.is_empty() {
479        return Ok("each".into());
480    }
481    domain::limit_text("Unit", unit, 16)
482}
483
484/// Creates an item together with its first revision `A` (in work) and
485/// returns that revision.
486///
487/// # Invariants
488///
489/// * **Permission:** editor role (403 otherwise); a project group is required
490///   (400), it must exist and be visible (400 "Unknown project"), not
491///   archived (400) and the actor a member of it (403).
492/// * **Locks:** the project group row (`FOR SHARE`, so a visibility change
493///   cannot move the counters meanwhile) and the number counter row, which
494///   stays locked until the commit.
495/// * **Audit:** `item.create`.
496/// * **Notifications:** none; the creator only starts watching the item.
497/// * **Webhooks:** none.
498/// * **Data:** a generated number steps the group's counter (numbers already
499///   taken are skipped); the first revision `A` is in work and checked out by
500///   the creator. A failed creation rolls the counter back with it.
501/// * **Atomic:** one transaction; concurrent creations get distinct numbers
502///   (`tests/numbering_and_watches.rs`).
503pub async fn create(pool: &PgPool, actor: &Actor, input: NewItem) -> AppResult<Revision> {
504    let mut tx = pool.begin().await?;
505    let created = create_tx(&mut tx, actor, input).await?;
506    tx.commit().await?;
507    revisions::get(pool, actor, created.revision_id).await
508}
509
510/// The longest idempotency key accepted.
511pub const MAX_IDEMPOTENCY_KEY: usize = 255;
512
513/// Validates a client-supplied idempotency key (printable ASCII, 1..=255).
514pub fn validate_idempotency_key(key: &str) -> AppResult<&str> {
515    let key = key.trim();
516    if key.is_empty()
517        || key.len() > MAX_IDEMPOTENCY_KEY
518        || !key.bytes().all(|b| (0x21..=0x7e).contains(&b))
519    {
520        return Err(AppError::BadRequest(format!(
521            "idempotency_key must be 1 to {MAX_IDEMPOTENCY_KEY} printable ASCII characters without spaces."
522        )));
523    }
524    Ok(key)
525}
526
527/// [`create`] with an idempotency key: the first request with a key creates
528/// the item; a repetition by the same user with the same `payload_hash`
529/// returns that item (second value `true`, nothing is created); the same key
530/// with another payload is a conflict (409).
531///
532/// # Invariants
533///
534/// * **Permission / locks / audit / data:** as [`create`]; a replay is a
535///   read: the original revision is returned only while the actor may read it.
536/// * **Atomic:** the key row is inserted first, in the creation's transaction.
537///   Two simultaneous requests with one key meet at the primary key: the
538///   second waits for the first to commit (replay) or roll back (it then
539///   creates the item itself), so exactly one item exists. A refused or
540///   failed creation leaves no key behind.
541pub async fn create_idempotent(
542    pool: &PgPool,
543    actor: &Actor,
544    input: NewItem,
545    key: &str,
546    payload_hash: &str,
547) -> AppResult<(Revision, bool)> {
548    let key = validate_idempotency_key(key)?;
549    let mut tx = pool.begin().await?;
550    let inserted = sqlx::query(
551        "INSERT INTO item_idempotency (user_id, key, payload_hash) VALUES ($1, $2, $3)
552         ON CONFLICT (user_id, key) DO NOTHING",
553    )
554    .bind(actor.id)
555    .bind(key)
556    .bind(payload_hash)
557    .execute(&mut *tx)
558    .await?
559    .rows_affected();
560    if inserted == 0 {
561        let (hash, revision_id) = sqlx::query_as::<_, (String, Option<Uuid>)>(
562            "SELECT payload_hash, revision_id FROM item_idempotency WHERE user_id = $1 AND key = $2",
563        )
564        .bind(actor.id)
565        .bind(key)
566        .fetch_one(&mut *tx)
567        .await?;
568        drop(tx);
569        if hash != payload_hash {
570            return Err(AppError::Conflict(
571                "This idempotency key was used for a different request; use a new key for a different item.".into(),
572            ));
573        }
574        let revision_id = revision_id.ok_or_else(|| {
575            AppError::Conflict("The request with this idempotency key is still running.".into())
576        })?;
577        return Ok((revisions::get(pool, actor, revision_id).await?, true));
578    }
579    let created = create_tx(&mut tx, actor, input).await?;
580    sqlx::query("UPDATE item_idempotency SET revision_id = $3 WHERE user_id = $1 AND key = $2")
581        .bind(actor.id)
582        .bind(key)
583        .bind(created.revision_id)
584        .execute(&mut *tx)
585        .await?;
586    tx.commit().await?;
587    Ok((
588        revisions::get(pool, actor, created.revision_id).await?,
589        false,
590    ))
591}
592
593/// Creates an item and stores its first files in the new revision – one
594/// transaction, so there is never an item without the files it was created
595/// with. The revision stays checked out by the creator.
596///
597/// # Invariants
598///
599/// * **Permission / locks / audit / data:** as [`create`], then the new
600///   revision is locked (`revisions::lock`) and the files are stored like in
601///   [`files::attach`] (`file.upload` per file).
602/// * **Notifications:** none; the creator only starts watching the item.
603/// * **Webhooks:** `simulation.result_added` when the item is a simulation
604///   and result values are read from the uploaded files (see
605///   [`files::attach`]); otherwise none.
606/// * **Atomic:** one transaction; a refused upload leaves no item behind.
607pub async fn create_with_files(
608    pool: &PgPool,
609    actor: &Actor,
610    input: NewItem,
611    uploads: &[files::Upload],
612) -> AppResult<Revision> {
613    let mut tx = pool.begin().await?;
614    let created = create_tx(&mut tx, actor, input).await?;
615    if !uploads.is_empty() {
616        let rev = revisions::lock(&mut tx, created.revision_id).await?;
617        files::store_uploads(&mut tx, actor, &rev, uploads, "").await?;
618    }
619    tx.commit().await?;
620    revisions::get(pool, actor, created.revision_id).await
621}
622
623/// What [`create_tx`] created.
624#[derive(Clone, Debug)]
625pub(crate) struct Created {
626    pub item_id: Uuid,
627    pub revision_id: Uuid,
628    pub number: String,
629}
630
631/// Creates an item inside the caller's transaction (imports create many items
632/// atomically). The first revision is checked out by the actor.
633pub(crate) async fn create_tx(
634    conn: &mut PgConnection,
635    actor: &Actor,
636    input: NewItem,
637) -> AppResult<Created> {
638    actor.require(Role::Editor)?;
639    let attrs = revisions::RevisionUpdate {
640        name: input.name,
641        description: input.description,
642        material: input.material,
643        mass_kg: input.mass_kg,
644        unit_cost: input.unit_cost,
645        attributes: input.attributes,
646    }
647    .validated()?;
648    let unit = validate_unit(&input.unit)?;
649    let attribute_values = attributes::apply_changes(
650        &mut *conn,
651        input.item_type.as_str(),
652        &serde_json::json!({}),
653        &attrs.attributes,
654    )
655    .await?;
656    let manual_number = match input.number.as_deref().map(str::trim) {
657        Some(n) if !n.is_empty() => Some(domain::normalize_item_number(n)?),
658        _ => None,
659    };
660
661    // Every new item belongs to a project group the author is a member of.
662    let project_id = input.project_id.ok_or_else(|| {
663        AppError::BadRequest("Choose the project group the item belongs to.".into())
664    })?;
665    let tx = conn;
666    let (code, active, scheme, visibility) =
667        sqlx::query_as::<_, (String, bool, Option<String>, String)>(
668            // FOR SHARE: a visibility change moves the counters (number_counter)
669            "SELECT code, active, number_scheme, visibility FROM projects WHERE id = $1 FOR SHARE",
670        )
671        .bind(project_id)
672        .fetch_optional(&mut *tx)
673        .await?
674        .ok_or_else(access::unknown_project)?;
675    access::ensure_visible(actor, Some(project_id), access::unknown_project())?;
676    if !active {
677        return Err(AppError::BadRequest(format!(
678            "The project group {code} is archived."
679        )));
680    }
681    if !actor.can_edit_project(Some(project_id)) {
682        return Err(AppError::Forbidden(format!(
683            "You are not a member of the project group {code}; ask an admin to add you."
684        )));
685    }
686
687    let number = match manual_number {
688        Some(n) => n,
689        None => {
690            // The project group's numbering scheme; numbers that were already
691            // entered manually (or by another scheme) are skipped.
692            let scheme = domain::NumberScheme::of_project(scheme.as_deref())?;
693            let ctx = domain::number_context(&code, input.item_type, Utc::now());
694            let (counter, seed) = projects::number_counter(project_id, &visibility, &scheme, &ctx);
695            let own_scheme = scheme != domain::NumberScheme::default_scheme();
696            let mut attempts = 0;
697            let mut jumped = false;
698            loop {
699                let next = projects::next_number(tx, &counter, seed.as_deref()).await?;
700                let generated = scheme.format(&ctx, next);
701                let candidate = domain::normalize_item_number(&generated).map_err(|_| {
702                    AppError::BadRequest(format!(
703                        "The numbering scheme of the project group {code} gives the invalid item number '{generated}'; enter a number or change the scheme."
704                    ))
705                })?;
706                // the group of the item that holds the number already
707                let taken = sqlx::query_scalar::<_, Option<Uuid>>(
708                    "SELECT project_id FROM items WHERE number = $1",
709                )
710                .bind(&candidate)
711                .fetch_optional(&mut *tx)
712                .await?;
713                let Some(holder) = taken else {
714                    break candidate;
715                };
716                // A scheme of the group's own that runs into the numbers of a
717                // group the actor may not see (e.g. one copying a restricted
718                // group's prefix) stops instead of skipping: skipping would
719                // count the hidden items in one call, follow every new one
720                // and use up the hidden group's next numbers. The error says
721                // no more than typing that one number in (and the counter
722                // stays where it was). The built-in shared counter has
723                // counted past every generated number already, so it only
724                // meets typed-in numbers and keeps skipping them.
725                if own_scheme && !actor.can_see_project(holder) {
726                    return Err(AppError::Conflict(format!(
727                        "The numbering scheme of the project group {code} gives the item number '{candidate}', which is already in use; change the scheme or enter a number."
728                    )));
729                }
730                if !jumped {
731                    // Numbers of this pattern exist already (entered by hand,
732                    // imported, or a scheme that continues an old
733                    // convention): continue after the highest one at once.
734                    // Only items the actor can see count, so the jump tells
735                    // nothing about hidden groups (their numbers are met
736                    // one by one as above).
737                    jumped = true;
738                    let (prefix, _) = scheme.around_seq(&ctx);
739                    let pattern = format!(
740                        "{}%",
741                        prefix
742                            .replace('\\', "\\\\")
743                            .replace('%', "\\%")
744                            .replace('_', "\\_")
745                    );
746                    let highest = sqlx::query_as::<_, (String, Option<Uuid>)>(
747                        "SELECT number, project_id FROM items WHERE number LIKE $1 ESCAPE '\\'",
748                    )
749                    .bind(&pattern)
750                    .fetch_all(&mut *tx)
751                    .await?
752                    .iter()
753                    .filter(|(_, project)| actor.can_see_project(*project))
754                    .filter_map(|(n, _)| scheme.seq_of(&ctx, n))
755                    .max();
756                    if let Some(highest) = highest {
757                        sqlx::query(
758                            "UPDATE counters SET value = GREATEST(value, $2) WHERE name = $1",
759                        )
760                        .bind(&counter)
761                        .bind(highest)
762                        .execute(&mut *tx)
763                        .await?;
764                    }
765                    continue;
766                }
767                attempts += 1;
768                if attempts > 1000 {
769                    return Err(AppError::Conflict(
770                        "Could not allocate an item number.".into(),
771                    ));
772                }
773            }
774        }
775    };
776
777    let item_id = Uuid::new_v4();
778    sqlx::query(
779        "INSERT INTO items (id, number, item_type, unit, project_id, created_by, owner_id, classification)
780         VALUES ($1, $2, $3, $4, $5, $6, $6, $7)",
781    )
782    .bind(item_id)
783    .bind(&number)
784    .bind(input.item_type.as_str())
785    .bind(&unit)
786    .bind(project_id)
787    .bind(actor.id)
788    .bind(input.classification.as_str())
789    .execute(&mut *tx)
790    .await
791    .map_err(|e| {
792        AppError::unique_violation(e, format!("The item number '{number}' is already in use."))
793    })?;
794
795    let rev_id = Uuid::new_v4();
796    sqlx::query(
797        "INSERT INTO revisions (id, item_id, seq, code, state, name, description, material,
798                                mass_kg, unit_cost, created_by, checked_out_by, checked_out_at,
799                                attributes)
800         VALUES ($1, $2, 1, $3, 'in_work', $4, $5, $6, $7, $8, $9, $9, now(), $10)",
801    )
802    .bind(rev_id)
803    .bind(item_id)
804    .bind(domain::revision_code(1))
805    .bind(&attrs.name)
806    .bind(&attrs.description)
807    .bind(&attrs.material)
808    .bind(attrs.mass_kg)
809    .bind(attrs.unit_cost)
810    .bind(actor.id)
811    .bind(&attribute_values)
812    .execute(&mut *tx)
813    .await?;
814
815    audit::record(
816        tx,
817        Some(actor),
818        "item.create",
819        "item",
820        Some(item_id),
821        format!(
822            "Created {} {number} – {}",
823            input.item_type.label(),
824            attrs.name
825        ),
826        json!({ "revision_id": rev_id, "item_type": input.item_type.as_str() }),
827    )
828    .await?;
829    // The creator follows the item (notifications about checks and releases).
830    notifications::add_watch(tx, actor.id, "item", item_id).await?;
831
832    Ok(Created {
833        item_id,
834        revision_id: rev_id,
835        number,
836    })
837}
838
839/// Updates item master data. The unit can only change while nothing has
840/// been released yet, because released BOMs depend on it.
841///
842/// # Invariants
843///
844/// * **Permission:** editor role (403); the item is visible to the actor
845///   ("not found" otherwise) and the actor a member of its project group and
846///   of the target group (403). A missing target group is a 400. Moving out
847///   of a restricted group needs a manager of that group or an admin (403,
848///   `ensure_may_take_out`).
849/// * **Locks:** the item row (`FOR UPDATE`).
850/// * **Audit:** `item.update`.
851/// * **Notifications / webhooks:** none.
852/// * **Data:** the unit cannot change once a revision is in review, released
853///   or obsolete (409).
854/// * **Atomic:** one transaction.
855pub async fn update_master(
856    pool: &PgPool,
857    actor: &Actor,
858    item_id: Uuid,
859    project_id: Option<Uuid>,
860    unit: &str,
861    classification: Option<Classification>,
862) -> AppResult<Item> {
863    actor.require(Role::Editor)?;
864    let unit = validate_unit(unit)?;
865    let mut tx = pool.begin().await?;
866    let current =
867        sqlx::query_as::<_, Item>(&format!("{ITEM_SELECT} WHERE i.id = $1 FOR UPDATE OF i"))
868            .bind(item_id)
869            .fetch_optional(&mut *tx)
870            .await?
871            .ok_or_else(|| AppError::not_found("Item not found."))?;
872    access::ensure_visible(actor, current.project_id, access::not_found_item())?;
873    // Moving an item requires membership in the old and the new project group.
874    if !actor.can_edit_project(current.project_id) {
875        return Err(AppError::Forbidden(format!(
876            "Only members of the project group {} can change {}.",
877            current.project_code.as_deref().unwrap_or("?"),
878            current.number
879        )));
880    }
881    let Some(pid) = project_id else {
882        return Err(AppError::BadRequest("Choose a project group.".into()));
883    };
884    let exists =
885        sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1)")
886            .bind(pid)
887            .fetch_one(&mut *tx)
888            .await?;
889    if !exists {
890        return Err(access::unknown_project());
891    }
892    access::ensure_visible(actor, Some(pid), access::unknown_project())?;
893    if !actor.can_edit_project(Some(pid)) {
894        return Err(AppError::Forbidden(
895            "You can only move items into project groups you are a member of.".into(),
896        ));
897    }
898    // Moving out of a restricted group declassifies the item: only a manager
899    // of that group (or an admin) may do it.
900    if current.project_id != Some(pid) {
901        if let Some(source) = current.project_id {
902            ensure_may_take_out(&mut tx, actor, source, "Moving").await?;
903        }
904    }
905    if unit != current.unit {
906        let released = sqlx::query_scalar::<_, bool>(
907            "SELECT EXISTS(SELECT 1 FROM revisions WHERE item_id = $1 AND state IN ('released', 'obsolete', 'in_review'))",
908        )
909        .bind(item_id)
910        .fetch_one(&mut *tx)
911        .await?;
912        if released {
913            return Err(AppError::Conflict(
914                "The unit cannot be changed after a revision was submitted or released.".into(),
915            ));
916        }
917    }
918    let classification = classification
919        .map(Classification::as_str)
920        .unwrap_or(current.classification.as_str());
921    sqlx::query("UPDATE items SET project_id = $2, unit = $3, classification = $4 WHERE id = $1")
922        .bind(item_id)
923        .bind(project_id)
924        .bind(&unit)
925        .bind(classification)
926        .execute(&mut *tx)
927        .await?;
928    audit::record(
929        &mut tx,
930        Some(actor),
931        "item.update",
932        "item",
933        Some(item_id),
934        format!("Updated master data of {}", current.number),
935        json!({ "project_id": project_id, "unit": unit, "classification": classification }),
936    )
937    .await?;
938    tx.commit().await?;
939    get(pool, actor, item_id).await
940}
941
942/// Fails unless the actor may move or copy data out of the project group
943/// `source`: always allowed for internal groups; for restricted groups only
944/// for administrators and managers of the group. `what` = "Moving" or
945/// "Copying".
946pub(crate) async fn ensure_may_take_out(
947    conn: &mut PgConnection,
948    actor: &Actor,
949    source: Uuid,
950    what: &str,
951) -> AppResult<()> {
952    let restricted = sqlx::query_scalar::<_, bool>(
953        "SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1 AND visibility = 'restricted')",
954    )
955    .bind(source)
956    .fetch_one(&mut *conn)
957    .await?;
958    if restricted && !crate::services::projects::can_manage(conn, actor, source).await? {
959        return Err(AppError::Forbidden(format!(
960            "{what} out of a restricted group requires a manager of that group."
961        )));
962    }
963    Ok(())
964}
965
966/// Deletes an item created by mistake. Only allowed while it has a single
967/// in-work revision, is not used in any BOM, not part of any change, no
968/// simulation basis, no design that units were built to and – in a
969/// restricted group – no affected item of a change request.
970///
971/// # Invariants
972///
973/// * **Permission:** editor role (403); the item is visible to the actor
974///   ("not found" otherwise); only its creator (as a member of the group) or
975///   an admin may delete it (403).
976/// * **Locks:** the item row (`FOR UPDATE`); the project group row
977///   (`FOR SHARE`) when the item has one.
978/// * **Audit:** `item.delete`; in an unrestricted group also
979///   `change_request.remove_item` for every change request that named the item.
980/// * **Notifications:** the people of those change requests (unrestricted
981///   groups only).
982/// * **Webhooks:** none.
983/// * **Refused with 409:** more than one revision or one not in work, used in
984///   a BOM, part of a change, basis of a simulation, units were recorded as
985///   built to it (a build record names one of its revisions), or
986///   (restricted group) an affected item of a change request.
987/// * **Atomic:** one transaction.
988pub async fn delete(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<String> {
989    actor.require(Role::Editor)?;
990    let mut tx = pool.begin().await?;
991    let item = sqlx::query_as::<_, Item>(&format!("{ITEM_SELECT} WHERE i.id = $1 FOR UPDATE OF i"))
992        .bind(item_id)
993        .fetch_optional(&mut *tx)
994        .await?
995        .ok_or_else(|| AppError::not_found("Item not found."))?;
996    access::ensure_visible(actor, item.project_id, access::not_found_item())?;
997    if (item.created_by != actor.id || !actor.can_edit_project(item.project_id))
998        && !actor.is_admin()
999    {
1000        return Err(AppError::Forbidden(
1001            "Only the creator (as member of the project group) or an administrator can delete an item.".into(),
1002        ));
1003    }
1004    let not_in_work = sqlx::query_scalar::<_, i64>(
1005        "SELECT COUNT(*) FROM revisions WHERE item_id = $1 AND (state <> 'in_work' OR seq > 1)",
1006    )
1007    .bind(item_id)
1008    .fetch_one(&mut *tx)
1009    .await?;
1010    if not_in_work > 0 {
1011        return Err(AppError::Conflict(
1012            "Items that were ever submitted, released or revised cannot be deleted; mark them obsolete instead.".into(),
1013        ));
1014    }
1015    let used = sqlx::query_scalar::<_, bool>(
1016        "SELECT EXISTS(SELECT 1 FROM bom_lines WHERE child_item_id = $1)",
1017    )
1018    .bind(item_id)
1019    .fetch_one(&mut *tx)
1020    .await?;
1021    if used {
1022        return Err(AppError::Conflict(
1023            "The item is used in a BOM; remove it from all BOMs first.".into(),
1024        ));
1025    }
1026    let in_change = sqlx::query_scalar::<_, bool>(
1027        "SELECT EXISTS(SELECT 1 FROM change_items ci JOIN revisions r ON r.id = ci.revision_id WHERE r.item_id = $1)",
1028    )
1029    .bind(item_id)
1030    .fetch_one(&mut *tx)
1031    .await?;
1032    if in_change {
1033        return Err(AppError::Conflict(
1034            "The item is part of a change; remove it from the change first.".into(),
1035        ));
1036    }
1037    if crate::services::simulations::item_is_basis(&mut tx, item_id).await? {
1038        return Err(AppError::Conflict(
1039            "A simulation is based on the item; remove it from the simulation's basis first."
1040                .into(),
1041        ));
1042    }
1043    if crate::services::as_built::item_is_design(&mut tx, item_id).await? {
1044        return Err(AppError::Conflict(
1045            "Units were recorded as built to the item; it stays for their traceability.".into(),
1046        ));
1047    }
1048    // A request is hidden while it names a hidden item; deleting an item of
1049    // a restricted group (the row would cascade) would silently declassify
1050    // the request. Items of internal groups hide no request, so their rows
1051    // simply go: refusing would tell the creator of a public item about a
1052    // request they cannot see (and cannot edit).
1053    let restricted = match item.project_id {
1054        Some(p) => sqlx::query_scalar::<_, String>(
1055            "SELECT visibility FROM projects WHERE id = $1 FOR SHARE",
1056        )
1057        .bind(p)
1058        .fetch_optional(&mut *tx)
1059        .await?
1060        .is_some_and(|v| v == ProjectVisibility::Restricted.as_str()),
1061        None => false,
1062    };
1063    let in_request = restricted
1064        && sqlx::query_scalar::<_, bool>(
1065            "SELECT EXISTS(SELECT 1 FROM change_request_items WHERE item_id = $1)",
1066        )
1067        .bind(item_id)
1068        .fetch_one(&mut *tx)
1069        .await?;
1070    if in_request {
1071        return Err(AppError::Conflict(
1072            "The item is an affected item of a change request; remove it from the request first."
1073                .into(),
1074        ));
1075    }
1076    if !restricted {
1077        // the rows go with the item: every request records it in its
1078        // history and tells its people (only readers of the request see it)
1079        crate::services::requests::item_deleted(&mut tx, actor, item_id, &item.number).await?;
1080    }
1081    sqlx::query("DELETE FROM items WHERE id = $1")
1082        .bind(item_id)
1083        .execute(&mut *tx)
1084        .await?;
1085    audit::record(
1086        &mut tx,
1087        Some(actor),
1088        "item.delete",
1089        "item",
1090        Some(item_id),
1091        format!("Deleted item {}", item.number),
1092        json!({}),
1093    )
1094    .await?;
1095    tx.commit().await?;
1096    Ok(item.number)
1097}
1098
1099/// Parent revisions whose BOM contains the item, at most `limit` (a
1100/// standard part can be used in tens of thousands of BOMs) – only parents
1101/// the actor may read (see [`hidden_parents`] for the rest).
1102pub async fn where_used(
1103    pool: &PgPool,
1104    actor: &Actor,
1105    item_id: Uuid,
1106    limit: i64,
1107) -> AppResult<Vec<WhereUsed>> {
1108    Ok(sqlx::query_as::<_, WhereUsed>(&format!(
1109        "SELECT bl.id AS line_id, r.id AS parent_revision_id, i.id AS parent_item_id,
1110                i.number AS parent_number, r.code AS parent_code, r.name AS parent_name,
1111                r.state AS parent_state, bl.find_number, bl.quantity, rc.code AS released_child_code
1112         FROM bom_lines bl
1113         JOIN revisions r ON r.id = bl.parent_revision_id
1114         JOIN items i ON i.id = r.item_id
1115         LEFT JOIN revisions rc ON rc.id = bl.released_child_revision_id
1116         WHERE bl.child_item_id = $1 AND {}
1117         ORDER BY i.number, r.seq DESC
1118         LIMIT $3",
1119        access::item_visible("i", 2)
1120    ))
1121    .bind(item_id)
1122    .bind(&actor.hidden_projects)
1123    .bind(limit.max(1))
1124    .fetch_all(pool)
1125    .await?)
1126}
1127
1128/// Number of distinct parent items that use the item but that the actor may
1129/// not read ("+N assemblies in groups you cannot see").
1130pub async fn hidden_parents(pool: &PgPool, actor: &Actor, item_id: Uuid) -> AppResult<i64> {
1131    if actor.hidden_projects.is_empty() {
1132        return Ok(0);
1133    }
1134    Ok(sqlx::query_scalar::<_, i64>(&format!(
1135        "SELECT COUNT(DISTINCT i.id)
1136         FROM bom_lines bl
1137         JOIN revisions r ON r.id = bl.parent_revision_id
1138         JOIN items i ON i.id = r.item_id
1139         WHERE bl.child_item_id = $1 AND NOT {}",
1140        access::item_visible("i", 2)
1141    ))
1142    .bind(item_id)
1143    .bind(&actor.hidden_projects)
1144    .fetch_one(pool)
1145    .await?)
1146}
1147
1148/// Most levels a multi-level where-used report walks up.
1149pub const MAX_WHERE_USED_LEVELS: i32 = 25;
1150/// Most usages a multi-level where-used report returns.
1151pub const MAX_WHERE_USED_ROWS: i64 = 2000;
1152
1153/// Multi-level where-used: every BOM line that uses the item or (up to
1154/// `levels` levels up) one of its parents. Like the single-level report it
1155/// looks at the BOMs of all revisions. Each item is reported on the lowest
1156/// level it is reached on: the walk goes up one level per query (the parents
1157/// not seen before form the next level), so a part used in thousands of
1158/// assemblies costs one query per level instead of a recursive expansion
1159/// of every path. The report stops at [`MAX_WHERE_USED_ROWS`] rows.
1160pub async fn where_used_levels(
1161    pool: &PgPool,
1162    actor: &Actor,
1163    item_id: Uuid,
1164    levels: i32,
1165) -> AppResult<Vec<WhereUsedLevel>> {
1166    let levels = levels.clamp(1, MAX_WHERE_USED_LEVELS);
1167    let mut out: Vec<WhereUsedLevel> = Vec::new();
1168    let mut seen = std::collections::HashSet::from([item_id]);
1169    let mut frontier = vec![item_id];
1170    // Only parents the actor may read are reported, and the walk goes up
1171    // only through them.
1172    let sql = format!(
1173        "SELECT $2::int AS level, ci.number AS child_number,
1174                bl.id AS line_id, r.id AS parent_revision_id, i.id AS parent_item_id,
1175                i.number AS parent_number, r.code AS parent_code, r.name AS parent_name,
1176                r.state AS parent_state, bl.find_number, bl.quantity, rc.code AS released_child_code
1177         FROM bom_lines bl
1178         JOIN items ci ON ci.id = bl.child_item_id
1179         JOIN revisions r ON r.id = bl.parent_revision_id
1180         JOIN items i ON i.id = r.item_id
1181         LEFT JOIN revisions rc ON rc.id = bl.released_child_revision_id
1182         WHERE bl.child_item_id = ANY($1) AND {}
1183         ORDER BY i.number, r.seq DESC, ci.number
1184         LIMIT $3",
1185        access::item_visible("i", 4)
1186    );
1187    for level in 1..=levels {
1188        let remaining = MAX_WHERE_USED_ROWS - out.len() as i64;
1189        if frontier.is_empty() || remaining <= 0 {
1190            break;
1191        }
1192        let rows = sqlx::query_as::<_, WhereUsedLevel>(&sql)
1193            .bind(&frontier)
1194            .bind(level)
1195            .bind(remaining)
1196            .bind(&actor.hidden_projects)
1197            .fetch_all(pool)
1198            .await?;
1199        // A truncated level ends the report, so the next frontier is only
1200        // needed when every row of this level was read.
1201        frontier = rows
1202            .iter()
1203            .map(|r| r.usage.parent_item_id)
1204            .filter(|id| seen.insert(*id))
1205            .collect();
1206        out.extend(rows);
1207    }
1208    Ok(out)
1209}