Skip to main content

backbone_bucket/application/service/
locking_service.rs

1//! File Locking Service
2//!
3//! Hand-written — NOT generated. This file is safe from regeneration.
4//!
5//! Manages file editing locks to prevent concurrent edits.
6//! Supports lock acquisition, release, refresh, admin break, and expired lock cleanup.
7
8use std::sync::Arc;
9
10use chrono::{Duration, Utc};
11use uuid::Uuid;
12
13use super::error::{ServiceError, ServiceResult};
14use crate::domain::entity::{FileLock, LockStatus};
15use crate::infrastructure::persistence::FileLockRepository;
16use crate::infrastructure::persistence::StoredFileRepository;
17
18/// Default lock duration: 30 minutes
19const DEFAULT_LOCK_DURATION_MINUTES: i64 = 30;
20
21/// Service for managing file editing locks.
22pub struct LockingService {
23    lock_repo: Arc<FileLockRepository>,
24    file_repo: Arc<StoredFileRepository>,
25}
26
27impl LockingService {
28    pub fn new(lock_repo: Arc<FileLockRepository>, file_repo: Arc<StoredFileRepository>) -> Self {
29        Self { lock_repo, file_repo }
30    }
31
32    /// Acquire an editing lock on a file.
33    ///
34    /// If the file is already locked by the same user, refreshes the lock.
35    /// If locked by another user and the lock is still valid, returns an error.
36    /// Expired locks are automatically cleaned up.
37    pub async fn acquire_lock(
38        &self,
39        file_id: Uuid,
40        user_id: Uuid,
41        duration_minutes: Option<i64>,
42    ) -> ServiceResult<FileLock> {
43        // Verify file exists
44        let _file = self.file_repo
45            .find_by_id(&file_id.to_string())
46            .await
47            .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
48            .ok_or(ServiceError::NotFound)?;
49
50        // Check existing lock
51        if let Some(existing) = self.lock_repo.find_by_file_id(file_id).await.map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))? {
52            if existing.is_expired() {
53                // Expired — remove it
54                self.lock_repo.delete(&existing.id.to_string()).await.map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?;
55            } else if existing.user_id == user_id {
56                // Same user — refresh
57                return self.refresh_lock(file_id, user_id).await;
58            } else {
59                // Different user — conflict
60                return Err(ServiceError::AlreadyExists(format!(
61                    "File {} is locked by user {} until {}",
62                    file_id, existing.user_id, existing.expires_at
63                )));
64            }
65        }
66
67        let minutes = duration_minutes.unwrap_or(DEFAULT_LOCK_DURATION_MINUTES);
68        let now = Utc::now();
69        let expires_at = now + Duration::minutes(minutes);
70
71        let lock = FileLock::new(file_id, user_id, now, expires_at, LockStatus::Active);
72
73        let created = self.lock_repo
74            .create(&lock)
75            .await
76            .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?;
77
78        Ok(created)
79    }
80
81    /// Release a lock. Only the lock owner can release it.
82    pub async fn release_lock(&self, file_id: Uuid, user_id: Uuid) -> ServiceResult<()> {
83        let lock = self.lock_repo
84            .find_by_file_id(file_id)
85            .await
86            .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
87            .ok_or(ServiceError::NotFound)?;
88
89        if lock.user_id != user_id {
90            return Err(ServiceError::Validation(
91                format!("Lock on file {} is owned by a different user", file_id)
92            ));
93        }
94
95        self.lock_repo.delete(&lock.id.to_string()).await.map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?;
96        Ok(())
97    }
98
99    /// Refresh an existing lock's expiry time.
100    pub async fn refresh_lock(&self, file_id: Uuid, user_id: Uuid) -> ServiceResult<FileLock> {
101        let mut lock = self.lock_repo
102            .find_by_file_id(file_id)
103            .await
104            .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
105            .ok_or(ServiceError::NotFound)?;
106
107        if lock.user_id != user_id {
108            return Err(ServiceError::Validation(
109                format!("Lock on file {} is owned by a different user", file_id)
110            ));
111        }
112
113        lock.expires_at = Utc::now() + Duration::minutes(DEFAULT_LOCK_DURATION_MINUTES);
114        lock.refreshed_at = Some(Utc::now());
115        lock.metadata.touch();
116
117        let id_str = lock.id.to_string();
118        let updated = self.lock_repo
119            .update(&id_str, &lock)
120            .await
121            .map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?
122            .ok_or(ServiceError::NotFound)?;
123
124        Ok(updated)
125    }
126
127    /// Admin-only: forcibly break a lock regardless of owner.
128    pub async fn break_lock(&self, file_id: Uuid) -> ServiceResult<()> {
129        if let Some(lock) = self.lock_repo.find_by_file_id(file_id).await.map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))? {
130            self.lock_repo.delete(&lock.id.to_string()).await.map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?;
131        }
132        Ok(())
133    }
134
135    /// Get the active (non-expired) lock for a file, if any.
136    /// Automatically cleans up expired locks.
137    pub async fn get_active_lock(&self, file_id: Uuid) -> ServiceResult<Option<FileLock>> {
138        if let Some(lock) = self.lock_repo.find_by_file_id(file_id).await.map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))? {
139            if lock.is_expired() {
140                self.lock_repo.delete(&lock.id.to_string()).await.map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))?;
141                return Ok(None);
142            }
143            return Ok(Some(lock));
144        }
145        Ok(None)
146    }
147
148    /// Cleanup all expired locks. Returns the number of locks removed.
149    pub async fn cleanup_expired(&self) -> ServiceResult<u64> {
150        self.lock_repo.empty_trash().await.map_err(|e| ServiceError::Repository(backbone_core::RepositoryError::DatabaseError(e.to_string())))
151    }
152}