diff --git a/crates/domain/src/catalog/entities.rs b/crates/domain/src/catalog/entities.rs new file mode 100644 index 0000000..b06b614 --- /dev/null +++ b/crates/domain/src/catalog/entities.rs @@ -0,0 +1,251 @@ +use crate::common::errors::DomainError; +use crate::common::value_objects::{Checksum, DateTimeStamp, StructuredData, SystemId}; + +// --- Asset --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum AssetType { + Image, + Video, + LivePhoto, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SourceReference { + pub volume_id: SystemId, + pub relative_path: String, + pub checksum: Checksum, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Asset { + pub asset_id: SystemId, + pub source_reference: SourceReference, + pub asset_type: AssetType, + pub mime_type: String, + pub file_size: u64, + pub is_processed: bool, + pub owner_user_id: SystemId, + pub created_at: DateTimeStamp, +} + +impl Asset { + pub fn new( + source_reference: SourceReference, + asset_type: AssetType, + mime_type: impl Into, + file_size: u64, + owner: SystemId, + ) -> Self { + Self { + asset_id: SystemId::new(), + source_reference, + asset_type, + mime_type: mime_type.into(), + file_size, + is_processed: false, + owner_user_id: owner, + created_at: DateTimeStamp::now(), + } + } + + pub fn mark_processed(&mut self) { + self.is_processed = true; + } +} + +// --- AssetMetadata --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum MetadataSource { + ExifExtracted, + AiGenerated, + UserEdited, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AssetMetadata { + pub asset_id: SystemId, + pub metadata_source: MetadataSource, + pub data: StructuredData, + pub updated_at: DateTimeStamp, +} + +impl AssetMetadata { + pub fn new(asset_id: SystemId, source: MetadataSource, data: StructuredData) -> Self { + Self { + asset_id, + metadata_source: source, + data, + updated_at: DateTimeStamp::now(), + } + } +} + +// --- AssetStack --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum StackType { + LivePhoto, + FormatPair, + BurstSequence, + ExposureBracket, + ManualGroup, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum StackMemberRole { + PrimaryDisplay, + HighResSource, + MotionClip, + AlternateFrame, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AssetStackMember { + pub asset_id: SystemId, + pub role: StackMemberRole, + pub sort_order: u32, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AssetStack { + pub stack_id: SystemId, + pub stack_type: StackType, + pub primary_asset_id: SystemId, + pub owner_user_id: SystemId, + pub members: Vec, +} + +impl AssetStack { + pub fn new(stack_type: StackType, primary_asset_id: SystemId, owner: SystemId) -> Self { + let primary_member = AssetStackMember { + asset_id: primary_asset_id, + role: StackMemberRole::PrimaryDisplay, + sort_order: 0, + }; + Self { + stack_id: SystemId::new(), + stack_type, + primary_asset_id, + owner_user_id: owner, + members: vec![primary_member], + } + } + + pub fn add_member(&mut self, asset_id: SystemId, role: StackMemberRole) -> Result<(), DomainError> { + if self.members.iter().any(|m| m.asset_id == asset_id) { + return Err(DomainError::Conflict( + "Asset already exists in stack".to_string(), + )); + } + let next_order = self.members.iter().map(|m| m.sort_order).max().unwrap_or(0) + 1; + self.members.push(AssetStackMember { + asset_id, + role, + sort_order: next_order, + }); + Ok(()) + } +} + +// --- DerivativeAsset --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DerivativeProfile { + ThumbnailSquare, + ThumbnailLarge, + WebOptimized, + VideoSd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum GenerationStatus { + Pending, + Ready, + Failed, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DerivativeAsset { + pub derivative_id: SystemId, + pub parent_asset_id: SystemId, + pub profile_type: DerivativeProfile, + pub storage_path: String, + pub mime_type: String, + pub file_size: u64, + pub dimensions: (u32, u32), + pub generation_status: GenerationStatus, +} + +impl DerivativeAsset { + pub fn new_pending(parent: SystemId, profile: DerivativeProfile, path: impl Into) -> Self { + Self { + derivative_id: SystemId::new(), + parent_asset_id: parent, + profile_type: profile, + storage_path: path.into(), + mime_type: String::new(), + file_size: 0, + dimensions: (0, 0), + generation_status: GenerationStatus::Pending, + } + } + + pub fn mark_ready(&mut self, mime_type: impl Into, size: u64, dims: (u32, u32)) { + self.mime_type = mime_type.into(); + self.file_size = size; + self.dimensions = dims; + self.generation_status = GenerationStatus::Ready; + } + + pub fn mark_failed(&mut self) { + self.generation_status = GenerationStatus::Failed; + } +} + +// --- Duplicate --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DetectionMethod { + ExactHash, + PerceptualHash, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DuplicateStatus { + Unresolved, + Resolved, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DuplicateCandidate { + pub asset_id: SystemId, + pub similarity_score: f64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DuplicateGroup { + pub group_id: SystemId, + pub detection_method: DetectionMethod, + pub status: DuplicateStatus, + pub candidates: Vec, +} + +impl DuplicateGroup { + pub fn new_exact(asset_a: SystemId, asset_b: SystemId) -> Self { + Self { + group_id: SystemId::new(), + detection_method: DetectionMethod::ExactHash, + status: DuplicateStatus::Unresolved, + candidates: vec![ + DuplicateCandidate { asset_id: asset_a, similarity_score: 1.0 }, + DuplicateCandidate { asset_id: asset_b, similarity_score: 1.0 }, + ], + } + } + + pub fn resolve(&mut self) { + self.status = DuplicateStatus::Resolved; + } +} diff --git a/crates/domain/src/catalog/mod.rs b/crates/domain/src/catalog/mod.rs new file mode 100644 index 0000000..707f32a --- /dev/null +++ b/crates/domain/src/catalog/mod.rs @@ -0,0 +1,7 @@ +pub mod entities; +pub mod ports; +pub mod services; + +pub use entities::*; +pub use ports::*; +pub use services::*; diff --git a/crates/domain/src/catalog/ports.rs b/crates/domain/src/catalog/ports.rs new file mode 100644 index 0000000..aec9251 --- /dev/null +++ b/crates/domain/src/catalog/ports.rs @@ -0,0 +1,58 @@ +use async_trait::async_trait; +use crate::common::errors::DomainError; +use crate::common::value_objects::{Checksum, SystemId}; +use super::entities::{ + Asset, AssetMetadata, AssetStack, DerivativeAsset, DerivativeProfile, + DuplicateGroup, MetadataSource, +}; + +// --- AssetRepository --- + +#[async_trait] +pub trait AssetRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_checksum(&self, checksum: &Checksum) -> Result, DomainError>; + async fn find_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result, DomainError>; + async fn save(&self, asset: &Asset) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- AssetMetadataRepository --- + +#[async_trait] +pub trait AssetMetadataRepository: Send + Sync { + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn find_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result, DomainError>; + async fn save(&self, metadata: &AssetMetadata) -> Result<(), DomainError>; + async fn delete_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<(), DomainError>; +} + +// --- AssetStackRepository --- + +#[async_trait] +pub trait AssetStackRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- DerivativeRepository --- + +#[async_trait] +pub trait DerivativeRepository: Send + Sync { + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn find_by_asset_and_profile(&self, asset_id: &SystemId, profile: DerivativeProfile) -> Result, DomainError>; + async fn save(&self, derivative: &DerivativeAsset) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- DuplicateRepository --- + +#[async_trait] +pub trait DuplicateRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_unresolved(&self) -> Result, DomainError>; + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/services/metadata_resolver.rs b/crates/domain/src/catalog/services.rs similarity index 89% rename from crates/domain/src/services/metadata_resolver.rs rename to crates/domain/src/catalog/services.rs index 8cbe1cc..824f05d 100644 --- a/crates/domain/src/services/metadata_resolver.rs +++ b/crates/domain/src/catalog/services.rs @@ -1,5 +1,5 @@ -use crate::entities::{AssetMetadata, MetadataSource}; -use crate::value_objects::{MetadataValue, StructuredData}; +use super::entities::{AssetMetadata, MetadataSource}; +use crate::common::value_objects::{MetadataValue, StructuredData}; /// Merge metadata layers by priority: ExifExtracted < AiGenerated < UserEdited. /// Later (higher-priority) layers overwrite earlier ones. diff --git a/crates/domain/src/errors.rs b/crates/domain/src/common/errors.rs similarity index 100% rename from crates/domain/src/errors.rs rename to crates/domain/src/common/errors.rs diff --git a/crates/domain/src/events.rs b/crates/domain/src/common/events.rs similarity index 94% rename from crates/domain/src/events.rs rename to crates/domain/src/common/events.rs index 8353d20..a1fe15b 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/common/events.rs @@ -1,4 +1,4 @@ -use crate::value_objects::{DateTimeStamp, SystemId}; +use crate::common::value_objects::{DateTimeStamp, SystemId}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum DomainEvent { diff --git a/crates/domain/src/common/mod.rs b/crates/domain/src/common/mod.rs new file mode 100644 index 0000000..02d78e3 --- /dev/null +++ b/crates/domain/src/common/mod.rs @@ -0,0 +1,4 @@ +pub mod errors; +pub mod events; +pub mod ports; +pub mod value_objects; diff --git a/crates/domain/src/ports/event_publisher.rs b/crates/domain/src/common/ports.rs similarity index 67% rename from crates/domain/src/ports/event_publisher.rs rename to crates/domain/src/common/ports.rs index 41c2f3a..e841119 100644 --- a/crates/domain/src/ports/event_publisher.rs +++ b/crates/domain/src/common/ports.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; -use crate::{errors::DomainError, events::DomainEvent}; +use crate::common::errors::DomainError; +use crate::common::events::DomainEvent; #[async_trait] pub trait EventPublisher: Send + Sync { diff --git a/crates/domain/src/value_objects/checksum.rs b/crates/domain/src/common/value_objects/checksum.rs similarity index 95% rename from crates/domain/src/value_objects/checksum.rs rename to crates/domain/src/common/value_objects/checksum.rs index 25a5411..f4071df 100644 --- a/crates/domain/src/value_objects/checksum.rs +++ b/crates/domain/src/common/value_objects/checksum.rs @@ -1,4 +1,4 @@ -use crate::errors::DomainError; +use crate::common::errors::DomainError; #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct Checksum(String); diff --git a/crates/domain/src/value_objects/date_time_stamp.rs b/crates/domain/src/common/value_objects/date_time_stamp.rs similarity index 100% rename from crates/domain/src/value_objects/date_time_stamp.rs rename to crates/domain/src/common/value_objects/date_time_stamp.rs diff --git a/crates/domain/src/value_objects/email.rs b/crates/domain/src/common/value_objects/email.rs similarity index 93% rename from crates/domain/src/value_objects/email.rs rename to crates/domain/src/common/value_objects/email.rs index 6f96a61..4e99011 100644 --- a/crates/domain/src/value_objects/email.rs +++ b/crates/domain/src/common/value_objects/email.rs @@ -1,4 +1,4 @@ -use crate::errors::DomainError; +use crate::common::errors::DomainError; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Email(String); @@ -20,4 +20,3 @@ impl std::fmt::Display for Email { write!(f, "{}", self.0) } } - diff --git a/crates/domain/src/value_objects/filter_criteria.rs b/crates/domain/src/common/value_objects/filter_criteria.rs similarity index 100% rename from crates/domain/src/value_objects/filter_criteria.rs rename to crates/domain/src/common/value_objects/filter_criteria.rs diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/common/value_objects/mod.rs similarity index 100% rename from crates/domain/src/value_objects/mod.rs rename to crates/domain/src/common/value_objects/mod.rs diff --git a/crates/domain/src/value_objects/password.rs b/crates/domain/src/common/value_objects/password.rs similarity index 100% rename from crates/domain/src/value_objects/password.rs rename to crates/domain/src/common/value_objects/password.rs diff --git a/crates/domain/src/value_objects/structured_data.rs b/crates/domain/src/common/value_objects/structured_data.rs similarity index 100% rename from crates/domain/src/value_objects/structured_data.rs rename to crates/domain/src/common/value_objects/structured_data.rs diff --git a/crates/domain/src/value_objects/system_id.rs b/crates/domain/src/common/value_objects/system_id.rs similarity index 100% rename from crates/domain/src/value_objects/system_id.rs rename to crates/domain/src/common/value_objects/system_id.rs diff --git a/crates/domain/src/entities/asset.rs b/crates/domain/src/entities/asset.rs deleted file mode 100644 index 81ea669..0000000 --- a/crates/domain/src/entities/asset.rs +++ /dev/null @@ -1,52 +0,0 @@ -use crate::value_objects::{Checksum, DateTimeStamp, SystemId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum AssetType { - Image, - Video, - LivePhoto, -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct SourceReference { - pub volume_id: SystemId, - pub relative_path: String, - pub checksum: Checksum, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Asset { - pub asset_id: SystemId, - pub source_reference: SourceReference, - pub asset_type: AssetType, - pub mime_type: String, - pub file_size: u64, - pub is_processed: bool, - pub owner_user_id: SystemId, - pub created_at: DateTimeStamp, -} - -impl Asset { - pub fn new( - source_reference: SourceReference, - asset_type: AssetType, - mime_type: impl Into, - file_size: u64, - owner: SystemId, - ) -> Self { - Self { - asset_id: SystemId::new(), - source_reference, - asset_type, - mime_type: mime_type.into(), - file_size, - is_processed: false, - owner_user_id: owner, - created_at: DateTimeStamp::now(), - } - } - - pub fn mark_processed(&mut self) { - self.is_processed = true; - } -} diff --git a/crates/domain/src/entities/asset_metadata.rs b/crates/domain/src/entities/asset_metadata.rs deleted file mode 100644 index 8acb28c..0000000 --- a/crates/domain/src/entities/asset_metadata.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::value_objects::{DateTimeStamp, StructuredData, SystemId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] -pub enum MetadataSource { - ExifExtracted, - AiGenerated, - UserEdited, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AssetMetadata { - pub asset_id: SystemId, - pub metadata_source: MetadataSource, - pub data: StructuredData, - pub updated_at: DateTimeStamp, -} - -impl AssetMetadata { - pub fn new(asset_id: SystemId, source: MetadataSource, data: StructuredData) -> Self { - Self { - asset_id, - metadata_source: source, - data, - updated_at: DateTimeStamp::now(), - } - } -} diff --git a/crates/domain/src/entities/asset_stack.rs b/crates/domain/src/entities/asset_stack.rs deleted file mode 100644 index 5c2d038..0000000 --- a/crates/domain/src/entities/asset_stack.rs +++ /dev/null @@ -1,67 +0,0 @@ -use crate::errors::DomainError; -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum StackType { - LivePhoto, - FormatPair, - BurstSequence, - ExposureBracket, - ManualGroup, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum StackMemberRole { - PrimaryDisplay, - HighResSource, - MotionClip, - AlternateFrame, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AssetStackMember { - pub asset_id: SystemId, - pub role: StackMemberRole, - pub sort_order: u32, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AssetStack { - pub stack_id: SystemId, - pub stack_type: StackType, - pub primary_asset_id: SystemId, - pub owner_user_id: SystemId, - pub members: Vec, -} - -impl AssetStack { - pub fn new(stack_type: StackType, primary_asset_id: SystemId, owner: SystemId) -> Self { - let primary_member = AssetStackMember { - asset_id: primary_asset_id, - role: StackMemberRole::PrimaryDisplay, - sort_order: 0, - }; - Self { - stack_id: SystemId::new(), - stack_type, - primary_asset_id, - owner_user_id: owner, - members: vec![primary_member], - } - } - - pub fn add_member(&mut self, asset_id: SystemId, role: StackMemberRole) -> Result<(), DomainError> { - if self.members.iter().any(|m| m.asset_id == asset_id) { - return Err(DomainError::Conflict( - "Asset already exists in stack".to_string(), - )); - } - let next_order = self.members.iter().map(|m| m.sort_order).max().unwrap_or(0) + 1; - self.members.push(AssetStackMember { - asset_id, - role, - sort_order: next_order, - }); - Ok(()) - } -} diff --git a/crates/domain/src/entities/collection.rs b/crates/domain/src/entities/collection.rs deleted file mode 100644 index 75ee678..0000000 --- a/crates/domain/src/entities/collection.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::value_objects::{DateTimeStamp, FilterCriteria, SystemId}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Collection { - pub collection_id: SystemId, - pub name: String, - pub creator_user_id: SystemId, - pub criteria: FilterCriteria, - pub created_at: DateTimeStamp, -} - -impl Collection { - pub fn new(name: impl Into, creator: SystemId, criteria: FilterCriteria) -> Self { - Self { - collection_id: SystemId::new(), - name: name.into(), - creator_user_id: creator, - criteria, - created_at: DateTimeStamp::now(), - } - } -} diff --git a/crates/domain/src/entities/derivative_asset.rs b/crates/domain/src/entities/derivative_asset.rs deleted file mode 100644 index 27e49f2..0000000 --- a/crates/domain/src/entities/derivative_asset.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum DerivativeProfile { - ThumbnailSquare, - ThumbnailLarge, - WebOptimized, - VideoSd, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum GenerationStatus { - Pending, - Ready, - Failed, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct DerivativeAsset { - pub derivative_id: SystemId, - pub parent_asset_id: SystemId, - pub profile_type: DerivativeProfile, - pub storage_path: String, - pub mime_type: String, - pub file_size: u64, - pub dimensions: (u32, u32), - pub generation_status: GenerationStatus, -} - -impl DerivativeAsset { - pub fn new_pending(parent: SystemId, profile: DerivativeProfile, path: impl Into) -> Self { - Self { - derivative_id: SystemId::new(), - parent_asset_id: parent, - profile_type: profile, - storage_path: path.into(), - mime_type: String::new(), - file_size: 0, - dimensions: (0, 0), - generation_status: GenerationStatus::Pending, - } - } - - pub fn mark_ready(&mut self, mime_type: impl Into, size: u64, dims: (u32, u32)) { - self.mime_type = mime_type.into(); - self.file_size = size; - self.dimensions = dims; - self.generation_status = GenerationStatus::Ready; - } - - pub fn mark_failed(&mut self) { - self.generation_status = GenerationStatus::Failed; - } -} diff --git a/crates/domain/src/entities/duplicate.rs b/crates/domain/src/entities/duplicate.rs deleted file mode 100644 index 3050b17..0000000 --- a/crates/domain/src/entities/duplicate.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum DetectionMethod { - ExactHash, - PerceptualHash, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum DuplicateStatus { - Unresolved, - Resolved, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct DuplicateCandidate { - pub asset_id: SystemId, - pub similarity_score: f64, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct DuplicateGroup { - pub group_id: SystemId, - pub detection_method: DetectionMethod, - pub status: DuplicateStatus, - pub candidates: Vec, -} - -impl DuplicateGroup { - pub fn new_exact(asset_a: SystemId, asset_b: SystemId) -> Self { - Self { - group_id: SystemId::new(), - detection_method: DetectionMethod::ExactHash, - status: DuplicateStatus::Unresolved, - candidates: vec![ - DuplicateCandidate { asset_id: asset_a, similarity_score: 1.0 }, - DuplicateCandidate { asset_id: asset_b, similarity_score: 1.0 }, - ], - } - } - - pub fn resolve(&mut self) { - self.status = DuplicateStatus::Resolved; - } -} diff --git a/crates/domain/src/entities/group.rs b/crates/domain/src/entities/group.rs deleted file mode 100644 index ac63851..0000000 --- a/crates/domain/src/entities/group.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::collections::HashSet; -use crate::errors::DomainError; -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Group { - pub group_id: SystemId, - pub name: String, - pub owner_user_id: SystemId, - pub members: HashSet, -} - -impl Group { - pub fn new(name: impl Into, owner_user_id: SystemId) -> Self { - let mut members = HashSet::new(); - members.insert(owner_user_id); - Self { - group_id: SystemId::new(), - name: name.into(), - owner_user_id, - members, - } - } - - pub fn add_member(&mut self, user_id: SystemId) -> Result<(), DomainError> { - if self.members.contains(&user_id) { - return Err(DomainError::Conflict(format!("User {user_id} is already a member"))); - } - self.members.insert(user_id); - Ok(()) - } - - pub fn remove_member(&mut self, user_id: SystemId) -> Result<(), DomainError> { - if user_id == self.owner_user_id { - return Err(DomainError::Validation("Cannot remove the group owner".to_string())); - } - if !self.members.remove(&user_id) { - return Err(DomainError::NotFound(format!("User {user_id} is not a member"))); - } - Ok(()) - } - - pub fn is_member(&self, user_id: &SystemId) -> bool { - self.members.contains(user_id) - } -} diff --git a/crates/domain/src/entities/ingest_session.rs b/crates/domain/src/entities/ingest_session.rs deleted file mode 100644 index 5447778..0000000 --- a/crates/domain/src/entities/ingest_session.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::errors::DomainError; -use crate::value_objects::{Checksum, DateTimeStamp, SystemId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum IngestStatus { - Uploading, - AwaitingProcessing, - Processing, - Completed, - Failed, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct IngestSession { - pub session_id: SystemId, - pub uploader_user_id: SystemId, - pub client_device_id: String, - pub original_filename: String, - pub client_checksum: Checksum, - pub target_library_path_id: SystemId, - pub status: IngestStatus, - pub created_at: DateTimeStamp, - pub error_message: Option, -} - -impl IngestSession { - pub fn new( - uploader: SystemId, - device_id: impl Into, - filename: impl Into, - checksum: Checksum, - target_path: SystemId, - ) -> Self { - Self { - session_id: SystemId::new(), - uploader_user_id: uploader, - client_device_id: device_id.into(), - original_filename: filename.into(), - client_checksum: checksum, - target_library_path_id: target_path, - status: IngestStatus::Uploading, - created_at: DateTimeStamp::now(), - error_message: None, - } - } - - pub fn advance_to(&mut self, status: IngestStatus) -> Result<(), DomainError> { - let valid = matches!( - (self.status, status), - (IngestStatus::Uploading, IngestStatus::AwaitingProcessing) - | (IngestStatus::AwaitingProcessing, IngestStatus::Processing) - | (IngestStatus::Processing, IngestStatus::Completed) - ) || (status == IngestStatus::Failed && !self.is_terminal()); - - if !valid { - return Err(DomainError::Validation(format!( - "Invalid transition from {:?} to {:?}", - self.status, status - ))); - } - self.status = status; - Ok(()) - } - - pub fn fail(&mut self, message: impl Into) { - self.status = IngestStatus::Failed; - self.error_message = Some(message.into()); - } - - fn is_terminal(&self) -> bool { - matches!(self.status, IngestStatus::Completed | IngestStatus::Failed) - } -} diff --git a/crates/domain/src/entities/invite_code.rs b/crates/domain/src/entities/invite_code.rs deleted file mode 100644 index 5abb0d4..0000000 --- a/crates/domain/src/entities/invite_code.rs +++ /dev/null @@ -1,41 +0,0 @@ -use chrono::Utc; -use crate::value_objects::{DateTimeStamp, SystemId}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct InviteCode { - pub code_id: SystemId, - pub scope_id: SystemId, - pub created_by_user_id: SystemId, - pub expires_at: Option, - pub max_uses: Option, - pub use_count: u32, - pub assigned_role_id: SystemId, -} - -impl InviteCode { - pub fn new(scope_id: SystemId, created_by: SystemId, role_id: SystemId) -> Self { - Self { - code_id: SystemId::new(), - scope_id, - created_by_user_id: created_by, - expires_at: None, - max_uses: None, - use_count: 0, - assigned_role_id: role_id, - } - } - - pub fn is_valid(&self) -> bool { - if self.expires_at.is_some_and(|exp| exp.as_datetime() < &Utc::now()) { - return false; - } - if self.max_uses.is_some_and(|max| self.use_count >= max) { - return false; - } - true - } - - pub fn record_use(&mut self) { - self.use_count += 1; - } -} diff --git a/crates/domain/src/entities/job.rs b/crates/domain/src/entities/job.rs deleted file mode 100644 index 5f4eda7..0000000 --- a/crates/domain/src/entities/job.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::errors::DomainError; -use crate::value_objects::{DateTimeStamp, StructuredData, SystemId}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub enum JobType { - ScanDirectory, - ExtractMetadata, - GenerateDerivative, - SyncSidecar, - DetectDuplicates, - Custom(String), -} - -impl PartialEq for JobType { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::ScanDirectory, Self::ScanDirectory) => true, - (Self::ExtractMetadata, Self::ExtractMetadata) => true, - (Self::GenerateDerivative, Self::GenerateDerivative) => true, - (Self::SyncSidecar, Self::SyncSidecar) => true, - (Self::DetectDuplicates, Self::DetectDuplicates) => true, - (Self::Custom(a), Self::Custom(b)) => a == b, - _ => false, - } - } -} - -impl Eq for JobType {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum JobStatus { - Queued, - Processing, - Completed, - Failed, - Cancelled, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Job { - pub job_id: SystemId, - pub job_type: JobType, - pub target_asset_id: Option, - pub batch_id: Option, - pub status: JobStatus, - pub priority: u32, - pub payload: StructuredData, - pub result_data: Option, - pub retry_count: u32, - pub max_retries: u32, - pub created_at: DateTimeStamp, - pub started_at: Option, - pub completed_at: Option, - pub error_message: Option, -} - -impl Job { - pub fn new(job_type: JobType, priority: u32, payload: StructuredData) -> Self { - Self { - job_id: SystemId::new(), - job_type, - target_asset_id: None, - batch_id: None, - status: JobStatus::Queued, - priority, - payload, - result_data: None, - retry_count: 0, - max_retries: 3, - created_at: DateTimeStamp::now(), - started_at: None, - completed_at: None, - error_message: None, - } - } - - pub fn with_target(mut self, asset_id: SystemId) -> Self { - self.target_asset_id = Some(asset_id); - self - } - - pub fn with_batch(mut self, batch_id: SystemId) -> Self { - self.batch_id = Some(batch_id); - self - } - - pub fn start(&mut self) -> Result<(), DomainError> { - if self.status != JobStatus::Queued { - return Err(DomainError::Conflict( - format!("Job can only start from Queued, currently {:?}", self.status), - )); - } - self.status = JobStatus::Processing; - self.started_at = Some(DateTimeStamp::now()); - Ok(()) - } - - pub fn complete(&mut self, result: StructuredData) { - self.status = JobStatus::Completed; - self.result_data = Some(result); - self.completed_at = Some(DateTimeStamp::now()); - } - - pub fn fail(&mut self, error: impl Into) { - self.retry_count += 1; - self.error_message = Some(error.into()); - self.started_at = None; - if self.retry_count >= self.max_retries { - self.status = JobStatus::Failed; - } else { - self.status = JobStatus::Queued; - } - } - - pub fn cancel(&mut self) { - self.status = JobStatus::Cancelled; - self.completed_at = Some(DateTimeStamp::now()); - } - - pub fn can_retry(&self) -> bool { - self.retry_count < self.max_retries - } -} diff --git a/crates/domain/src/entities/job_batch.rs b/crates/domain/src/entities/job_batch.rs deleted file mode 100644 index a045dcb..0000000 --- a/crates/domain/src/entities/job_batch.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum BatchStatus { - InProgress, - CompletedWithErrors, - Completed, - Cancelled, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct JobBatch { - pub batch_id: SystemId, - pub batch_type: String, - pub total_jobs: u32, - pub completed_count: u32, - pub failed_count: u32, - pub status: BatchStatus, -} - -impl JobBatch { - pub fn new(batch_type: impl Into, total_jobs: u32) -> Self { - Self { - batch_id: SystemId::new(), - batch_type: batch_type.into(), - total_jobs, - completed_count: 0, - failed_count: 0, - status: BatchStatus::InProgress, - } - } - - pub fn record_completion(&mut self) { - self.completed_count += 1; - self.check_finished(); - } - - pub fn record_failure(&mut self) { - self.failed_count += 1; - self.check_finished(); - } - - pub fn progress_percent(&self) -> f64 { - if self.total_jobs == 0 { - return 100.0; - } - ((self.completed_count + self.failed_count) as f64 / self.total_jobs as f64) * 100.0 - } - - fn check_finished(&mut self) { - if self.completed_count + self.failed_count >= self.total_jobs { - self.status = if self.failed_count > 0 { - BatchStatus::CompletedWithErrors - } else { - BatchStatus::Completed - }; - } - } -} diff --git a/crates/domain/src/entities/library_path.rs b/crates/domain/src/entities/library_path.rs deleted file mode 100644 index fcff282..0000000 --- a/crates/domain/src/entities/library_path.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum OwnershipPolicy { - UserOwned, - GroupOwned, - Unassigned, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct LibraryPath { - pub path_id: SystemId, - pub volume_id: SystemId, - pub relative_path: String, - pub is_ingest_destination: bool, - pub ownership_policy: OwnershipPolicy, - pub designated_owner_id: Option, -} - -impl LibraryPath { - pub fn new_user_owned( - volume_id: SystemId, - relative_path: impl Into, - owner_id: SystemId, - is_ingest_destination: bool, - ) -> Self { - Self { - path_id: SystemId::new(), - volume_id, - relative_path: relative_path.into(), - is_ingest_destination, - ownership_policy: OwnershipPolicy::UserOwned, - designated_owner_id: Some(owner_id), - } - } - - pub fn new_unassigned(volume_id: SystemId, relative_path: impl Into) -> Self { - Self { - path_id: SystemId::new(), - volume_id, - relative_path: relative_path.into(), - is_ingest_destination: false, - ownership_policy: OwnershipPolicy::Unassigned, - designated_owner_id: None, - } - } -} diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs deleted file mode 100644 index 945c550..0000000 --- a/crates/domain/src/entities/mod.rs +++ /dev/null @@ -1,74 +0,0 @@ -// Identity & Access (Tasks 3-4) -pub mod permission; -pub mod role; -mod user; -mod group; - -pub use permission::{Permission, PermissionAction, ResourceType}; -pub use role::Role; -pub use user::User; -pub use group::Group; - -// Storage & Sources (Task 6) -mod storage_volume; -mod library_path; -mod ingest_session; -mod quota; - -pub use storage_volume::StorageVolume; -pub use library_path::{LibraryPath, OwnershipPolicy}; -pub use ingest_session::{IngestSession, IngestStatus}; -pub use quota::{QuotaDefinition, QuotaRule, TimePeriod, UsageLedgerEntry, UsageType}; - -// Media Catalog (Task 8) -mod asset; -mod asset_metadata; -mod asset_stack; -mod derivative_asset; -mod duplicate; - -pub use asset::{Asset, AssetType, SourceReference}; -pub use asset_metadata::{AssetMetadata, MetadataSource}; -pub use asset_stack::{AssetStack, AssetStackMember, StackMemberRole, StackType}; -pub use derivative_asset::{DerivativeAsset, DerivativeProfile, GenerationStatus}; -pub use duplicate::{DetectionMethod, DuplicateCandidate, DuplicateGroup, DuplicateStatus}; - -// Organization (Task 10) -mod album; -mod tag; -mod collection; - -pub use album::{Album, AlbumEntry}; -pub use tag::{AssetTag, Tag, TagSource}; -pub use collection::Collection; - -// Sharing (Task 11) -mod share_scope; -mod share_target; -mod share_link; -mod invite_code; -mod visibility_filter; - -pub use share_scope::{ScopeType, ShareScope, ShareableType}; -pub use share_target::{ShareTarget, TargetType}; -pub use share_link::{LinkAccessLevel, ShareLink}; -pub use invite_code::InviteCode; -pub use visibility_filter::VisibilityFilter; - -// Sidecar Sync (Task 12) -mod sidecar_record; -mod sidecar_config; - -pub use sidecar_record::{SidecarRecord, SyncStatus}; -pub use sidecar_config::{ConflictPolicy, SidecarConfig, SyncMode}; - -// Processing (Task 13) -mod job; -mod job_batch; -mod plugin; -mod processing_pipeline; - -pub use job::{Job, JobStatus, JobType}; -pub use job_batch::{BatchStatus, JobBatch}; -pub use plugin::{Plugin, PluginType}; -pub use processing_pipeline::{PipelineStep, ProcessingPipeline}; diff --git a/crates/domain/src/entities/permission.rs b/crates/domain/src/entities/permission.rs deleted file mode 100644 index 977f1f4..0000000 --- a/crates/domain/src/entities/permission.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::collections::HashSet; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub enum PermissionAction { - ReadAsset, - ReadMetadata, - ReadLocation, - ReadPerson, - WriteMetadata, - DeleteAsset, - ManageAccess, - ManageUsers, - ManageSystem, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub enum ResourceType { - Asset, - Album, - Collection, - Person, - Directory, - Global, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct Permission { - pub action: PermissionAction, - pub resource_type: ResourceType, -} - -impl Permission { - pub fn new(action: PermissionAction, resource_type: ResourceType) -> Self { - Self { action, resource_type } - } -} - -pub fn viewer_permissions() -> HashSet { - HashSet::from([ - Permission::new(PermissionAction::ReadAsset, ResourceType::Global), - Permission::new(PermissionAction::ReadMetadata, ResourceType::Global), - ]) -} - -pub fn contributor_permissions() -> HashSet { - let mut perms = viewer_permissions(); - perms.insert(Permission::new(PermissionAction::WriteMetadata, ResourceType::Global)); - perms -} - -pub fn admin_permissions() -> HashSet { - let mut perms = contributor_permissions(); - perms.insert(Permission::new(PermissionAction::DeleteAsset, ResourceType::Global)); - perms.insert(Permission::new(PermissionAction::ManageAccess, ResourceType::Global)); - perms.insert(Permission::new(PermissionAction::ManageUsers, ResourceType::Global)); - perms.insert(Permission::new(PermissionAction::ManageSystem, ResourceType::Global)); - perms.insert(Permission::new(PermissionAction::ReadLocation, ResourceType::Global)); - perms.insert(Permission::new(PermissionAction::ReadPerson, ResourceType::Global)); - perms -} diff --git a/crates/domain/src/entities/plugin.rs b/crates/domain/src/entities/plugin.rs deleted file mode 100644 index 0f14bf9..0000000 --- a/crates/domain/src/entities/plugin.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::value_objects::{StructuredData, SystemId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum PluginType { - MediaProcessor, - ScheduledTask, - SidecarWriter, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Plugin { - pub plugin_id: SystemId, - pub name: String, - pub plugin_type: PluginType, - pub is_enabled: bool, - pub configuration: StructuredData, -} - -impl Plugin { - pub fn new(name: impl Into, plugin_type: PluginType) -> Self { - Self { - plugin_id: SystemId::new(), - name: name.into(), - plugin_type, - is_enabled: true, - configuration: StructuredData::new(), - } - } - - pub fn disable(&mut self) { - self.is_enabled = false; - } - - pub fn enable(&mut self) { - self.is_enabled = true; - } -} diff --git a/crates/domain/src/entities/processing_pipeline.rs b/crates/domain/src/entities/processing_pipeline.rs deleted file mode 100644 index 2fdaeda..0000000 --- a/crates/domain/src/entities/processing_pipeline.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::value_objects::{StructuredData, SystemId}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PipelineStep { - pub plugin_id: SystemId, - pub step_order: u32, - pub configuration: StructuredData, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ProcessingPipeline { - pub pipeline_id: SystemId, - pub trigger_event: String, - pub steps: Vec, -} - -impl ProcessingPipeline { - pub fn new(trigger_event: impl Into) -> Self { - Self { - pipeline_id: SystemId::new(), - trigger_event: trigger_event.into(), - steps: Vec::new(), - } - } - - pub fn add_step(&mut self, plugin_id: SystemId, config: StructuredData) { - let next_order = self.steps.iter().map(|s| s.step_order).max().unwrap_or(0) - + if self.steps.is_empty() { 0 } else { 1 }; - self.steps.push(PipelineStep { - plugin_id, - step_order: next_order, - configuration: config, - }); - } -} diff --git a/crates/domain/src/entities/quota.rs b/crates/domain/src/entities/quota.rs deleted file mode 100644 index 590a321..0000000 --- a/crates/domain/src/entities/quota.rs +++ /dev/null @@ -1,92 +0,0 @@ -use crate::value_objects::{DateTimeStamp, SystemId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum UsageType { - StorageBytes, - ProcessJobs, - ApiCalls, - IndexingSize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum TimePeriod { - Daily, - Monthly, - Lifetime, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct QuotaRule { - pub rule_id: SystemId, - pub dimension: UsageType, - pub limit_value: u64, - pub time_period: TimePeriod, - pub is_unlimited: bool, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct QuotaDefinition { - pub quota_id: SystemId, - pub owner_scope: SystemId, - pub is_enforced: bool, - pub rules: Vec, -} - -impl QuotaDefinition { - pub fn new(owner_scope: SystemId) -> Self { - Self { - quota_id: SystemId::new(), - owner_scope, - is_enforced: true, - rules: Vec::new(), - } - } - - pub fn add_rule(&mut self, dimension: UsageType, limit_value: u64, time_period: TimePeriod) { - self.rules.push(QuotaRule { - rule_id: SystemId::new(), - dimension, - limit_value, - time_period, - is_unlimited: false, - }); - } - - pub fn add_unlimited_rule(&mut self, dimension: UsageType) { - self.rules.push(QuotaRule { - rule_id: SystemId::new(), - dimension, - limit_value: 0, - time_period: TimePeriod::Lifetime, - is_unlimited: true, - }); - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct UsageLedgerEntry { - pub entry_id: SystemId, - pub user_id: SystemId, - pub usage_type: UsageType, - pub consumed_amount: u64, - pub timestamp: DateTimeStamp, - pub context: String, -} - -impl UsageLedgerEntry { - pub fn new( - user_id: SystemId, - usage_type: UsageType, - amount: u64, - context: impl Into, - ) -> Self { - Self { - entry_id: SystemId::new(), - user_id, - usage_type, - consumed_amount: amount, - timestamp: DateTimeStamp::now(), - context: context.into(), - } - } -} diff --git a/crates/domain/src/entities/role.rs b/crates/domain/src/entities/role.rs deleted file mode 100644 index 688b2ec..0000000 --- a/crates/domain/src/entities/role.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::collections::HashSet; -use crate::value_objects::SystemId; -use super::permission::{Permission, PermissionAction, ResourceType}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Role { - pub role_id: SystemId, - pub name: String, - pub permissions: HashSet, - pub is_system_default: bool, -} - -impl Role { - pub fn new(name: impl Into, permissions: HashSet, is_system_default: bool) -> Self { - Self { - role_id: SystemId::new(), - name: name.into(), - permissions, - is_system_default, - } - } - - pub fn has_permission(&self, action: PermissionAction, resource_type: ResourceType) -> bool { - self.permissions.contains(&Permission::new(action, resource_type)) - } -} diff --git a/crates/domain/src/entities/share_link.rs b/crates/domain/src/entities/share_link.rs deleted file mode 100644 index 06957d2..0000000 --- a/crates/domain/src/entities/share_link.rs +++ /dev/null @@ -1,54 +0,0 @@ -use chrono::Utc; -use crate::value_objects::{DateTimeStamp, SystemId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum LinkAccessLevel { - ViewOnly, - LimitedSearch, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ShareLink { - pub scope_id: SystemId, - pub token: String, - pub expires_at: Option, - pub access_level: LinkAccessLevel, - pub is_active: bool, - pub max_uses: Option, - pub use_count: u32, -} - -impl ShareLink { - pub fn new(scope_id: SystemId, token: impl Into, access_level: LinkAccessLevel) -> Self { - Self { - scope_id, - token: token.into(), - expires_at: None, - access_level, - is_active: true, - max_uses: None, - use_count: 0, - } - } - - pub fn is_valid(&self) -> bool { - if !self.is_active { - return false; - } - if self.expires_at.is_some_and(|exp| exp.as_datetime() < &Utc::now()) { - return false; - } - if self.max_uses.is_some_and(|max| self.use_count >= max) { - return false; - } - true - } - - pub fn record_use(&mut self) { - self.use_count += 1; - } - - pub fn deactivate(&mut self) { - self.is_active = false; - } -} diff --git a/crates/domain/src/entities/share_scope.rs b/crates/domain/src/entities/share_scope.rs deleted file mode 100644 index 2a9ef0c..0000000 --- a/crates/domain/src/entities/share_scope.rs +++ /dev/null @@ -1,56 +0,0 @@ -use chrono::Utc; -use crate::value_objects::{DateTimeStamp, SystemId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum ScopeType { - Private, - User, - Group, - Link, - Public, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum ShareableType { - Asset, - Album, - Collection, - Directory, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ShareScope { - pub scope_id: SystemId, - pub scope_type: ScopeType, - pub shareable_type: ShareableType, - pub shareable_id: SystemId, - pub created_by_user_id: SystemId, - pub expires_at: Option, - pub created_at: DateTimeStamp, -} - -impl ShareScope { - pub fn new( - scope_type: ScopeType, - shareable_type: ShareableType, - shareable_id: SystemId, - created_by: SystemId, - ) -> Self { - Self { - scope_id: SystemId::new(), - scope_type, - shareable_type, - shareable_id, - created_by_user_id: created_by, - expires_at: None, - created_at: DateTimeStamp::now(), - } - } - - pub fn is_expired(&self) -> bool { - match &self.expires_at { - Some(exp) => exp.as_datetime() < &Utc::now(), - None => false, - } - } -} diff --git a/crates/domain/src/entities/share_target.rs b/crates/domain/src/entities/share_target.rs deleted file mode 100644 index dd4af08..0000000 --- a/crates/domain/src/entities/share_target.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum TargetType { - User, - Group, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ShareTarget { - pub scope_id: SystemId, - pub target_type: TargetType, - pub target_id: SystemId, - pub role_id: SystemId, -} - -impl ShareTarget { - pub fn new(scope_id: SystemId, target_type: TargetType, target_id: SystemId, role_id: SystemId) -> Self { - Self { scope_id, target_type, target_id, role_id } - } -} diff --git a/crates/domain/src/entities/sidecar_config.rs b/crates/domain/src/entities/sidecar_config.rs deleted file mode 100644 index 7ab71e7..0000000 --- a/crates/domain/src/entities/sidecar_config.rs +++ /dev/null @@ -1,30 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum SyncMode { - Auto, - Scheduled, - Manual, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum ConflictPolicy { - DbWins, - FileWins, - RequireUserDecision, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct SidecarConfig { - pub export_base_path: String, - pub sync_mode: SyncMode, - pub conflict_resolution_policy: ConflictPolicy, -} - -impl Default for SidecarConfig { - fn default() -> Self { - Self { - export_base_path: "/kphotos/sidecars".to_string(), - sync_mode: SyncMode::Auto, - conflict_resolution_policy: ConflictPolicy::DbWins, - } - } -} diff --git a/crates/domain/src/entities/storage_volume.rs b/crates/domain/src/entities/storage_volume.rs deleted file mode 100644 index 48a5d89..0000000 --- a/crates/domain/src/entities/storage_volume.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct StorageVolume { - pub volume_id: SystemId, - pub volume_name: String, - pub uri_prefix: String, - pub is_writable: bool, - pub available_bytes: u64, -} - -impl StorageVolume { - pub fn new(name: impl Into, uri_prefix: impl Into, is_writable: bool) -> Self { - Self { - volume_id: SystemId::new(), - volume_name: name.into(), - uri_prefix: uri_prefix.into(), - is_writable, - available_bytes: 0, - } - } -} diff --git a/crates/domain/src/entities/tag.rs b/crates/domain/src/entities/tag.rs deleted file mode 100644 index 4e64939..0000000 --- a/crates/domain/src/entities/tag.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::value_objects::SystemId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum TagSource { - UserManual, - AiGenerated, - ExifExtracted, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Tag { - pub tag_id: SystemId, - pub name: String, - pub tag_source: TagSource, -} - -impl Tag { - pub fn new_manual(name: impl Into) -> Self { - Self { - tag_id: SystemId::new(), - name: name.into(), - tag_source: TagSource::UserManual, - } - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct AssetTag { - pub asset_id: SystemId, - pub tag_id: SystemId, - pub tagged_by_user_id: Option, - pub confidence: f64, -} - -impl AssetTag { - pub fn new_manual(asset_id: SystemId, tag_id: SystemId, user_id: SystemId) -> Self { - Self { - asset_id, - tag_id, - tagged_by_user_id: Some(user_id), - confidence: 1.0, - } - } -} diff --git a/crates/domain/src/entities/user.rs b/crates/domain/src/entities/user.rs deleted file mode 100644 index 41e39b1..0000000 --- a/crates/domain/src/entities/user.rs +++ /dev/null @@ -1,23 +0,0 @@ -use chrono::{DateTime, Utc}; -use crate::value_objects::{Email, PasswordHash, SystemId}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct User { - pub id: SystemId, - pub username: String, - pub email: Email, - pub password_hash: PasswordHash, - pub created_at: DateTime, -} - -impl User { - pub fn new(username: impl Into, email: Email, password_hash: PasswordHash) -> Self { - Self { - id: SystemId::new(), - username: username.into(), - email, - password_hash, - created_at: Utc::now(), - } - } -} diff --git a/crates/domain/src/entities/visibility_filter.rs b/crates/domain/src/entities/visibility_filter.rs deleted file mode 100644 index 7b9b8ea..0000000 --- a/crates/domain/src/entities/visibility_filter.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::value_objects::{StructuredData, SystemId}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct VisibilityFilter { - pub filter_id: SystemId, - pub scope_id: SystemId, - pub role_id: SystemId, - pub hidden_fields: Vec, -} - -impl VisibilityFilter { - pub fn new(scope_id: SystemId, role_id: SystemId, hidden_fields: Vec) -> Self { - Self { - filter_id: SystemId::new(), - scope_id, - role_id, - hidden_fields, - } - } - - pub fn apply(&self, data: &StructuredData) -> StructuredData { - let mut result = data.clone(); - for field in &self.hidden_fields { - result.remove(field); - } - result - } -} diff --git a/crates/domain/src/identity/entities.rs b/crates/domain/src/identity/entities.rs new file mode 100644 index 0000000..664df4e --- /dev/null +++ b/crates/domain/src/identity/entities.rs @@ -0,0 +1,158 @@ +use std::collections::HashSet; +use chrono::{DateTime, Utc}; +use crate::common::errors::DomainError; +use crate::common::value_objects::{Email, PasswordHash, SystemId}; + +// --- Permission --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum PermissionAction { + ReadAsset, + ReadMetadata, + ReadLocation, + ReadPerson, + WriteMetadata, + DeleteAsset, + ManageAccess, + ManageUsers, + ManageSystem, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum ResourceType { + Asset, + Album, + Collection, + Person, + Directory, + Global, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct Permission { + pub action: PermissionAction, + pub resource_type: ResourceType, +} + +impl Permission { + pub fn new(action: PermissionAction, resource_type: ResourceType) -> Self { + Self { action, resource_type } + } +} + +pub fn viewer_permissions() -> HashSet { + HashSet::from([ + Permission::new(PermissionAction::ReadAsset, ResourceType::Global), + Permission::new(PermissionAction::ReadMetadata, ResourceType::Global), + ]) +} + +pub fn contributor_permissions() -> HashSet { + let mut perms = viewer_permissions(); + perms.insert(Permission::new(PermissionAction::WriteMetadata, ResourceType::Global)); + perms +} + +pub fn admin_permissions() -> HashSet { + let mut perms = contributor_permissions(); + perms.insert(Permission::new(PermissionAction::DeleteAsset, ResourceType::Global)); + perms.insert(Permission::new(PermissionAction::ManageAccess, ResourceType::Global)); + perms.insert(Permission::new(PermissionAction::ManageUsers, ResourceType::Global)); + perms.insert(Permission::new(PermissionAction::ManageSystem, ResourceType::Global)); + perms.insert(Permission::new(PermissionAction::ReadLocation, ResourceType::Global)); + perms.insert(Permission::new(PermissionAction::ReadPerson, ResourceType::Global)); + perms +} + +// --- Role --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Role { + pub role_id: SystemId, + pub name: String, + pub permissions: HashSet, + pub is_system_default: bool, +} + +impl Role { + pub fn new(name: impl Into, permissions: HashSet, is_system_default: bool) -> Self { + Self { + role_id: SystemId::new(), + name: name.into(), + permissions, + is_system_default, + } + } + + pub fn has_permission(&self, action: PermissionAction, resource_type: ResourceType) -> bool { + self.permissions.contains(&Permission::new(action, resource_type)) + } +} + +// --- User --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct User { + pub id: SystemId, + pub username: String, + pub email: Email, + pub password_hash: PasswordHash, + pub created_at: DateTime, +} + +impl User { + pub fn new(username: impl Into, email: Email, password_hash: PasswordHash) -> Self { + Self { + id: SystemId::new(), + username: username.into(), + email, + password_hash, + created_at: Utc::now(), + } + } +} + +// --- Group --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Group { + pub group_id: SystemId, + pub name: String, + pub owner_user_id: SystemId, + pub members: HashSet, +} + +impl Group { + pub fn new(name: impl Into, owner_user_id: SystemId) -> Self { + let mut members = HashSet::new(); + members.insert(owner_user_id); + Self { + group_id: SystemId::new(), + name: name.into(), + owner_user_id, + members, + } + } + + pub fn add_member(&mut self, user_id: SystemId) -> Result<(), DomainError> { + if self.members.contains(&user_id) { + return Err(DomainError::Conflict(format!("User {user_id} is already a member"))); + } + self.members.insert(user_id); + Ok(()) + } + + pub fn remove_member(&mut self, user_id: SystemId) -> Result<(), DomainError> { + if user_id == self.owner_user_id { + return Err(DomainError::Validation("Cannot remove the group owner".to_string())); + } + if !self.members.remove(&user_id) { + return Err(DomainError::NotFound(format!("User {user_id} is not a member"))); + } + Ok(()) + } + + pub fn is_member(&self, user_id: &SystemId) -> bool { + self.members.contains(user_id) + } +} diff --git a/crates/domain/src/identity/mod.rs b/crates/domain/src/identity/mod.rs new file mode 100644 index 0000000..707f32a --- /dev/null +++ b/crates/domain/src/identity/mod.rs @@ -0,0 +1,7 @@ +pub mod entities; +pub mod ports; +pub mod services; + +pub use entities::*; +pub use ports::*; +pub use services::*; diff --git a/crates/domain/src/identity/ports.rs b/crates/domain/src/identity/ports.rs new file mode 100644 index 0000000..151818d --- /dev/null +++ b/crates/domain/src/identity/ports.rs @@ -0,0 +1,50 @@ +use async_trait::async_trait; +use crate::common::errors::DomainError; +use crate::common::value_objects::{Email, PasswordHash, SystemId}; +use super::entities::{Group, Role, User}; + +// --- UserRepository --- + +#[async_trait] +pub trait UserRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_email(&self, email: &Email) -> Result, DomainError>; + async fn find_by_username(&self, username: &str) -> Result, DomainError>; + async fn save(&self, user: &User) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- RoleRepository --- + +#[async_trait] +pub trait RoleRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_name(&self, name: &str) -> Result, DomainError>; + async fn find_defaults(&self) -> Result, DomainError>; + async fn save(&self, role: &Role) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- GroupRepository --- + +#[async_trait] +pub trait GroupRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_user(&self, user_id: &SystemId) -> Result, DomainError>; + async fn save(&self, group: &Group) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- Auth --- + +#[async_trait] +pub trait PasswordHasher: Send + Sync { + async fn hash(&self, password: &str) -> Result; + async fn verify(&self, password: &str, hash: &PasswordHash) -> Result; +} + +#[async_trait] +pub trait TokenIssuer: Send + Sync { + async fn issue(&self, user_id: &SystemId, role: &str) -> Result; + async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError>; +} diff --git a/crates/domain/src/services/permission_service.rs b/crates/domain/src/identity/services.rs similarity index 89% rename from crates/domain/src/services/permission_service.rs rename to crates/domain/src/identity/services.rs index c19486f..cf5d418 100644 --- a/crates/domain/src/services/permission_service.rs +++ b/crates/domain/src/identity/services.rs @@ -1,5 +1,5 @@ use std::collections::HashSet; -use crate::entities::{Permission, PermissionAction, ResourceType, Role}; +use super::entities::{Permission, PermissionAction, ResourceType, Role}; pub struct PermissionChecker; diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 16d4b74..72f35c6 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -1,6 +1,57 @@ -pub mod entities; -pub mod errors; -pub mod events; -pub mod ports; -pub mod services; -pub mod value_objects; +pub mod common; +pub mod identity; +pub mod storage; +pub mod catalog; +pub mod organization; +pub mod sharing; +pub mod sidecar; +pub mod processing; + +// Facade — old import paths still work +pub mod errors { + pub use crate::common::errors::*; +} +pub mod events { + pub use crate::common::events::*; +} +pub mod value_objects { + pub use crate::common::value_objects::*; +} +pub mod entities { + pub use crate::identity::entities::*; + pub use crate::storage::entities::*; + pub use crate::catalog::entities::*; + pub use crate::organization::entities::*; + pub use crate::sharing::entities::*; + pub use crate::sidecar::entities::*; + pub use crate::processing::entities::*; + + // Sub-module alias for `domain::entities::permission::` imports + pub mod permission { + pub use crate::identity::entities::{ + Permission, PermissionAction, ResourceType, + viewer_permissions, contributor_permissions, admin_permissions, + }; + } +} +pub mod ports { + pub use crate::common::ports::*; + pub use crate::identity::ports::*; + pub use crate::storage::ports::*; + pub use crate::catalog::ports::*; + pub use crate::organization::ports::*; + pub use crate::sharing::ports::*; + pub use crate::sidecar::ports::*; + pub use crate::processing::ports::*; +} +pub mod services { + pub mod permission_service { + pub use crate::identity::services::*; + } + pub mod quota_checker { + pub use crate::storage::services::*; + } + pub mod metadata_resolver { + pub use crate::catalog::services::*; + } +} diff --git a/crates/domain/src/entities/album.rs b/crates/domain/src/organization/entities.rs similarity index 53% rename from crates/domain/src/entities/album.rs rename to crates/domain/src/organization/entities.rs index 2feb67e..5c87709 100644 --- a/crates/domain/src/entities/album.rs +++ b/crates/domain/src/organization/entities.rs @@ -1,5 +1,7 @@ -use crate::errors::DomainError; -use crate::value_objects::{DateTimeStamp, SystemId}; +use crate::common::errors::DomainError; +use crate::common::value_objects::{DateTimeStamp, FilterCriteria, SystemId}; + +// --- Album --- #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AlbumEntry { @@ -62,3 +64,71 @@ impl Album { self.entries.len() } } + +// --- Tag --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum TagSource { + UserManual, + AiGenerated, + ExifExtracted, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Tag { + pub tag_id: SystemId, + pub name: String, + pub tag_source: TagSource, +} + +impl Tag { + pub fn new_manual(name: impl Into) -> Self { + Self { + tag_id: SystemId::new(), + name: name.into(), + tag_source: TagSource::UserManual, + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AssetTag { + pub asset_id: SystemId, + pub tag_id: SystemId, + pub tagged_by_user_id: Option, + pub confidence: f64, +} + +impl AssetTag { + pub fn new_manual(asset_id: SystemId, tag_id: SystemId, user_id: SystemId) -> Self { + Self { + asset_id, + tag_id, + tagged_by_user_id: Some(user_id), + confidence: 1.0, + } + } +} + +// --- Collection --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Collection { + pub collection_id: SystemId, + pub name: String, + pub creator_user_id: SystemId, + pub criteria: FilterCriteria, + pub created_at: DateTimeStamp, +} + +impl Collection { + pub fn new(name: impl Into, creator: SystemId, criteria: FilterCriteria) -> Self { + Self { + collection_id: SystemId::new(), + name: name.into(), + creator_user_id: creator, + criteria, + created_at: DateTimeStamp::now(), + } + } +} diff --git a/crates/domain/src/organization/mod.rs b/crates/domain/src/organization/mod.rs new file mode 100644 index 0000000..637ecc2 --- /dev/null +++ b/crates/domain/src/organization/mod.rs @@ -0,0 +1,5 @@ +pub mod entities; +pub mod ports; + +pub use entities::*; +pub use ports::*; diff --git a/crates/domain/src/organization/ports.rs b/crates/domain/src/organization/ports.rs new file mode 100644 index 0000000..739e64c --- /dev/null +++ b/crates/domain/src/organization/ports.rs @@ -0,0 +1,36 @@ +use async_trait::async_trait; +use crate::common::errors::DomainError; +use crate::common::value_objects::SystemId; +use super::entities::{Album, AssetTag, Collection, Tag}; + +// --- AlbumRepository --- + +#[async_trait] +pub trait AlbumRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_creator(&self, creator_id: &SystemId) -> Result, DomainError>; + async fn save(&self, album: &Album) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- TagRepository --- + +#[async_trait] +pub trait TagRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_name(&self, name: &str) -> Result, DomainError>; + async fn find_tags_for_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn save_tag(&self, tag: &Tag) -> Result<(), DomainError>; + async fn save_asset_tag(&self, asset_tag: &AssetTag) -> Result<(), DomainError>; + async fn remove_asset_tag(&self, asset_id: &SystemId, tag_id: &SystemId) -> Result<(), DomainError>; +} + +// --- CollectionRepository --- + +#[async_trait] +pub trait CollectionRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_creator(&self, creator_id: &SystemId) -> Result, DomainError>; + async fn save(&self, collection: &Collection) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/ports/album_repo.rs b/crates/domain/src/ports/album_repo.rs deleted file mode 100644 index 55d4101..0000000 --- a/crates/domain/src/ports/album_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::Album, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait AlbumRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_creator(&self, creator_id: &SystemId) -> Result, DomainError>; - async fn save(&self, album: &Album) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/asset_metadata_repo.rs b/crates/domain/src/ports/asset_metadata_repo.rs deleted file mode 100644 index 07975f8..0000000 --- a/crates/domain/src/ports/asset_metadata_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::{AssetMetadata, MetadataSource}, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait AssetMetadataRepository: Send + Sync { - async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; - async fn find_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result, DomainError>; - async fn save(&self, metadata: &AssetMetadata) -> Result<(), DomainError>; - async fn delete_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/asset_repo.rs b/crates/domain/src/ports/asset_repo.rs deleted file mode 100644 index c7a5537..0000000 --- a/crates/domain/src/ports/asset_repo.rs +++ /dev/null @@ -1,11 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::Asset, errors::DomainError, value_objects::{Checksum, SystemId}}; - -#[async_trait] -pub trait AssetRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_checksum(&self, checksum: &Checksum) -> Result, DomainError>; - async fn find_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result, DomainError>; - async fn save(&self, asset: &Asset) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/asset_stack_repo.rs b/crates/domain/src/ports/asset_stack_repo.rs deleted file mode 100644 index f8a8b66..0000000 --- a/crates/domain/src/ports/asset_stack_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::AssetStack, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait AssetStackRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; - async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/auth.rs b/crates/domain/src/ports/auth.rs deleted file mode 100644 index 6222ba1..0000000 --- a/crates/domain/src/ports/auth.rs +++ /dev/null @@ -1,14 +0,0 @@ -use async_trait::async_trait; -use crate::{errors::DomainError, value_objects::{PasswordHash, SystemId}}; - -#[async_trait] -pub trait PasswordHasher: Send + Sync { - async fn hash(&self, password: &str) -> Result; - async fn verify(&self, password: &str, hash: &PasswordHash) -> Result; -} - -#[async_trait] -pub trait TokenIssuer: Send + Sync { - async fn issue(&self, user_id: &SystemId, role: &str) -> Result; - async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError>; -} diff --git a/crates/domain/src/ports/collection_repo.rs b/crates/domain/src/ports/collection_repo.rs deleted file mode 100644 index 15e89c7..0000000 --- a/crates/domain/src/ports/collection_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::Collection, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait CollectionRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_creator(&self, creator_id: &SystemId) -> Result, DomainError>; - async fn save(&self, collection: &Collection) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/derivative_repo.rs b/crates/domain/src/ports/derivative_repo.rs deleted file mode 100644 index 98d8262..0000000 --- a/crates/domain/src/ports/derivative_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::{DerivativeAsset, DerivativeProfile}, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait DerivativeRepository: Send + Sync { - async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; - async fn find_by_asset_and_profile(&self, asset_id: &SystemId, profile: DerivativeProfile) -> Result, DomainError>; - async fn save(&self, derivative: &DerivativeAsset) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/duplicate_repo.rs b/crates/domain/src/ports/duplicate_repo.rs deleted file mode 100644 index 12e2fb6..0000000 --- a/crates/domain/src/ports/duplicate_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::DuplicateGroup, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait DuplicateRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_unresolved(&self) -> Result, DomainError>; - async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; - async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/file_storage.rs b/crates/domain/src/ports/file_storage.rs deleted file mode 100644 index f8d467c..0000000 --- a/crates/domain/src/ports/file_storage.rs +++ /dev/null @@ -1,20 +0,0 @@ -use async_trait::async_trait; -use bytes::Bytes; -use crate::errors::DomainError; - -#[derive(Debug, Clone)] -pub struct FileEntry { - pub path: String, - pub size_bytes: u64, - pub is_directory: bool, -} - -#[async_trait] -pub trait FileStoragePort: Send + Sync { - async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError>; - async fn read_file(&self, path: &str) -> Result; - async fn delete_file(&self, path: &str) -> Result<(), DomainError>; - async fn list_directory(&self, path: &str) -> Result, DomainError>; - async fn file_exists(&self, path: &str) -> Result; - async fn available_space(&self) -> Result; -} diff --git a/crates/domain/src/ports/group_repo.rs b/crates/domain/src/ports/group_repo.rs deleted file mode 100644 index 6d37966..0000000 --- a/crates/domain/src/ports/group_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::Group, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait GroupRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_user(&self, user_id: &SystemId) -> Result, DomainError>; - async fn save(&self, group: &Group) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/ingest_session_repo.rs b/crates/domain/src/ports/ingest_session_repo.rs deleted file mode 100644 index de6cc3c..0000000 --- a/crates/domain/src/ports/ingest_session_repo.rs +++ /dev/null @@ -1,9 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::IngestSession, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait IngestSessionRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_user(&self, user_id: &SystemId) -> Result, DomainError>; - async fn save(&self, session: &IngestSession) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/job_batch_repo.rs b/crates/domain/src/ports/job_batch_repo.rs deleted file mode 100644 index 66abc5d..0000000 --- a/crates/domain/src/ports/job_batch_repo.rs +++ /dev/null @@ -1,8 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::JobBatch, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait JobBatchRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn save(&self, batch: &JobBatch) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/job_repo.rs b/crates/domain/src/ports/job_repo.rs deleted file mode 100644 index a309170..0000000 --- a/crates/domain/src/ports/job_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::Job, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait JobRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_next_queued(&self) -> Result, DomainError>; - async fn find_by_batch(&self, batch_id: &SystemId) -> Result, DomainError>; - async fn save(&self, job: &Job) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/library_path_repo.rs b/crates/domain/src/ports/library_path_repo.rs deleted file mode 100644 index 874c2d3..0000000 --- a/crates/domain/src/ports/library_path_repo.rs +++ /dev/null @@ -1,11 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::LibraryPath, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait LibraryPathRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_volume(&self, volume_id: &SystemId) -> Result, DomainError>; - async fn find_ingest_destinations(&self, owner_id: &SystemId) -> Result, DomainError>; - async fn save(&self, path: &LibraryPath) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs deleted file mode 100644 index d707384..0000000 --- a/crates/domain/src/ports/mod.rs +++ /dev/null @@ -1,74 +0,0 @@ -// Identity & Access (Tasks 4-5) -mod auth; -mod event_publisher; -mod group_repo; -mod role_repo; -mod storage; -mod user_repo; - -pub use auth::{PasswordHasher, TokenIssuer}; -pub use event_publisher::EventPublisher; -pub use group_repo::GroupRepository; -pub use role_repo::RoleRepository; -pub use storage::{DataStream, StoragePort, StorageReader, StorageWriter}; -pub use user_repo::UserRepository; - -// Storage & Sources (Task 7) -mod storage_volume_repo; -mod library_path_repo; -mod ingest_session_repo; -mod quota_repo; -mod file_storage; - -pub use storage_volume_repo::StorageVolumeRepository; -pub use library_path_repo::LibraryPathRepository; -pub use ingest_session_repo::IngestSessionRepository; -pub use quota_repo::{QuotaRepository, UsageLedgerRepository}; -pub use file_storage::{FileEntry, FileStoragePort}; - -// Media Catalog (Task 9) -mod asset_repo; -mod asset_metadata_repo; -mod asset_stack_repo; -mod derivative_repo; -mod duplicate_repo; - -pub use asset_repo::AssetRepository; -pub use asset_metadata_repo::AssetMetadataRepository; -pub use asset_stack_repo::AssetStackRepository; -pub use derivative_repo::DerivativeRepository; -pub use duplicate_repo::DuplicateRepository; - -// Organization (Task 10) -mod album_repo; -mod tag_repo; -mod collection_repo; - -pub use album_repo::AlbumRepository; -pub use tag_repo::TagRepository; -pub use collection_repo::CollectionRepository; - -// Sharing (Task 11) -mod share_repo; -mod visibility_filter_repo; - -pub use share_repo::ShareRepository; -pub use visibility_filter_repo::VisibilityFilterRepository; - -// Sidecar Sync (Task 12) -mod sidecar_repo; -mod sidecar_writer; - -pub use sidecar_repo::SidecarRepository; -pub use sidecar_writer::SidecarWriterPort; - -// Processing (Task 13) -mod job_repo; -mod job_batch_repo; -mod plugin_repo; -mod pipeline_repo; - -pub use job_repo::JobRepository; -pub use job_batch_repo::JobBatchRepository; -pub use plugin_repo::PluginRepository; -pub use pipeline_repo::PipelineRepository; diff --git a/crates/domain/src/ports/pipeline_repo.rs b/crates/domain/src/ports/pipeline_repo.rs deleted file mode 100644 index ee20d44..0000000 --- a/crates/domain/src/ports/pipeline_repo.rs +++ /dev/null @@ -1,9 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::ProcessingPipeline, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait PipelineRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_trigger(&self, event: &str) -> Result, DomainError>; - async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/plugin_repo.rs b/crates/domain/src/ports/plugin_repo.rs deleted file mode 100644 index 9b84aef..0000000 --- a/crates/domain/src/ports/plugin_repo.rs +++ /dev/null @@ -1,9 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::Plugin, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait PluginRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_enabled(&self) -> Result, DomainError>; - async fn save(&self, plugin: &Plugin) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/quota_repo.rs b/crates/domain/src/ports/quota_repo.rs deleted file mode 100644 index 8dd275d..0000000 --- a/crates/domain/src/ports/quota_repo.rs +++ /dev/null @@ -1,24 +0,0 @@ -use async_trait::async_trait; -use crate::{ - entities::{QuotaDefinition, UsageLedgerEntry, UsageType}, - errors::DomainError, - value_objects::{DateTimeStamp, SystemId}, -}; - -#[async_trait] -pub trait QuotaRepository: Send + Sync { - async fn find_by_owner(&self, owner_id: &SystemId) -> Result, DomainError>; - async fn save(&self, quota: &QuotaDefinition) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} - -#[async_trait] -pub trait UsageLedgerRepository: Send + Sync { - async fn record(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError>; - async fn sum_usage( - &self, - user_id: &SystemId, - usage_type: UsageType, - since: Option, - ) -> Result; -} diff --git a/crates/domain/src/ports/role_repo.rs b/crates/domain/src/ports/role_repo.rs deleted file mode 100644 index 0f59a21..0000000 --- a/crates/domain/src/ports/role_repo.rs +++ /dev/null @@ -1,11 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::Role, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait RoleRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_name(&self, name: &str) -> Result, DomainError>; - async fn find_defaults(&self) -> Result, DomainError>; - async fn save(&self, role: &Role) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/sidecar_repo.rs b/crates/domain/src/ports/sidecar_repo.rs deleted file mode 100644 index 36ef14c..0000000 --- a/crates/domain/src/ports/sidecar_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::{SidecarRecord, SyncStatus}, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait SidecarRepository: Send + Sync { - async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; - async fn find_by_status(&self, status: SyncStatus) -> Result, DomainError>; - async fn save(&self, record: &SidecarRecord) -> Result<(), DomainError>; - async fn delete(&self, asset_id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/sidecar_writer.rs b/crates/domain/src/ports/sidecar_writer.rs deleted file mode 100644 index aaf56ec..0000000 --- a/crates/domain/src/ports/sidecar_writer.rs +++ /dev/null @@ -1,9 +0,0 @@ -use async_trait::async_trait; -use crate::{errors::DomainError, value_objects::StructuredData}; - -#[async_trait] -pub trait SidecarWriterPort: Send + Sync { - fn format_name(&self) -> &str; - async fn write_sidecar(&self, data: &StructuredData, path: &str) -> Result<(), DomainError>; - async fn read_sidecar(&self, path: &str) -> Result; -} diff --git a/crates/domain/src/ports/storage.rs b/crates/domain/src/ports/storage.rs deleted file mode 100644 index 0a77deb..0000000 --- a/crates/domain/src/ports/storage.rs +++ /dev/null @@ -1,52 +0,0 @@ -use async_trait::async_trait; -use bytes::Bytes; -use futures::stream::{self, BoxStream, StreamExt}; -use crate::errors::DomainError; - -pub type DataStream = BoxStream<'static, Result>; - -/// Read operations on object storage. Keys are full paths relative to the adapter root. -#[async_trait] -pub trait StorageReader: Send + Sync { - /// Returns the content of `key` as a stream. Returns `DomainError::NotFound` if absent. - async fn get(&self, key: &str) -> Result; - - /// Lists all keys whose path begins with `prefix`, or all keys when `prefix` is `None`. - /// Returned keys are **full paths from the adapter root**, not relative to `prefix`. - /// Example: `list(Some("docs"))` returns `["docs/readme.txt"]`, not `["readme.txt"]`. - async fn list(&self, prefix: Option<&str>) -> Result, DomainError>; - - /// Convenience: reads the entire content of `key` into memory. Wraps `get`. - async fn get_bytes(&self, key: &str) -> Result { - let mut stream = self.get(key).await?; - let mut buf: Vec = Vec::new(); - while let Some(chunk) = stream.next().await { - buf.extend_from_slice(&chunk?); - } - Ok(Bytes::from(buf)) - } -} - -/// Write operations on object storage. -#[async_trait] -pub trait StorageWriter: Send + Sync { - /// Stores `data` at `key`. Overwrites any existing content at that key silently. - async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError>; - - /// Deletes `key`. Returns `Ok(())` even if the key does not exist (idempotent). - async fn delete(&self, key: &str) -> Result<(), DomainError>; - - /// Convenience: stores an in-memory buffer at `key`. Wraps `put`. - async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), DomainError> { - self.put(key, Box::pin(stream::once(async move { Ok(data) }))).await - } -} - -/// Combined read + write storage interface. -/// -/// **Usage note:** `Arc` is the intended DI type everywhere. -/// `StorageReader` and `StorageWriter` exist for implementation clarity, but Rust does not -/// support narrowing `Arc` to `Arc` at runtime. -/// Inject `Arc` into constructors and pass `.clone()` from the factory. -pub trait StoragePort: StorageReader + StorageWriter {} -impl StoragePort for T {} diff --git a/crates/domain/src/ports/storage_volume_repo.rs b/crates/domain/src/ports/storage_volume_repo.rs deleted file mode 100644 index 96c86b5..0000000 --- a/crates/domain/src/ports/storage_volume_repo.rs +++ /dev/null @@ -1,10 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::StorageVolume, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait StorageVolumeRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_all(&self) -> Result, DomainError>; - async fn save(&self, volume: &StorageVolume) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/tag_repo.rs b/crates/domain/src/ports/tag_repo.rs deleted file mode 100644 index 6d763be..0000000 --- a/crates/domain/src/ports/tag_repo.rs +++ /dev/null @@ -1,12 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::{AssetTag, Tag}, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait TagRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_name(&self, name: &str) -> Result, DomainError>; - async fn find_tags_for_asset(&self, asset_id: &SystemId) -> Result, DomainError>; - async fn save_tag(&self, tag: &Tag) -> Result<(), DomainError>; - async fn save_asset_tag(&self, asset_tag: &AssetTag) -> Result<(), DomainError>; - async fn remove_asset_tag(&self, asset_id: &SystemId, tag_id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/user_repo.rs b/crates/domain/src/ports/user_repo.rs deleted file mode 100644 index ec23ebd..0000000 --- a/crates/domain/src/ports/user_repo.rs +++ /dev/null @@ -1,11 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::User, errors::DomainError, value_objects::{Email, SystemId}}; - -#[async_trait] -pub trait UserRepository: Send + Sync { - async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; - async fn find_by_email(&self, email: &Email) -> Result, DomainError>; - async fn find_by_username(&self, username: &str) -> Result, DomainError>; - async fn save(&self, user: &User) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/ports/visibility_filter_repo.rs b/crates/domain/src/ports/visibility_filter_repo.rs deleted file mode 100644 index 0c3b2fc..0000000 --- a/crates/domain/src/ports/visibility_filter_repo.rs +++ /dev/null @@ -1,9 +0,0 @@ -use async_trait::async_trait; -use crate::{entities::VisibilityFilter, errors::DomainError, value_objects::SystemId}; - -#[async_trait] -pub trait VisibilityFilterRepository: Send + Sync { - async fn find_by_scope_and_role(&self, scope_id: &SystemId, role_id: &SystemId) -> Result, DomainError>; - async fn save(&self, filter: &VisibilityFilter) -> Result<(), DomainError>; - async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; -} diff --git a/crates/domain/src/processing/entities.rs b/crates/domain/src/processing/entities.rs new file mode 100644 index 0000000..1561bf6 --- /dev/null +++ b/crates/domain/src/processing/entities.rs @@ -0,0 +1,259 @@ +use crate::common::errors::DomainError; +use crate::common::value_objects::{DateTimeStamp, StructuredData, SystemId}; + +// --- Job --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum JobType { + ScanDirectory, + ExtractMetadata, + GenerateDerivative, + SyncSidecar, + DetectDuplicates, + Custom(String), +} + +impl PartialEq for JobType { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::ScanDirectory, Self::ScanDirectory) => true, + (Self::ExtractMetadata, Self::ExtractMetadata) => true, + (Self::GenerateDerivative, Self::GenerateDerivative) => true, + (Self::SyncSidecar, Self::SyncSidecar) => true, + (Self::DetectDuplicates, Self::DetectDuplicates) => true, + (Self::Custom(a), Self::Custom(b)) => a == b, + _ => false, + } + } +} + +impl Eq for JobType {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum JobStatus { + Queued, + Processing, + Completed, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Job { + pub job_id: SystemId, + pub job_type: JobType, + pub target_asset_id: Option, + pub batch_id: Option, + pub status: JobStatus, + pub priority: u32, + pub payload: StructuredData, + pub result_data: Option, + pub retry_count: u32, + pub max_retries: u32, + pub created_at: DateTimeStamp, + pub started_at: Option, + pub completed_at: Option, + pub error_message: Option, +} + +impl Job { + pub fn new(job_type: JobType, priority: u32, payload: StructuredData) -> Self { + Self { + job_id: SystemId::new(), + job_type, + target_asset_id: None, + batch_id: None, + status: JobStatus::Queued, + priority, + payload, + result_data: None, + retry_count: 0, + max_retries: 3, + created_at: DateTimeStamp::now(), + started_at: None, + completed_at: None, + error_message: None, + } + } + + pub fn with_target(mut self, asset_id: SystemId) -> Self { + self.target_asset_id = Some(asset_id); + self + } + + pub fn with_batch(mut self, batch_id: SystemId) -> Self { + self.batch_id = Some(batch_id); + self + } + + pub fn start(&mut self) -> Result<(), DomainError> { + if self.status != JobStatus::Queued { + return Err(DomainError::Conflict( + format!("Job can only start from Queued, currently {:?}", self.status), + )); + } + self.status = JobStatus::Processing; + self.started_at = Some(DateTimeStamp::now()); + Ok(()) + } + + pub fn complete(&mut self, result: StructuredData) { + self.status = JobStatus::Completed; + self.result_data = Some(result); + self.completed_at = Some(DateTimeStamp::now()); + } + + pub fn fail(&mut self, error: impl Into) { + self.retry_count += 1; + self.error_message = Some(error.into()); + self.started_at = None; + if self.retry_count >= self.max_retries { + self.status = JobStatus::Failed; + } else { + self.status = JobStatus::Queued; + } + } + + pub fn cancel(&mut self) { + self.status = JobStatus::Cancelled; + self.completed_at = Some(DateTimeStamp::now()); + } + + pub fn can_retry(&self) -> bool { + self.retry_count < self.max_retries + } +} + +// --- JobBatch --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum BatchStatus { + InProgress, + CompletedWithErrors, + Completed, + Cancelled, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct JobBatch { + pub batch_id: SystemId, + pub batch_type: String, + pub total_jobs: u32, + pub completed_count: u32, + pub failed_count: u32, + pub status: BatchStatus, +} + +impl JobBatch { + pub fn new(batch_type: impl Into, total_jobs: u32) -> Self { + Self { + batch_id: SystemId::new(), + batch_type: batch_type.into(), + total_jobs, + completed_count: 0, + failed_count: 0, + status: BatchStatus::InProgress, + } + } + + pub fn record_completion(&mut self) { + self.completed_count += 1; + self.check_finished(); + } + + pub fn record_failure(&mut self) { + self.failed_count += 1; + self.check_finished(); + } + + pub fn progress_percent(&self) -> f64 { + if self.total_jobs == 0 { + return 100.0; + } + ((self.completed_count + self.failed_count) as f64 / self.total_jobs as f64) * 100.0 + } + + fn check_finished(&mut self) { + if self.completed_count + self.failed_count >= self.total_jobs { + self.status = if self.failed_count > 0 { + BatchStatus::CompletedWithErrors + } else { + BatchStatus::Completed + }; + } + } +} + +// --- Plugin --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum PluginType { + MediaProcessor, + ScheduledTask, + SidecarWriter, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Plugin { + pub plugin_id: SystemId, + pub name: String, + pub plugin_type: PluginType, + pub is_enabled: bool, + pub configuration: StructuredData, +} + +impl Plugin { + pub fn new(name: impl Into, plugin_type: PluginType) -> Self { + Self { + plugin_id: SystemId::new(), + name: name.into(), + plugin_type, + is_enabled: true, + configuration: StructuredData::new(), + } + } + + pub fn disable(&mut self) { + self.is_enabled = false; + } + + pub fn enable(&mut self) { + self.is_enabled = true; + } +} + +// --- ProcessingPipeline --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PipelineStep { + pub plugin_id: SystemId, + pub step_order: u32, + pub configuration: StructuredData, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProcessingPipeline { + pub pipeline_id: SystemId, + pub trigger_event: String, + pub steps: Vec, +} + +impl ProcessingPipeline { + pub fn new(trigger_event: impl Into) -> Self { + Self { + pipeline_id: SystemId::new(), + trigger_event: trigger_event.into(), + steps: Vec::new(), + } + } + + pub fn add_step(&mut self, plugin_id: SystemId, config: StructuredData) { + let next_order = self.steps.iter().map(|s| s.step_order).max().unwrap_or(0) + + if self.steps.is_empty() { 0 } else { 1 }; + self.steps.push(PipelineStep { + plugin_id, + step_order: next_order, + configuration: config, + }); + } +} diff --git a/crates/domain/src/processing/mod.rs b/crates/domain/src/processing/mod.rs new file mode 100644 index 0000000..637ecc2 --- /dev/null +++ b/crates/domain/src/processing/mod.rs @@ -0,0 +1,5 @@ +pub mod entities; +pub mod ports; + +pub use entities::*; +pub use ports::*; diff --git a/crates/domain/src/processing/ports.rs b/crates/domain/src/processing/ports.rs new file mode 100644 index 0000000..51c0494 --- /dev/null +++ b/crates/domain/src/processing/ports.rs @@ -0,0 +1,40 @@ +use async_trait::async_trait; +use crate::common::errors::DomainError; +use crate::common::value_objects::SystemId; +use super::entities::{Job, JobBatch, Plugin, ProcessingPipeline}; + +// --- JobRepository --- + +#[async_trait] +pub trait JobRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_next_queued(&self) -> Result, DomainError>; + async fn find_by_batch(&self, batch_id: &SystemId) -> Result, DomainError>; + async fn save(&self, job: &Job) -> Result<(), DomainError>; +} + +// --- JobBatchRepository --- + +#[async_trait] +pub trait JobBatchRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn save(&self, batch: &JobBatch) -> Result<(), DomainError>; +} + +// --- PluginRepository --- + +#[async_trait] +pub trait PluginRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_enabled(&self) -> Result, DomainError>; + async fn save(&self, plugin: &Plugin) -> Result<(), DomainError>; +} + +// --- PipelineRepository --- + +#[async_trait] +pub trait PipelineRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_trigger(&self, event: &str) -> Result, DomainError>; + async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/services/mod.rs b/crates/domain/src/services/mod.rs deleted file mode 100644 index 3233e22..0000000 --- a/crates/domain/src/services/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod metadata_resolver; -pub mod permission_service; -pub mod quota_checker; diff --git a/crates/domain/src/sharing/entities.rs b/crates/domain/src/sharing/entities.rs new file mode 100644 index 0000000..cff34f4 --- /dev/null +++ b/crates/domain/src/sharing/entities.rs @@ -0,0 +1,204 @@ +use chrono::Utc; +use crate::common::value_objects::{DateTimeStamp, StructuredData, SystemId}; + +// --- ShareScope --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ScopeType { + Private, + User, + Group, + Link, + Public, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ShareableType { + Asset, + Album, + Collection, + Directory, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ShareScope { + pub scope_id: SystemId, + pub scope_type: ScopeType, + pub shareable_type: ShareableType, + pub shareable_id: SystemId, + pub created_by_user_id: SystemId, + pub expires_at: Option, + pub created_at: DateTimeStamp, +} + +impl ShareScope { + pub fn new( + scope_type: ScopeType, + shareable_type: ShareableType, + shareable_id: SystemId, + created_by: SystemId, + ) -> Self { + Self { + scope_id: SystemId::new(), + scope_type, + shareable_type, + shareable_id, + created_by_user_id: created_by, + expires_at: None, + created_at: DateTimeStamp::now(), + } + } + + pub fn is_expired(&self) -> bool { + match &self.expires_at { + Some(exp) => exp.as_datetime() < &Utc::now(), + None => false, + } + } +} + +// --- ShareTarget --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum TargetType { + User, + Group, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ShareTarget { + pub scope_id: SystemId, + pub target_type: TargetType, + pub target_id: SystemId, + pub role_id: SystemId, +} + +impl ShareTarget { + pub fn new(scope_id: SystemId, target_type: TargetType, target_id: SystemId, role_id: SystemId) -> Self { + Self { scope_id, target_type, target_id, role_id } + } +} + +// --- ShareLink --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum LinkAccessLevel { + ViewOnly, + LimitedSearch, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ShareLink { + pub scope_id: SystemId, + pub token: String, + pub expires_at: Option, + pub access_level: LinkAccessLevel, + pub is_active: bool, + pub max_uses: Option, + pub use_count: u32, +} + +impl ShareLink { + pub fn new(scope_id: SystemId, token: impl Into, access_level: LinkAccessLevel) -> Self { + Self { + scope_id, + token: token.into(), + expires_at: None, + access_level, + is_active: true, + max_uses: None, + use_count: 0, + } + } + + pub fn is_valid(&self) -> bool { + if !self.is_active { + return false; + } + if self.expires_at.is_some_and(|exp| exp.as_datetime() < &Utc::now()) { + return false; + } + if self.max_uses.is_some_and(|max| self.use_count >= max) { + return false; + } + true + } + + pub fn record_use(&mut self) { + self.use_count += 1; + } + + pub fn deactivate(&mut self) { + self.is_active = false; + } +} + +// --- InviteCode --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct InviteCode { + pub code_id: SystemId, + pub scope_id: SystemId, + pub created_by_user_id: SystemId, + pub expires_at: Option, + pub max_uses: Option, + pub use_count: u32, + pub assigned_role_id: SystemId, +} + +impl InviteCode { + pub fn new(scope_id: SystemId, created_by: SystemId, role_id: SystemId) -> Self { + Self { + code_id: SystemId::new(), + scope_id, + created_by_user_id: created_by, + expires_at: None, + max_uses: None, + use_count: 0, + assigned_role_id: role_id, + } + } + + pub fn is_valid(&self) -> bool { + if self.expires_at.is_some_and(|exp| exp.as_datetime() < &Utc::now()) { + return false; + } + if self.max_uses.is_some_and(|max| self.use_count >= max) { + return false; + } + true + } + + pub fn record_use(&mut self) { + self.use_count += 1; + } +} + +// --- VisibilityFilter --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct VisibilityFilter { + pub filter_id: SystemId, + pub scope_id: SystemId, + pub role_id: SystemId, + pub hidden_fields: Vec, +} + +impl VisibilityFilter { + pub fn new(scope_id: SystemId, role_id: SystemId, hidden_fields: Vec) -> Self { + Self { + filter_id: SystemId::new(), + scope_id, + role_id, + hidden_fields, + } + } + + pub fn apply(&self, data: &StructuredData) -> StructuredData { + let mut result = data.clone(); + for field in &self.hidden_fields { + result.remove(field); + } + result + } +} diff --git a/crates/domain/src/sharing/mod.rs b/crates/domain/src/sharing/mod.rs new file mode 100644 index 0000000..637ecc2 --- /dev/null +++ b/crates/domain/src/sharing/mod.rs @@ -0,0 +1,5 @@ +pub mod entities; +pub mod ports; + +pub use entities::*; +pub use ports::*; diff --git a/crates/domain/src/ports/share_repo.rs b/crates/domain/src/sharing/ports.rs similarity index 64% rename from crates/domain/src/ports/share_repo.rs rename to crates/domain/src/sharing/ports.rs index 1ae1fd2..7a12921 100644 --- a/crates/domain/src/ports/share_repo.rs +++ b/crates/domain/src/sharing/ports.rs @@ -1,9 +1,9 @@ use async_trait::async_trait; -use crate::{ - entities::{InviteCode, ShareLink, ShareScope, ShareTarget}, - errors::DomainError, - value_objects::SystemId, -}; +use crate::common::errors::DomainError; +use crate::common::value_objects::SystemId; +use super::entities::{InviteCode, ShareLink, ShareScope, ShareTarget, VisibilityFilter}; + +// --- ShareRepository --- #[async_trait] pub trait ShareRepository: Send + Sync { @@ -22,3 +22,12 @@ pub trait ShareRepository: Send + Sync { async fn save_invite(&self, invite: &InviteCode) -> Result<(), DomainError>; async fn find_invite_by_id(&self, id: &SystemId) -> Result, DomainError>; } + +// --- VisibilityFilterRepository --- + +#[async_trait] +pub trait VisibilityFilterRepository: Send + Sync { + async fn find_by_scope_and_role(&self, scope_id: &SystemId, role_id: &SystemId) -> Result, DomainError>; + async fn save(&self, filter: &VisibilityFilter) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/entities/sidecar_record.rs b/crates/domain/src/sidecar/entities.rs similarity index 63% rename from crates/domain/src/entities/sidecar_record.rs rename to crates/domain/src/sidecar/entities.rs index ade3f8a..ad831f8 100644 --- a/crates/domain/src/entities/sidecar_record.rs +++ b/crates/domain/src/sidecar/entities.rs @@ -1,4 +1,6 @@ -use crate::value_objects::{Checksum, DateTimeStamp, SystemId}; +use crate::common::value_objects::{Checksum, DateTimeStamp, SystemId}; + +// --- SidecarRecord --- #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SyncStatus { @@ -55,3 +57,36 @@ impl SidecarRecord { self.error_message = Some(message.into()); } } + +// --- SidecarConfig --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum SyncMode { + Auto, + Scheduled, + Manual, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ConflictPolicy { + DbWins, + FileWins, + RequireUserDecision, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SidecarConfig { + pub export_base_path: String, + pub sync_mode: SyncMode, + pub conflict_resolution_policy: ConflictPolicy, +} + +impl Default for SidecarConfig { + fn default() -> Self { + Self { + export_base_path: "/kphotos/sidecars".to_string(), + sync_mode: SyncMode::Auto, + conflict_resolution_policy: ConflictPolicy::DbWins, + } + } +} diff --git a/crates/domain/src/sidecar/mod.rs b/crates/domain/src/sidecar/mod.rs new file mode 100644 index 0000000..637ecc2 --- /dev/null +++ b/crates/domain/src/sidecar/mod.rs @@ -0,0 +1,5 @@ +pub mod entities; +pub mod ports; + +pub use entities::*; +pub use ports::*; diff --git a/crates/domain/src/sidecar/ports.rs b/crates/domain/src/sidecar/ports.rs new file mode 100644 index 0000000..aa577e5 --- /dev/null +++ b/crates/domain/src/sidecar/ports.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; +use crate::common::errors::DomainError; +use crate::common::value_objects::{StructuredData, SystemId}; +use super::entities::{SidecarRecord, SyncStatus}; + +// --- SidecarRepository --- + +#[async_trait] +pub trait SidecarRepository: Send + Sync { + async fn find_by_asset(&self, asset_id: &SystemId) -> Result, DomainError>; + async fn find_by_status(&self, status: SyncStatus) -> Result, DomainError>; + async fn save(&self, record: &SidecarRecord) -> Result<(), DomainError>; + async fn delete(&self, asset_id: &SystemId) -> Result<(), DomainError>; +} + +// --- SidecarWriterPort --- + +#[async_trait] +pub trait SidecarWriterPort: Send + Sync { + fn format_name(&self) -> &str; + async fn write_sidecar(&self, data: &StructuredData, path: &str) -> Result<(), DomainError>; + async fn read_sidecar(&self, path: &str) -> Result; +} diff --git a/crates/domain/src/storage/entities.rs b/crates/domain/src/storage/entities.rs new file mode 100644 index 0000000..641f361 --- /dev/null +++ b/crates/domain/src/storage/entities.rs @@ -0,0 +1,239 @@ +use crate::common::errors::DomainError; +use crate::common::value_objects::{Checksum, DateTimeStamp, SystemId}; + +// --- StorageVolume --- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct StorageVolume { + pub volume_id: SystemId, + pub volume_name: String, + pub uri_prefix: String, + pub is_writable: bool, + pub available_bytes: u64, +} + +impl StorageVolume { + pub fn new(name: impl Into, uri_prefix: impl Into, is_writable: bool) -> Self { + Self { + volume_id: SystemId::new(), + volume_name: name.into(), + uri_prefix: uri_prefix.into(), + is_writable, + available_bytes: 0, + } + } +} + +// --- LibraryPath --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum OwnershipPolicy { + UserOwned, + GroupOwned, + Unassigned, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LibraryPath { + pub path_id: SystemId, + pub volume_id: SystemId, + pub relative_path: String, + pub is_ingest_destination: bool, + pub ownership_policy: OwnershipPolicy, + pub designated_owner_id: Option, +} + +impl LibraryPath { + pub fn new_user_owned( + volume_id: SystemId, + relative_path: impl Into, + owner_id: SystemId, + is_ingest_destination: bool, + ) -> Self { + Self { + path_id: SystemId::new(), + volume_id, + relative_path: relative_path.into(), + is_ingest_destination, + ownership_policy: OwnershipPolicy::UserOwned, + designated_owner_id: Some(owner_id), + } + } + + pub fn new_unassigned(volume_id: SystemId, relative_path: impl Into) -> Self { + Self { + path_id: SystemId::new(), + volume_id, + relative_path: relative_path.into(), + is_ingest_destination: false, + ownership_policy: OwnershipPolicy::Unassigned, + designated_owner_id: None, + } + } +} + +// --- IngestSession --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum IngestStatus { + Uploading, + AwaitingProcessing, + Processing, + Completed, + Failed, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct IngestSession { + pub session_id: SystemId, + pub uploader_user_id: SystemId, + pub client_device_id: String, + pub original_filename: String, + pub client_checksum: Checksum, + pub target_library_path_id: SystemId, + pub status: IngestStatus, + pub created_at: DateTimeStamp, + pub error_message: Option, +} + +impl IngestSession { + pub fn new( + uploader: SystemId, + device_id: impl Into, + filename: impl Into, + checksum: Checksum, + target_path: SystemId, + ) -> Self { + Self { + session_id: SystemId::new(), + uploader_user_id: uploader, + client_device_id: device_id.into(), + original_filename: filename.into(), + client_checksum: checksum, + target_library_path_id: target_path, + status: IngestStatus::Uploading, + created_at: DateTimeStamp::now(), + error_message: None, + } + } + + pub fn advance_to(&mut self, status: IngestStatus) -> Result<(), DomainError> { + let valid = matches!( + (self.status, status), + (IngestStatus::Uploading, IngestStatus::AwaitingProcessing) + | (IngestStatus::AwaitingProcessing, IngestStatus::Processing) + | (IngestStatus::Processing, IngestStatus::Completed) + ) || (status == IngestStatus::Failed && !self.is_terminal()); + + if !valid { + return Err(DomainError::Validation(format!( + "Invalid transition from {:?} to {:?}", + self.status, status + ))); + } + self.status = status; + Ok(()) + } + + pub fn fail(&mut self, message: impl Into) { + self.status = IngestStatus::Failed; + self.error_message = Some(message.into()); + } + + fn is_terminal(&self) -> bool { + matches!(self.status, IngestStatus::Completed | IngestStatus::Failed) + } +} + +// --- Quota --- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum UsageType { + StorageBytes, + ProcessJobs, + ApiCalls, + IndexingSize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum TimePeriod { + Daily, + Monthly, + Lifetime, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct QuotaRule { + pub rule_id: SystemId, + pub dimension: UsageType, + pub limit_value: u64, + pub time_period: TimePeriod, + pub is_unlimited: bool, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct QuotaDefinition { + pub quota_id: SystemId, + pub owner_scope: SystemId, + pub is_enforced: bool, + pub rules: Vec, +} + +impl QuotaDefinition { + pub fn new(owner_scope: SystemId) -> Self { + Self { + quota_id: SystemId::new(), + owner_scope, + is_enforced: true, + rules: Vec::new(), + } + } + + pub fn add_rule(&mut self, dimension: UsageType, limit_value: u64, time_period: TimePeriod) { + self.rules.push(QuotaRule { + rule_id: SystemId::new(), + dimension, + limit_value, + time_period, + is_unlimited: false, + }); + } + + pub fn add_unlimited_rule(&mut self, dimension: UsageType) { + self.rules.push(QuotaRule { + rule_id: SystemId::new(), + dimension, + limit_value: 0, + time_period: TimePeriod::Lifetime, + is_unlimited: true, + }); + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UsageLedgerEntry { + pub entry_id: SystemId, + pub user_id: SystemId, + pub usage_type: UsageType, + pub consumed_amount: u64, + pub timestamp: DateTimeStamp, + pub context: String, +} + +impl UsageLedgerEntry { + pub fn new( + user_id: SystemId, + usage_type: UsageType, + amount: u64, + context: impl Into, + ) -> Self { + Self { + entry_id: SystemId::new(), + user_id, + usage_type, + consumed_amount: amount, + timestamp: DateTimeStamp::now(), + context: context.into(), + } + } +} diff --git a/crates/domain/src/storage/mod.rs b/crates/domain/src/storage/mod.rs new file mode 100644 index 0000000..707f32a --- /dev/null +++ b/crates/domain/src/storage/mod.rs @@ -0,0 +1,7 @@ +pub mod entities; +pub mod ports; +pub mod services; + +pub use entities::*; +pub use ports::*; +pub use services::*; diff --git a/crates/domain/src/storage/ports.rs b/crates/domain/src/storage/ports.rs new file mode 100644 index 0000000..2d9ca5a --- /dev/null +++ b/crates/domain/src/storage/ports.rs @@ -0,0 +1,128 @@ +use async_trait::async_trait; +use bytes::Bytes; +use futures::stream::{self, BoxStream, StreamExt}; +use crate::common::errors::DomainError; +use crate::common::value_objects::{DateTimeStamp, SystemId}; +use super::entities::{ + IngestSession, LibraryPath, QuotaDefinition, StorageVolume, + UsageLedgerEntry, UsageType, +}; + +// --- StorageVolumeRepository --- + +#[async_trait] +pub trait StorageVolumeRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_all(&self) -> Result, DomainError>; + async fn save(&self, volume: &StorageVolume) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- LibraryPathRepository --- + +#[async_trait] +pub trait LibraryPathRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_volume(&self, volume_id: &SystemId) -> Result, DomainError>; + async fn find_ingest_destinations(&self, owner_id: &SystemId) -> Result, DomainError>; + async fn save(&self, path: &LibraryPath) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +// --- IngestSessionRepository --- + +#[async_trait] +pub trait IngestSessionRepository: Send + Sync { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_by_user(&self, user_id: &SystemId) -> Result, DomainError>; + async fn save(&self, session: &IngestSession) -> Result<(), DomainError>; +} + +// --- QuotaRepository --- + +#[async_trait] +pub trait QuotaRepository: Send + Sync { + async fn find_by_owner(&self, owner_id: &SystemId) -> Result, DomainError>; + async fn save(&self, quota: &QuotaDefinition) -> Result<(), DomainError>; + async fn delete(&self, id: &SystemId) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait UsageLedgerRepository: Send + Sync { + async fn record(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError>; + async fn sum_usage( + &self, + user_id: &SystemId, + usage_type: UsageType, + since: Option, + ) -> Result; +} + +// --- FileStoragePort --- + +#[derive(Debug, Clone)] +pub struct FileEntry { + pub path: String, + pub size_bytes: u64, + pub is_directory: bool, +} + +#[async_trait] +pub trait FileStoragePort: Send + Sync { + async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError>; + async fn read_file(&self, path: &str) -> Result; + async fn delete_file(&self, path: &str) -> Result<(), DomainError>; + async fn list_directory(&self, path: &str) -> Result, DomainError>; + async fn file_exists(&self, path: &str) -> Result; + async fn available_space(&self) -> Result; +} + +// --- StoragePort (object storage) --- + +pub type DataStream = BoxStream<'static, Result>; + +/// Read operations on object storage. Keys are full paths relative to the adapter root. +#[async_trait] +pub trait StorageReader: Send + Sync { + /// Returns the content of `key` as a stream. Returns `DomainError::NotFound` if absent. + async fn get(&self, key: &str) -> Result; + + /// Lists all keys whose path begins with `prefix`, or all keys when `prefix` is `None`. + /// Returned keys are **full paths from the adapter root**, not relative to `prefix`. + /// Example: `list(Some("docs"))` returns `["docs/readme.txt"]`, not `["readme.txt"]`. + async fn list(&self, prefix: Option<&str>) -> Result, DomainError>; + + /// Convenience: reads the entire content of `key` into memory. Wraps `get`. + async fn get_bytes(&self, key: &str) -> Result { + let mut stream = self.get(key).await?; + let mut buf: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + buf.extend_from_slice(&chunk?); + } + Ok(Bytes::from(buf)) + } +} + +/// Write operations on object storage. +#[async_trait] +pub trait StorageWriter: Send + Sync { + /// Stores `data` at `key`. Overwrites any existing content at that key silently. + async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError>; + + /// Deletes `key`. Returns `Ok(())` even if the key does not exist (idempotent). + async fn delete(&self, key: &str) -> Result<(), DomainError>; + + /// Convenience: stores an in-memory buffer at `key`. Wraps `put`. + async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), DomainError> { + self.put(key, Box::pin(stream::once(async move { Ok(data) }))).await + } +} + +/// Combined read + write storage interface. +/// +/// **Usage note:** `Arc` is the intended DI type everywhere. +/// `StorageReader` and `StorageWriter` exist for implementation clarity, but Rust does not +/// support narrowing `Arc` to `Arc` at runtime. +/// Inject `Arc` into constructors and pass `.clone()` from the factory. +pub trait StoragePort: StorageReader + StorageWriter {} +impl StoragePort for T {} diff --git a/crates/domain/src/services/quota_checker.rs b/crates/domain/src/storage/services.rs similarity index 94% rename from crates/domain/src/services/quota_checker.rs rename to crates/domain/src/storage/services.rs index 41fda49..63d1714 100644 --- a/crates/domain/src/services/quota_checker.rs +++ b/crates/domain/src/storage/services.rs @@ -1,6 +1,6 @@ use chrono::{Datelike, NaiveDate, TimeZone, Utc}; -use crate::entities::{QuotaDefinition, TimePeriod, UsageType}; -use crate::value_objects::DateTimeStamp; +use super::entities::{QuotaDefinition, TimePeriod, UsageType}; +use crate::common::value_objects::DateTimeStamp; pub struct QuotaCheckResult { pub allowed: bool, diff --git a/crates/domain/tests/catalog/entities.rs b/crates/domain/tests/catalog/entities.rs new file mode 100644 index 0000000..d0a3039 --- /dev/null +++ b/crates/domain/tests/catalog/entities.rs @@ -0,0 +1,117 @@ +use domain::entities::{ + Asset, AssetMetadata, AssetStack, AssetType, DerivativeAsset, DerivativeProfile, + DetectionMethod, DuplicateGroup, DuplicateStatus, GenerationStatus, + MetadataSource, SourceReference, StackMemberRole, StackType, +}; +use domain::errors::DomainError; +use domain::value_objects::{Checksum, MetadataValue, StructuredData, SystemId}; + +// --- Asset --- + +fn make_asset() -> Asset { + let src = SourceReference { + volume_id: SystemId::new(), + relative_path: "photos/img.jpg".to_string(), + checksum: Checksum::new("a".repeat(64)).unwrap(), + }; + Asset::new(src, AssetType::Image, "image/jpeg", 1024, SystemId::new()) +} + +#[test] +fn new_asset_is_unprocessed() { + let a = make_asset(); + assert!(!a.is_processed); +} + +#[test] +fn mark_processed() { + let mut a = make_asset(); + a.mark_processed(); + assert!(a.is_processed); +} + +// --- AssetMetadata --- + +#[test] +fn metadata_source_ordering() { + assert!(MetadataSource::ExifExtracted < MetadataSource::AiGenerated); + assert!(MetadataSource::AiGenerated < MetadataSource::UserEdited); +} + +#[test] +fn create_metadata_layer() { + let mut data = StructuredData::new(); + data.insert("camera", MetadataValue::String("Canon".to_string())); + let meta = AssetMetadata::new(SystemId::new(), MetadataSource::ExifExtracted, data); + assert_eq!(meta.metadata_source, MetadataSource::ExifExtracted); + assert_eq!(meta.data.get_string("camera"), Some("Canon")); +} + +// --- AssetStack --- + +#[test] +fn new_stack_contains_primary() { + let primary = SystemId::new(); + let stack = AssetStack::new(StackType::LivePhoto, primary.clone(), SystemId::new()); + assert_eq!(stack.members.len(), 1); + assert_eq!(stack.members[0].asset_id, primary); + assert_eq!(stack.members[0].role, StackMemberRole::PrimaryDisplay); + assert_eq!(stack.members[0].sort_order, 0); +} + +#[test] +fn add_motion_clip() { + let mut stack = AssetStack::new(StackType::LivePhoto, SystemId::new(), SystemId::new()); + let clip_id = SystemId::new(); + stack.add_member(clip_id.clone(), StackMemberRole::MotionClip).unwrap(); + assert_eq!(stack.members.len(), 2); + assert_eq!(stack.members[1].asset_id, clip_id); + assert_eq!(stack.members[1].sort_order, 1); +} + +#[test] +fn cannot_add_duplicate_to_stack() { + let primary = SystemId::new(); + let mut stack = AssetStack::new(StackType::LivePhoto, primary.clone(), SystemId::new()); + let result = stack.add_member(primary, StackMemberRole::HighResSource); + assert!(matches!(result, Err(DomainError::Conflict(_)))); +} + +// --- DerivativeAsset --- + +#[test] +fn derivative_lifecycle() { + let mut d = DerivativeAsset::new_pending( + SystemId::new(), + DerivativeProfile::ThumbnailSquare, + "/thumbs/abc.webp", + ); + assert_eq!(d.generation_status, GenerationStatus::Pending); + assert_eq!(d.file_size, 0); + assert_eq!(d.dimensions, (0, 0)); + + d.mark_ready("image/webp", 4096, (256, 256)); + assert_eq!(d.generation_status, GenerationStatus::Ready); + assert_eq!(d.mime_type, "image/webp"); + assert_eq!(d.file_size, 4096); + assert_eq!(d.dimensions, (256, 256)); +} + +// --- Duplicate --- + +#[test] +fn exact_duplicate_group() { + let g = DuplicateGroup::new_exact(SystemId::new(), SystemId::new()); + assert_eq!(g.detection_method, DetectionMethod::ExactHash); + assert_eq!(g.status, DuplicateStatus::Unresolved); + assert_eq!(g.candidates.len(), 2); + assert_eq!(g.candidates[0].similarity_score, 1.0); + assert_eq!(g.candidates[1].similarity_score, 1.0); +} + +#[test] +fn resolve_group() { + let mut g = DuplicateGroup::new_exact(SystemId::new(), SystemId::new()); + g.resolve(); + assert_eq!(g.status, DuplicateStatus::Resolved); +} diff --git a/crates/domain/tests/catalog/mod.rs b/crates/domain/tests/catalog/mod.rs new file mode 100644 index 0000000..bc1f555 --- /dev/null +++ b/crates/domain/tests/catalog/mod.rs @@ -0,0 +1,2 @@ +mod entities; +mod services; diff --git a/crates/domain/tests/services/metadata_resolver.rs b/crates/domain/tests/catalog/services.rs similarity index 100% rename from crates/domain/tests/services/metadata_resolver.rs rename to crates/domain/tests/catalog/services.rs diff --git a/crates/domain/tests/events.rs b/crates/domain/tests/common/events.rs similarity index 100% rename from crates/domain/tests/events.rs rename to crates/domain/tests/common/events.rs diff --git a/crates/domain/tests/common/mod.rs b/crates/domain/tests/common/mod.rs new file mode 100644 index 0000000..f185018 --- /dev/null +++ b/crates/domain/tests/common/mod.rs @@ -0,0 +1,2 @@ +mod value_objects; +mod events; diff --git a/crates/domain/tests/value_objects/checksum.rs b/crates/domain/tests/common/value_objects/checksum.rs similarity index 100% rename from crates/domain/tests/value_objects/checksum.rs rename to crates/domain/tests/common/value_objects/checksum.rs diff --git a/crates/domain/tests/value_objects/date_time_stamp.rs b/crates/domain/tests/common/value_objects/date_time_stamp.rs similarity index 100% rename from crates/domain/tests/value_objects/date_time_stamp.rs rename to crates/domain/tests/common/value_objects/date_time_stamp.rs diff --git a/crates/domain/tests/value_objects/filter_criteria.rs b/crates/domain/tests/common/value_objects/filter_criteria.rs similarity index 100% rename from crates/domain/tests/value_objects/filter_criteria.rs rename to crates/domain/tests/common/value_objects/filter_criteria.rs diff --git a/crates/domain/tests/value_objects/mod.rs b/crates/domain/tests/common/value_objects/mod.rs similarity index 100% rename from crates/domain/tests/value_objects/mod.rs rename to crates/domain/tests/common/value_objects/mod.rs diff --git a/crates/domain/tests/value_objects/structured_data.rs b/crates/domain/tests/common/value_objects/structured_data.rs similarity index 100% rename from crates/domain/tests/value_objects/structured_data.rs rename to crates/domain/tests/common/value_objects/structured_data.rs diff --git a/crates/domain/tests/value_objects/system_id.rs b/crates/domain/tests/common/value_objects/system_id.rs similarity index 100% rename from crates/domain/tests/value_objects/system_id.rs rename to crates/domain/tests/common/value_objects/system_id.rs diff --git a/crates/domain/tests/domain_tests.rs b/crates/domain/tests/domain_tests.rs index e32a9dc..f58ea75 100644 --- a/crates/domain/tests/domain_tests.rs +++ b/crates/domain/tests/domain_tests.rs @@ -1,4 +1,8 @@ -mod entities; -mod events; -mod services; -mod value_objects; +mod common; +mod identity; +mod storage; +mod catalog; +mod organization; +mod sharing; +mod sidecar; +mod processing; diff --git a/crates/domain/tests/entities/asset.rs b/crates/domain/tests/entities/asset.rs deleted file mode 100644 index a266732..0000000 --- a/crates/domain/tests/entities/asset.rs +++ /dev/null @@ -1,24 +0,0 @@ -use domain::entities::{Asset, AssetType, SourceReference}; -use domain::value_objects::{Checksum, SystemId}; - -fn make_asset() -> Asset { - let src = SourceReference { - volume_id: SystemId::new(), - relative_path: "photos/img.jpg".to_string(), - checksum: Checksum::new("a".repeat(64)).unwrap(), - }; - Asset::new(src, AssetType::Image, "image/jpeg", 1024, SystemId::new()) -} - -#[test] -fn new_asset_is_unprocessed() { - let a = make_asset(); - assert!(!a.is_processed); -} - -#[test] -fn mark_processed() { - let mut a = make_asset(); - a.mark_processed(); - assert!(a.is_processed); -} diff --git a/crates/domain/tests/entities/asset_metadata.rs b/crates/domain/tests/entities/asset_metadata.rs deleted file mode 100644 index 13126fa..0000000 --- a/crates/domain/tests/entities/asset_metadata.rs +++ /dev/null @@ -1,17 +0,0 @@ -use domain::entities::{AssetMetadata, MetadataSource}; -use domain::value_objects::{MetadataValue, StructuredData, SystemId}; - -#[test] -fn metadata_source_ordering() { - assert!(MetadataSource::ExifExtracted < MetadataSource::AiGenerated); - assert!(MetadataSource::AiGenerated < MetadataSource::UserEdited); -} - -#[test] -fn create_metadata_layer() { - let mut data = StructuredData::new(); - data.insert("camera", MetadataValue::String("Canon".to_string())); - let meta = AssetMetadata::new(SystemId::new(), MetadataSource::ExifExtracted, data); - assert_eq!(meta.metadata_source, MetadataSource::ExifExtracted); - assert_eq!(meta.data.get_string("camera"), Some("Canon")); -} diff --git a/crates/domain/tests/entities/asset_stack.rs b/crates/domain/tests/entities/asset_stack.rs deleted file mode 100644 index b9a26b4..0000000 --- a/crates/domain/tests/entities/asset_stack.rs +++ /dev/null @@ -1,31 +0,0 @@ -use domain::entities::{AssetStack, StackMemberRole, StackType}; -use domain::errors::DomainError; -use domain::value_objects::SystemId; - -#[test] -fn new_stack_contains_primary() { - let primary = SystemId::new(); - let stack = AssetStack::new(StackType::LivePhoto, primary.clone(), SystemId::new()); - assert_eq!(stack.members.len(), 1); - assert_eq!(stack.members[0].asset_id, primary); - assert_eq!(stack.members[0].role, StackMemberRole::PrimaryDisplay); - assert_eq!(stack.members[0].sort_order, 0); -} - -#[test] -fn add_motion_clip() { - let mut stack = AssetStack::new(StackType::LivePhoto, SystemId::new(), SystemId::new()); - let clip_id = SystemId::new(); - stack.add_member(clip_id.clone(), StackMemberRole::MotionClip).unwrap(); - assert_eq!(stack.members.len(), 2); - assert_eq!(stack.members[1].asset_id, clip_id); - assert_eq!(stack.members[1].sort_order, 1); -} - -#[test] -fn cannot_add_duplicate() { - let primary = SystemId::new(); - let mut stack = AssetStack::new(StackType::LivePhoto, primary.clone(), SystemId::new()); - let result = stack.add_member(primary, StackMemberRole::HighResSource); - assert!(matches!(result, Err(DomainError::Conflict(_)))); -} diff --git a/crates/domain/tests/entities/derivative_asset.rs b/crates/domain/tests/entities/derivative_asset.rs deleted file mode 100644 index f8a2280..0000000 --- a/crates/domain/tests/entities/derivative_asset.rs +++ /dev/null @@ -1,20 +0,0 @@ -use domain::entities::{DerivativeAsset, DerivativeProfile, GenerationStatus}; -use domain::value_objects::SystemId; - -#[test] -fn lifecycle() { - let mut d = DerivativeAsset::new_pending( - SystemId::new(), - DerivativeProfile::ThumbnailSquare, - "/thumbs/abc.webp", - ); - assert_eq!(d.generation_status, GenerationStatus::Pending); - assert_eq!(d.file_size, 0); - assert_eq!(d.dimensions, (0, 0)); - - d.mark_ready("image/webp", 4096, (256, 256)); - assert_eq!(d.generation_status, GenerationStatus::Ready); - assert_eq!(d.mime_type, "image/webp"); - assert_eq!(d.file_size, 4096); - assert_eq!(d.dimensions, (256, 256)); -} diff --git a/crates/domain/tests/entities/duplicate.rs b/crates/domain/tests/entities/duplicate.rs deleted file mode 100644 index f403d4d..0000000 --- a/crates/domain/tests/entities/duplicate.rs +++ /dev/null @@ -1,19 +0,0 @@ -use domain::entities::{DetectionMethod, DuplicateGroup, DuplicateStatus}; -use domain::value_objects::SystemId; - -#[test] -fn exact_duplicate_group() { - let g = DuplicateGroup::new_exact(SystemId::new(), SystemId::new()); - assert_eq!(g.detection_method, DetectionMethod::ExactHash); - assert_eq!(g.status, DuplicateStatus::Unresolved); - assert_eq!(g.candidates.len(), 2); - assert_eq!(g.candidates[0].similarity_score, 1.0); - assert_eq!(g.candidates[1].similarity_score, 1.0); -} - -#[test] -fn resolve_group() { - let mut g = DuplicateGroup::new_exact(SystemId::new(), SystemId::new()); - g.resolve(); - assert_eq!(g.status, DuplicateStatus::Resolved); -} diff --git a/crates/domain/tests/entities/group.rs b/crates/domain/tests/entities/group.rs deleted file mode 100644 index b4f8637..0000000 --- a/crates/domain/tests/entities/group.rs +++ /dev/null @@ -1,41 +0,0 @@ -use domain::entities::Group; -use domain::errors::DomainError; -use domain::value_objects::SystemId; - -#[test] -fn owner_auto_member() { - let owner = SystemId::new(); - let g = Group::new("team", owner); - assert!(g.is_member(&owner)); - assert_eq!(g.members.len(), 1); -} - -#[test] -fn add_and_remove() { - let owner = SystemId::new(); - let member = SystemId::new(); - let mut g = Group::new("team", owner); - g.add_member(member).unwrap(); - assert!(g.is_member(&member)); - assert_eq!(g.members.len(), 2); - g.remove_member(member).unwrap(); - assert!(!g.is_member(&member)); -} - -#[test] -fn cannot_remove_owner() { - let owner = SystemId::new(); - let mut g = Group::new("team", owner); - let result = g.remove_member(owner); - assert!(matches!(result, Err(DomainError::Validation(_)))); -} - -#[test] -fn cannot_add_duplicate() { - let owner = SystemId::new(); - let member = SystemId::new(); - let mut g = Group::new("team", owner); - g.add_member(member).unwrap(); - let result = g.add_member(member); - assert!(matches!(result, Err(DomainError::Conflict(_)))); -} diff --git a/crates/domain/tests/entities/job.rs b/crates/domain/tests/entities/job.rs deleted file mode 100644 index 31543ae..0000000 --- a/crates/domain/tests/entities/job.rs +++ /dev/null @@ -1,53 +0,0 @@ -use domain::entities::{Job, JobStatus, JobType}; -use domain::errors::DomainError; -use domain::value_objects::StructuredData; - -#[test] -fn job_lifecycle_success() { - let mut job = Job::new(JobType::ExtractMetadata, 5, StructuredData::new()); - assert_eq!(job.status, JobStatus::Queued); - - job.start().unwrap(); - assert_eq!(job.status, JobStatus::Processing); - assert!(job.started_at.is_some()); - - job.complete(StructuredData::new()); - assert_eq!(job.status, JobStatus::Completed); - assert!(job.result_data.is_some()); - assert!(job.completed_at.is_some()); -} - -#[test] -fn retry_on_failure() { - let mut job = Job::new(JobType::ScanDirectory, 1, StructuredData::new()); - job.start().unwrap(); - - job.fail("timeout"); - assert_eq!(job.status, JobStatus::Queued); - assert_eq!(job.retry_count, 1); - assert!(job.can_retry()); - assert!(job.started_at.is_none()); -} - -#[test] -fn fails_after_max_retries() { - let mut job = Job::new(JobType::ScanDirectory, 1, StructuredData::new()); - job.max_retries = 2; - - job.start().unwrap(); - job.fail("err1"); - assert_eq!(job.status, JobStatus::Queued); - - job.start().unwrap(); - job.fail("err2"); - assert_eq!(job.status, JobStatus::Failed); - assert!(!job.can_retry()); -} - -#[test] -fn cannot_start_from_processing() { - let mut job = Job::new(JobType::ScanDirectory, 1, StructuredData::new()); - job.start().unwrap(); - let result = job.start(); - assert!(matches!(result, Err(DomainError::Conflict(_)))); -} diff --git a/crates/domain/tests/entities/job_batch.rs b/crates/domain/tests/entities/job_batch.rs deleted file mode 100644 index e925ac2..0000000 --- a/crates/domain/tests/entities/job_batch.rs +++ /dev/null @@ -1,31 +0,0 @@ -use domain::entities::{BatchStatus, JobBatch}; - -#[test] -fn completes_when_all_done() { - let mut batch = JobBatch::new("scan", 3); - batch.record_completion(); - batch.record_completion(); - batch.record_completion(); - assert_eq!(batch.status, BatchStatus::Completed); -} - -#[test] -fn completes_with_errors() { - let mut batch = JobBatch::new("scan", 3); - batch.record_completion(); - batch.record_failure(); - batch.record_completion(); - assert_eq!(batch.status, BatchStatus::CompletedWithErrors); -} - -#[test] -fn progress_tracking() { - let mut batch = JobBatch::new("scan", 4); - assert_eq!(batch.progress_percent(), 0.0); - - batch.record_completion(); - assert_eq!(batch.progress_percent(), 25.0); - - batch.record_completion(); - assert_eq!(batch.progress_percent(), 50.0); -} diff --git a/crates/domain/tests/entities/library_path.rs b/crates/domain/tests/entities/library_path.rs deleted file mode 100644 index 174d893..0000000 --- a/crates/domain/tests/entities/library_path.rs +++ /dev/null @@ -1,13 +0,0 @@ -use domain::entities::{LibraryPath, OwnershipPolicy}; -use domain::value_objects::SystemId; - -#[test] -fn user_owned_path() { - let vol = SystemId::new(); - let owner = SystemId::new(); - let lp = LibraryPath::new_user_owned(vol, "/photos", owner, true); - assert_eq!(lp.ownership_policy, OwnershipPolicy::UserOwned); - assert_eq!(lp.designated_owner_id, Some(owner)); - assert!(lp.is_ingest_destination); - assert_eq!(lp.volume_id, vol); -} diff --git a/crates/domain/tests/entities/mod.rs b/crates/domain/tests/entities/mod.rs deleted file mode 100644 index 9f12d10..0000000 --- a/crates/domain/tests/entities/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod group; -mod permission; -mod role; -mod user; -mod storage_volume; -mod library_path; -mod ingest_session; -mod quota; -mod asset; -mod asset_metadata; -mod asset_stack; -mod derivative_asset; -mod duplicate; -mod album; -mod tag; -mod share_scope; -mod share_link; -mod sidecar_record; -mod job; -mod job_batch; -mod processing_pipeline; diff --git a/crates/domain/tests/entities/permission.rs b/crates/domain/tests/entities/permission.rs deleted file mode 100644 index 800c9d6..0000000 --- a/crates/domain/tests/entities/permission.rs +++ /dev/null @@ -1,19 +0,0 @@ -use domain::entities::permission::{ - admin_permissions, contributor_permissions, viewer_permissions, - Permission, PermissionAction, ResourceType, -}; - -#[test] -fn admin_is_superset_of_contributor() { - let admin = admin_permissions(); - let contrib = contributor_permissions(); - assert!(contrib.is_subset(&admin)); - assert!(admin.len() > contrib.len()); -} - -#[test] -fn viewer_cannot_write() { - let viewer = viewer_permissions(); - let write = Permission::new(PermissionAction::WriteMetadata, ResourceType::Global); - assert!(!viewer.contains(&write)); -} diff --git a/crates/domain/tests/entities/processing_pipeline.rs b/crates/domain/tests/entities/processing_pipeline.rs deleted file mode 100644 index a664a4b..0000000 --- a/crates/domain/tests/entities/processing_pipeline.rs +++ /dev/null @@ -1,17 +0,0 @@ -use domain::entities::ProcessingPipeline; -use domain::value_objects::{StructuredData, SystemId}; - -#[test] -fn steps_ordered() { - let mut pipeline = ProcessingPipeline::new("asset.created"); - assert!(pipeline.steps.is_empty()); - - pipeline.add_step(SystemId::new(), StructuredData::new()); - pipeline.add_step(SystemId::new(), StructuredData::new()); - pipeline.add_step(SystemId::new(), StructuredData::new()); - - assert_eq!(pipeline.steps.len(), 3); - assert_eq!(pipeline.steps[0].step_order, 0); - assert_eq!(pipeline.steps[1].step_order, 1); - assert_eq!(pipeline.steps[2].step_order, 2); -} diff --git a/crates/domain/tests/entities/quota.rs b/crates/domain/tests/entities/quota.rs deleted file mode 100644 index b5aecc8..0000000 --- a/crates/domain/tests/entities/quota.rs +++ /dev/null @@ -1,17 +0,0 @@ -use domain::entities::{QuotaDefinition, TimePeriod, UsageType}; -use domain::value_objects::SystemId; - -#[test] -fn quota_with_rules() { - let mut q = QuotaDefinition::new(SystemId::new()); - assert!(q.is_enforced); - assert!(q.rules.is_empty()); - - q.add_rule(UsageType::StorageBytes, 1_000_000, TimePeriod::Monthly); - q.add_unlimited_rule(UsageType::ApiCalls); - - assert_eq!(q.rules.len(), 2); - assert!(!q.rules[0].is_unlimited); - assert_eq!(q.rules[0].limit_value, 1_000_000); - assert!(q.rules[1].is_unlimited); -} diff --git a/crates/domain/tests/entities/role.rs b/crates/domain/tests/entities/role.rs deleted file mode 100644 index 67299cc..0000000 --- a/crates/domain/tests/entities/role.rs +++ /dev/null @@ -1,9 +0,0 @@ -use domain::entities::{Role, PermissionAction, ResourceType}; -use domain::entities::permission::viewer_permissions; - -#[test] -fn role_checks_permission() { - let role = Role::new("viewer", viewer_permissions(), true); - assert!(role.has_permission(PermissionAction::ReadAsset, ResourceType::Global)); - assert!(!role.has_permission(PermissionAction::DeleteAsset, ResourceType::Global)); -} diff --git a/crates/domain/tests/entities/share_link.rs b/crates/domain/tests/entities/share_link.rs deleted file mode 100644 index 38faa8e..0000000 --- a/crates/domain/tests/entities/share_link.rs +++ /dev/null @@ -1,25 +0,0 @@ -use domain::entities::{LinkAccessLevel, ShareLink}; -use domain::value_objects::SystemId; - -#[test] -fn new_link_is_valid() { - let link = ShareLink::new(SystemId::new(), "tok123", LinkAccessLevel::ViewOnly); - assert!(link.is_valid()); - assert_eq!(link.use_count, 0); -} - -#[test] -fn deactivated_invalid() { - let mut link = ShareLink::new(SystemId::new(), "tok123", LinkAccessLevel::ViewOnly); - link.deactivate(); - assert!(!link.is_valid()); -} - -#[test] -fn max_uses_exhausted() { - let mut link = ShareLink::new(SystemId::new(), "tok123", LinkAccessLevel::ViewOnly); - link.max_uses = Some(2); - link.record_use(); - link.record_use(); - assert!(!link.is_valid()); -} diff --git a/crates/domain/tests/entities/share_scope.rs b/crates/domain/tests/entities/share_scope.rs deleted file mode 100644 index d557028..0000000 --- a/crates/domain/tests/entities/share_scope.rs +++ /dev/null @@ -1,16 +0,0 @@ -use chrono::{Duration, Utc}; -use domain::entities::{ScopeType, ShareScope, ShareableType}; -use domain::value_objects::{DateTimeStamp, SystemId}; - -#[test] -fn not_expired_when_no_expiry() { - let scope = ShareScope::new(ScopeType::Link, ShareableType::Album, SystemId::new(), SystemId::new()); - assert!(!scope.is_expired()); -} - -#[test] -fn expired_when_past() { - let mut scope = ShareScope::new(ScopeType::Link, ShareableType::Album, SystemId::new(), SystemId::new()); - scope.expires_at = Some(DateTimeStamp::from_datetime(Utc::now() - Duration::hours(1))); - assert!(scope.is_expired()); -} diff --git a/crates/domain/tests/entities/storage_volume.rs b/crates/domain/tests/entities/storage_volume.rs deleted file mode 100644 index 77a052d..0000000 --- a/crates/domain/tests/entities/storage_volume.rs +++ /dev/null @@ -1,10 +0,0 @@ -use domain::entities::StorageVolume; - -#[test] -fn creates_read_only_volume() { - let vol = StorageVolume::new("archive", "s3://bucket/", false); - assert_eq!(vol.volume_name, "archive"); - assert_eq!(vol.uri_prefix, "s3://bucket/"); - assert!(!vol.is_writable); - assert_eq!(vol.available_bytes, 0); -} diff --git a/crates/domain/tests/entities/tag.rs b/crates/domain/tests/entities/tag.rs deleted file mode 100644 index 0b2460b..0000000 --- a/crates/domain/tests/entities/tag.rs +++ /dev/null @@ -1,13 +0,0 @@ -use domain::entities::{AssetTag, Tag, TagSource}; -use domain::value_objects::SystemId; - -#[test] -fn manual_tag_has_full_confidence() { - let tag = Tag::new_manual("sunset"); - assert_eq!(tag.name, "sunset"); - assert_eq!(tag.tag_source, TagSource::UserManual); - - let asset_tag = AssetTag::new_manual(SystemId::new(), tag.tag_id.clone(), SystemId::new()); - assert_eq!(asset_tag.confidence, 1.0); - assert!(asset_tag.tagged_by_user_id.is_some()); -} diff --git a/crates/domain/tests/entities/user.rs b/crates/domain/tests/entities/user.rs deleted file mode 100644 index 793645e..0000000 --- a/crates/domain/tests/entities/user.rs +++ /dev/null @@ -1,11 +0,0 @@ -use domain::entities::User; -use domain::value_objects::{Email, PasswordHash}; - -#[test] -fn creates_user_with_unique_id() { - let a = User::new("alice", Email::new("a@example.com").unwrap(), PasswordHash::from_hash("h".into())); - let b = User::new("bob", Email::new("b@example.com").unwrap(), PasswordHash::from_hash("h".into())); - assert_ne!(a.id, b.id); - assert_eq!(a.username, "alice"); - assert_eq!(b.username, "bob"); -} diff --git a/crates/domain/tests/identity/entities.rs b/crates/domain/tests/identity/entities.rs new file mode 100644 index 0000000..ed3518f --- /dev/null +++ b/crates/domain/tests/identity/entities.rs @@ -0,0 +1,84 @@ +use domain::entities::permission::{ + admin_permissions, contributor_permissions, viewer_permissions, + Permission, PermissionAction, ResourceType, +}; +use domain::entities::{Group, Role, User}; +use domain::errors::DomainError; +use domain::value_objects::{Email, PasswordHash, SystemId}; + +// --- Permission --- + +#[test] +fn admin_is_superset_of_contributor() { + let admin = admin_permissions(); + let contrib = contributor_permissions(); + assert!(contrib.is_subset(&admin)); + assert!(admin.len() > contrib.len()); +} + +#[test] +fn viewer_cannot_write() { + let viewer = viewer_permissions(); + let write = Permission::new(PermissionAction::WriteMetadata, ResourceType::Global); + assert!(!viewer.contains(&write)); +} + +// --- Role --- + +#[test] +fn role_checks_permission() { + let role = Role::new("viewer", viewer_permissions(), true); + assert!(role.has_permission(PermissionAction::ReadAsset, ResourceType::Global)); + assert!(!role.has_permission(PermissionAction::DeleteAsset, ResourceType::Global)); +} + +// --- User --- + +#[test] +fn creates_user_with_unique_id() { + let a = User::new("alice", Email::new("a@example.com").unwrap(), PasswordHash::from_hash("h".into())); + let b = User::new("bob", Email::new("b@example.com").unwrap(), PasswordHash::from_hash("h".into())); + assert_ne!(a.id, b.id); + assert_eq!(a.username, "alice"); + assert_eq!(b.username, "bob"); +} + +// --- Group --- + +#[test] +fn owner_auto_member() { + let owner = SystemId::new(); + let g = Group::new("team", owner); + assert!(g.is_member(&owner)); + assert_eq!(g.members.len(), 1); +} + +#[test] +fn add_and_remove() { + let owner = SystemId::new(); + let member = SystemId::new(); + let mut g = Group::new("team", owner); + g.add_member(member).unwrap(); + assert!(g.is_member(&member)); + assert_eq!(g.members.len(), 2); + g.remove_member(member).unwrap(); + assert!(!g.is_member(&member)); +} + +#[test] +fn cannot_remove_owner() { + let owner = SystemId::new(); + let mut g = Group::new("team", owner); + let result = g.remove_member(owner); + assert!(matches!(result, Err(DomainError::Validation(_)))); +} + +#[test] +fn cannot_add_duplicate() { + let owner = SystemId::new(); + let member = SystemId::new(); + let mut g = Group::new("team", owner); + g.add_member(member).unwrap(); + let result = g.add_member(member); + assert!(matches!(result, Err(DomainError::Conflict(_)))); +} diff --git a/crates/domain/tests/identity/mod.rs b/crates/domain/tests/identity/mod.rs new file mode 100644 index 0000000..bc1f555 --- /dev/null +++ b/crates/domain/tests/identity/mod.rs @@ -0,0 +1,2 @@ +mod entities; +mod services; diff --git a/crates/domain/tests/services/permission_service.rs b/crates/domain/tests/identity/services.rs similarity index 95% rename from crates/domain/tests/services/permission_service.rs rename to crates/domain/tests/identity/services.rs index ad8b2de..2d4d2f6 100644 --- a/crates/domain/tests/services/permission_service.rs +++ b/crates/domain/tests/identity/services.rs @@ -33,7 +33,6 @@ fn roles_additive() { #[test] fn global_covers_specific() { let role = Role::new("admin", admin_permissions(), true); - // Global ReadAsset should cover Asset-scoped check assert!(PermissionChecker::has_permission( &[role], PermissionAction::ReadAsset, diff --git a/crates/domain/tests/entities/album.rs b/crates/domain/tests/organization/entities.rs similarity index 61% rename from crates/domain/tests/entities/album.rs rename to crates/domain/tests/organization/entities.rs index c53d47b..6baae23 100644 --- a/crates/domain/tests/entities/album.rs +++ b/crates/domain/tests/organization/entities.rs @@ -1,7 +1,9 @@ -use domain::entities::Album; +use domain::entities::{Album, AssetTag, Tag, TagSource}; use domain::errors::DomainError; use domain::value_objects::SystemId; +// --- Album --- + #[test] fn add_and_remove_asset() { let mut album = Album::new("Vacation", SystemId::new()); @@ -25,3 +27,16 @@ fn cannot_add_duplicate() { let result = album.add_asset(asset, user); assert!(matches!(result, Err(DomainError::Conflict(_)))); } + +// --- Tag --- + +#[test] +fn manual_tag_has_full_confidence() { + let tag = Tag::new_manual("sunset"); + assert_eq!(tag.name, "sunset"); + assert_eq!(tag.tag_source, TagSource::UserManual); + + let asset_tag = AssetTag::new_manual(SystemId::new(), tag.tag_id.clone(), SystemId::new()); + assert_eq!(asset_tag.confidence, 1.0); + assert!(asset_tag.tagged_by_user_id.is_some()); +} diff --git a/crates/domain/tests/organization/mod.rs b/crates/domain/tests/organization/mod.rs new file mode 100644 index 0000000..2ff6041 --- /dev/null +++ b/crates/domain/tests/organization/mod.rs @@ -0,0 +1 @@ +mod entities; diff --git a/crates/domain/tests/processing/entities.rs b/crates/domain/tests/processing/entities.rs new file mode 100644 index 0000000..5804fed --- /dev/null +++ b/crates/domain/tests/processing/entities.rs @@ -0,0 +1,104 @@ +use domain::entities::{BatchStatus, Job, JobBatch, JobStatus, JobType, ProcessingPipeline}; +use domain::errors::DomainError; +use domain::value_objects::{StructuredData, SystemId}; + +// --- Job --- + +#[test] +fn job_lifecycle_success() { + let mut job = Job::new(JobType::ExtractMetadata, 5, StructuredData::new()); + assert_eq!(job.status, JobStatus::Queued); + + job.start().unwrap(); + assert_eq!(job.status, JobStatus::Processing); + assert!(job.started_at.is_some()); + + job.complete(StructuredData::new()); + assert_eq!(job.status, JobStatus::Completed); + assert!(job.result_data.is_some()); + assert!(job.completed_at.is_some()); +} + +#[test] +fn retry_on_failure() { + let mut job = Job::new(JobType::ScanDirectory, 1, StructuredData::new()); + job.start().unwrap(); + + job.fail("timeout"); + assert_eq!(job.status, JobStatus::Queued); + assert_eq!(job.retry_count, 1); + assert!(job.can_retry()); + assert!(job.started_at.is_none()); +} + +#[test] +fn fails_after_max_retries() { + let mut job = Job::new(JobType::ScanDirectory, 1, StructuredData::new()); + job.max_retries = 2; + + job.start().unwrap(); + job.fail("err1"); + assert_eq!(job.status, JobStatus::Queued); + + job.start().unwrap(); + job.fail("err2"); + assert_eq!(job.status, JobStatus::Failed); + assert!(!job.can_retry()); +} + +#[test] +fn cannot_start_from_processing() { + let mut job = Job::new(JobType::ScanDirectory, 1, StructuredData::new()); + job.start().unwrap(); + let result = job.start(); + assert!(matches!(result, Err(DomainError::Conflict(_)))); +} + +// --- JobBatch --- + +#[test] +fn completes_when_all_done() { + let mut batch = JobBatch::new("scan", 3); + batch.record_completion(); + batch.record_completion(); + batch.record_completion(); + assert_eq!(batch.status, BatchStatus::Completed); +} + +#[test] +fn completes_with_errors() { + let mut batch = JobBatch::new("scan", 3); + batch.record_completion(); + batch.record_failure(); + batch.record_completion(); + assert_eq!(batch.status, BatchStatus::CompletedWithErrors); +} + +#[test] +fn progress_tracking() { + let mut batch = JobBatch::new("scan", 4); + assert_eq!(batch.progress_percent(), 0.0); + + batch.record_completion(); + assert_eq!(batch.progress_percent(), 25.0); + + batch.record_completion(); + assert_eq!(batch.progress_percent(), 50.0); +} + +// --- ProcessingPipeline --- + +#[test] +fn steps_ordered() { + let mut pipeline = ProcessingPipeline::new("asset.created"); + assert!(pipeline.steps.is_empty()); + + pipeline.add_step(SystemId::new(), StructuredData::new()); + pipeline.add_step(SystemId::new(), StructuredData::new()); + pipeline.add_step(SystemId::new(), StructuredData::new()); + + assert_eq!(pipeline.steps.len(), 3); + assert_eq!(pipeline.steps[0].step_order, 0); + assert_eq!(pipeline.steps[1].step_order, 1); + assert_eq!(pipeline.steps[2].step_order, 2); +} diff --git a/crates/domain/tests/processing/mod.rs b/crates/domain/tests/processing/mod.rs new file mode 100644 index 0000000..2ff6041 --- /dev/null +++ b/crates/domain/tests/processing/mod.rs @@ -0,0 +1 @@ +mod entities; diff --git a/crates/domain/tests/services/mod.rs b/crates/domain/tests/services/mod.rs deleted file mode 100644 index a026b62..0000000 --- a/crates/domain/tests/services/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod permission_service; -mod quota_checker; -mod metadata_resolver; diff --git a/crates/domain/tests/sharing/entities.rs b/crates/domain/tests/sharing/entities.rs new file mode 100644 index 0000000..a1fe7e6 --- /dev/null +++ b/crates/domain/tests/sharing/entities.rs @@ -0,0 +1,43 @@ +use chrono::{Duration, Utc}; +use domain::entities::{LinkAccessLevel, ScopeType, ShareLink, ShareScope, ShareableType}; +use domain::value_objects::{DateTimeStamp, SystemId}; + +// --- ShareScope --- + +#[test] +fn not_expired_when_no_expiry() { + let scope = ShareScope::new(ScopeType::Link, ShareableType::Album, SystemId::new(), SystemId::new()); + assert!(!scope.is_expired()); +} + +#[test] +fn expired_when_past() { + let mut scope = ShareScope::new(ScopeType::Link, ShareableType::Album, SystemId::new(), SystemId::new()); + scope.expires_at = Some(DateTimeStamp::from_datetime(Utc::now() - Duration::hours(1))); + assert!(scope.is_expired()); +} + +// --- ShareLink --- + +#[test] +fn new_link_is_valid() { + let link = ShareLink::new(SystemId::new(), "tok123", LinkAccessLevel::ViewOnly); + assert!(link.is_valid()); + assert_eq!(link.use_count, 0); +} + +#[test] +fn deactivated_invalid() { + let mut link = ShareLink::new(SystemId::new(), "tok123", LinkAccessLevel::ViewOnly); + link.deactivate(); + assert!(!link.is_valid()); +} + +#[test] +fn max_uses_exhausted() { + let mut link = ShareLink::new(SystemId::new(), "tok123", LinkAccessLevel::ViewOnly); + link.max_uses = Some(2); + link.record_use(); + link.record_use(); + assert!(!link.is_valid()); +} diff --git a/crates/domain/tests/sharing/mod.rs b/crates/domain/tests/sharing/mod.rs new file mode 100644 index 0000000..2ff6041 --- /dev/null +++ b/crates/domain/tests/sharing/mod.rs @@ -0,0 +1 @@ +mod entities; diff --git a/crates/domain/tests/entities/sidecar_record.rs b/crates/domain/tests/sidecar/entities.rs similarity index 100% rename from crates/domain/tests/entities/sidecar_record.rs rename to crates/domain/tests/sidecar/entities.rs diff --git a/crates/domain/tests/sidecar/mod.rs b/crates/domain/tests/sidecar/mod.rs new file mode 100644 index 0000000..2ff6041 --- /dev/null +++ b/crates/domain/tests/sidecar/mod.rs @@ -0,0 +1 @@ +mod entities; diff --git a/crates/domain/tests/entities/ingest_session.rs b/crates/domain/tests/storage/entities.rs similarity index 54% rename from crates/domain/tests/entities/ingest_session.rs rename to crates/domain/tests/storage/entities.rs index 94cdbb0..d243b37 100644 --- a/crates/domain/tests/entities/ingest_session.rs +++ b/crates/domain/tests/storage/entities.rs @@ -1,7 +1,36 @@ -use domain::entities::{IngestSession, IngestStatus}; +use domain::entities::{ + IngestSession, IngestStatus, LibraryPath, OwnershipPolicy, + QuotaDefinition, StorageVolume, TimePeriod, UsageType, +}; use domain::errors::DomainError; use domain::value_objects::{Checksum, SystemId}; +// --- StorageVolume --- + +#[test] +fn creates_read_only_volume() { + let vol = StorageVolume::new("archive", "s3://bucket/", false); + assert_eq!(vol.volume_name, "archive"); + assert_eq!(vol.uri_prefix, "s3://bucket/"); + assert!(!vol.is_writable); + assert_eq!(vol.available_bytes, 0); +} + +// --- LibraryPath --- + +#[test] +fn user_owned_path() { + let vol = SystemId::new(); + let owner = SystemId::new(); + let lp = LibraryPath::new_user_owned(vol, "/photos", owner, true); + assert_eq!(lp.ownership_policy, OwnershipPolicy::UserOwned); + assert_eq!(lp.designated_owner_id, Some(owner)); + assert!(lp.is_ingest_destination); + assert_eq!(lp.volume_id, vol); +} + +// --- IngestSession --- + fn make_session() -> IngestSession { let checksum = Checksum::new("a".repeat(64)).unwrap(); IngestSession::new( @@ -36,7 +65,6 @@ fn invalid_transition_rejected() { fn can_fail_from_any_non_terminal() { for target in [IngestStatus::Uploading, IngestStatus::AwaitingProcessing, IngestStatus::Processing] { let mut s = make_session(); - // advance to target state if target == IngestStatus::AwaitingProcessing || target == IngestStatus::Processing { s.advance_to(IngestStatus::AwaitingProcessing).unwrap(); } @@ -47,3 +75,20 @@ fn can_fail_from_any_non_terminal() { assert_eq!(s.status, IngestStatus::Failed); } } + +// --- Quota --- + +#[test] +fn quota_with_rules() { + let mut q = QuotaDefinition::new(SystemId::new()); + assert!(q.is_enforced); + assert!(q.rules.is_empty()); + + q.add_rule(UsageType::StorageBytes, 1_000_000, TimePeriod::Monthly); + q.add_unlimited_rule(UsageType::ApiCalls); + + assert_eq!(q.rules.len(), 2); + assert!(!q.rules[0].is_unlimited); + assert_eq!(q.rules[0].limit_value, 1_000_000); + assert!(q.rules[1].is_unlimited); +} diff --git a/crates/domain/tests/storage/mod.rs b/crates/domain/tests/storage/mod.rs new file mode 100644 index 0000000..bc1f555 --- /dev/null +++ b/crates/domain/tests/storage/mod.rs @@ -0,0 +1,2 @@ +mod entities; +mod services; diff --git a/crates/domain/tests/services/quota_checker.rs b/crates/domain/tests/storage/services.rs similarity index 100% rename from crates/domain/tests/services/quota_checker.rs rename to crates/domain/tests/storage/services.rs