Skip to main content

backbone_bucket/domain/entity/
file_comment.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sqlx::FromRow;
4use uuid::Uuid;
5
6use super::CommentStatus;
7use super::AuditMetadata;
8
9use super::*;
10
11use crate::domain::state_machine::{FileCommentStateMachine, FileCommentState, StateMachineError};
12
13use thiserror::Error;
14
15/// Domain error for commenterror operations
16#[derive(Debug, Clone, Error)]
17pub enum CommentError {
18    #[error("{0}")]
19    Message(String),
20
21    #[error("Not found: {0}")]
22    NotFound(String),
23
24    #[error("Validation failed: {0}")]
25    ValidationFailed(String),
26
27    #[error("Conflict: {0}")]
28    Conflict(String),
29}
30
31impl From<String> for CommentError {
32    fn from(msg: String) -> Self { Self::Message(msg) }
33}
34
35impl From<&str> for CommentError {
36    fn from(msg: &str) -> Self { Self::Message(msg.to_string()) }
37}
38
39
40/// Strongly-typed ID for FileComment
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(transparent)]
43pub struct FileCommentId(pub Uuid);
44
45impl FileCommentId {
46    pub fn new(id: Uuid) -> Self { Self(id) }
47    pub fn generate() -> Self { Self(Uuid::new_v4()) }
48    pub fn into_inner(self) -> Uuid { self.0 }
49}
50
51impl std::fmt::Display for FileCommentId {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "{}", self.0)
54    }
55}
56
57impl std::str::FromStr for FileCommentId {
58    type Err = uuid::Error;
59    fn from_str(s: &str) -> Result<Self, Self::Err> {
60        Ok(Self(Uuid::parse_str(s)?))
61    }
62}
63
64impl From<Uuid> for FileCommentId {
65    fn from(id: Uuid) -> Self { Self(id) }
66}
67
68impl From<FileCommentId> for Uuid {
69    fn from(id: FileCommentId) -> Self { id.0 }
70}
71
72impl AsRef<Uuid> for FileCommentId {
73    fn as_ref(&self) -> &Uuid { &self.0 }
74}
75
76impl std::ops::Deref for FileCommentId {
77    type Target = Uuid;
78    fn deref(&self) -> &Self::Target { &self.0 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
82pub struct FileComment {
83    pub id: Uuid,
84    pub file_id: Uuid,
85    pub user_id: Uuid,
86    pub parent_id: Option<Uuid>,
87    pub content: String,
88    pub annotation_region: Option<serde_json::Value>,
89    pub mentions: Vec<Uuid>,
90    pub resolved: bool,
91    pub resolved_by: Option<Uuid>,
92    pub resolved_at: Option<DateTime<Utc>>,
93    pub(crate) status: CommentStatus,
94    #[serde(default)]
95    #[sqlx(json)]
96    pub metadata: AuditMetadata,
97}
98
99impl FileComment {
100    /// Create a builder for FileComment
101    pub fn builder() -> FileCommentBuilder {
102        <FileCommentBuilder as Default>::default()
103    }
104
105    /// Create a new FileComment with required fields
106    pub fn new(file_id: Uuid, user_id: Uuid, content: String, mentions: Vec<Uuid>, resolved: bool, status: CommentStatus) -> Self {
107        Self {
108            id: Uuid::new_v4(),
109            file_id,
110            user_id,
111            parent_id: None,
112            content,
113            annotation_region: None,
114            mentions,
115            resolved,
116            resolved_by: None,
117            resolved_at: None,
118            status,
119            metadata: AuditMetadata::default(),
120        }
121    }
122
123    /// Get the entity's unique identifier
124    pub fn id(&self) -> &Uuid {
125        &self.id
126    }
127
128    /// Get a strongly-typed ID for this entity
129    pub fn typed_id(&self) -> FileCommentId {
130        FileCommentId(self.id)
131    }
132
133    /// Get when this entity was created
134    pub fn created_at(&self) -> Option<&DateTime<Utc>> {
135        self.metadata.created_at.as_ref()
136    }
137
138    /// Get when this entity was last updated
139    pub fn updated_at(&self) -> Option<&DateTime<Utc>> {
140        self.metadata.updated_at.as_ref()
141    }
142
143    /// Check if this entity is soft deleted
144    pub fn is_deleted(&self) -> bool {
145        self.metadata.deleted_at.is_some()
146    }
147
148    /// Check if this entity is active (not deleted)
149    pub fn is_active(&self) -> bool {
150        self.metadata.deleted_at.is_none()
151    }
152
153    /// Get when this entity was deleted
154    pub fn deleted_at(&self) -> Option<&DateTime<Utc>> {
155        self.metadata.deleted_at.as_ref()
156    }
157
158    /// Get who created this entity
159    pub fn created_by(&self) -> Option<&Uuid> {
160        self.metadata.created_by.as_ref()
161    }
162
163    /// Get who last updated this entity
164    pub fn updated_by(&self) -> Option<&Uuid> {
165        self.metadata.updated_by.as_ref()
166    }
167
168    /// Get who deleted this entity
169    pub fn deleted_by(&self) -> Option<&Uuid> {
170        self.metadata.deleted_by.as_ref()
171    }
172
173    /// Get the current status
174    pub fn status(&self) -> &CommentStatus {
175        &self.status
176    }
177
178
179    // ==========================================================
180    // Fluent Setters (with_* for optional fields)
181    // ==========================================================
182
183    /// Set the parent_id field (chainable)
184    pub fn with_parent_id(mut self, value: Uuid) -> Self {
185        self.parent_id = Some(value);
186        self
187    }
188
189    /// Set the annotation_region field (chainable)
190    pub fn with_annotation_region(mut self, value: serde_json::Value) -> Self {
191        self.annotation_region = Some(value);
192        self
193    }
194
195    /// Set the resolved_by field (chainable)
196    pub fn with_resolved_by(mut self, value: Uuid) -> Self {
197        self.resolved_by = Some(value);
198        self
199    }
200
201    /// Set the resolved_at field (chainable)
202    pub fn with_resolved_at(mut self, value: DateTime<Utc>) -> Self {
203        self.resolved_at = Some(value);
204        self
205    }
206
207    // ==========================================================
208    // State Machine
209    // ==========================================================
210
211    /// Transition to a new state via the status state machine.
212    ///
213    /// Returns `Err` if the transition is not permitted from the current state.
214    /// Use this method instead of assigning `self.status` directly.
215    pub fn transition_to(&mut self, new_state: FileCommentState) -> Result<(), StateMachineError> {
216        let current = self.status.to_string().parse::<FileCommentState>()?;
217        let mut sm = FileCommentStateMachine::from_state(current);
218        sm.transition_to_state(new_state)?;
219        self.status = new_state.to_string().parse::<CommentStatus>()
220            .map_err(|e| StateMachineError::InvalidState(e.to_string()))?;
221        Ok(())
222    }
223
224    // ==========================================================
225    // Partial Update
226    // ==========================================================
227
228    /// Apply partial updates from a map of field name to JSON value
229    pub fn apply_patch(&mut self, fields: std::collections::HashMap<String, serde_json::Value>) {
230        for (key, value) in fields {
231            match key.as_str() {
232                "file_id" => {
233                    if let Ok(v) = serde_json::from_value(value) { self.file_id = v; }
234                }
235                "user_id" => {
236                    if let Ok(v) = serde_json::from_value(value) { self.user_id = v; }
237                }
238                "parent_id" => {
239                    if let Ok(v) = serde_json::from_value(value) { self.parent_id = v; }
240                }
241                "content" => {
242                    if let Ok(v) = serde_json::from_value(value) { self.content = v; }
243                }
244                "annotation_region" => {
245                    if let Ok(v) = serde_json::from_value(value) { self.annotation_region = v; }
246                }
247                "mentions" => {
248                    if let Ok(v) = serde_json::from_value(value) { self.mentions = v; }
249                }
250                "resolved" => {
251                    if let Ok(v) = serde_json::from_value(value) { self.resolved = v; }
252                }
253                "resolved_by" => {
254                    if let Ok(v) = serde_json::from_value(value) { self.resolved_by = v; }
255                }
256                "resolved_at" => {
257                    if let Ok(v) = serde_json::from_value(value) { self.resolved_at = v; }
258                }
259                _ => {} // ignore unknown fields
260            }
261        }
262    }
263
264}
265
266impl super::Entity for FileComment {
267    type Id = Uuid;
268
269    fn entity_id(&self) -> &Self::Id {
270        &self.id
271    }
272
273    fn entity_type() -> &'static str {
274        "FileComment"
275    }
276}
277
278impl backbone_core::PersistentEntity for FileComment {
279    fn entity_id(&self) -> String {
280        self.id.to_string()
281    }
282    fn set_entity_id(&mut self, id: String) {
283        if let Ok(uuid) = uuid::Uuid::parse_str(&id) {
284            self.id = uuid;
285        }
286    }
287    fn created_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
288        self.metadata.created_at
289    }
290    fn set_created_at(&mut self, ts: chrono::DateTime<chrono::Utc>) {
291        self.metadata.created_at = Some(ts);
292    }
293    fn updated_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
294        self.metadata.updated_at
295    }
296    fn set_updated_at(&mut self, ts: chrono::DateTime<chrono::Utc>) {
297        self.metadata.updated_at = Some(ts);
298    }
299    fn deleted_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
300        self.metadata.deleted_at
301    }
302    fn set_deleted_at(&mut self, ts: Option<chrono::DateTime<chrono::Utc>>) {
303        self.metadata.deleted_at = ts;
304    }
305}
306
307impl backbone_orm::EntityRepoMeta for FileComment {
308    fn column_types() -> std::collections::HashMap<String, String> {
309        let mut m = std::collections::HashMap::new();
310        m.insert("id".to_string(), "uuid".to_string());
311        m.insert("file_id".to_string(), "uuid".to_string());
312        m.insert("user_id".to_string(), "uuid".to_string());
313        m.insert("parent_id".to_string(), "uuid".to_string());
314        m.insert("status".to_string(), "comment_status".to_string());
315        m.insert("resolved_at".to_string(), "timestamptz".to_string());
316        m
317    }
318    fn search_fields() -> &'static [&'static str] {
319        &["content"]
320    }
321}
322
323/// Builder for FileComment entity
324///
325/// Provides a fluent API for constructing FileComment instances.
326/// System fields (id, metadata, timestamps) are auto-initialized.
327#[derive(Debug, Clone, Default)]
328pub struct FileCommentBuilder {
329    file_id: Option<Uuid>,
330    user_id: Option<Uuid>,
331    parent_id: Option<Uuid>,
332    content: Option<String>,
333    annotation_region: Option<serde_json::Value>,
334    mentions: Option<Vec<Uuid>>,
335    resolved: Option<bool>,
336    resolved_by: Option<Uuid>,
337    resolved_at: Option<DateTime<Utc>>,
338    status: Option<CommentStatus>,
339}
340
341impl FileCommentBuilder {
342    /// Set the file_id field (required)
343    pub fn file_id(mut self, value: Uuid) -> Self {
344        self.file_id = Some(value);
345        self
346    }
347
348    /// Set the user_id field (required)
349    pub fn user_id(mut self, value: Uuid) -> Self {
350        self.user_id = Some(value);
351        self
352    }
353
354    /// Set the parent_id field (optional)
355    pub fn parent_id(mut self, value: Uuid) -> Self {
356        self.parent_id = Some(value);
357        self
358    }
359
360    /// Set the content field (required)
361    pub fn content(mut self, value: String) -> Self {
362        self.content = Some(value);
363        self
364    }
365
366    /// Set the annotation_region field (optional)
367    pub fn annotation_region(mut self, value: serde_json::Value) -> Self {
368        self.annotation_region = Some(value);
369        self
370    }
371
372    /// Set the mentions field (required)
373    pub fn mentions(mut self, value: Vec<Uuid>) -> Self {
374        self.mentions = Some(value);
375        self
376    }
377
378    /// Set the resolved field (default: `false`)
379    pub fn resolved(mut self, value: bool) -> Self {
380        self.resolved = Some(value);
381        self
382    }
383
384    /// Set the resolved_by field (optional)
385    pub fn resolved_by(mut self, value: Uuid) -> Self {
386        self.resolved_by = Some(value);
387        self
388    }
389
390    /// Set the resolved_at field (optional)
391    pub fn resolved_at(mut self, value: DateTime<Utc>) -> Self {
392        self.resolved_at = Some(value);
393        self
394    }
395
396    /// Set the status field (default: `CommentStatus::default()`)
397    pub fn status(mut self, value: CommentStatus) -> Self {
398        self.status = Some(value);
399        self
400    }
401
402    /// Build the FileComment entity
403    ///
404    /// Returns Err if any required field without a default is missing.
405    pub fn build(self) -> Result<FileComment, String> {
406        let file_id = self.file_id.ok_or_else(|| "file_id is required".to_string())?;
407        let user_id = self.user_id.ok_or_else(|| "user_id is required".to_string())?;
408        let content = self.content.ok_or_else(|| "content is required".to_string())?;
409        let mentions = self.mentions.ok_or_else(|| "mentions is required".to_string())?;
410
411        Ok(FileComment {
412            id: Uuid::new_v4(),
413            file_id,
414            user_id,
415            parent_id: self.parent_id,
416            content,
417            annotation_region: self.annotation_region,
418            mentions,
419            resolved: self.resolved.unwrap_or(false),
420            resolved_by: self.resolved_by,
421            resolved_at: self.resolved_at,
422            status: self.status.unwrap_or_default(),
423            metadata: AuditMetadata::default(),
424        })
425    }
426}
427
428// This file's extension (hand-written; ADR-0031).
429#[path = "file_comment.ext.rs"]
430mod file_comment_ext;
431pub use file_comment_ext::*;