refactor: restructure domain crate by bounded context

This commit is contained in:
2026-05-31 04:44:48 +02:00
parent 2b62d1ec81
commit de93373b43
136 changed files with 2111 additions and 2096 deletions

View File

@@ -0,0 +1,251 @@
use crate::common::errors::DomainError;
use crate::common::value_objects::{Checksum, DateTimeStamp, StructuredData, SystemId};
// --- Asset ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AssetType {
Image,
Video,
LivePhoto,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SourceReference {
pub volume_id: SystemId,
pub relative_path: String,
pub checksum: Checksum,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Asset {
pub asset_id: SystemId,
pub source_reference: SourceReference,
pub asset_type: AssetType,
pub mime_type: String,
pub file_size: u64,
pub is_processed: bool,
pub owner_user_id: SystemId,
pub created_at: DateTimeStamp,
}
impl Asset {
pub fn new(
source_reference: SourceReference,
asset_type: AssetType,
mime_type: impl Into<String>,
file_size: u64,
owner: SystemId,
) -> Self {
Self {
asset_id: SystemId::new(),
source_reference,
asset_type,
mime_type: mime_type.into(),
file_size,
is_processed: false,
owner_user_id: owner,
created_at: DateTimeStamp::now(),
}
}
pub fn mark_processed(&mut self) {
self.is_processed = true;
}
}
// --- AssetMetadata ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub enum MetadataSource {
ExifExtracted,
AiGenerated,
UserEdited,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AssetMetadata {
pub asset_id: SystemId,
pub metadata_source: MetadataSource,
pub data: StructuredData,
pub updated_at: DateTimeStamp,
}
impl AssetMetadata {
pub fn new(asset_id: SystemId, source: MetadataSource, data: StructuredData) -> Self {
Self {
asset_id,
metadata_source: source,
data,
updated_at: DateTimeStamp::now(),
}
}
}
// --- AssetStack ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum StackType {
LivePhoto,
FormatPair,
BurstSequence,
ExposureBracket,
ManualGroup,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum StackMemberRole {
PrimaryDisplay,
HighResSource,
MotionClip,
AlternateFrame,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AssetStackMember {
pub asset_id: SystemId,
pub role: StackMemberRole,
pub sort_order: u32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AssetStack {
pub stack_id: SystemId,
pub stack_type: StackType,
pub primary_asset_id: SystemId,
pub owner_user_id: SystemId,
pub members: Vec<AssetStackMember>,
}
impl AssetStack {
pub fn new(stack_type: StackType, primary_asset_id: SystemId, owner: SystemId) -> Self {
let primary_member = AssetStackMember {
asset_id: primary_asset_id,
role: StackMemberRole::PrimaryDisplay,
sort_order: 0,
};
Self {
stack_id: SystemId::new(),
stack_type,
primary_asset_id,
owner_user_id: owner,
members: vec![primary_member],
}
}
pub fn add_member(&mut self, asset_id: SystemId, role: StackMemberRole) -> Result<(), DomainError> {
if self.members.iter().any(|m| m.asset_id == asset_id) {
return Err(DomainError::Conflict(
"Asset already exists in stack".to_string(),
));
}
let next_order = self.members.iter().map(|m| m.sort_order).max().unwrap_or(0) + 1;
self.members.push(AssetStackMember {
asset_id,
role,
sort_order: next_order,
});
Ok(())
}
}
// --- DerivativeAsset ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DerivativeProfile {
ThumbnailSquare,
ThumbnailLarge,
WebOptimized,
VideoSd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum GenerationStatus {
Pending,
Ready,
Failed,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DerivativeAsset {
pub derivative_id: SystemId,
pub parent_asset_id: SystemId,
pub profile_type: DerivativeProfile,
pub storage_path: String,
pub mime_type: String,
pub file_size: u64,
pub dimensions: (u32, u32),
pub generation_status: GenerationStatus,
}
impl DerivativeAsset {
pub fn new_pending(parent: SystemId, profile: DerivativeProfile, path: impl Into<String>) -> Self {
Self {
derivative_id: SystemId::new(),
parent_asset_id: parent,
profile_type: profile,
storage_path: path.into(),
mime_type: String::new(),
file_size: 0,
dimensions: (0, 0),
generation_status: GenerationStatus::Pending,
}
}
pub fn mark_ready(&mut self, mime_type: impl Into<String>, size: u64, dims: (u32, u32)) {
self.mime_type = mime_type.into();
self.file_size = size;
self.dimensions = dims;
self.generation_status = GenerationStatus::Ready;
}
pub fn mark_failed(&mut self) {
self.generation_status = GenerationStatus::Failed;
}
}
// --- Duplicate ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DetectionMethod {
ExactHash,
PerceptualHash,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DuplicateStatus {
Unresolved,
Resolved,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DuplicateCandidate {
pub asset_id: SystemId,
pub similarity_score: f64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DuplicateGroup {
pub group_id: SystemId,
pub detection_method: DetectionMethod,
pub status: DuplicateStatus,
pub candidates: Vec<DuplicateCandidate>,
}
impl DuplicateGroup {
pub fn new_exact(asset_a: SystemId, asset_b: SystemId) -> Self {
Self {
group_id: SystemId::new(),
detection_method: DetectionMethod::ExactHash,
status: DuplicateStatus::Unresolved,
candidates: vec![
DuplicateCandidate { asset_id: asset_a, similarity_score: 1.0 },
DuplicateCandidate { asset_id: asset_b, similarity_score: 1.0 },
],
}
}
pub fn resolve(&mut self) {
self.status = DuplicateStatus::Resolved;
}
}

View File

@@ -0,0 +1,7 @@
pub mod entities;
pub mod ports;
pub mod services;
pub use entities::*;
pub use ports::*;
pub use services::*;

View File

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use crate::common::errors::DomainError;
use crate::common::value_objects::{Checksum, SystemId};
use super::entities::{
Asset, AssetMetadata, AssetStack, DerivativeAsset, DerivativeProfile,
DuplicateGroup, MetadataSource,
};
// --- AssetRepository ---
#[async_trait]
pub trait AssetRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError>;
async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError>;
async fn find_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError>;
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
// --- AssetMetadataRepository ---
#[async_trait]
pub trait AssetMetadataRepository: Send + Sync {
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetMetadata>, DomainError>;
async fn find_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<Option<AssetMetadata>, DomainError>;
async fn save(&self, metadata: &AssetMetadata) -> Result<(), DomainError>;
async fn delete_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<(), DomainError>;
}
// --- AssetStackRepository ---
#[async_trait]
pub trait AssetStackRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError>;
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>;
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
// --- DerivativeRepository ---
#[async_trait]
pub trait DerivativeRepository: Send + Sync {
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<DerivativeAsset>, DomainError>;
async fn find_by_asset_and_profile(&self, asset_id: &SystemId, profile: DerivativeProfile) -> Result<Option<DerivativeAsset>, DomainError>;
async fn save(&self, derivative: &DerivativeAsset) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
// --- DuplicateRepository ---
#[async_trait]
pub trait DuplicateRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<DuplicateGroup>, DomainError>;
async fn find_unresolved(&self) -> Result<Vec<DuplicateGroup>, DomainError>;
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<DuplicateGroup>, DomainError>;
async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError>;
}

View File

@@ -1,5 +1,5 @@
use crate::entities::{AssetMetadata, MetadataSource}; use super::entities::{AssetMetadata, MetadataSource};
use crate::value_objects::{MetadataValue, StructuredData}; use crate::common::value_objects::{MetadataValue, StructuredData};
/// Merge metadata layers by priority: ExifExtracted < AiGenerated < UserEdited. /// Merge metadata layers by priority: ExifExtracted < AiGenerated < UserEdited.
/// Later (higher-priority) layers overwrite earlier ones. /// Later (higher-priority) layers overwrite earlier ones.

View File

@@ -1,4 +1,4 @@
use crate::value_objects::{DateTimeStamp, SystemId}; use crate::common::value_objects::{DateTimeStamp, SystemId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DomainEvent { pub enum DomainEvent {

View File

@@ -0,0 +1,4 @@
pub mod errors;
pub mod events;
pub mod ports;
pub mod value_objects;

View File

@@ -1,5 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::{errors::DomainError, events::DomainEvent}; use crate::common::errors::DomainError;
use crate::common::events::DomainEvent;
#[async_trait] #[async_trait]
pub trait EventPublisher: Send + Sync { pub trait EventPublisher: Send + Sync {

View File

@@ -1,4 +1,4 @@
use crate::errors::DomainError; use crate::common::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Checksum(String); pub struct Checksum(String);

View File

@@ -1,4 +1,4 @@
use crate::errors::DomainError; use crate::common::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Email(String); pub struct Email(String);
@@ -20,4 +20,3 @@ impl std::fmt::Display for Email {
write!(f, "{}", self.0) write!(f, "{}", self.0)
} }
} }

View File

@@ -1,52 +0,0 @@
use crate::value_objects::{Checksum, DateTimeStamp, SystemId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AssetType {
Image,
Video,
LivePhoto,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SourceReference {
pub volume_id: SystemId,
pub relative_path: String,
pub checksum: Checksum,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Asset {
pub asset_id: SystemId,
pub source_reference: SourceReference,
pub asset_type: AssetType,
pub mime_type: String,
pub file_size: u64,
pub is_processed: bool,
pub owner_user_id: SystemId,
pub created_at: DateTimeStamp,
}
impl Asset {
pub fn new(
source_reference: SourceReference,
asset_type: AssetType,
mime_type: impl Into<String>,
file_size: u64,
owner: SystemId,
) -> Self {
Self {
asset_id: SystemId::new(),
source_reference,
asset_type,
mime_type: mime_type.into(),
file_size,
is_processed: false,
owner_user_id: owner,
created_at: DateTimeStamp::now(),
}
}
pub fn mark_processed(&mut self) {
self.is_processed = true;
}
}

View File

@@ -1,27 +0,0 @@
use crate::value_objects::{DateTimeStamp, StructuredData, SystemId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub enum MetadataSource {
ExifExtracted,
AiGenerated,
UserEdited,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AssetMetadata {
pub asset_id: SystemId,
pub metadata_source: MetadataSource,
pub data: StructuredData,
pub updated_at: DateTimeStamp,
}
impl AssetMetadata {
pub fn new(asset_id: SystemId, source: MetadataSource, data: StructuredData) -> Self {
Self {
asset_id,
metadata_source: source,
data,
updated_at: DateTimeStamp::now(),
}
}
}

View File

@@ -1,67 +0,0 @@
use crate::errors::DomainError;
use crate::value_objects::SystemId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum StackType {
LivePhoto,
FormatPair,
BurstSequence,
ExposureBracket,
ManualGroup,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum StackMemberRole {
PrimaryDisplay,
HighResSource,
MotionClip,
AlternateFrame,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AssetStackMember {
pub asset_id: SystemId,
pub role: StackMemberRole,
pub sort_order: u32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AssetStack {
pub stack_id: SystemId,
pub stack_type: StackType,
pub primary_asset_id: SystemId,
pub owner_user_id: SystemId,
pub members: Vec<AssetStackMember>,
}
impl AssetStack {
pub fn new(stack_type: StackType, primary_asset_id: SystemId, owner: SystemId) -> Self {
let primary_member = AssetStackMember {
asset_id: primary_asset_id,
role: StackMemberRole::PrimaryDisplay,
sort_order: 0,
};
Self {
stack_id: SystemId::new(),
stack_type,
primary_asset_id,
owner_user_id: owner,
members: vec![primary_member],
}
}
pub fn add_member(&mut self, asset_id: SystemId, role: StackMemberRole) -> Result<(), DomainError> {
if self.members.iter().any(|m| m.asset_id == asset_id) {
return Err(DomainError::Conflict(
"Asset already exists in stack".to_string(),
));
}
let next_order = self.members.iter().map(|m| m.sort_order).max().unwrap_or(0) + 1;
self.members.push(AssetStackMember {
asset_id,
role,
sort_order: next_order,
});
Ok(())
}
}

View File

@@ -1,22 +0,0 @@
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

@@ -1,54 +0,0 @@
use crate::value_objects::SystemId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DerivativeProfile {
ThumbnailSquare,
ThumbnailLarge,
WebOptimized,
VideoSd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum GenerationStatus {
Pending,
Ready,
Failed,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DerivativeAsset {
pub derivative_id: SystemId,
pub parent_asset_id: SystemId,
pub profile_type: DerivativeProfile,
pub storage_path: String,
pub mime_type: String,
pub file_size: u64,
pub dimensions: (u32, u32),
pub generation_status: GenerationStatus,
}
impl DerivativeAsset {
pub fn new_pending(parent: SystemId, profile: DerivativeProfile, path: impl Into<String>) -> Self {
Self {
derivative_id: SystemId::new(),
parent_asset_id: parent,
profile_type: profile,
storage_path: path.into(),
mime_type: String::new(),
file_size: 0,
dimensions: (0, 0),
generation_status: GenerationStatus::Pending,
}
}
pub fn mark_ready(&mut self, mime_type: impl Into<String>, size: u64, dims: (u32, u32)) {
self.mime_type = mime_type.into();
self.file_size = size;
self.dimensions = dims;
self.generation_status = GenerationStatus::Ready;
}
pub fn mark_failed(&mut self) {
self.generation_status = GenerationStatus::Failed;
}
}

View File

@@ -1,45 +0,0 @@
use crate::value_objects::SystemId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DetectionMethod {
ExactHash,
PerceptualHash,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum DuplicateStatus {
Unresolved,
Resolved,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DuplicateCandidate {
pub asset_id: SystemId,
pub similarity_score: f64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DuplicateGroup {
pub group_id: SystemId,
pub detection_method: DetectionMethod,
pub status: DuplicateStatus,
pub candidates: Vec<DuplicateCandidate>,
}
impl DuplicateGroup {
pub fn new_exact(asset_a: SystemId, asset_b: SystemId) -> Self {
Self {
group_id: SystemId::new(),
detection_method: DetectionMethod::ExactHash,
status: DuplicateStatus::Unresolved,
candidates: vec![
DuplicateCandidate { asset_id: asset_a, similarity_score: 1.0 },
DuplicateCandidate { asset_id: asset_b, similarity_score: 1.0 },
],
}
}
pub fn resolve(&mut self) {
self.status = DuplicateStatus::Resolved;
}
}

View File

@@ -1,46 +0,0 @@
use std::collections::HashSet;
use crate::errors::DomainError;
use crate::value_objects::SystemId;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Group {
pub group_id: SystemId,
pub name: String,
pub owner_user_id: SystemId,
pub members: HashSet<SystemId>,
}
impl Group {
pub fn new(name: impl Into<String>, owner_user_id: SystemId) -> Self {
let mut members = HashSet::new();
members.insert(owner_user_id);
Self {
group_id: SystemId::new(),
name: name.into(),
owner_user_id,
members,
}
}
pub fn add_member(&mut self, user_id: SystemId) -> Result<(), DomainError> {
if self.members.contains(&user_id) {
return Err(DomainError::Conflict(format!("User {user_id} is already a member")));
}
self.members.insert(user_id);
Ok(())
}
pub fn remove_member(&mut self, user_id: SystemId) -> Result<(), DomainError> {
if user_id == self.owner_user_id {
return Err(DomainError::Validation("Cannot remove the group owner".to_string()));
}
if !self.members.remove(&user_id) {
return Err(DomainError::NotFound(format!("User {user_id} is not a member")));
}
Ok(())
}
pub fn is_member(&self, user_id: &SystemId) -> bool {
self.members.contains(user_id)
}
}

View File

@@ -1,73 +0,0 @@
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<String>,
}
impl IngestSession {
pub fn new(
uploader: SystemId,
device_id: impl Into<String>,
filename: impl Into<String>,
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<String>) {
self.status = IngestStatus::Failed;
self.error_message = Some(message.into());
}
fn is_terminal(&self) -> bool {
matches!(self.status, IngestStatus::Completed | IngestStatus::Failed)
}
}

View File

@@ -1,41 +0,0 @@
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<DateTimeStamp>,
pub max_uses: Option<u32>,
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 self.expires_at.is_some_and(|exp| exp.as_datetime() < &Utc::now()) {
return false;
}
if self.max_uses.is_some_and(|max| self.use_count >= max) {
return false;
}
true
}
pub fn record_use(&mut self) {
self.use_count += 1;
}
}

View File

@@ -1,123 +0,0 @@
use crate::errors::DomainError;
use crate::value_objects::{DateTimeStamp, StructuredData, SystemId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum JobType {
ScanDirectory,
ExtractMetadata,
GenerateDerivative,
SyncSidecar,
DetectDuplicates,
Custom(String),
}
impl PartialEq for JobType {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::ScanDirectory, Self::ScanDirectory) => true,
(Self::ExtractMetadata, Self::ExtractMetadata) => true,
(Self::GenerateDerivative, Self::GenerateDerivative) => true,
(Self::SyncSidecar, Self::SyncSidecar) => true,
(Self::DetectDuplicates, Self::DetectDuplicates) => true,
(Self::Custom(a), Self::Custom(b)) => a == b,
_ => false,
}
}
}
impl Eq for JobType {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum JobStatus {
Queued,
Processing,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Job {
pub job_id: SystemId,
pub job_type: JobType,
pub target_asset_id: Option<SystemId>,
pub batch_id: Option<SystemId>,
pub status: JobStatus,
pub priority: u32,
pub payload: StructuredData,
pub result_data: Option<StructuredData>,
pub retry_count: u32,
pub max_retries: u32,
pub created_at: DateTimeStamp,
pub started_at: Option<DateTimeStamp>,
pub completed_at: Option<DateTimeStamp>,
pub error_message: Option<String>,
}
impl Job {
pub fn new(job_type: JobType, priority: u32, payload: StructuredData) -> Self {
Self {
job_id: SystemId::new(),
job_type,
target_asset_id: None,
batch_id: None,
status: JobStatus::Queued,
priority,
payload,
result_data: None,
retry_count: 0,
max_retries: 3,
created_at: DateTimeStamp::now(),
started_at: None,
completed_at: None,
error_message: None,
}
}
pub fn with_target(mut self, asset_id: SystemId) -> Self {
self.target_asset_id = Some(asset_id);
self
}
pub fn with_batch(mut self, batch_id: SystemId) -> Self {
self.batch_id = Some(batch_id);
self
}
pub fn start(&mut self) -> Result<(), DomainError> {
if self.status != JobStatus::Queued {
return Err(DomainError::Conflict(
format!("Job can only start from Queued, currently {:?}", self.status),
));
}
self.status = JobStatus::Processing;
self.started_at = Some(DateTimeStamp::now());
Ok(())
}
pub fn complete(&mut self, result: StructuredData) {
self.status = JobStatus::Completed;
self.result_data = Some(result);
self.completed_at = Some(DateTimeStamp::now());
}
pub fn fail(&mut self, error: impl Into<String>) {
self.retry_count += 1;
self.error_message = Some(error.into());
self.started_at = None;
if self.retry_count >= self.max_retries {
self.status = JobStatus::Failed;
} else {
self.status = JobStatus::Queued;
}
}
pub fn cancel(&mut self) {
self.status = JobStatus::Cancelled;
self.completed_at = Some(DateTimeStamp::now());
}
pub fn can_retry(&self) -> bool {
self.retry_count < self.max_retries
}
}

View File

@@ -1,59 +0,0 @@
use crate::value_objects::SystemId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum BatchStatus {
InProgress,
CompletedWithErrors,
Completed,
Cancelled,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct JobBatch {
pub batch_id: SystemId,
pub batch_type: String,
pub total_jobs: u32,
pub completed_count: u32,
pub failed_count: u32,
pub status: BatchStatus,
}
impl JobBatch {
pub fn new(batch_type: impl Into<String>, total_jobs: u32) -> Self {
Self {
batch_id: SystemId::new(),
batch_type: batch_type.into(),
total_jobs,
completed_count: 0,
failed_count: 0,
status: BatchStatus::InProgress,
}
}
pub fn record_completion(&mut self) {
self.completed_count += 1;
self.check_finished();
}
pub fn record_failure(&mut self) {
self.failed_count += 1;
self.check_finished();
}
pub fn progress_percent(&self) -> f64 {
if self.total_jobs == 0 {
return 100.0;
}
((self.completed_count + self.failed_count) as f64 / self.total_jobs as f64) * 100.0
}
fn check_finished(&mut self) {
if self.completed_count + self.failed_count >= self.total_jobs {
self.status = if self.failed_count > 0 {
BatchStatus::CompletedWithErrors
} else {
BatchStatus::Completed
};
}
}
}

View File

@@ -1,47 +0,0 @@
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<SystemId>,
}
impl LibraryPath {
pub fn new_user_owned(
volume_id: SystemId,
relative_path: impl Into<String>,
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<String>) -> 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,
}
}
}

View File

@@ -1,74 +0,0 @@
// Identity & Access (Tasks 3-4)
pub mod permission;
pub mod role;
mod user;
mod group;
pub use permission::{Permission, PermissionAction, ResourceType};
pub use role::Role;
pub use user::User;
pub use group::Group;
// Storage & Sources (Task 6)
mod storage_volume;
mod library_path;
mod ingest_session;
mod quota;
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};
// Media Catalog (Task 8)
mod asset;
mod asset_metadata;
mod asset_stack;
mod derivative_asset;
mod duplicate;
pub use asset::{Asset, AssetType, SourceReference};
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};
// Organization (Task 10)
mod album;
mod tag;
mod collection;
pub use album::{Album, AlbumEntry};
pub use tag::{AssetTag, Tag, TagSource};
pub use collection::Collection;
// Sharing (Task 11)
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;
// Sidecar Sync (Task 12)
mod sidecar_record;
mod sidecar_config;
pub use sidecar_record::{SidecarRecord, SyncStatus};
pub use sidecar_config::{ConflictPolicy, SidecarConfig, SyncMode};
// Processing (Task 13)
mod job;
mod job_batch;
mod plugin;
mod processing_pipeline;
pub use job::{Job, JobStatus, JobType};
pub use job_batch::{BatchStatus, JobBatch};
pub use plugin::{Plugin, PluginType};
pub use processing_pipeline::{PipelineStep, ProcessingPipeline};

View File

@@ -1,60 +0,0 @@
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum PermissionAction {
ReadAsset,
ReadMetadata,
ReadLocation,
ReadPerson,
WriteMetadata,
DeleteAsset,
ManageAccess,
ManageUsers,
ManageSystem,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum ResourceType {
Asset,
Album,
Collection,
Person,
Directory,
Global,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Permission {
pub action: PermissionAction,
pub resource_type: ResourceType,
}
impl Permission {
pub fn new(action: PermissionAction, resource_type: ResourceType) -> Self {
Self { action, resource_type }
}
}
pub fn viewer_permissions() -> HashSet<Permission> {
HashSet::from([
Permission::new(PermissionAction::ReadAsset, ResourceType::Global),
Permission::new(PermissionAction::ReadMetadata, ResourceType::Global),
])
}
pub fn contributor_permissions() -> HashSet<Permission> {
let mut perms = viewer_permissions();
perms.insert(Permission::new(PermissionAction::WriteMetadata, ResourceType::Global));
perms
}
pub fn admin_permissions() -> HashSet<Permission> {
let mut perms = contributor_permissions();
perms.insert(Permission::new(PermissionAction::DeleteAsset, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageAccess, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageUsers, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageSystem, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ReadLocation, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ReadPerson, ResourceType::Global));
perms
}

View File

@@ -1,37 +0,0 @@
use crate::value_objects::{StructuredData, SystemId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PluginType {
MediaProcessor,
ScheduledTask,
SidecarWriter,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Plugin {
pub plugin_id: SystemId,
pub name: String,
pub plugin_type: PluginType,
pub is_enabled: bool,
pub configuration: StructuredData,
}
impl Plugin {
pub fn new(name: impl Into<String>, plugin_type: PluginType) -> Self {
Self {
plugin_id: SystemId::new(),
name: name.into(),
plugin_type,
is_enabled: true,
configuration: StructuredData::new(),
}
}
pub fn disable(&mut self) {
self.is_enabled = false;
}
pub fn enable(&mut self) {
self.is_enabled = true;
}
}

View File

@@ -1,35 +0,0 @@
use crate::value_objects::{StructuredData, SystemId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PipelineStep {
pub plugin_id: SystemId,
pub step_order: u32,
pub configuration: StructuredData,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProcessingPipeline {
pub pipeline_id: SystemId,
pub trigger_event: String,
pub steps: Vec<PipelineStep>,
}
impl ProcessingPipeline {
pub fn new(trigger_event: impl Into<String>) -> Self {
Self {
pipeline_id: SystemId::new(),
trigger_event: trigger_event.into(),
steps: Vec::new(),
}
}
pub fn add_step(&mut self, plugin_id: SystemId, config: StructuredData) {
let next_order = self.steps.iter().map(|s| s.step_order).max().unwrap_or(0)
+ if self.steps.is_empty() { 0 } else { 1 };
self.steps.push(PipelineStep {
plugin_id,
step_order: next_order,
configuration: config,
});
}
}

View File

@@ -1,92 +0,0 @@
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<QuotaRule>,
}
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<String>,
) -> Self {
Self {
entry_id: SystemId::new(),
user_id,
usage_type,
consumed_amount: amount,
timestamp: DateTimeStamp::now(),
context: context.into(),
}
}
}

View File

@@ -1,26 +0,0 @@
use std::collections::HashSet;
use crate::value_objects::SystemId;
use super::permission::{Permission, PermissionAction, ResourceType};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Role {
pub role_id: SystemId,
pub name: String,
pub permissions: HashSet<Permission>,
pub is_system_default: bool,
}
impl Role {
pub fn new(name: impl Into<String>, permissions: HashSet<Permission>, is_system_default: bool) -> Self {
Self {
role_id: SystemId::new(),
name: name.into(),
permissions,
is_system_default,
}
}
pub fn has_permission(&self, action: PermissionAction, resource_type: ResourceType) -> bool {
self.permissions.contains(&Permission::new(action, resource_type))
}
}

View File

@@ -1,54 +0,0 @@
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<DateTimeStamp>,
pub access_level: LinkAccessLevel,
pub is_active: bool,
pub max_uses: Option<u32>,
pub use_count: u32,
}
impl ShareLink {
pub fn new(scope_id: SystemId, token: impl Into<String>, 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 self.expires_at.is_some_and(|exp| exp.as_datetime() < &Utc::now()) {
return false;
}
if self.max_uses.is_some_and(|max| 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;
}
}

View File

@@ -1,56 +0,0 @@
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<DateTimeStamp>,
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,
}
}
}

View File

@@ -1,21 +0,0 @@
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 }
}
}

View File

@@ -1,30 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SyncMode {
Auto,
Scheduled,
Manual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ConflictPolicy {
DbWins,
FileWins,
RequireUserDecision,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SidecarConfig {
pub export_base_path: String,
pub sync_mode: SyncMode,
pub conflict_resolution_policy: ConflictPolicy,
}
impl Default for SidecarConfig {
fn default() -> Self {
Self {
export_base_path: "/kphotos/sidecars".to_string(),
sync_mode: SyncMode::Auto,
conflict_resolution_policy: ConflictPolicy::DbWins,
}
}
}

View File

@@ -1,22 +0,0 @@
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<String>, uri_prefix: impl Into<String>, is_writable: bool) -> Self {
Self {
volume_id: SystemId::new(),
volume_name: name.into(),
uri_prefix: uri_prefix.into(),
is_writable,
available_bytes: 0,
}
}
}

View File

@@ -1,44 +0,0 @@
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

@@ -1,23 +0,0 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{Email, PasswordHash, SystemId};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
pub id: SystemId,
pub username: String,
pub email: Email,
pub password_hash: PasswordHash,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(username: impl Into<String>, email: Email, password_hash: PasswordHash) -> Self {
Self {
id: SystemId::new(),
username: username.into(),
email,
password_hash,
created_at: Utc::now(),
}
}
}

View File

@@ -1,28 +0,0 @@
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<String>,
}
impl VisibilityFilter {
pub fn new(scope_id: SystemId, role_id: SystemId, hidden_fields: Vec<String>) -> 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
}
}

View File

@@ -0,0 +1,158 @@
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use crate::common::errors::DomainError;
use crate::common::value_objects::{Email, PasswordHash, SystemId};
// --- Permission ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum PermissionAction {
ReadAsset,
ReadMetadata,
ReadLocation,
ReadPerson,
WriteMetadata,
DeleteAsset,
ManageAccess,
ManageUsers,
ManageSystem,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum ResourceType {
Asset,
Album,
Collection,
Person,
Directory,
Global,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct Permission {
pub action: PermissionAction,
pub resource_type: ResourceType,
}
impl Permission {
pub fn new(action: PermissionAction, resource_type: ResourceType) -> Self {
Self { action, resource_type }
}
}
pub fn viewer_permissions() -> HashSet<Permission> {
HashSet::from([
Permission::new(PermissionAction::ReadAsset, ResourceType::Global),
Permission::new(PermissionAction::ReadMetadata, ResourceType::Global),
])
}
pub fn contributor_permissions() -> HashSet<Permission> {
let mut perms = viewer_permissions();
perms.insert(Permission::new(PermissionAction::WriteMetadata, ResourceType::Global));
perms
}
pub fn admin_permissions() -> HashSet<Permission> {
let mut perms = contributor_permissions();
perms.insert(Permission::new(PermissionAction::DeleteAsset, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageAccess, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageUsers, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ManageSystem, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ReadLocation, ResourceType::Global));
perms.insert(Permission::new(PermissionAction::ReadPerson, ResourceType::Global));
perms
}
// --- Role ---
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Role {
pub role_id: SystemId,
pub name: String,
pub permissions: HashSet<Permission>,
pub is_system_default: bool,
}
impl Role {
pub fn new(name: impl Into<String>, permissions: HashSet<Permission>, is_system_default: bool) -> Self {
Self {
role_id: SystemId::new(),
name: name.into(),
permissions,
is_system_default,
}
}
pub fn has_permission(&self, action: PermissionAction, resource_type: ResourceType) -> bool {
self.permissions.contains(&Permission::new(action, resource_type))
}
}
// --- User ---
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct User {
pub id: SystemId,
pub username: String,
pub email: Email,
pub password_hash: PasswordHash,
pub created_at: DateTime<Utc>,
}
impl User {
pub fn new(username: impl Into<String>, email: Email, password_hash: PasswordHash) -> Self {
Self {
id: SystemId::new(),
username: username.into(),
email,
password_hash,
created_at: Utc::now(),
}
}
}
// --- Group ---
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Group {
pub group_id: SystemId,
pub name: String,
pub owner_user_id: SystemId,
pub members: HashSet<SystemId>,
}
impl Group {
pub fn new(name: impl Into<String>, owner_user_id: SystemId) -> Self {
let mut members = HashSet::new();
members.insert(owner_user_id);
Self {
group_id: SystemId::new(),
name: name.into(),
owner_user_id,
members,
}
}
pub fn add_member(&mut self, user_id: SystemId) -> Result<(), DomainError> {
if self.members.contains(&user_id) {
return Err(DomainError::Conflict(format!("User {user_id} is already a member")));
}
self.members.insert(user_id);
Ok(())
}
pub fn remove_member(&mut self, user_id: SystemId) -> Result<(), DomainError> {
if user_id == self.owner_user_id {
return Err(DomainError::Validation("Cannot remove the group owner".to_string()));
}
if !self.members.remove(&user_id) {
return Err(DomainError::NotFound(format!("User {user_id} is not a member")));
}
Ok(())
}
pub fn is_member(&self, user_id: &SystemId) -> bool {
self.members.contains(user_id)
}
}

View File

@@ -0,0 +1,7 @@
pub mod entities;
pub mod ports;
pub mod services;
pub use entities::*;
pub use ports::*;
pub use services::*;

View File

@@ -0,0 +1,50 @@
use async_trait::async_trait;
use crate::common::errors::DomainError;
use crate::common::value_objects::{Email, PasswordHash, SystemId};
use super::entities::{Group, Role, User};
// --- UserRepository ---
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &str) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
// --- RoleRepository ---
#[async_trait]
pub trait RoleRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Role>, DomainError>;
async fn find_by_name(&self, name: &str) -> Result<Option<Role>, DomainError>;
async fn find_defaults(&self) -> Result<Vec<Role>, DomainError>;
async fn save(&self, role: &Role) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
// --- GroupRepository ---
#[async_trait]
pub trait GroupRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Group>, DomainError>;
async fn find_by_user(&self, user_id: &SystemId) -> Result<Vec<Group>, DomainError>;
async fn save(&self, group: &Group) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
// --- Auth ---
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait TokenIssuer: Send + Sync {
async fn issue(&self, user_id: &SystemId, role: &str) -> Result<String, DomainError>;
async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError>;
}

View File

@@ -1,5 +1,5 @@
use std::collections::HashSet; use std::collections::HashSet;
use crate::entities::{Permission, PermissionAction, ResourceType, Role}; use super::entities::{Permission, PermissionAction, ResourceType, Role};
pub struct PermissionChecker; pub struct PermissionChecker;

View File

@@ -1,6 +1,57 @@
pub mod entities; pub mod common;
pub mod errors; pub mod identity;
pub mod events; pub mod storage;
pub mod ports; pub mod catalog;
pub mod services; pub mod organization;
pub mod value_objects; pub mod sharing;
pub mod sidecar;
pub mod processing;
// Facade — old import paths still work
pub mod errors {
pub use crate::common::errors::*;
}
pub mod events {
pub use crate::common::events::*;
}
pub mod value_objects {
pub use crate::common::value_objects::*;
}
pub mod entities {
pub use crate::identity::entities::*;
pub use crate::storage::entities::*;
pub use crate::catalog::entities::*;
pub use crate::organization::entities::*;
pub use crate::sharing::entities::*;
pub use crate::sidecar::entities::*;
pub use crate::processing::entities::*;
// Sub-module alias for `domain::entities::permission::` imports
pub mod permission {
pub use crate::identity::entities::{
Permission, PermissionAction, ResourceType,
viewer_permissions, contributor_permissions, admin_permissions,
};
}
}
pub mod ports {
pub use crate::common::ports::*;
pub use crate::identity::ports::*;
pub use crate::storage::ports::*;
pub use crate::catalog::ports::*;
pub use crate::organization::ports::*;
pub use crate::sharing::ports::*;
pub use crate::sidecar::ports::*;
pub use crate::processing::ports::*;
}
pub mod services {
pub mod permission_service {
pub use crate::identity::services::*;
}
pub mod quota_checker {
pub use crate::storage::services::*;
}
pub mod metadata_resolver {
pub use crate::catalog::services::*;
}
}

View File

@@ -1,5 +1,7 @@
use crate::errors::DomainError; use crate::common::errors::DomainError;
use crate::value_objects::{DateTimeStamp, SystemId}; use crate::common::value_objects::{DateTimeStamp, FilterCriteria, SystemId};
// --- Album ---
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AlbumEntry { pub struct AlbumEntry {
@@ -62,3 +64,71 @@ impl Album {
self.entries.len() self.entries.len()
} }
} }
// --- Tag ---
#[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,
}
}
}
// --- Collection ---
#[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

@@ -0,0 +1,5 @@
pub mod entities;
pub mod ports;
pub use entities::*;
pub use ports::*;

View File

@@ -0,0 +1,36 @@
use async_trait::async_trait;
use crate::common::errors::DomainError;
use crate::common::value_objects::SystemId;
use super::entities::{Album, AssetTag, Collection, Tag};
// --- AlbumRepository ---
#[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>;
}
// --- TagRepository ---
#[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>;
}
// --- CollectionRepository ---
#[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

@@ -1,10 +0,0 @@
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

@@ -1,10 +0,0 @@
use async_trait::async_trait;
use crate::{entities::{AssetMetadata, MetadataSource}, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait AssetMetadataRepository: Send + Sync {
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetMetadata>, DomainError>;
async fn find_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<Option<AssetMetadata>, DomainError>;
async fn save(&self, metadata: &AssetMetadata) -> Result<(), DomainError>;
async fn delete_by_asset_and_source(&self, asset_id: &SystemId, source: MetadataSource) -> Result<(), DomainError>;
}

View File

@@ -1,11 +0,0 @@
use async_trait::async_trait;
use crate::{entities::Asset, errors::DomainError, value_objects::{Checksum, SystemId}};
#[async_trait]
pub trait AssetRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError>;
async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError>;
async fn find_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError>;
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,10 +0,0 @@
use async_trait::async_trait;
use crate::{entities::AssetStack, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait AssetStackRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError>;
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>;
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,14 +0,0 @@
use async_trait::async_trait;
use crate::{errors::DomainError, value_objects::{PasswordHash, SystemId}};
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, password: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait TokenIssuer: Send + Sync {
async fn issue(&self, user_id: &SystemId, role: &str) -> Result<String, DomainError>;
async fn verify(&self, token: &str) -> Result<(SystemId, String), DomainError>;
}

View File

@@ -1,10 +0,0 @@
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

@@ -1,10 +0,0 @@
use async_trait::async_trait;
use crate::{entities::{DerivativeAsset, DerivativeProfile}, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait DerivativeRepository: Send + Sync {
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<DerivativeAsset>, DomainError>;
async fn find_by_asset_and_profile(&self, asset_id: &SystemId, profile: DerivativeProfile) -> Result<Option<DerivativeAsset>, DomainError>;
async fn save(&self, derivative: &DerivativeAsset) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,10 +0,0 @@
use async_trait::async_trait;
use crate::{entities::DuplicateGroup, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait DuplicateRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<DuplicateGroup>, DomainError>;
async fn find_unresolved(&self) -> Result<Vec<DuplicateGroup>, DomainError>;
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<DuplicateGroup>, DomainError>;
async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError>;
}

View File

@@ -1,20 +0,0 @@
use async_trait::async_trait;
use bytes::Bytes;
use crate::errors::DomainError;
#[derive(Debug, Clone)]
pub struct FileEntry {
pub path: String,
pub size_bytes: u64,
pub is_directory: bool,
}
#[async_trait]
pub trait FileStoragePort: Send + Sync {
async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError>;
async fn read_file(&self, path: &str) -> Result<Bytes, DomainError>;
async fn delete_file(&self, path: &str) -> Result<(), DomainError>;
async fn list_directory(&self, path: &str) -> Result<Vec<FileEntry>, DomainError>;
async fn file_exists(&self, path: &str) -> Result<bool, DomainError>;
async fn available_space(&self) -> Result<u64, DomainError>;
}

View File

@@ -1,10 +0,0 @@
use async_trait::async_trait;
use crate::{entities::Group, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait GroupRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Group>, DomainError>;
async fn find_by_user(&self, user_id: &SystemId) -> Result<Vec<Group>, DomainError>;
async fn save(&self, group: &Group) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,9 +0,0 @@
use async_trait::async_trait;
use crate::{entities::IngestSession, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait IngestSessionRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<IngestSession>, DomainError>;
async fn find_by_user(&self, user_id: &SystemId) -> Result<Vec<IngestSession>, DomainError>;
async fn save(&self, session: &IngestSession) -> Result<(), DomainError>;
}

View File

@@ -1,8 +0,0 @@
use async_trait::async_trait;
use crate::{entities::JobBatch, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait JobBatchRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<JobBatch>, DomainError>;
async fn save(&self, batch: &JobBatch) -> Result<(), DomainError>;
}

View File

@@ -1,10 +0,0 @@
use async_trait::async_trait;
use crate::{entities::Job, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait JobRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Job>, DomainError>;
async fn find_next_queued(&self) -> Result<Option<Job>, DomainError>;
async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError>;
async fn save(&self, job: &Job) -> Result<(), DomainError>;
}

View File

@@ -1,11 +0,0 @@
use async_trait::async_trait;
use crate::{entities::LibraryPath, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait LibraryPathRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<LibraryPath>, DomainError>;
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError>;
async fn find_ingest_destinations(&self, owner_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError>;
async fn save(&self, path: &LibraryPath) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,74 +0,0 @@
// Identity & Access (Tasks 4-5)
mod auth;
mod event_publisher;
mod group_repo;
mod role_repo;
mod storage;
mod user_repo;
pub use auth::{PasswordHasher, TokenIssuer};
pub use event_publisher::EventPublisher;
pub use group_repo::GroupRepository;
pub use role_repo::RoleRepository;
pub use storage::{DataStream, StoragePort, StorageReader, StorageWriter};
pub use user_repo::UserRepository;
// Storage & Sources (Task 7)
mod storage_volume_repo;
mod library_path_repo;
mod ingest_session_repo;
mod quota_repo;
mod file_storage;
pub use storage_volume_repo::StorageVolumeRepository;
pub use library_path_repo::LibraryPathRepository;
pub use ingest_session_repo::IngestSessionRepository;
pub use quota_repo::{QuotaRepository, UsageLedgerRepository};
pub use file_storage::{FileEntry, FileStoragePort};
// Media Catalog (Task 9)
mod asset_repo;
mod asset_metadata_repo;
mod asset_stack_repo;
mod derivative_repo;
mod duplicate_repo;
pub use asset_repo::AssetRepository;
pub use asset_metadata_repo::AssetMetadataRepository;
pub use asset_stack_repo::AssetStackRepository;
pub use derivative_repo::DerivativeRepository;
pub use duplicate_repo::DuplicateRepository;
// Organization (Task 10)
mod album_repo;
mod tag_repo;
mod collection_repo;
pub use album_repo::AlbumRepository;
pub use tag_repo::TagRepository;
pub use collection_repo::CollectionRepository;
// Sharing (Task 11)
mod share_repo;
mod visibility_filter_repo;
pub use share_repo::ShareRepository;
pub use visibility_filter_repo::VisibilityFilterRepository;
// Sidecar Sync (Task 12)
mod sidecar_repo;
mod sidecar_writer;
pub use sidecar_repo::SidecarRepository;
pub use sidecar_writer::SidecarWriterPort;
// Processing (Task 13)
mod job_repo;
mod job_batch_repo;
mod plugin_repo;
mod pipeline_repo;
pub use job_repo::JobRepository;
pub use job_batch_repo::JobBatchRepository;
pub use plugin_repo::PluginRepository;
pub use pipeline_repo::PipelineRepository;

View File

@@ -1,9 +0,0 @@
use async_trait::async_trait;
use crate::{entities::ProcessingPipeline, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait PipelineRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<ProcessingPipeline>, DomainError>;
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError>;
async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>;
}

View File

@@ -1,9 +0,0 @@
use async_trait::async_trait;
use crate::{entities::Plugin, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait PluginRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Plugin>, DomainError>;
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError>;
async fn save(&self, plugin: &Plugin) -> Result<(), DomainError>;
}

View File

@@ -1,24 +0,0 @@
use async_trait::async_trait;
use crate::{
entities::{QuotaDefinition, UsageLedgerEntry, UsageType},
errors::DomainError,
value_objects::{DateTimeStamp, SystemId},
};
#[async_trait]
pub trait QuotaRepository: Send + Sync {
async fn find_by_owner(&self, owner_id: &SystemId) -> Result<Option<QuotaDefinition>, DomainError>;
async fn save(&self, quota: &QuotaDefinition) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
#[async_trait]
pub trait UsageLedgerRepository: Send + Sync {
async fn record(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError>;
async fn sum_usage(
&self,
user_id: &SystemId,
usage_type: UsageType,
since: Option<DateTimeStamp>,
) -> Result<u64, DomainError>;
}

View File

@@ -1,11 +0,0 @@
use async_trait::async_trait;
use crate::{entities::Role, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait RoleRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Role>, DomainError>;
async fn find_by_name(&self, name: &str) -> Result<Option<Role>, DomainError>;
async fn find_defaults(&self) -> Result<Vec<Role>, DomainError>;
async fn save(&self, role: &Role) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,10 +0,0 @@
use async_trait::async_trait;
use crate::{entities::{SidecarRecord, SyncStatus}, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait SidecarRepository: Send + Sync {
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Option<SidecarRecord>, DomainError>;
async fn find_by_status(&self, status: SyncStatus) -> Result<Vec<SidecarRecord>, DomainError>;
async fn save(&self, record: &SidecarRecord) -> Result<(), DomainError>;
async fn delete(&self, asset_id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,9 +0,0 @@
use async_trait::async_trait;
use crate::{errors::DomainError, value_objects::StructuredData};
#[async_trait]
pub trait SidecarWriterPort: Send + Sync {
fn format_name(&self) -> &str;
async fn write_sidecar(&self, data: &StructuredData, path: &str) -> Result<(), DomainError>;
async fn read_sidecar(&self, path: &str) -> Result<StructuredData, DomainError>;
}

View File

@@ -1,52 +0,0 @@
use async_trait::async_trait;
use bytes::Bytes;
use futures::stream::{self, BoxStream, StreamExt};
use crate::errors::DomainError;
pub type DataStream = BoxStream<'static, Result<Bytes, DomainError>>;
/// Read operations on object storage. Keys are full paths relative to the adapter root.
#[async_trait]
pub trait StorageReader: Send + Sync {
/// Returns the content of `key` as a stream. Returns `DomainError::NotFound` if absent.
async fn get(&self, key: &str) -> Result<DataStream, DomainError>;
/// Lists all keys whose path begins with `prefix`, or all keys when `prefix` is `None`.
/// Returned keys are **full paths from the adapter root**, not relative to `prefix`.
/// Example: `list(Some("docs"))` returns `["docs/readme.txt"]`, not `["readme.txt"]`.
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, DomainError>;
/// Convenience: reads the entire content of `key` into memory. Wraps `get`.
async fn get_bytes(&self, key: &str) -> Result<Bytes, DomainError> {
let mut stream = self.get(key).await?;
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
buf.extend_from_slice(&chunk?);
}
Ok(Bytes::from(buf))
}
}
/// Write operations on object storage.
#[async_trait]
pub trait StorageWriter: Send + Sync {
/// Stores `data` at `key`. Overwrites any existing content at that key silently.
async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError>;
/// Deletes `key`. Returns `Ok(())` even if the key does not exist (idempotent).
async fn delete(&self, key: &str) -> Result<(), DomainError>;
/// Convenience: stores an in-memory buffer at `key`. Wraps `put`.
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), DomainError> {
self.put(key, Box::pin(stream::once(async move { Ok(data) }))).await
}
}
/// Combined read + write storage interface.
///
/// **Usage note:** `Arc<dyn StoragePort>` is the intended DI type everywhere.
/// `StorageReader` and `StorageWriter` exist for implementation clarity, but Rust does not
/// support narrowing `Arc<dyn StoragePort>` to `Arc<dyn StorageReader>` at runtime.
/// Inject `Arc<dyn StoragePort>` into constructors and pass `.clone()` from the factory.
pub trait StoragePort: StorageReader + StorageWriter {}
impl<T: StorageReader + StorageWriter> StoragePort for T {}

View File

@@ -1,10 +0,0 @@
use async_trait::async_trait;
use crate::{entities::StorageVolume, errors::DomainError, value_objects::SystemId};
#[async_trait]
pub trait StorageVolumeRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<StorageVolume>, DomainError>;
async fn find_all(&self) -> Result<Vec<StorageVolume>, DomainError>;
async fn save(&self, volume: &StorageVolume) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,12 +0,0 @@
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

@@ -1,11 +0,0 @@
use async_trait::async_trait;
use crate::{entities::User, errors::DomainError, value_objects::{Email, SystemId}};
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &str) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,9 +0,0 @@
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<Option<VisibilityFilter>, DomainError>;
async fn save(&self, filter: &VisibilityFilter) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -0,0 +1,259 @@
use crate::common::errors::DomainError;
use crate::common::value_objects::{DateTimeStamp, StructuredData, SystemId};
// --- Job ---
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum JobType {
ScanDirectory,
ExtractMetadata,
GenerateDerivative,
SyncSidecar,
DetectDuplicates,
Custom(String),
}
impl PartialEq for JobType {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::ScanDirectory, Self::ScanDirectory) => true,
(Self::ExtractMetadata, Self::ExtractMetadata) => true,
(Self::GenerateDerivative, Self::GenerateDerivative) => true,
(Self::SyncSidecar, Self::SyncSidecar) => true,
(Self::DetectDuplicates, Self::DetectDuplicates) => true,
(Self::Custom(a), Self::Custom(b)) => a == b,
_ => false,
}
}
}
impl Eq for JobType {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum JobStatus {
Queued,
Processing,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Job {
pub job_id: SystemId,
pub job_type: JobType,
pub target_asset_id: Option<SystemId>,
pub batch_id: Option<SystemId>,
pub status: JobStatus,
pub priority: u32,
pub payload: StructuredData,
pub result_data: Option<StructuredData>,
pub retry_count: u32,
pub max_retries: u32,
pub created_at: DateTimeStamp,
pub started_at: Option<DateTimeStamp>,
pub completed_at: Option<DateTimeStamp>,
pub error_message: Option<String>,
}
impl Job {
pub fn new(job_type: JobType, priority: u32, payload: StructuredData) -> Self {
Self {
job_id: SystemId::new(),
job_type,
target_asset_id: None,
batch_id: None,
status: JobStatus::Queued,
priority,
payload,
result_data: None,
retry_count: 0,
max_retries: 3,
created_at: DateTimeStamp::now(),
started_at: None,
completed_at: None,
error_message: None,
}
}
pub fn with_target(mut self, asset_id: SystemId) -> Self {
self.target_asset_id = Some(asset_id);
self
}
pub fn with_batch(mut self, batch_id: SystemId) -> Self {
self.batch_id = Some(batch_id);
self
}
pub fn start(&mut self) -> Result<(), DomainError> {
if self.status != JobStatus::Queued {
return Err(DomainError::Conflict(
format!("Job can only start from Queued, currently {:?}", self.status),
));
}
self.status = JobStatus::Processing;
self.started_at = Some(DateTimeStamp::now());
Ok(())
}
pub fn complete(&mut self, result: StructuredData) {
self.status = JobStatus::Completed;
self.result_data = Some(result);
self.completed_at = Some(DateTimeStamp::now());
}
pub fn fail(&mut self, error: impl Into<String>) {
self.retry_count += 1;
self.error_message = Some(error.into());
self.started_at = None;
if self.retry_count >= self.max_retries {
self.status = JobStatus::Failed;
} else {
self.status = JobStatus::Queued;
}
}
pub fn cancel(&mut self) {
self.status = JobStatus::Cancelled;
self.completed_at = Some(DateTimeStamp::now());
}
pub fn can_retry(&self) -> bool {
self.retry_count < self.max_retries
}
}
// --- JobBatch ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum BatchStatus {
InProgress,
CompletedWithErrors,
Completed,
Cancelled,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct JobBatch {
pub batch_id: SystemId,
pub batch_type: String,
pub total_jobs: u32,
pub completed_count: u32,
pub failed_count: u32,
pub status: BatchStatus,
}
impl JobBatch {
pub fn new(batch_type: impl Into<String>, total_jobs: u32) -> Self {
Self {
batch_id: SystemId::new(),
batch_type: batch_type.into(),
total_jobs,
completed_count: 0,
failed_count: 0,
status: BatchStatus::InProgress,
}
}
pub fn record_completion(&mut self) {
self.completed_count += 1;
self.check_finished();
}
pub fn record_failure(&mut self) {
self.failed_count += 1;
self.check_finished();
}
pub fn progress_percent(&self) -> f64 {
if self.total_jobs == 0 {
return 100.0;
}
((self.completed_count + self.failed_count) as f64 / self.total_jobs as f64) * 100.0
}
fn check_finished(&mut self) {
if self.completed_count + self.failed_count >= self.total_jobs {
self.status = if self.failed_count > 0 {
BatchStatus::CompletedWithErrors
} else {
BatchStatus::Completed
};
}
}
}
// --- Plugin ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PluginType {
MediaProcessor,
ScheduledTask,
SidecarWriter,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Plugin {
pub plugin_id: SystemId,
pub name: String,
pub plugin_type: PluginType,
pub is_enabled: bool,
pub configuration: StructuredData,
}
impl Plugin {
pub fn new(name: impl Into<String>, plugin_type: PluginType) -> Self {
Self {
plugin_id: SystemId::new(),
name: name.into(),
plugin_type,
is_enabled: true,
configuration: StructuredData::new(),
}
}
pub fn disable(&mut self) {
self.is_enabled = false;
}
pub fn enable(&mut self) {
self.is_enabled = true;
}
}
// --- ProcessingPipeline ---
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PipelineStep {
pub plugin_id: SystemId,
pub step_order: u32,
pub configuration: StructuredData,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProcessingPipeline {
pub pipeline_id: SystemId,
pub trigger_event: String,
pub steps: Vec<PipelineStep>,
}
impl ProcessingPipeline {
pub fn new(trigger_event: impl Into<String>) -> Self {
Self {
pipeline_id: SystemId::new(),
trigger_event: trigger_event.into(),
steps: Vec::new(),
}
}
pub fn add_step(&mut self, plugin_id: SystemId, config: StructuredData) {
let next_order = self.steps.iter().map(|s| s.step_order).max().unwrap_or(0)
+ if self.steps.is_empty() { 0 } else { 1 };
self.steps.push(PipelineStep {
plugin_id,
step_order: next_order,
configuration: config,
});
}
}

View File

@@ -0,0 +1,5 @@
pub mod entities;
pub mod ports;
pub use entities::*;
pub use ports::*;

View File

@@ -0,0 +1,40 @@
use async_trait::async_trait;
use crate::common::errors::DomainError;
use crate::common::value_objects::SystemId;
use super::entities::{Job, JobBatch, Plugin, ProcessingPipeline};
// --- JobRepository ---
#[async_trait]
pub trait JobRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Job>, DomainError>;
async fn find_next_queued(&self) -> Result<Option<Job>, DomainError>;
async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError>;
async fn save(&self, job: &Job) -> Result<(), DomainError>;
}
// --- JobBatchRepository ---
#[async_trait]
pub trait JobBatchRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<JobBatch>, DomainError>;
async fn save(&self, batch: &JobBatch) -> Result<(), DomainError>;
}
// --- PluginRepository ---
#[async_trait]
pub trait PluginRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Plugin>, DomainError>;
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError>;
async fn save(&self, plugin: &Plugin) -> Result<(), DomainError>;
}
// --- PipelineRepository ---
#[async_trait]
pub trait PipelineRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<ProcessingPipeline>, DomainError>;
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError>;
async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>;
}

View File

@@ -1,3 +0,0 @@
pub mod metadata_resolver;
pub mod permission_service;
pub mod quota_checker;

View File

@@ -0,0 +1,204 @@
use chrono::Utc;
use crate::common::value_objects::{DateTimeStamp, StructuredData, SystemId};
// --- ShareScope ---
#[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<DateTimeStamp>,
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,
}
}
}
// --- ShareTarget ---
#[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 }
}
}
// --- ShareLink ---
#[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<DateTimeStamp>,
pub access_level: LinkAccessLevel,
pub is_active: bool,
pub max_uses: Option<u32>,
pub use_count: u32,
}
impl ShareLink {
pub fn new(scope_id: SystemId, token: impl Into<String>, 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 self.expires_at.is_some_and(|exp| exp.as_datetime() < &Utc::now()) {
return false;
}
if self.max_uses.is_some_and(|max| 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;
}
}
// --- InviteCode ---
#[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<DateTimeStamp>,
pub max_uses: Option<u32>,
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 self.expires_at.is_some_and(|exp| exp.as_datetime() < &Utc::now()) {
return false;
}
if self.max_uses.is_some_and(|max| self.use_count >= max) {
return false;
}
true
}
pub fn record_use(&mut self) {
self.use_count += 1;
}
}
// --- VisibilityFilter ---
#[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<String>,
}
impl VisibilityFilter {
pub fn new(scope_id: SystemId, role_id: SystemId, hidden_fields: Vec<String>) -> 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
}
}

