From 1d3060fa129cd1d2f0d1a6d0914a7a773dacb52c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 03:31:33 +0200 Subject: [PATCH] domain: add Organization entities and ports (Album, Tag, Collection, FilterCriteria) --- crates/domain/Cargo.toml | 4 +- crates/domain/src/entities/album.rs | 64 +++++++++++++++++++ crates/domain/src/entities/collection.rs | 22 +++++++ crates/domain/src/entities/mod.rs | 8 +++ crates/domain/src/entities/tag.rs | 44 +++++++++++++ crates/domain/src/ports/album_repo.rs | 10 +++ crates/domain/src/ports/collection_repo.rs | 10 +++ crates/domain/src/ports/mod.rs | 8 +++ crates/domain/src/ports/tag_repo.rs | 12 ++++ .../src/value_objects/filter_criteria.rs | 43 +++++++++++++ crates/domain/src/value_objects/mod.rs | 2 + crates/domain/tests/entities/album.rs | 27 ++++++++ crates/domain/tests/entities/mod.rs | 2 + crates/domain/tests/entities/tag.rs | 13 ++++ .../tests/value_objects/filter_criteria.rs | 13 ++++ crates/domain/tests/value_objects/mod.rs | 1 + 16 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 crates/domain/src/entities/album.rs create mode 100644 crates/domain/src/entities/collection.rs create mode 100644 crates/domain/src/entities/tag.rs create mode 100644 crates/domain/src/ports/album_repo.rs create mode 100644 crates/domain/src/ports/collection_repo.rs create mode 100644 crates/domain/src/ports/tag_repo.rs create mode 100644 crates/domain/src/value_objects/filter_criteria.rs create mode 100644 crates/domain/tests/entities/album.rs create mode 100644 crates/domain/tests/entities/tag.rs create mode 100644 crates/domain/tests/value_objects/filter_criteria.rs diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 90ddc68..3e91bc8 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -7,10 +7,8 @@ edition = "2024" uuid = { workspace = true } chrono = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } bytes = { workspace = true } futures = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } diff --git a/crates/domain/src/entities/album.rs b/crates/domain/src/entities/album.rs new file mode 100644 index 0000000..2feb67e --- /dev/null +++ b/crates/domain/src/entities/album.rs @@ -0,0 +1,64 @@ +use crate::errors::DomainError; +use crate::value_objects::{DateTimeStamp, SystemId}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AlbumEntry { + pub asset_id: SystemId, + pub sort_order: u32, + pub added_at: DateTimeStamp, + pub added_by_user_id: SystemId, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Album { + pub album_id: SystemId, + pub title: String, + pub description: String, + pub creator_user_id: SystemId, + pub cover_asset_id: Option, + pub start_date: Option, + pub end_date: Option, + pub entries: Vec, + pub created_at: DateTimeStamp, +} + +impl Album { + pub fn new(title: impl Into, creator: SystemId) -> Self { + Self { + album_id: SystemId::new(), + title: title.into(), + description: String::new(), + creator_user_id: creator, + cover_asset_id: None, + start_date: None, + end_date: None, + entries: Vec::new(), + created_at: DateTimeStamp::now(), + } + } + + pub fn add_asset(&mut self, asset_id: SystemId, added_by: SystemId) -> Result<(), DomainError> { + if self.entries.iter().any(|e| e.asset_id == asset_id) { + return Err(DomainError::Conflict("Asset already in album".to_string())); + } + let next_order = self.entries.iter().map(|e| e.sort_order).max().unwrap_or(0) + if self.entries.is_empty() { 0 } else { 1 }; + self.entries.push(AlbumEntry { + asset_id, + sort_order: next_order, + added_at: DateTimeStamp::now(), + added_by_user_id: added_by, + }); + Ok(()) + } + + pub fn remove_asset(&mut self, asset_id: &SystemId) -> Result<(), DomainError> { + let idx = self.entries.iter().position(|e| &e.asset_id == asset_id) + .ok_or_else(|| DomainError::NotFound("Asset not in album".to_string()))?; + self.entries.remove(idx); + Ok(()) + } + + pub fn asset_count(&self) -> usize { + self.entries.len() + } +} diff --git a/crates/domain/src/entities/collection.rs b/crates/domain/src/entities/collection.rs new file mode 100644 index 0000000..75ee678 --- /dev/null +++ b/crates/domain/src/entities/collection.rs @@ -0,0 +1,22 @@ +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/mod.rs b/crates/domain/src/entities/mod.rs index e4a4ffb..06dfadd 100644 --- a/crates/domain/src/entities/mod.rs +++ b/crates/domain/src/entities/mod.rs @@ -25,3 +25,11 @@ 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}; + +mod album; +mod tag; +mod collection; + +pub use album::{Album, AlbumEntry}; +pub use tag::{AssetTag, Tag, TagSource}; +pub use collection::Collection; diff --git a/crates/domain/src/entities/tag.rs b/crates/domain/src/entities/tag.rs new file mode 100644 index 0000000..4e64939 --- /dev/null +++ b/crates/domain/src/entities/tag.rs @@ -0,0 +1,44 @@ +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/ports/album_repo.rs b/crates/domain/src/ports/album_repo.rs new file mode 100644 index 0000000..55d4101 --- /dev/null +++ b/crates/domain/src/ports/album_repo.rs @@ -0,0 +1,10 @@ +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/collection_repo.rs b/crates/domain/src/ports/collection_repo.rs new file mode 100644 index 0000000..15e89c7 --- /dev/null +++ b/crates/domain/src/ports/collection_repo.rs @@ -0,0 +1,10 @@ +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/mod.rs b/crates/domain/src/ports/mod.rs index 3c5dba8..d872304 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -31,3 +31,11 @@ pub use asset_metadata_repo::AssetMetadataRepository; pub use asset_stack_repo::AssetStackRepository; pub use derivative_repo::DerivativeRepository; pub use duplicate_repo::DuplicateRepository; + +mod album_repo; +mod tag_repo; +mod collection_repo; + +pub use album_repo::AlbumRepository; +pub use tag_repo::TagRepository; +pub use collection_repo::CollectionRepository; diff --git a/crates/domain/src/ports/tag_repo.rs b/crates/domain/src/ports/tag_repo.rs new file mode 100644 index 0000000..6d763be --- /dev/null +++ b/crates/domain/src/ports/tag_repo.rs @@ -0,0 +1,12 @@ +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/value_objects/filter_criteria.rs b/crates/domain/src/value_objects/filter_criteria.rs new file mode 100644 index 0000000..8a99278 --- /dev/null +++ b/crates/domain/src/value_objects/filter_criteria.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum FilterOperator { + Equals, + NotEquals, + Contains, + Between, + GreaterThan, + LessThan, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FilterCondition { + pub field: String, + pub op: FilterOperator, + pub value: serde_json::Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum FilterCriteria { + And(Vec), + Or(Vec), + Condition(FilterCondition), +} + +impl FilterCriteria { + pub fn and(conditions: Vec) -> Self { + Self::And(conditions) + } + + pub fn or(conditions: Vec) -> Self { + Self::Or(conditions) + } + + pub fn condition(field: impl Into, op: FilterOperator, value: serde_json::Value) -> Self { + Self::Condition(FilterCondition { + field: field.into(), + op, + value, + }) + } +} diff --git a/crates/domain/src/value_objects/mod.rs b/crates/domain/src/value_objects/mod.rs index 3f5df93..d5a3f26 100644 --- a/crates/domain/src/value_objects/mod.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -1,6 +1,7 @@ mod checksum; mod date_time_stamp; mod email; +pub mod filter_criteria; mod password; mod structured_data; mod system_id; @@ -8,6 +9,7 @@ mod system_id; pub use checksum::Checksum; pub use date_time_stamp::DateTimeStamp; pub use email::Email; +pub use filter_criteria::{FilterCondition, FilterCriteria, FilterOperator}; pub use password::PasswordHash; pub use structured_data::{MetadataValue, StructuredData}; pub use system_id::SystemId; diff --git a/crates/domain/tests/entities/album.rs b/crates/domain/tests/entities/album.rs new file mode 100644 index 0000000..c53d47b --- /dev/null +++ b/crates/domain/tests/entities/album.rs @@ -0,0 +1,27 @@ +use domain::entities::Album; +use domain::errors::DomainError; +use domain::value_objects::SystemId; + +#[test] +fn add_and_remove_asset() { + let mut album = Album::new("Vacation", SystemId::new()); + let asset = SystemId::new(); + let user = SystemId::new(); + + album.add_asset(asset.clone(), user.clone()).unwrap(); + assert_eq!(album.asset_count(), 1); + + album.remove_asset(&asset).unwrap(); + assert_eq!(album.asset_count(), 0); +} + +#[test] +fn cannot_add_duplicate() { + let mut album = Album::new("Vacation", SystemId::new()); + let asset = SystemId::new(); + let user = SystemId::new(); + + album.add_asset(asset.clone(), user.clone()).unwrap(); + let result = album.add_asset(asset, user); + assert!(matches!(result, Err(DomainError::Conflict(_)))); +} diff --git a/crates/domain/tests/entities/mod.rs b/crates/domain/tests/entities/mod.rs index 402eaf5..9fb6d40 100644 --- a/crates/domain/tests/entities/mod.rs +++ b/crates/domain/tests/entities/mod.rs @@ -11,3 +11,5 @@ mod asset_metadata; mod asset_stack; mod derivative_asset; mod duplicate; +mod album; +mod tag; diff --git a/crates/domain/tests/entities/tag.rs b/crates/domain/tests/entities/tag.rs new file mode 100644 index 0000000..0b2460b --- /dev/null +++ b/crates/domain/tests/entities/tag.rs @@ -0,0 +1,13 @@ +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/value_objects/filter_criteria.rs b/crates/domain/tests/value_objects/filter_criteria.rs new file mode 100644 index 0000000..ea9cbc5 --- /dev/null +++ b/crates/domain/tests/value_objects/filter_criteria.rs @@ -0,0 +1,13 @@ +use domain::value_objects::{FilterCriteria, FilterOperator}; + +#[test] +fn serde_roundtrip() { + let criteria = FilterCriteria::and(vec![ + FilterCriteria::condition("rating", FilterOperator::GreaterThan, serde_json::json!(3)), + FilterCriteria::condition("type", FilterOperator::Equals, serde_json::json!("image")), + ]); + + let json = serde_json::to_string(&criteria).unwrap(); + let back: FilterCriteria = serde_json::from_str(&json).unwrap(); + assert_eq!(criteria, back); +} diff --git a/crates/domain/tests/value_objects/mod.rs b/crates/domain/tests/value_objects/mod.rs index 01fcb64..9661636 100644 --- a/crates/domain/tests/value_objects/mod.rs +++ b/crates/domain/tests/value_objects/mod.rs @@ -1,4 +1,5 @@ mod checksum; mod date_time_stamp; +mod filter_criteria; mod structured_data; mod system_id;