teamcraft/services/
bulk.rs1use 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
20pub const MAX_ITEMS: usize = 200;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Action {
25 Revise,
27 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 pub revision_id: Option<Uuid>,
46 pub code: Option<String>,
47 pub ok: bool,
48 pub error: Option<String>,
49}
50
51fn 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
63pub 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
75pub 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}