View File

@@ -0,0 +1,5 @@
pub mod entities;
pub mod ports;
pub use entities::*;
pub use ports::*;

View File

@@ -1,9 +1,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::{ use crate::common::errors::DomainError;
entities::{InviteCode, ShareLink, ShareScope, ShareTarget}, use crate::common::value_objects::SystemId;
errors::DomainError, use super::entities::{InviteCode, ShareLink, ShareScope, ShareTarget, VisibilityFilter};
value_objects::SystemId,
}; // --- ShareRepository ---
#[async_trait] #[async_trait]
pub trait ShareRepository: Send + Sync { pub trait ShareRepository: Send + Sync {
@@ -22,3 +22,12 @@ pub trait ShareRepository: Send + Sync {
async fn save_invite(&self, invite: &InviteCode) -> Result<(), DomainError>; async fn save_invite(&self, invite: &InviteCode) -> Result<(), DomainError>;
async fn find_invite_by_id(&self, id: &SystemId) -> Result<Option<InviteCode>, DomainError>; async fn find_invite_by_id(&self, id: &SystemId) -> Result<Option<InviteCode>, DomainError>;
} }
// --- VisibilityFilterRepository ---
#[async_trait]
pub trait VisibilityFilterRepository: Send + Sync {
async fn find_by_scope_and_role(&self, scope_id: &SystemId, role_id: &SystemId) -> Result<Option<VisibilityFilter>, DomainError>;
async fn save(&self, filter: &VisibilityFilter) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}

View File

@@ -1,4 +1,6 @@
use crate::value_objects::{Checksum, DateTimeStamp, SystemId}; use crate::common::value_objects::{Checksum, DateTimeStamp, SystemId};
// --- SidecarRecord ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SyncStatus { pub enum SyncStatus {
@@ -55,3 +57,36 @@ impl SidecarRecord {
self.error_message = Some(message.into()); self.error_message = Some(message.into());
} }
} }
// --- SidecarConfig ---
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SyncMode {
Auto,
Scheduled,
Manual,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ConflictPolicy {
DbWins,
FileWins,
RequireUserDecision,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SidecarConfig {
pub export_base_path: String,
pub sync_mode: SyncMode,
pub conflict_resolution_policy: ConflictPolicy,
}
impl Default for SidecarConfig {
fn default() -> Self {
Self {
export_base_path: "/kphotos/sidecars".to_string(),
sync_mode: SyncMode::Auto,
conflict_resolution_policy: ConflictPolicy::DbWins,
}
}
}

View File

@@ -0,0 +1,5 @@
pub mod entities;
pub mod ports;
pub use entities::*;
pub use ports::*;

View File

@@ -0,0 +1,23 @@
use async_trait::async_trait;
use crate::common::errors::DomainError;
use crate::common::value_objects::{StructuredData, SystemId};
use super::entities::{SidecarRecord, SyncStatus};
// --- SidecarRepository ---
#[async_trait]
pub trait SidecarRepository: Send + Sync {
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Option<SidecarRecord>, DomainError>;
async fn find_by_status(&self, status: SyncStatus) -> Result<Vec<SidecarRecord>, DomainError>;
async fn save(&self, record: &SidecarRecord) -> Result<(), DomainError>;
async fn delete(&self, asset_id: &SystemId) -> Result<(), DomainError>;
}
// --- SidecarWriterPort ---
#[async_trait]
pub trait SidecarWriterPort: Send + Sync {
fn format_name(&self) -> &str;
async fn write_sidecar(&self, data: &StructuredData, path: &str) -> Result<(), DomainError>;
async fn read_sidecar(&self, path: &str) -> Result<StructuredData, DomainError>;
}

View File

@@ -0,0 +1,239 @@
use crate::common::errors::DomainError;
use crate::common::value_objects::{Checksum, DateTimeStamp, SystemId};
// --- StorageVolume ---
#[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<String>, uri_prefix: impl Into<String>, is_writable: bool) -> Self {
Self {
volume_id: SystemId::new(),
volume_name: name.into(),
uri_prefix: uri_prefix.into(),
is_writable,
available_bytes: 0,
}
}
}
// --- LibraryPath ---
#[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<SystemId>,
}
impl LibraryPath {
pub fn new_user_owned(
volume_id: SystemId,
relative_path: impl Into<String>,
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<String>) -> 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,
}
}
}
// --- IngestSession ---
#[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<String>,
}
impl IngestSession {
pub fn new(
uploader: SystemId,
device_id: impl Into<String>,
filename: impl Into<String>,
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<String>) {
self.status = IngestStatus::Failed;
self.error_message = Some(message.into());
}
fn is_terminal(&self) -> bool {
matches!(self.status, IngestStatus::Completed | IngestStatus::Failed)
}
}
// --- Quota ---
#[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<QuotaRule>,
}
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<String>,
) -> Self {
Self {
entry_id: SystemId::new(),
user_id,
usage_type,
consumed_amount: amount,
timestamp: DateTimeStamp::now(),
context: context.into(),
}
}
}

