Skip to main content

teamcraft/services/
bulk.rs

1//! Lifecycle operations on many items at once: revise or obsolete the latest
2//! revision of every listed item. Each item goes through the same service
3//! as a single operation (`revisions::revise` / `revisions::obsolete`) in
4//! its own transaction, so permissions, locks, audit records, notifications
5//! and webhooks are exactly those of the single operation, and one refusal
6//! does not stop the others; the outcome lists every item. (Releasing many
7//! items together is a change order with many revisions.)
8
9use serde::Serialize;
10use sqlx::PgPool;
11use uuid::Uuid;
12
13use crate::{
14    auth::Actor,
15    domain::Role,
16    error::{AppError, AppResult},
17    services::{items, revisions},
18};
19
20/// Most items of one bulk request.
21pub const MAX_ITEMS: usize = 200;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Action {
25    /// A new revision in work, checked out to the actor (`revision.revise`).
26    Revise,
27    /// The released latest revision becomes obsolete (`revision.obsolete`).
28    Obsolete,
29}
30
31impl Action {
32    pub fn parse(s: &str) -> Option<Action> {
33        match s.trim() {
34            "revise" => Some(Action::Revise),
35            "obsolete" => Some(Action::Obsolete),
36            _ => None,
37        }
38    }
39}
40
41#[derive(Clone, Debug, Serialize)]
42pub struct Outcome {
43    pub item_number: String,
44    /// The revision the action produced or changed.
45    pub revision_id: Option<Uuid>,
46    pub code: Option<String>,
47    pub ok: bool,
48    pub error: Option<String>,
49}
50
51/// The message of a refused item: user errors as they are, internal errors
52/// only logged.
53fn message(number: &str, e: AppError) -> String {
54    match e {
55        AppError::Database(_) | AppError::Io(_) | AppError::Other(_) => {
56            tracing::error!(item = number, error = %e, "bulk operation failed");
57            "Internal error – see the server log.".to_string()
58        }
59        e => e.to_string(),
60    }
61}
62
63/// The item numbers once each, in the given order (trimmed, upper-case).
64pub fn unique_numbers(numbers: &[String]) -> Vec<String> {
65    let mut out: Vec<String> = Vec::new();
66    for n in numbers {
67        let n = n.trim().to_uppercase();
68        if !n.is_empty() && !out.contains(&n) {
69            out.push(n);
70        }
71    }
72    out
73}
74
75/// Applies `action` to the latest revision of every listed item.
76///
77/// # Invariants
78///
79/// * **Permission:** CAD editor (403); per item those of the single
80///   operation – a hidden or unknown item is reported as "not found".
81/// * **Limits:** 1 to [`MAX_ITEMS`] items (400).
82/// * **Atomic:** per item; the outcome says which items succeeded.
83pub async fn apply(
84    pool: &PgPool,
85    actor: &Actor,
86    action: Action,
87    numbers: &[String],
88) -> AppResult<Vec<Outcome>> {
89    actor.require(Role::Editor)?;
90    let numbers = unique_numbers(numbers);
91    if numbers.is_empty() {
92        return Err(AppError::BadRequest(
93            "List at least one item number.".into(),
94        ));
95    }
96    if numbers.len() > MAX_ITEMS {
97        return Err(AppError::BadRequest(format!(
98            "At most {MAX_ITEMS} items at a time."
99        )));
100    }
101    let mut outcomes = Vec::with_capacity(numbers.len());
102    for number in numbers {
103        let result = async {
104            let item = items::get_by_number(pool, actor, &number).await?;
105            let latest = items::latest_revision(pool, actor, item.id).await?;
106            match action {
107                Action::Revise => revisions::revise(pool, actor, latest.id).await,
108                Action::Obsolete => revisions::obsolete(pool, actor, latest.id).await,
109            }
110        }
111        .await;
112        outcomes.push(match result {
113            Ok(rev) => Outcome {
114                item_number: number,
115                revision_id: Some(rev.id),
116                code: Some(rev.code),
117                ok: true,
118                error: None,
119            },
120            Err(e) => Outcome {
121                error: Some(message(&number, e)),
122                item_number: number,
123                revision_id: None,
124                code: None,
125                ok: false,
126            },
127        });
128    }
129    Ok(outcomes)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn actions_and_numbers() {
138        assert_eq!(Action::parse("revise"), Some(Action::Revise));
139        assert_eq!(Action::parse(" obsolete "), Some(Action::Obsolete));
140        assert_eq!(Action::parse("release"), None);
141        assert_eq!(
142            unique_numbers(&[
143                " prt-000001".into(),
144                "PRT-000001".into(),
145                "".into(),
146                "ASM-000002".into()
147            ]),
148            ["PRT-000001", "ASM-000002"]
149        );
150    }
151}