Skip to main content

teamcraft/services/
result_proposals.rs

1//! Result values proposed by the assistant from a report.
2//!
3//! The assistant reads a CFD or FEA report (`read_file_text`) and proposes key
4//! numbers with `propose_result_values`. A proposal is a **draft**: it names
5//! the file version it was read from and quotes the text, nothing more. It
6//! becomes a result value only when a CAD editor of the project group
7//! accepts it in TeamCraft while the simulation revision is checked out to
8//! them – the same rule as for a value typed in. The assistant never writes a
9//! final value and never releases anything. Every proposal, acceptance and
10//! rejection is audited in its own transaction.
11
12use 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
28/// Proposals in one call.
29pub const MAX_PER_CALL: usize = 50;
30
31/// Pending proposals of one revision.
32pub const MAX_PENDING: i64 = 200;
33
34/// Longest quote kept.
35pub const MAX_QUOTE_CHARS: usize = 400;
36
37/// A proposed result value.
38#[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    /// The file the value was read from, and its version.
51    pub source_file: String,
52    pub source_version: i32,
53    /// The text of the report the value stands in.
54    pub quote: String,
55    /// `pending`, `accepted` or `rejected`.
56    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/// What the assistant proposes.
78#[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    /// File name in the revision.
95    pub file: String,
96    /// The text of the report that holds the value, as `read_file_text` gave it.
97    pub quote: String,
98}
99
100/// Lower case, runs of blanks as one space: quotes survive line breaks.
101fn squash(text: &str) -> String {
102    text.split_whitespace()
103        .collect::<Vec<_>>()
104        .join(" ")
105        .to_lowercase()
106}
107
108/// The proposals of a revision the actor may see, pending first.
109pub 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
126/// One proposal, if the actor may see its revision.
127pub 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
139/// Whether the actor may work on the project group of the revision.
140fn 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
161/// Stores proposals as drafts (all or none). Needs a simulation revision
162/// that is still in work and a CAD editor of its project group; the file
163/// must exist in the revision and the quote must stand in its text.
164///
165/// # Invariants
166///
167/// * **Permission:** CAD editor in the project group; the revision is visible
168///   (404), belongs to a simulation (400) and is in work (409). No check-out
169///   is needed: a proposal is a draft, not a result value.
170/// * **Locks:** the simulation revision (`FOR UPDATE`).
171/// * **Data:** at most 50 values per call (400); the file must be in the
172///   revision and the quote in its current text (400). A newer proposal
173///   replaces a pending one of the same name and load case; more than 200
174///   pending proposals on the revision is a 400.
175/// * **Audit:** `simulation.proposal.create` per proposal, including the quote
176///   and the file version.
177/// * **Notifications / webhooks:** none.
178/// * **Atomic:** one transaction; all proposals are stored or none.
179pub 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        // the file, its text and the quote
234        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(&quote)) {
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        // a newer proposal replaces a pending one of the same value
257        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(&quote)
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
329/// Makes a pending proposal a result value (same name and load case = update).
330/// The revision must be checked out to the actor, like for any change of its
331/// values.
332///
333/// # Invariants
334///
335/// * **Permission:** CAD editor in the project group, simulation revision in
336///   work and checked out by the actor (403 / 409), as for a value typed in.
337///   A proposal that is not pending gets a 409.
338/// * **Locks:** the proposal row, then the simulation revision (both
339///   `FOR UPDATE`), so two accepts of one proposal cannot both succeed.
340/// * **Data:** the value is written by `simulations::write_results` with its
341///   source (file, version, proposer); the proposal becomes `accepted`.
342/// * **Audit:** `simulation.proposal.accept` (written by `write_results`).
343/// * **Notifications:** none.
344/// * **Webhooks:** `simulation.result_added`, in the same transaction.
345/// * **Atomic:** one transaction (`tests/result_proposals.rs`); a second accept
346///   gets a 409.
347pub 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
399/// Rejects a pending proposal (no check-out needed: nothing changes in the
400/// revision).
401///
402/// # Invariants
403///
404/// * **Permission:** CAD editor in the project group; a proposal that is not
405///   pending gets a 409.
406/// * **Locks:** the proposal row, then the simulation revision (both
407///   `FOR UPDATE`).
408/// * **Audit:** `simulation.proposal.reject`.
409/// * **Notifications / webhooks:** none.
410/// * **Atomic:** one transaction (`tests/result_proposals.rs`).
411pub 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}