View File

@@ -0,0 +1,7 @@
pub mod entities;
pub mod ports;
pub mod services;
pub use entities::*;
pub use ports::*;
pub use services::*;

View File

@@ -0,0 +1,128 @@
use async_trait::async_trait;
use bytes::Bytes;
use futures::stream::{self, BoxStream, StreamExt};
use crate::common::errors::DomainError;
use crate::common::value_objects::{DateTimeStamp, SystemId};
use super::entities::{
IngestSession, LibraryPath, QuotaDefinition, StorageVolume,
UsageLedgerEntry, UsageType,
};
// --- StorageVolumeRepository ---
#[async_trait]
pub trait StorageVolumeRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<StorageVolume>, DomainError>;
async fn find_all(&self) -> Result<Vec<StorageVolume>, DomainError>;
async fn save(&self, volume: &StorageVolume) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
// --- LibraryPathRepository ---
#[async_trait]
pub trait LibraryPathRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<LibraryPath>, DomainError>;
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError>;
async fn find_ingest_destinations(&self, owner_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError>;
async fn save(&self, path: &LibraryPath) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
// --- IngestSessionRepository ---
#[async_trait]
pub trait IngestSessionRepository: Send + Sync {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<IngestSession>, DomainError>;
async fn find_by_user(&self, user_id: &SystemId) -> Result<Vec<IngestSession>, DomainError>;
async fn save(&self, session: &IngestSession) -> Result<(), DomainError>;
}
// --- QuotaRepository ---
#[async_trait]
pub trait QuotaRepository: Send + Sync {
async fn find_by_owner(&self, owner_id: &SystemId) -> Result<Option<QuotaDefinition>, DomainError>;
async fn save(&self, quota: &QuotaDefinition) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
}
#[async_trait]
pub trait UsageLedgerRepository: Send + Sync {
async fn record(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError>;
async fn sum_usage(
&self,
user_id: &SystemId,
usage_type: UsageType,
since: Option<DateTimeStamp>,
) -> Result<u64, DomainError>;
}
// --- FileStoragePort ---
#[derive(Debug, Clone)]
pub struct FileEntry {
pub path: String,
pub size_bytes: u64,
pub is_directory: bool,
}
#[async_trait]
pub trait FileStoragePort: Send + Sync {
async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError>;
async fn read_file(&self, path: &str) -> Result<Bytes, DomainError>;
async fn delete_file(&self, path: &str) -> Result<(), DomainError>;
async fn list_directory(&self, path: &str) -> Result<Vec<FileEntry>, DomainError>;
async fn file_exists(&self, path: &str) -> Result<bool, DomainError>;
async fn available_space(&self) -> Result<u64, DomainError>;
}
// --- StoragePort (object storage) ---
pub type DataStream = BoxStream<'static, Result<Bytes, DomainError>>;
/// Read operations on object storage. Keys are full paths relative to the adapter root.
#[async_trait]
pub trait StorageReader: Send + Sync {
/// Returns the content of `key` as a stream. Returns `DomainError::NotFound` if absent.
async fn get(&self, key: &str) -> Result<DataStream, DomainError>;
/// Lists all keys whose path begins with `prefix`, or all keys when `prefix` is `None`.
/// Returned keys are **full paths from the adapter root**, not relative to `prefix`.
/// Example: `list(Some("docs"))` returns `["docs/readme.txt"]`, not `["readme.txt"]`.
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, DomainError>;
/// Convenience: reads the entire content of `key` into memory. Wraps `get`.
async fn get_bytes(&self, key: &str) -> Result<Bytes, DomainError> {
let mut stream = self.get(key).await?;
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
buf.extend_from_slice(&chunk?);
}
Ok(Bytes::from(buf))
}
}
/// Write operations on object storage.
#[async_trait]
pub trait StorageWriter: Send + Sync {
/// Stores `data` at `key`. Overwrites any existing content at that key silently.
async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError>;
/// Deletes `key`. Returns `Ok(())` even if the key does not exist (idempotent).
async fn delete(&self, key: &str) -> Result<(), DomainError>;
/// Convenience: stores an in-memory buffer at `key`. Wraps `put`.
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), DomainError> {
self.put(key, Box::pin(stream::once(async move { Ok(data) }))).await
}
}
/// Combined read + write storage interface.
///
/// **Usage note:** `Arc<dyn StoragePort>` is the intended DI type everywhere.
/// `StorageReader` and `StorageWriter` exist for implementation clarity, but Rust does not
/// support narrowing `Arc<dyn StoragePort>` to `Arc<dyn StorageReader>` at runtime.
/// Inject `Arc<dyn StoragePort>` into constructors and pass `.clone()` from the factory.
pub trait StoragePort: StorageReader + StorageWriter {}
impl<T: StorageReader + StorageWriter> StoragePort for T {}

