From 3c5c4ed9b1bcadbc6da102e2603b7e52fdd09069 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 03:23:34 +0200 Subject: [PATCH] domain: add Storage & Sources entities (StorageVolume, LibraryPath, IngestSession, Quota) --- crates/domain/src/entities/ingest_session.rs | 73 +++++++++++++++ crates/domain/src/entities/library_path.rs | 47 ++++++++++ crates/domain/src/entities/mod.rs | 8 ++ crates/domain/src/entities/quota.rs | 92 +++++++++++++++++++ crates/domain/src/entities/storage_volume.rs | 22 +++++ .../domain/tests/entities/ingest_session.rs | 49 ++++++++++ crates/domain/tests/entities/library_path.rs | 13 +++ crates/domain/tests/entities/mod.rs | 4 + crates/domain/tests/entities/quota.rs | 17 ++++ .../domain/tests/entities/storage_volume.rs | 10 ++ 10 files changed, 335 insertions(+) create mode 100644 crates/domain/src/entities/ingest_session.rs create mode 100644 crates/domain/src/entities/library_path.rs create mode 100644 crates/domain/src/entities/quota.rs create mode 100644 crates/domain/src/entities/storage_volume.rs create mode 100644 crates/domain/tests/entities/ingest_session.rs create mode 100644 crates/domain/tests/entities/library_path.rs create mode 100644 crates/domain/tests/entities/quota.rs create mode 100644 crates/domain/tests/entities/storage_volume.rs diff --git a/crates/domain/src/entities/ingest_session.rs b/crates/domain/src/entities/ingest_session.rs new file mode 100644 index 0000000..5447778 --- /dev/null +++ b/crates/domain/src/entities/ingest_session.rs @@ -0,0 +1,73 @@ +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/library_path.rs b/crates/domain/src/entities/library_path.rs new file mode 100644 index 0000000..fcff282 --- /dev/null +++ b/crates/domain/src/entities/library_path.rs @@ -0,0 +1,47 @@ +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 index 4a56f3d..4ba8f18 100644 --- a/crates/domain/src/entities/mod.rs +++ b/crates/domain/src/entities/mod.rs @@ -2,8 +2,16 @@ pub mod permission; pub mod role; mod user; mod group; +mod storage_volume; +mod library_path; +mod ingest_session; +mod quota; pub use permission::{Permission, PermissionAction, ResourceType}; pub use role::Role; pub use user::User; pub use group::Group; +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}; diff --git a/crates/domain/src/entities/quota.rs b/crates/domain/src/entities/quota.rs new file mode 100644 index 0000000..590a321 --- /dev/null +++ b/crates/domain/src/entities/quota.rs @@ -0,0 +1,92 @@ +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/storage_volume.rs b/crates/domain/src/entities/storage_volume.rs new file mode 100644 index 0000000..48a5d89 --- /dev/null +++ b/crates/domain/src/entities/storage_volume.rs @@ -0,0 +1,22 @@ +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/tests/entities/ingest_session.rs b/crates/domain/tests/entities/ingest_session.rs new file mode 100644 index 0000000..94cdbb0 --- /dev/null +++ b/crates/domain/tests/entities/ingest_session.rs @@ -0,0 +1,49 @@ +use domain::entities::{IngestSession, IngestStatus}; +use domain::errors::DomainError; +use domain::value_objects::{Checksum, SystemId}; + +fn make_session() -> IngestSession { + let checksum = Checksum::new("a".repeat(64)).unwrap(); + IngestSession::new( + SystemId::new(), + "device-1", + "photo.jpg", + checksum, + SystemId::new(), + ) +} + +#[test] +fn valid_state_transitions() { + let mut s = make_session(); + assert_eq!(s.status, IngestStatus::Uploading); + s.advance_to(IngestStatus::AwaitingProcessing).unwrap(); + assert_eq!(s.status, IngestStatus::AwaitingProcessing); + s.advance_to(IngestStatus::Processing).unwrap(); + assert_eq!(s.status, IngestStatus::Processing); + s.advance_to(IngestStatus::Completed).unwrap(); + assert_eq!(s.status, IngestStatus::Completed); +} + +#[test] +fn invalid_transition_rejected() { + let mut s = make_session(); + let result = s.advance_to(IngestStatus::Completed); + assert!(matches!(result, Err(DomainError::Validation(_)))); +} + +#[test] +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(); + } + if target == IngestStatus::Processing { + s.advance_to(IngestStatus::Processing).unwrap(); + } + s.advance_to(IngestStatus::Failed).unwrap(); + assert_eq!(s.status, IngestStatus::Failed); + } +} diff --git a/crates/domain/tests/entities/library_path.rs b/crates/domain/tests/entities/library_path.rs new file mode 100644 index 0000000..174d893 --- /dev/null +++ b/crates/domain/tests/entities/library_path.rs @@ -0,0 +1,13 @@ +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 index ea52b41..633f67e 100644 --- a/crates/domain/tests/entities/mod.rs +++ b/crates/domain/tests/entities/mod.rs @@ -2,3 +2,7 @@ mod group; mod permission; mod role; mod user; +mod storage_volume; +mod library_path; +mod ingest_session; +mod quota; diff --git a/crates/domain/tests/entities/quota.rs b/crates/domain/tests/entities/quota.rs new file mode 100644 index 0000000..b5aecc8 --- /dev/null +++ b/crates/domain/tests/entities/quota.rs @@ -0,0 +1,17 @@ +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/storage_volume.rs b/crates/domain/tests/entities/storage_volume.rs new file mode 100644 index 0000000..77a052d --- /dev/null +++ b/crates/domain/tests/entities/storage_volume.rs @@ -0,0 +1,10 @@ +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); +}