1use serde::{Deserialize, Serialize};
13use serde_json::json;
14use sqlx::{PgConnection, PgPool};
15use uuid::Uuid;
16
17use crate::{
18 auth::Actor,
19 domain,
20 error::{AppError, AppResult},
21 models::Revision,
22 services::{
23 access, audit, revisions,
24 simulations::{self, Origin, ResultInput},
25 },
26};
27
28pub const MAX_PER_CALL: usize = 50;
30
31pub const MAX_PENDING: i64 = 200;
33
34pub const MAX_QUOTE_CHARS: usize = 400;
36
37#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
39pub struct Proposal {
40 pub id: Uuid,
41 pub revision_id: Uuid,
42 pub name: String,
43 pub value: f64,
44 pub unit: String,
45 pub lower_limit: Option<f64>,
46 pub upper_limit: Option<f64>,
47 pub load_case: String,
48 pub solver: String,
49 pub solver_version: String,
50 pub source_file: String,
52 pub source_version: i32,
53 pub quote: String,
55 pub status: String,
57 pub proposed_by: Uuid,
58 pub proposed_by_name: String,
59 pub created_at: chrono::DateTime<chrono::Utc>,
60 pub decided_by_name: Option<String>,
61 pub decided_at: Option<chrono::DateTime<chrono::Utc>>,
62}
63
64const SELECT: &str = "
65 SELECT p.id, p.revision_id, p.name, p.value, p.unit, p.lower_limit, p.upper_limit,
66 p.load_case, p.solver, p.solver_version, p.source_file, p.source_version, p.quote,
67 p.status, p.proposed_by, u.display_name AS proposed_by_name, p.created_at,
68 d.display_name AS decided_by_name, p.decided_at
69 FROM result_proposals p
70 JOIN users u ON u.id = p.proposed_by
71 LEFT JOIN users d ON d.id = p.decided_by";
72
73fn not_found() -> AppError {
74 AppError::not_found("Proposed value not found.")
75}
76
77#[derive(Clone, Debug, Default, Deserialize)]
79pub struct ProposalInput {
80 pub name: String,
81 pub value: f64,
82 #[serde(default)]
83 pub unit: String,
84 #[serde(default, alias = "limit_min", alias = "limit_low")]
85 pub lower_limit: Option<f64>,
86 #[serde(default, alias = "limit_max", alias = "limit_high")]
87 pub upper_limit: Option<f64>,
88 #[serde(default)]
89 pub load_case: String,
90 #[serde(default)]
91 pub solver: String,
92 #[serde(default)]
93 pub solver_version: String,
94 pub file: String,
96 pub quote: String,
98}
99
100fn squash(text: &str) -> String {
102 text.split_whitespace()
103 .collect::<Vec<_>>()
104 .join(" ")
105 .to_lowercase()
106}
107
108pub async fn list(
110 pool: &PgPool,
111 actor: &Actor,
112 revision_id: Uuid,
113 status: Option<&str>,
114) -> AppResult<Vec<Proposal>> {
115 revisions::get(pool, actor, revision_id).await?;
116 Ok(sqlx::query_as::<_, Proposal>(&format!(
117 "{SELECT} WHERE p.revision_id = $1 AND ($2::text IS NULL OR p.status = $2)
118 ORDER BY (p.status <> 'pending'), p.created_at DESC, lower(p.name)"
119 ))
120 .bind(revision_id)
121 .bind(status)
122 .fetch_all(pool)
123 .await?)
124}
125
126pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Proposal> {
128 let p = sqlx::query_as::<_, Proposal>(&format!("{SELECT} WHERE p.id = $1"))
129 .bind(id)
130 .fetch_optional(pool)
131 .await?
132 .ok_or_else(not_found)?;
133 revisions::get(pool, actor, p.revision_id)
134 .await
135 .map_err(|_| not_found())?;
136 Ok(p)
137}
138
139fn ensure_may_edit(rev: &Revision, actor: &Actor) -> AppResult<()> {
141 if actor.can_edit() && actor.can_edit_project(rev.project_id) {
142 Ok(())
143 } else {
144 Err(AppError::Forbidden(
145 "Only CAD editors of this project group can propose, accept or reject result values."
146 .into(),
147 ))
148 }
149}
150
151async fn lock_visible(
152 conn: &mut PgConnection,
153 actor: &Actor,
154 revision_id: Uuid,
155) -> AppResult<Revision> {
156 let rev = revisions::lock(conn, revision_id).await?;
157 access::ensure_visible(actor, rev.project_id, access::not_found_revision())?;
158 Ok(rev)
159}
160
161pub async fn propose(
180 pool: &PgPool,
181 actor: &Actor,
182 revision_id: Uuid,
183 inputs: Vec<ProposalInput>,
184) -> AppResult<Vec<Proposal>> {
185 let mut tx = pool.begin().await?;
186 let rev = lock_visible(&mut tx, actor, revision_id).await?;
187 simulations::ensure_simulation(&rev)?;
188 ensure_may_edit(&rev, actor)?;
189 if !revisions::state_of(&rev).is_editable() {
190 return Err(AppError::Conflict(format!(
191 "{} is {} – values are proposed for revisions that are still in work.",
192 revisions::label(&rev),
193 revisions::state_of(&rev).label()
194 )));
195 }
196 if inputs.is_empty() {
197 return Err(AppError::BadRequest("Give at least one value.".into()));
198 }
199 if inputs.len() > MAX_PER_CALL {
200 return Err(AppError::BadRequest(format!(
201 "At most {MAX_PER_CALL} values can be proposed at once."
202 )));
203 }
204 let mut ids = Vec::with_capacity(inputs.len());
205 let mut seen = std::collections::HashSet::new();
206 for (i, input) in inputs.into_iter().enumerate() {
207 let at = |e: AppError| AppError::BadRequest(format!("values[{i}]: {}", e.public_message()));
208 let checked = ResultInput {
209 name: input.name.clone(),
210 value: input.value,
211 unit: input.unit.clone(),
212 lower_limit: input.lower_limit,
213 upper_limit: input.upper_limit,
214 load_case: input.load_case.clone(),
215 solver: input.solver.clone(),
216 solver_version: input.solver_version.clone(),
217 note: String::new(),
218 source: String::new(),
219 }
220 .validated()
221 .map_err(at)?;
222 if !seen.insert((
223 checked.name.to_lowercase(),
224 checked.load_case.to_lowercase(),
225 )) {
226 return Err(AppError::BadRequest(format!(
227 "values[{i}]: '{}' is proposed twice.",
228 checked.name
229 )));
230 }
231 let file = domain::require_text("File", &input.file, 255).map_err(at)?;
232 let quote = domain::require_text("Quote", &input.quote, MAX_QUOTE_CHARS).map_err(at)?;
233 let row: Option<(String, i32, String)> = sqlx::query_as(
235 "SELECT f.filename, f.current_version, v.content_text
236 FROM files f JOIN file_versions v ON v.file_id = f.id AND v.version = f.current_version
237 WHERE f.revision_id = $1 AND lower(f.filename) = lower($2)",
238 )
239 .bind(rev.id)
240 .bind(&file)
241 .fetch_optional(&mut *tx)
242 .await?;
243 let Some((filename, version, text)) = row else {
244 return Err(AppError::BadRequest(format!(
245 "values[{i}]: the file '{file}' is not in {}.",
246 revisions::label(&rev)
247 )));
248 };
249 if !squash(&text).contains(&squash("e)) {
250 return Err(AppError::BadRequest(format!(
251 "values[{i}]: the quote for '{}' does not stand in {filename} v{version} – quote \
252 the text exactly as read_file_text returned it.",
253 checked.name
254 )));
255 }
256 sqlx::query(
258 "DELETE FROM result_proposals
259 WHERE revision_id = $1 AND status = 'pending'
260 AND lower(name) = lower($2) AND lower(load_case) = lower($3)",
261 )
262 .bind(rev.id)
263 .bind(&checked.name)
264 .bind(&checked.load_case)
265 .execute(&mut *tx)
266 .await?;
267 let id = Uuid::new_v4();
268 sqlx::query(
269 "INSERT INTO result_proposals (id, revision_id, name, value, unit, lower_limit,
270 upper_limit, load_case, solver, solver_version, source_file, source_version,
271 quote, proposed_by)
272 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
273 )
274 .bind(id)
275 .bind(rev.id)
276 .bind(&checked.name)
277 .bind(checked.value)
278 .bind(&checked.unit)
279 .bind(checked.lower_limit)
280 .bind(checked.upper_limit)
281 .bind(&checked.load_case)
282 .bind(&checked.solver)
283 .bind(&checked.solver_version)
284 .bind(&filename)
285 .bind(version)
286 .bind("e)
287 .bind(actor.id)
288 .execute(&mut *tx)
289 .await?;
290 audit::record(
291 &mut tx,
292 Some(actor),
293 "simulation.proposal.create",
294 "revision",
295 Some(rev.id),
296 format!(
297 "{}: proposed {} = {} {} from {filename} v{version}",
298 revisions::label(&rev),
299 checked.name,
300 checked.value,
301 checked.unit
302 ),
303 json!({ "item_id": rev.item_id, "proposal_id": id, "file": filename,
304 "version": version, "quote": quote }),
305 )
306 .await?;
307 ids.push(id);
308 }
309 let pending: i64 = sqlx::query_scalar(
310 "SELECT COUNT(*) FROM result_proposals WHERE revision_id = $1 AND status = 'pending'",
311 )
312 .bind(rev.id)
313 .fetch_one(&mut *tx)
314 .await?;
315 if pending > MAX_PENDING {
316 return Err(AppError::BadRequest(format!(
317 "{} has {MAX_PENDING} pending proposals already – accept or reject them first.",
318 revisions::label(&rev)
319 )));
320 }
321 tx.commit().await?;
322 let mut out = Vec::with_capacity(ids.len());
323 for id in ids {
324 out.push(get(pool, actor, id).await?);
325 }
326 Ok(out)
327}
328
329pub async fn accept(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Proposal> {
348 let existing = get(pool, actor, id).await?;
349 let mut tx = pool.begin().await?;
350 let status: String =
351 sqlx::query_scalar("SELECT status FROM result_proposals WHERE id = $1 FOR UPDATE")
352 .bind(id)
353 .fetch_optional(&mut *tx)
354 .await?
355 .ok_or_else(not_found)?;
356 if status != "pending" {
357 return Err(AppError::Conflict(format!(
358 "This value was already {status}."
359 )));
360 }
361 let rev = simulations::lock_editable(&mut tx, actor, existing.revision_id).await?;
362 ensure_may_edit(&rev, actor)?;
363 let input = ResultInput {
364 name: existing.name.clone(),
365 value: existing.value,
366 unit: existing.unit.clone(),
367 lower_limit: existing.lower_limit,
368 upper_limit: existing.upper_limit,
369 load_case: existing.load_case.clone(),
370 solver: existing.solver.clone(),
371 solver_version: existing.solver_version.clone(),
372 note: String::new(),
373 source: format!(
374 "{} v{} (proposed by {})",
375 existing.source_file, existing.source_version, existing.proposed_by_name
376 ),
377 };
378 simulations::write_results(
379 &mut tx,
380 actor,
381 &rev,
382 &[input],
383 false,
384 &Origin::Proposal(existing.source_file.clone()),
385 )
386 .await?;
387 sqlx::query(
388 "UPDATE result_proposals SET status = 'accepted', decided_by = $2, decided_at = now()
389 WHERE id = $1",
390 )
391 .bind(id)
392 .bind(actor.id)
393 .execute(&mut *tx)
394 .await?;
395 tx.commit().await?;
396 get(pool, actor, id).await
397}
398
399pub async fn reject(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Proposal> {
412 let existing = get(pool, actor, id).await?;
413 let mut tx = pool.begin().await?;
414 let status: String =
415 sqlx::query_scalar("SELECT status FROM result_proposals WHERE id = $1 FOR UPDATE")
416 .bind(id)
417 .fetch_optional(&mut *tx)
418 .await?
419 .ok_or_else(not_found)?;
420 if status != "pending" {
421 return Err(AppError::Conflict(format!(
422 "This value was already {status}."
423 )));
424 }
425 let rev = lock_visible(&mut tx, actor, existing.revision_id).await?;
426 ensure_may_edit(&rev, actor)?;
427 sqlx::query(
428 "UPDATE result_proposals SET status = 'rejected', decided_by = $2, decided_at = now()
429 WHERE id = $1",
430 )
431 .bind(id)
432 .bind(actor.id)
433 .execute(&mut *tx)
434 .await?;
435 audit::record(
436 &mut tx,
437 Some(actor),
438 "simulation.proposal.reject",
439 "revision",
440 Some(rev.id),
441 format!(
442 "{}: rejected the proposed value {} from {}",
443 revisions::label(&rev),
444 existing.name,
445 existing.source_file
446 ),
447 json!({ "item_id": rev.item_id, "proposal_id": id, "name": existing.name }),
448 )
449 .await?;
450 tx.commit().await?;
451 get(pool, actor, id).await
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn quotes_match_across_line_breaks_and_case() {
460 let text = "Pressure drop\nat nominal flow: 388.25 Pa";
461 assert!(squash(text).contains(&squash("pressure drop at nominal flow: 388.25 PA")));
462 assert!(!squash(text).contains(&squash("pressure drop: 388.25")));
463 }
464}