diff --git a/crates/domain/src/entities/invite_code.rs b/crates/domain/src/entities/invite_code.rs new file mode 100644 index 0000000..13a21d9 --- /dev/null +++ b/crates/domain/src/entities/invite_code.rs @@ -0,0 +1,45 @@ +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 let Some(exp) = &self.expires_at { + if exp.as_datetime() < &Utc::now() { + return false; + } + } + if let Some(max) = self.max_uses { + if self.use_count >= max { + return false; + } + } + true + } + + pub fn record_use(&mut self) { + self.use_count += 1; + } +} diff --git a/crates/domain/src/entities/mod.rs b/crates/domain/src/entities/mod.rs index 06dfadd..2b794e3 100644 --- a/crates/domain/src/entities/mod.rs +++ b/crates/domain/src/entities/mod.rs @@ -33,3 +33,15 @@ mod collection; pub use album::{Album, AlbumEntry}; pub use tag::{AssetTag, Tag, TagSource}; pub use collection::Collection; + +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; diff --git a/crates/domain/src/entities/share_link.rs b/crates/domain/src/entities/share_link.rs new file mode 100644 index 0000000..5448313 --- /dev/null +++ b/crates/domain/src/entities/share_link.rs @@ -0,0 +1,58 @@ +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 let Some(exp) = &self.expires_at { + if exp.as_datetime() < &Utc::now() { + return false; + } + } + if let Some(max) = self.max_uses { + if 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 new file mode 100644 index 0000000..2a9ef0c --- /dev/null +++ b/crates/domain/src/entities/share_scope.rs @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000..dd4af08 --- /dev/null +++ b/crates/domain/src/entities/share_target.rs @@ -0,0 +1,21 @@ +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/visibility_filter.rs b/crates/domain/src/entities/visibility_filter.rs new file mode 100644 index 0000000..7b9b8ea --- /dev/null +++ b/crates/domain/src/entities/visibility_filter.rs @@ -0,0 +1,28 @@ +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/ports/mod.rs b/crates/domain/src/ports/mod.rs index d872304..1719354 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -39,3 +39,9 @@ mod collection_repo; pub use album_repo::AlbumRepository; pub use tag_repo::TagRepository; pub use collection_repo::CollectionRepository; + +mod share_repo; +mod visibility_filter_repo; + +pub use share_repo::ShareRepository; +pub use visibility_filter_repo::VisibilityFilterRepository; diff --git a/crates/domain/src/ports/share_repo.rs b/crates/domain/src/ports/share_repo.rs new file mode 100644 index 0000000..1ae1fd2 --- /dev/null +++ b/crates/domain/src/ports/share_repo.rs @@ -0,0 +1,24 @@ +use async_trait::async_trait; +use crate::{ + entities::{InviteCode, ShareLink, ShareScope, ShareTarget}, + errors::DomainError, + value_objects::SystemId, +}; + +#[async_trait] +pub trait ShareRepository: Send + Sync { + async fn save_scope(&self, scope: &ShareScope) -> Result<(), DomainError>; + async fn find_scope_by_id(&self, id: &SystemId) -> Result, DomainError>; + async fn find_scopes_for_resource(&self, resource_id: &SystemId) -> Result, DomainError>; + async fn delete_scope(&self, id: &SystemId) -> Result<(), DomainError>; + + async fn save_target(&self, target: &ShareTarget) -> Result<(), DomainError>; + async fn find_targets_for_scope(&self, scope_id: &SystemId) -> Result, DomainError>; + async fn find_targets_for_user(&self, user_id: &SystemId) -> Result, DomainError>; + + async fn save_link(&self, link: &ShareLink) -> Result<(), DomainError>; + async fn find_link_by_token(&self, token: &str) -> Result, DomainError>; + + async fn save_invite(&self, invite: &InviteCode) -> Result<(), DomainError>; + async fn find_invite_by_id(&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 new file mode 100644 index 0000000..0c3b2fc --- /dev/null +++ b/crates/domain/src/ports/visibility_filter_repo.rs @@ -0,0 +1,9 @@ +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/tests/entities/mod.rs b/crates/domain/tests/entities/mod.rs index 9fb6d40..3e2d312 100644 --- a/crates/domain/tests/entities/mod.rs +++ b/crates/domain/tests/entities/mod.rs @@ -13,3 +13,5 @@ mod derivative_asset; mod duplicate; mod album; mod tag; +mod share_scope; +mod share_link; diff --git a/crates/domain/tests/entities/share_link.rs b/crates/domain/tests/entities/share_link.rs new file mode 100644 index 0000000..38faa8e --- /dev/null +++ b/crates/domain/tests/entities/share_link.rs @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..d557028 --- /dev/null +++ b/crates/domain/tests/entities/share_scope.rs @@ -0,0 +1,16 @@ +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()); +}