1use 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
19pub 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 #[sqlx(default)]
35 pub removable: bool,
36}
37
38pub 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
56pub 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
77pub 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 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
146pub 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
193fn 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}