Skip to main content

teamcraft/services/
attributes.rs

1//! Custom attributes (feature 5): definitions maintained by administrators
2//! and the validation of values against them.
3//!
4//! Values live in `revisions.attributes` (JSON object by key), so they are
5//! versioned with the revision like name, material and mass: frozen once
6//! released, copied by *revise* and *Save as*. The key and the data type of a
7//! definition never change after it was created; label, unit, choices, item
8//! types, order and the active flag do. Deactivating keeps stored values
9//! visible, it only stops offering the attribute.
10
11use serde_json::{json, Map, Value};
12use sqlx::{PgConnection, PgPool};
13use uuid::Uuid;
14
15use crate::{
16    auth::Actor,
17    domain::{self, AttributeType, ItemType, Role},
18    error::{AppError, AppResult},
19    models::AttributeDef,
20    services::audit,
21};
22
23const COLUMNS: &str =
24    "id, key, label, data_type, unit, choices, item_types, position, active, created_at";
25
26/// All definitions in display order; `active_only` leaves out deactivated ones.
27pub async fn list(pool: &PgPool, active_only: bool) -> AppResult<Vec<AttributeDef>> {
28    let mut conn = pool.acquire().await?;
29    list_conn(&mut conn, active_only).await
30}
31
32pub(crate) async fn list_conn(
33    conn: &mut PgConnection,
34    active_only: bool,
35) -> AppResult<Vec<AttributeDef>> {
36    Ok(sqlx::query_as::<_, AttributeDef>(&format!(
37        "SELECT {COLUMNS} FROM attribute_defs WHERE active OR NOT $1 ORDER BY position, label"
38    ))
39    .bind(active_only)
40    .fetch_all(&mut *conn)
41    .await?)
42}
43
44/// Whether the definition applies to items of `item_type`.
45pub fn applies_to(def: &AttributeDef, item_type: &str) -> bool {
46    def.item_types.is_empty() || def.item_types.iter().any(|t| t == item_type)
47}
48
49/// Active definitions for items of `item_type` – the fields of the forms.
50pub async fn for_item_type(pool: &PgPool, item_type: &str) -> AppResult<Vec<AttributeDef>> {
51    Ok(list(pool, true)
52        .await?
53        .into_iter()
54        .filter(|d| applies_to(d, item_type))
55        .collect())
56}
57
58pub async fn get_by_key(pool: &PgPool, key: &str) -> AppResult<AttributeDef> {
59    let key = domain::normalize_attribute_key(key)?;
60    sqlx::query_as::<_, AttributeDef>(&format!(
61        "SELECT {COLUMNS} FROM attribute_defs WHERE key = $1"
62    ))
63    .bind(&key)
64    .fetch_optional(pool)
65    .await?
66    .ok_or_else(|| AppError::not_found(format!("Attribute '{key}' not found.")))
67}
68
69pub async fn get(pool: &PgPool, id: Uuid) -> AppResult<AttributeDef> {
70    sqlx::query_as::<_, AttributeDef>(&format!(
71        "SELECT {COLUMNS} FROM attribute_defs WHERE id = $1"
72    ))
73    .bind(id)
74    .fetch_optional(pool)
75    .await?
76    .ok_or_else(|| AppError::not_found("Attribute not found."))
77}
78
79/// How many revisions store a value of the attribute.
80pub async fn usage(pool: &PgPool, key: &str) -> AppResult<i64> {
81    Ok(
82        sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM revisions WHERE attributes ? $1")
83            .bind(key)
84            .fetch_one(pool)
85            .await?,
86    )
87}
88
89fn data_type_of(def: &AttributeDef) -> AttributeType {
90    AttributeType::parse(&def.data_type).unwrap_or(AttributeType::Text)
91}
92
93/// The editable part of a definition.
94#[derive(Clone, Debug, Default)]
95pub struct AttributeSettings {
96    pub label: String,
97    pub unit: String,
98    /// Choices of a `choice` attribute (free text: one per line or `;`).
99    pub choices: String,
100    /// Item types (`part`, `assembly`, …); empty = all.
101    pub item_types: Vec<String>,
102    pub position: i32,
103    pub active: bool,
104}
105
106struct CleanSettings {
107    label: String,
108    unit: String,
109    choices: Vec<String>,
110    item_types: Vec<String>,
111}
112
113fn clean(settings: &AttributeSettings, data_type: AttributeType) -> AppResult<CleanSettings> {
114    let label = domain::require_text("Label", &settings.label, 100)?;
115    let unit = domain::limit_text("Unit", &settings.unit, 16)?;
116    let choices = domain::parse_attribute_choices(&settings.choices)?;
117    if data_type == AttributeType::Choice && choices.is_empty() {
118        return Err(AppError::BadRequest(
119            "A choice list needs at least one choice.".into(),
120        ));
121    }
122    if data_type != AttributeType::Choice && !choices.is_empty() {
123        return Err(AppError::BadRequest(
124            "Only choice lists have choices.".into(),
125        ));
126    }
127    let mut item_types = Vec::new();
128    for t in settings.item_types.iter().filter(|t| !t.trim().is_empty()) {
129        let parsed = ItemType::parse(t)
130            .ok_or_else(|| AppError::BadRequest(format!("Unknown item type '{t}'.")))?;
131        if !item_types.contains(&parsed.as_str().to_string()) {
132            item_types.push(parsed.as_str().to_string());
133        }
134    }
135    Ok(CleanSettings {
136        label,
137        unit,
138        choices,
139        item_types,
140    })
141}
142
143/// Creates a definition (administrators).
144///
145/// # Invariants
146///
147/// * **Permission:** admin role (403 otherwise). Key, data type and settings
148///   are validated (400); an existing key is a 409.
149/// * **Locks:** none beyond the unique key of the definition.
150/// * **Audit:** `attribute.create`.
151/// * **Notifications / webhooks:** none.
152/// * **Atomic:** one transaction.
153pub async fn create(
154    pool: &PgPool,
155    actor: &Actor,
156    key: &str,
157    data_type: &str,
158    settings: AttributeSettings,
159) -> AppResult<AttributeDef> {
160    actor.require(Role::Admin)?;
161    let key = domain::normalize_attribute_key(key)?;
162    let data_type = AttributeType::parse(data_type).ok_or_else(|| {
163        AppError::BadRequest("Data type must be text, number, boolean or choice.".into())
164    })?;
165    let s = clean(&settings, data_type)?;
166    let mut tx = pool.begin().await?;
167    let def = sqlx::query_as::<_, AttributeDef>(&format!(
168        "INSERT INTO attribute_defs (id, key, label, data_type, unit, choices, item_types,
169                                     position, active, created_by)
170         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, TRUE, $9)
171         RETURNING {COLUMNS}"
172    ))
173    .bind(Uuid::new_v4())
174    .bind(&key)
175    .bind(&s.label)
176    .bind(data_type.as_str())
177    .bind(&s.unit)
178    .bind(&s.choices)
179    .bind(&s.item_types)
180    .bind(settings.position)
181    .bind(actor.id)
182    .fetch_one(&mut *tx)
183    .await
184    .map_err(|e| {
185        AppError::unique_violation(e, format!("The attribute key '{key}' is already in use."))
186    })?;
187    audit::record(
188        &mut tx,
189        Some(actor),
190        "attribute.create",
191        "attribute",
192        Some(def.id),
193        format!("Created attribute {} ({})", def.label, def.key),
194        json!({ "key": def.key, "data_type": def.data_type, "item_types": def.item_types }),
195    )
196    .await?;
197    tx.commit().await?;
198    Ok(def)
199}
200
201/// Changes label, unit, choices, item types, order and the active flag
202/// (administrators). Key and data type stay.
203///
204/// # Invariants
205///
206/// * **Permission:** admin role (403 otherwise); an unknown definition is
207///   "not found".
208/// * **Locks:** the definition row (`FOR UPDATE`).
209/// * **Audit:** `attribute.update`.
210/// * **Notifications / webhooks:** none.
211/// * **Atomic:** one transaction.
212pub async fn update(
213    pool: &PgPool,
214    actor: &Actor,
215    id: Uuid,
216    settings: AttributeSettings,
217) -> AppResult<AttributeDef> {
218    actor.require(Role::Admin)?;
219    let mut tx = pool.begin().await?;
220    let current = sqlx::query_as::<_, AttributeDef>(&format!(
221        "SELECT {COLUMNS} FROM attribute_defs WHERE id = $1 FOR UPDATE"
222    ))
223    .bind(id)
224    .fetch_optional(&mut *tx)
225    .await?
226    .ok_or_else(|| AppError::not_found("Attribute not found."))?;
227    let s = clean(&settings, data_type_of(&current))?;
228    let def = sqlx::query_as::<_, AttributeDef>(&format!(
229        "UPDATE attribute_defs SET label = $2, unit = $3, choices = $4, item_types = $5,
230                                   position = $6, active = $7
231         WHERE id = $1 RETURNING {COLUMNS}"
232    ))
233    .bind(id)
234    .bind(&s.label)
235    .bind(&s.unit)
236    .bind(&s.choices)
237    .bind(&s.item_types)
238    .bind(settings.position)
239    .bind(settings.active)
240    .fetch_one(&mut *tx)
241    .await?;
242    audit::record(
243        &mut tx,
244        Some(actor),
245        "attribute.update",
246        "attribute",
247        Some(id),
248        format!("Updated attribute {} ({})", def.label, def.key),
249        json!({
250            "label": def.label, "unit": def.unit, "choices": def.choices,
251            "item_types": def.item_types, "position": def.position, "active": def.active,
252        }),
253    )
254    .await?;
255    tx.commit().await?;
256    Ok(def)
257}
258
259/// Whether `raw` (form or API text) denotes the stored value `v`.
260fn same_value(v: &Value, raw: &str) -> bool {
261    match v {
262        Value::String(s) => s == raw,
263        Value::Bool(b) => matches!(
264            (b, raw.to_ascii_lowercase().as_str()),
265            (true, "yes" | "true") | (false, "no" | "false")
266        ),
267        Value::Number(n) => n
268            .as_f64()
269            .zip(raw.replace(',', ".").parse::<f64>().ok())
270            .is_some_and(|(a, b)| a == b),
271        _ => false,
272    }
273}
274
275/// Applies attribute changes (`key → text`, empty text removes the value) to
276/// the stored values of a revision of an item of `item_type` and returns the
277/// new object. Values of other keys stay as they are.
278pub(crate) async fn apply_changes(
279    conn: &mut PgConnection,
280    item_type: &str,
281    current: &Value,
282    changes: &[(String, String)],
283) -> AppResult<Value> {
284    let mut values: Map<String, Value> = current.as_object().cloned().unwrap_or_default();
285    if changes.is_empty() {
286        return Ok(Value::Object(values));
287    }
288    let defs = list_conn(conn, false).await?;
289    for (raw_key, raw_value) in changes {
290        let key = domain::normalize_attribute_key(raw_key)?;
291        // A value sent back unchanged (the edit form sends every field) is
292        // kept even if it would no longer pass: a removed choice, an
293        // attribute deactivated or narrowed to other item types meanwhile.
294        if values
295            .get(&key)
296            .is_some_and(|v| same_value(v, raw_value.trim()))
297        {
298            continue;
299        }
300        let def = defs
301            .iter()
302            .find(|d| d.key == key)
303            .ok_or_else(|| AppError::BadRequest(format!("Unknown attribute '{key}'.")))?;
304        let parsed =
305            domain::parse_attribute_value(&def.label, data_type_of(def), &def.choices, raw_value)?;
306        match parsed {
307            None => {
308                values.remove(&key);
309            }
310            Some(value) => {
311                if !def.active {
312                    return Err(AppError::BadRequest(format!(
313                        "The attribute '{}' is no longer in use.",
314                        def.label
315                    )));
316                }
317                if !applies_to(def, item_type) {
318                    return Err(AppError::BadRequest(format!(
319                        "The attribute '{}' does not apply to items of type {item_type}.",
320                        def.label
321                    )));
322                }
323                values.insert(key, value);
324            }
325        }
326    }
327    Ok(Value::Object(values))
328}