backbone_bucket/domain/entity/
file_lock.rs1use chrono::{DateTime, Utc, Duration};
2use serde::{Deserialize, Serialize};
3use sqlx::FromRow;
4use uuid::Uuid;
5
6use super::LockStatus;
7use super::AuditMetadata;
8
9use super::*;
10
11use crate::domain::state_machine::{FileLockStateMachine, FileLockState, StateMachineError};
12
13use thiserror::Error;
14
15#[derive(Debug, Clone, Error)]
17pub enum LockError {
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 LockError {
32 fn from(msg: String) -> Self { Self::Message(msg) }
33}
34
35impl From<&str> for LockError {
36 fn from(msg: &str) -> Self { Self::Message(msg.to_string()) }
37}
38
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(transparent)]
43pub struct FileLockId(pub Uuid);
44
45impl FileLockId {
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 FileLockId {
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 FileLockId {
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 FileLockId {
65 fn from(id: Uuid) -> Self { Self(id) }
66}
67
68impl From<FileLockId> for Uuid {
69 fn from(id: FileLockId) -> Self { id.0 }
70}
71
72impl AsRef<Uuid> for FileLockId {
73 fn as_ref(&self) -> &Uuid { &self.0 }
74}
75
76impl std::ops::Deref for FileLockId {
77 type Target = Uuid;
78 fn deref(&self) -> &Self::Target { &self.0 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
82pub struct FileLock {
83 pub id: Uuid,
84 pub file_id: Uuid,
85 pub user_id: Uuid,
86 pub locked_at: DateTime<Utc>,
87 pub expires_at: DateTime<Utc>,
88 pub refreshed_at: Option<DateTime<Utc>>,
89 pub(crate) status: LockStatus,
90 #[serde(default)]
91 #[sqlx(json)]
92 pub metadata: AuditMetadata,
93}
94
95impl FileLock {
96 pub fn builder() -> FileLockBuilder {
98 <FileLockBuilder as Default>::default()
99 }
100
101 pub fn new(file_id: Uuid, user_id: Uuid, locked_at: DateTime<Utc>, expires_at: DateTime<Utc>, status: LockStatus) -> Self {
103 Self {
104 id: Uuid::new_v4(),
105 file_id,
106 user_id,
107 locked_at,
108 expires_at,
109 refreshed_at: None,
110 status,
111 metadata: AuditMetadata::default(),
112 }
113 }
114
115 pub fn id(&self) -> &Uuid {
117 &self.id
118 }
119
120 pub fn typed_id(&self) -> FileLockId {
122 FileLockId(self.id)
123 }
124
125 pub fn created_at(&self) -> Option<&DateTime<Utc>> {
127 self.metadata.created_at.as_ref()
128 }
129
130 pub fn updated_at(&self) -> Option<&DateTime<Utc>> {
132 self.metadata.updated_at.as_ref()
133 }
134
135 pub fn is_deleted(&self) -> bool {
137 self.metadata.deleted_at.is_some()
138 }
139
140 pub fn is_active(&self) -> bool {
142 self.metadata.deleted_at.is_none()
143 }
144
145 pub fn deleted_at(&self) -> Option<&DateTime<Utc>> {
147 self.metadata.deleted_at.as_ref()
148 }
149
150 pub fn created_by(&self) -> Option<&Uuid> {
152 self.metadata.created_by.as_ref()
153 }
154
155 pub fn updated_by(&self) -> Option<&Uuid> {
157 self.metadata.updated_by.as_ref()
158 }
159
160 pub fn deleted_by(&self) -> Option<&Uuid> {
162 self.metadata.deleted_by.as_ref()
163 }
164
165 pub fn status(&self) -> &LockStatus {
167 &self.status
168 }
169
170
171 pub fn with_refreshed_at(mut self, value: DateTime<Utc>) -> Self {
177 self.refreshed_at = Some(value);
178 self
179 }
180
181 pub fn transition_to(&mut self, new_state: FileLockState) -> Result<(), StateMachineError> {
190 let current = self.status.to_string().parse::<FileLockState>()?;
191 let mut sm = FileLockStateMachine::from_state(current);
192 sm.transition_to_state(new_state)?;
193 self.status = new_state.to_string().parse::<LockStatus>()
194 .map_err(|e| StateMachineError::InvalidState(e.to_string()))?;
195 Ok(())
196 }
197
198 pub fn apply_patch(&mut self, fields: std::collections::HashMap<String, serde_json::Value>) {
204 for (key, value) in fields {
205 match key.as_str() {
206 "file_id" => {
207 if let Ok(v) = serde_json::from_value(value) { self.file_id = v; }
208 }
209 "user_id" => {
210 if let Ok(v) = serde_json::from_value(value) { self.user_id = v; }
211 }
212 "locked_at" => {
213 if let Ok(v) = serde_json::from_value(value) { self.locked_at = v; }
214 }
215 "expires_at" => {
216 if let Ok(v) = serde_json::from_value(value) { self.expires_at = v; }
217 }
218 "refreshed_at" => {
219 if let Ok(v) = serde_json::from_value(value) { self.refreshed_at = v; }
220 }
221 _ => {} }
223 }
224 }
225
226 pub fn is_valid(&self) -> bool {
234 self.status == LockStatus::Active && !self.is_expired()
235 }
236
237 pub fn is_expired(&self) -> bool {
239 self.expires_at <= Utc::now()
240 }
241
242 pub fn is_owned_by(&self, user_id: Uuid) -> bool {
244 self.user_id == user_id
245 }
246
247 pub fn can_refresh(&self) -> bool {
249 self.is_valid()
250 }
251
252 pub fn refresh(&mut self, duration: Duration) -> Result<(), LockError> {
254 if !self.can_refresh() {
255 return Err(LockError::ValidationFailed(
256 "Cannot refresh lock: lock is not active or has expired".to_string(),
257 ));
258 }
259
260 let now = Utc::now();
261 self.expires_at = now + duration;
262 self.refreshed_at = Some(now);
263 self.metadata.touch();
264
265 Ok(())
266 }
267
268 pub fn time_remaining(&self) -> Duration {
270 let remaining = self.expires_at - Utc::now();
271 if remaining < Duration::zero() {
272 Duration::zero()
273 } else {
274 remaining
275 }
276 }
277
278 pub fn check_invariants(&self) -> Result<(), Vec<&'static str>> {
280 let mut errors = Vec::new();
281
282 if self.expires_at <= self.locked_at {
284 errors.push("expires_at must be after locked_at");
285 }
286
287 if self.status == LockStatus::Active && self.is_expired() {
289 errors.push("active lock must not be expired (expires_at must be in the future)");
290 }
291
292 if errors.is_empty() { Ok(()) } else { Err(errors) }
293 }
294 }
296
297impl super::Entity for FileLock {
298 type Id = Uuid;
299
300 fn entity_id(&self) -> &Self::Id {
301 &self.id
302 }
303
304 fn entity_type() -> &'static str {
305 "FileLock"
306 }
307}
308
309impl backbone_core::PersistentEntity for FileLock {
310 fn entity_id(&self) -> String {
311 self.id.to_string()
312 }
313 fn set_entity_id(&mut self, id: String) {
314 if let Ok(uuid) = uuid::Uuid::parse_str(&id) {
315 self.id = uuid;
316 }
317 }
318 fn created_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
319 self.metadata.created_at
320 }
321 fn set_created_at(&mut self, ts: chrono::DateTime<chrono::Utc>) {
322 self.metadata.created_at = Some(ts);
323 }
324 fn updated_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
325 self.metadata.updated_at
326 }
327 fn set_updated_at(&mut self, ts: chrono::DateTime<chrono::Utc>) {
328 self.metadata.updated_at = Some(ts);
329 }
330 fn deleted_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
331 self.metadata.deleted_at
332 }
333 fn set_deleted_at(&mut self, ts: Option<chrono::DateTime<chrono::Utc>>) {
334 self.metadata.deleted_at = ts;
335 }
336}
337
338impl backbone_orm::EntityRepoMeta for FileLock {
339 fn column_types() -> std::collections::HashMap<String, String> {
340 let mut m = std::collections::HashMap::new();
341 m.insert("id".to_string(), "uuid".to_string());
342 m.insert("file_id".to_string(), "uuid".to_string());
343 m.insert("user_id".to_string(), "uuid".to_string());
344 m.insert("status".to_string(), "lock_status".to_string());
345 m.insert("locked_at".to_string(), "timestamptz".to_string());
346 m.insert("expires_at".to_string(), "timestamptz".to_string());
347 m.insert("refreshed_at".to_string(), "timestamptz".to_string());
348 m
349 }
350 fn search_fields() -> &'static [&'static str] {
351 &[]
352 }
353}
354
355#[derive(Debug, Clone, Default)]
360pub struct FileLockBuilder {
361 file_id: Option<Uuid>,
362 user_id: Option<Uuid>,
363 locked_at: Option<DateTime<Utc>>,
364 expires_at: Option<DateTime<Utc>>,
365 refreshed_at: Option<DateTime<Utc>>,
366 status: Option<LockStatus>,
367}
368
369impl FileLockBuilder {
370 pub fn file_id(mut self, value: Uuid) -> Self {
372 self.file_id = Some(value);
373 self
374 }
375
376 pub fn user_id(mut self, value: Uuid) -> Self {
378 self.user_id = Some(value);
379 self
380 }
381
382 pub fn locked_at(mut self, value: DateTime<Utc>) -> Self {
384 self.locked_at = Some(value);
385 self
386 }
387
388 pub fn expires_at(mut self, value: DateTime<Utc>) -> Self {
390 self.expires_at = Some(value);
391 self
392 }
393
394 pub fn refreshed_at(mut self, value: DateTime<Utc>) -> Self {
396 self.refreshed_at = Some(value);
397 self
398 }
399
400 pub fn status(mut self, value: LockStatus) -> Self {
402 self.status = Some(value);
403 self
404 }
405
406 pub fn build(self) -> Result<FileLock, String> {
410 let file_id = self.file_id.ok_or_else(|| "file_id is required".to_string())?;
411 let user_id = self.user_id.ok_or_else(|| "user_id is required".to_string())?;
412 let expires_at = self.expires_at.ok_or_else(|| "expires_at is required".to_string())?;
413
414 Ok(FileLock {
415 id: Uuid::new_v4(),
416 file_id,
417 user_id,
418 locked_at: self.locked_at.unwrap_or(Utc::now()),
419 expires_at,
420 refreshed_at: self.refreshed_at,
421 status: self.status.unwrap_or_default(),
422 metadata: AuditMetadata::default(),
423 })
424 }
425}