domain: add Organization entities and ports (Album, Tag, Collection, FilterCriteria)
This commit is contained in:
@@ -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 }
|
|
||||||
|
|||||||
64
crates/domain/src/entities/album.rs
Normal file
64
crates/domain/src/entities/album.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/domain/src/entities/collection.rs
Normal file
22
crates/domain/src/entities/collection.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
44
crates/domain/src/entities/tag.rs
Normal file
44
crates/domain/src/entities/tag.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/domain/src/ports/album_repo.rs
Normal file
10
crates/domain/src/ports/album_repo.rs
Normal 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>;
|
||||||
|
}
|
||||||
10
crates/domain/src/ports/collection_repo.rs
Normal file
10
crates/domain/src/ports/collection_repo.rs
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
12
crates/domain/src/ports/tag_repo.rs
Normal file
12
crates/domain/src/ports/tag_repo.rs
Normal 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>;
|
||||||
|
}
|
||||||
43
crates/domain/src/value_objects/filter_criteria.rs
Normal file
43
crates/domain/src/value_objects/filter_criteria.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
27
crates/domain/tests/entities/album.rs
Normal file
27
crates/domain/tests/entities/album.rs
Normal 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(_))));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
13
crates/domain/tests/entities/tag.rs
Normal file
13
crates/domain/tests/entities/tag.rs
Normal 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());
|
||||||
|
}
|
||||||
13
crates/domain/tests/value_objects/filter_criteria.rs
Normal file
13
crates/domain/tests/value_objects/filter_criteria.rs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user