domain: add Organization entities and ports (Album, Tag, Collection, FilterCriteria)

This commit is contained in:
2026-05-31 03:31:33 +02:00
parent ccb61b72d7
commit 1d3060fa12
16 changed files with 280 additions and 3 deletions

View File

@@ -7,10 +7,8 @@ edition = "2024"
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

View File

@@ -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<SystemId>,
pub start_date: Option<DateTimeStamp>,
pub end_date: Option<DateTimeStamp>,
pub entries: Vec<AlbumEntry>,
pub created_at: DateTimeStamp,
}
impl Album {
pub fn new(title: impl Into<String>, 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()
}
}

View File

@@ -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<String>, creator: SystemId, criteria: FilterCriteria) -> Self {
Self {
collection_id: SystemId::new(),
name: name.into(),
creator_user_id: creator,
criteria,
created_at: DateTimeStamp::now(),
}
}
}

View File

@@ -25,3 +25,11 @@ pub use asset_metadata::{AssetMetadata, MetadataSource};
pub use asset_stack::{AssetStack, AssetStackMember, StackMemberRole, StackType}; pub use asset_stack::{AssetStack, AssetStackMember, StackMemberRole, StackType};
pub use derivative_asset::{DerivativeAsset, DerivativeProfile, GenerationStatus}; pub use derivative_asset::{DerivativeAsset, DerivativeProfile, GenerationStatus};
pub use duplicate::{DetectionMethod, DuplicateCandidate, DuplicateGroup, DuplicateStatus}; 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;

View File

@@ -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<String>) -> 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<SystemId>,
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,
}
}
}

View File

@@ -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<Option<Album>, DomainError>;
async fn find_by_creator(&self, creator_id: &SystemId) -> Result<Vec<Album>, DomainError>;
async fn save(&self, album: &Album) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -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<Option<Collection>, DomainError>;
async fn find_by_creator(&self, creator_id: &SystemId) -> Result<Vec<Collection>, DomainError>;
async fn save(&self, collection: &Collection) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -31,3 +31,11 @@ pub use asset_metadata_repo::AssetMetadataRepository;
pub use asset_stack_repo::AssetStackRepository; pub use asset_stack_repo::AssetStackRepository;
pub use derivative_repo::DerivativeRepository; pub use derivative_repo::DerivativeRepository;
pub use duplicate_repo::DuplicateRepository; 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;

View File

@@ -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<Option<Tag>, DomainError>;
async fn find_by_name(&self, name: &str) -> Result<Option<Tag>, DomainError>;
async fn find_tags_for_asset(&self, asset_id: &SystemId) -> Result<Vec<(Tag, AssetTag)>, 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>;
}

View File

@@ -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<FilterCriteria>),
Or(Vec<FilterCriteria>),
Condition(FilterCondition),
}
impl FilterCriteria {
pub fn and(conditions: Vec<FilterCriteria>) -> Self {
Self::And(conditions)
}
pub fn or(conditions: Vec<FilterCriteria>) -> Self {
Self::Or(conditions)
}
pub fn condition(field: impl Into<String>, op: FilterOperator, value: serde_json::Value) -> Self {
Self::Condition(FilterCondition {
field: field.into(),
op,
value,
})
}
}

View File

@@ -1,6 +1,7 @@
mod checksum; mod checksum;
mod date_time_stamp; mod date_time_stamp;
mod email; mod email;
pub mod filter_criteria;
mod password; mod password;
mod structured_data; mod structured_data;
mod system_id; mod system_id;
@@ -8,6 +9,7 @@ mod system_id;
pub use checksum::Checksum; pub use checksum::Checksum;
pub use date_time_stamp::DateTimeStamp; pub use date_time_stamp::DateTimeStamp;
pub use email::Email; pub use email::Email;
pub use filter_criteria::{FilterCondition, FilterCriteria, FilterOperator};
pub use password::PasswordHash; pub use password::PasswordHash;
pub use structured_data::{MetadataValue, StructuredData}; pub use structured_data::{MetadataValue, StructuredData};
pub use system_id::SystemId; pub use system_id::SystemId;

View File

@@ -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(_))));
}

View File

@@ -11,3 +11,5 @@ mod asset_metadata;
mod asset_stack; mod asset_stack;
mod derivative_asset; mod derivative_asset;
mod duplicate; mod duplicate;
mod album;
mod tag;

View File

@@ -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());
}

View File

@@ -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);
}

View File

@@ -1,4 +1,5 @@
mod checksum; mod checksum;
mod date_time_stamp; mod date_time_stamp;
mod filter_criteria;
mod structured_data; mod structured_data;
mod system_id; mod system_id;