Skip to main content

teamcraft/services/
annotations.rs

1//! 3D markup: notes pinned to points of a file version's 3D preview, for
2//! design reviews. Everyone who may read the file reads and adds notes
3//! (reviewers are often viewers); a note is removed by its author or an
4//! administrator. Adding and removing are audited on the revision.
5
6use chrono::{DateTime, Utc};
7use serde::Serialize;
8use serde_json::json;
9use sqlx::PgPool;
10use uuid::Uuid;
11
12use crate::{
13    auth::Actor,
14    domain::{self, Role},
15    error::{AppError, AppResult},
16    services::{audit, files},
17};
18
19/// Notes of one version at most.
20pub const MAX_PER_VERSION: i64 = 500;
21
22#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
23pub struct Annotation {
24    pub id: Uuid,
25    pub file_version_id: Uuid,
26    pub x: f64,
27    pub y: f64,
28    pub z: f64,
29    pub body: String,
30    pub author_id: Uuid,
31    pub author_name: String,
32    pub created_at: DateTime<Utc>,
33    /// The reader may remove it (author or administrator).
34    #[sqlx(default)]
35    pub removable: bool,
36}
37
38/// The notes of a file version the actor may read, oldest first.
39pub async fn list(pool: &PgPool, actor: &Actor, version_id: Uuid) -> AppResult<Vec<Annotation>> {
40    files::get_version(pool, actor, version_id).await?;
41    let mut notes = sqlx::query_as::<_, Annotation>(
42        "SELECT a.id, a.file_version_id, a.x, a.y, a.z, a.body, a.author_id,
43                u.display_name AS author_name, a.created_at
44         FROM model_annotations a JOIN users u ON u.id = a.author_id
45         WHERE a.file_version_id = $1 ORDER BY a.created_at, a.id",
46    )
47    .bind(version_id)
48    .fetch_all(pool)
49    .await?;
50    for n in &mut notes {
51        n.removable = n.author_id == actor.id || actor.role >= Role::Admin;
52    }
53    Ok(notes)
54}
55
56/// The point and text of a new note.
57pub struct NewAnnotation {
58    pub x: f64,
59    pub y: f64,
60    pub z: f64,
61    pub body: String,
62}
63
64fn validate(new: &NewAnnotation) -> AppResult<String> {
65    if ![new.x, new.y, new.z].iter().all(|v| v.is_finite()) {
66        return Err(AppError::BadRequest(
67            "The point of the note is invalid.".into(),
68        ));
69    }
70    let body = domain::limit_text("Note", new.body.trim(), 2000)?;
71    if body.is_empty() {
72        return Err(AppError::BadRequest("Write a note.".into()));
73    }
74    Ok(body)
75}
76
77/// Pins a note to a point of a file version.
78///
79/// # Invariants
80///
81/// * **Permission:** the version is readable ("not found" otherwise).
82/// * **Limits:** text 1–2000 characters, finite coordinates (400), at most
83///   [`MAX_PER_VERSION`] notes per version (409).
84/// * **Audit:** `annotation.add` on the revision.
85pub async fn add(
86    pool: &PgPool,
87    actor: &Actor,
88    version_id: Uuid,
89    new: NewAnnotation,
90) -> AppResult<Annotation> {
91    let body = validate(&new)?;
92    let (version, filename) = files::get_version(pool, actor, version_id).await?;
93    let file = files::get(pool, actor, version.file_id).await?;
94    let mut tx = pool.begin().await?;
95    // serialises the count per version
96    sqlx::query("SELECT id FROM file_versions WHERE id = $1 FOR NO KEY UPDATE")
97        .bind(version_id)
98        .execute(&mut *tx)
99        .await?;
100    let count: i64 =
101        sqlx::query_scalar("SELECT count(*) FROM model_annotations WHERE file_version_id = $1")
102            .bind(version_id)
103            .fetch_one(&mut *tx)
104            .await?;
105    if count >= MAX_PER_VERSION {
106        return Err(AppError::Conflict(format!(
107            "This version already has {count} notes."
108        )));
109    }
110    let id = Uuid::new_v4();
111    sqlx::query(
112        "INSERT INTO model_annotations (id, file_version_id, x, y, z, body, author_id)
113         VALUES ($1, $2, $3, $4, $5, $6, $7)",
114    )
115    .bind(id)
116    .bind(version_id)
117    .bind(new.x)
118    .bind(new.y)
119    .bind(new.z)
120    .bind(&body)
121    .bind(actor.id)
122    .execute(&mut *tx)
123    .await?;
124    audit::record(
125        &mut tx,
126        Some(actor),
127        "annotation.add",
128        "revision",
129        Some(file.revision_id),
130        format!(
131            "Note on {filename} v{}: {}",
132            version.version,
133            preview_of(&body)
134        ),
135        json!({ "annotation_id": id, "file_version_id": version_id }),
136    )
137    .await?;
138    tx.commit().await?;
139    list(pool, actor, version_id)
140        .await?
141        .into_iter()
142        .find(|n| n.id == id)
143        .ok_or_else(|| AppError::not_found("Note not found."))
144}
145
146/// Removes a note (its author or an administrator).
147///
148/// # Invariants
149///
150/// * **Permission:** the version is readable ("not found" otherwise); the
151///   actor wrote the note or is an administrator (403).
152/// * **Audit:** `annotation.remove` on the revision.
153pub async fn remove(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<()> {
154    let row: Option<(Uuid, Uuid, String)> = sqlx::query_as(
155        "SELECT file_version_id, author_id, body FROM model_annotations WHERE id = $1",
156    )
157    .bind(id)
158    .fetch_optional(pool)
159    .await?;
160    let Some((version_id, author_id, body)) = row else {
161        return Err(AppError::not_found("Note not found."));
162    };
163    let (version, filename) = files::get_version(pool, actor, version_id).await?;
164    if author_id != actor.id && actor.role < Role::Admin {
165        return Err(AppError::Forbidden(
166            "Only the author or an administrator removes a note.".into(),
167        ));
168    }
169    let file = files::get(pool, actor, version.file_id).await?;
170    let mut tx = pool.begin().await?;
171    sqlx::query("DELETE FROM model_annotations WHERE id = $1")
172        .bind(id)
173        .execute(&mut *tx)
174        .await?;
175    audit::record(
176        &mut tx,
177        Some(actor),
178        "annotation.remove",
179        "revision",
180        Some(file.revision_id),
181        format!(
182            "Removed a note on {filename} v{}: {}",
183            version.version,
184            preview_of(&body)
185        ),
186        json!({ "annotation_id": id, "file_version_id": version_id }),
187    )
188    .await?;
189    tx.commit().await?;
190    Ok(())
191}
192
193/// The first 80 characters of a note, for the audit summary.
194fn preview_of(body: &str) -> String {
195    let mut s: String = body.chars().take(80).collect();
196    if body.chars().count() > 80 {
197        s.push('…');
198    }
199    s
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn notes_are_validated() {
208        let ok = NewAnnotation {
209            x: 1.0,
210            y: 2.0,
211            z: 3.0,
212            body: "  wall too thin here ".into(),
213        };
214        assert_eq!(validate(&ok).unwrap(), "wall too thin here");
215        for bad in [
216            NewAnnotation {
217                x: f64::NAN,
218                ..ok_note()
219            },
220            NewAnnotation {
221                body: "   ".into(),
222                ..ok_note()
223            },
224            NewAnnotation {
225                body: "x".repeat(2001),
226                ..ok_note()
227            },
228        ] {
229            assert!(validate(&bad).is_err());
230        }
231        assert_eq!(preview_of(&"a".repeat(100)).chars().count(), 81);
232    }
233
234    fn ok_note() -> NewAnnotation {
235        NewAnnotation {
236            x: 0.0,
237            y: 0.0,
238            z: 0.0,
239            body: "note".into(),
240        }
241    }
242}