1use 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
26pub 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
44pub 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
49pub 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
79pub 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#[derive(Clone, Debug, Default)]
95pub struct AttributeSettings {
96 pub label: String,
97 pub unit: String,
98 pub choices: String,
100 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
143pub 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
201pub 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(¤t))?;
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
259fn 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
275pub(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 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}