View File

@@ -1,6 +1,6 @@
use chrono::{Datelike, NaiveDate, TimeZone, Utc}; use chrono::{Datelike, NaiveDate, TimeZone, Utc};
use crate::entities::{QuotaDefinition, TimePeriod, UsageType}; use super::entities::{QuotaDefinition, TimePeriod, UsageType};
use crate::value_objects::DateTimeStamp; use crate::common::value_objects::DateTimeStamp;
pub struct QuotaCheckResult { pub struct QuotaCheckResult {
pub allowed: bool, pub allowed: bool,

View File

@@ -0,0 +1,117 @@
use domain::entities::{
Asset, AssetMetadata, AssetStack, AssetType, DerivativeAsset, DerivativeProfile,
DetectionMethod, DuplicateGroup, DuplicateStatus, GenerationStatus,
MetadataSource, SourceReference, StackMemberRole, StackType,
};
use domain::errors::DomainError;
use domain::value_objects::{Checksum, MetadataValue, StructuredData, SystemId};
// --- Asset ---
fn make_asset() -> Asset {
let src = SourceReference {
volume_id: SystemId::new(),
relative_path: "photos/img.jpg".to_string(),
checksum: Checksum::new("a".repeat(64)).unwrap(),
};
Asset::new(src, AssetType::Image, "image/jpeg", 1024, SystemId::new())
}
#[test]
fn new_asset_is_unprocessed() {
let a = make_asset();
assert!(!a.is_processed);
}
#[test]
fn mark_processed() {
let mut a = make_asset();
a.mark_processed();
assert!(a.is_processed);
}
// --- AssetMetadata ---
#[test]
fn metadata_source_ordering() {
assert!(MetadataSource::ExifExtracted < MetadataSource::AiGenerated);
assert!(MetadataSource::AiGenerated < MetadataSource::UserEdited);
}
#[test]
fn create_metadata_layer() {
let mut data = StructuredData::new();
data.insert("camera", MetadataValue::String("Canon".to_string()));
let meta = AssetMetadata::new(SystemId::new(), MetadataSource::ExifExtracted, data);
assert_eq!(meta.metadata_source, MetadataSource::ExifExtracted);
assert_eq!(meta.data.get_string("camera"), Some("Canon"));
}
// --- AssetStack ---
#[test]
fn new_stack_contains_primary() {
let primary = SystemId::new();
let stack = AssetStack::new(StackType::LivePhoto, primary.clone(), SystemId::new());
assert_eq!(stack.members.len(), 1);
assert_eq!(stack.members[0].asset_id, primary);
assert_eq!(stack.members[0].role, StackMemberRole::PrimaryDisplay);
assert_eq!(stack.members[0].sort_order, 0);
}
#[test]
fn add_motion_clip() {
let mut stack = AssetStack::new(StackType::LivePhoto, SystemId::new(), SystemId::new());
let clip_id = SystemId::new();
stack.add_member(clip_id.clone(), StackMemberRole::MotionClip).unwrap();
assert_eq!(stack.members.len(), 2);
assert_eq!(stack.members[1].asset_id, clip_id);
assert_eq!(stack.members[1].sort_order, 1);
}
#[test]
fn cannot_add_duplicate_to_stack() {
let primary = SystemId::new();
let mut stack = AssetStack::new(StackType::LivePhoto, primary.clone(), SystemId::new());
let result = stack.add_member(primary, StackMemberRole::HighResSource);
assert!(matches!(result, Err(DomainError::Conflict(_))));
}
// --- DerivativeAsset ---
#[test]
fn derivative_lifecycle() {
let mut d = DerivativeAsset::new_pending(
SystemId::new(),
DerivativeProfile::ThumbnailSquare,
"/thumbs/abc.webp",
);
assert_eq!(d.generation_status, GenerationStatus::Pending);
assert_eq!(d.file_size, 0);
assert_eq!(d.dimensions, (0, 0));
d.mark_ready("image/webp", 4096, (256, 256));
assert_eq!(d.generation_status, GenerationStatus::Ready);
assert_eq!(d.mime_type, "image/webp");
assert_eq!(d.file_size, 4096);
assert_eq!(d.dimensions, (256, 256));
}
// --- Duplicate ---
#[test]
fn exact_duplicate_group() {
let g = DuplicateGroup::new_exact(SystemId::new(), SystemId::new());
assert_eq!(g.detection_method, DetectionMethod::ExactHash);
assert_eq!(g.status, DuplicateStatus::Unresolved);
assert_eq!(g.candidates.len(), 2);
assert_eq!(g.candidates[0].similarity_score, 1.0);
assert_eq!(g.candidates[1].similarity_score, 1.0);
}
#[test]
fn resolve_group() {
let mut g = DuplicateGroup::new_exact(SystemId::new(), SystemId::new());
g.resolve();
assert_eq!(g.status, DuplicateStatus::Resolved);
}

View File

@@ -0,0 +1,2 @@
mod entities;
mod services;

View File

@@ -0,0 +1,2 @@
mod value_objects;
mod events;

Some files were not shown because too many files have changed in this diff Show More