use crate::db::PgPool; use async_trait::async_trait; use chrono::{DateTime, Utc}; use domain::{ entities::{ InviteCode, LinkAccessLevel, ScopeType, ShareLink, ShareScope, ShareTarget, ShareableType, TargetType, }, errors::DomainError, ports::ShareRepository, value_objects::{DateTimeStamp, SystemId}, }; use uuid::Uuid; // --- String constants for DB enum mapping --- const SCOPE_PRIVATE: &str = "private"; const SCOPE_USER: &str = "user"; const SCOPE_GROUP: &str = "group"; const SCOPE_LINK: &str = "link"; const SCOPE_PUBLIC: &str = "public"; const SHAREABLE_ASSET: &str = "asset"; const SHAREABLE_ALBUM: &str = "album"; const SHAREABLE_COLLECTION: &str = "collection"; const SHAREABLE_DIRECTORY: &str = "directory"; const TARGET_USER: &str = "user"; const TARGET_GROUP: &str = "group"; const ACCESS_VIEW_ONLY: &str = "view_only"; const ACCESS_LIMITED_SEARCH: &str = "limited_search"; // --- Row structs --- #[derive(sqlx::FromRow)] struct ShareScopeRow { scope_id: Uuid, scope_type: String, shareable_type: String, shareable_id: Uuid, created_by_user_id: Uuid, expires_at: Option>, created_at: DateTime, } #[derive(sqlx::FromRow)] struct ShareTargetRow { scope_id: Uuid, target_type: String, target_id: Uuid, role_id: Uuid, } #[derive(sqlx::FromRow)] struct ShareLinkRow { scope_id: Uuid, token: String, expires_at: Option>, access_level: String, is_active: bool, max_uses: Option, use_count: i32, } #[derive(sqlx::FromRow)] struct InviteCodeRow { code_id: Uuid, scope_id: Uuid, created_by_user_id: Uuid, expires_at: Option>, max_uses: Option, use_count: i32, assigned_role_id: Uuid, } // --- Enum conversions --- fn scope_type_to_str(t: ScopeType) -> &'static str { match t { ScopeType::Private => SCOPE_PRIVATE, ScopeType::User => SCOPE_USER, ScopeType::Group => SCOPE_GROUP, ScopeType::Link => SCOPE_LINK, ScopeType::Public => SCOPE_PUBLIC, } } fn scope_type_from_str(s: &str) -> Result { match s { SCOPE_PRIVATE => Ok(ScopeType::Private), SCOPE_USER => Ok(ScopeType::User), SCOPE_GROUP => Ok(ScopeType::Group), SCOPE_LINK => Ok(ScopeType::Link), SCOPE_PUBLIC => Ok(ScopeType::Public), _ => Err(DomainError::Internal(format!("Unknown scope_type: {s}"))), } } fn shareable_type_to_str(t: ShareableType) -> &'static str { match t { ShareableType::Asset => SHAREABLE_ASSET, ShareableType::Album => SHAREABLE_ALBUM, ShareableType::Collection => SHAREABLE_COLLECTION, ShareableType::Directory => SHAREABLE_DIRECTORY, } } fn shareable_type_from_str(s: &str) -> Result { match s { SHAREABLE_ASSET => Ok(ShareableType::Asset), SHAREABLE_ALBUM => Ok(ShareableType::Album), SHAREABLE_COLLECTION => Ok(ShareableType::Collection), SHAREABLE_DIRECTORY => Ok(ShareableType::Directory), _ => Err(DomainError::Internal(format!( "Unknown shareable_type: {s}" ))), } } fn target_type_to_str(t: TargetType) -> &'static str { match t { TargetType::User => TARGET_USER, TargetType::Group => TARGET_GROUP, } } fn target_type_from_str(s: &str) -> Result { match s { TARGET_USER => Ok(TargetType::User), TARGET_GROUP => Ok(TargetType::Group), _ => Err(DomainError::Internal(format!("Unknown target_type: {s}"))), } } fn access_level_to_str(t: LinkAccessLevel) -> &'static str { match t { LinkAccessLevel::ViewOnly => ACCESS_VIEW_ONLY, LinkAccessLevel::LimitedSearch => ACCESS_LIMITED_SEARCH, } } fn access_level_from_str(s: &str) -> Result { match s { ACCESS_VIEW_ONLY => Ok(LinkAccessLevel::ViewOnly), ACCESS_LIMITED_SEARCH => Ok(LinkAccessLevel::LimitedSearch), _ => Err(DomainError::Internal(format!("Unknown access_level: {s}"))), } } // --- Row → Domain conversions --- impl TryFrom for ShareScope { type Error = DomainError; fn try_from(r: ShareScopeRow) -> Result { Ok(Self { scope_id: SystemId::from_uuid(r.scope_id), scope_type: scope_type_from_str(&r.scope_type)?, shareable_type: shareable_type_from_str(&r.shareable_type)?, shareable_id: SystemId::from_uuid(r.shareable_id), created_by_user_id: SystemId::from_uuid(r.created_by_user_id), expires_at: r.expires_at.map(DateTimeStamp::from_datetime), created_at: DateTimeStamp::from_datetime(r.created_at), }) } } impl TryFrom for ShareTarget { type Error = DomainError; fn try_from(r: ShareTargetRow) -> Result { Ok(Self { scope_id: SystemId::from_uuid(r.scope_id), target_type: target_type_from_str(&r.target_type)?, target_id: SystemId::from_uuid(r.target_id), role_id: SystemId::from_uuid(r.role_id), }) } } impl TryFrom for ShareLink { type Error = DomainError; fn try_from(r: ShareLinkRow) -> Result { Ok(Self { scope_id: SystemId::from_uuid(r.scope_id), token: r.token, expires_at: r.expires_at.map(DateTimeStamp::from_datetime), access_level: access_level_from_str(&r.access_level)?, is_active: r.is_active, max_uses: r.max_uses.map(|v| v as u32), use_count: r.use_count as u32, }) } } impl TryFrom for InviteCode { type Error = DomainError; fn try_from(r: InviteCodeRow) -> Result { Ok(Self { code_id: SystemId::from_uuid(r.code_id), scope_id: SystemId::from_uuid(r.scope_id), created_by_user_id: SystemId::from_uuid(r.created_by_user_id), expires_at: r.expires_at.map(DateTimeStamp::from_datetime), max_uses: r.max_uses.map(|v| v as u32), use_count: r.use_count as u32, assigned_role_id: SystemId::from_uuid(r.assigned_role_id), }) } } // --- Repository --- pub struct PostgresShareRepository { pool: PgPool, } impl PostgresShareRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl ShareRepository for PostgresShareRepository { // --- Scopes --- async fn save_scope(&self, scope: &ShareScope) -> Result<(), DomainError> { sqlx::query( "INSERT INTO share_scopes (scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (scope_id) DO UPDATE SET scope_type = EXCLUDED.scope_type, expires_at = EXCLUDED.expires_at", ) .bind(*scope.scope_id.as_uuid()) .bind(scope_type_to_str(scope.scope_type)) .bind(shareable_type_to_str(scope.shareable_type)) .bind(*scope.shareable_id.as_uuid()) .bind(*scope.created_by_user_id.as_uuid()) .bind(scope.expires_at.as_ref().map(|d| d.as_datetime()).copied()) .bind(scope.created_at.as_datetime()) .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(()) } async fn find_scope_by_id(&self, id: &SystemId) -> Result, DomainError> { let row = sqlx::query_as::<_, ShareScopeRow>( "SELECT scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at FROM share_scopes WHERE scope_id = $1", ) .bind(*id.as_uuid()) .fetch_optional(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; row.map(TryInto::try_into).transpose() } async fn find_scopes_for_resource( &self, resource_id: &SystemId, ) -> Result, DomainError> { let rows = sqlx::query_as::<_, ShareScopeRow>( "SELECT scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at FROM share_scopes WHERE shareable_id = $1", ) .bind(*resource_id.as_uuid()) .fetch_all(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; rows.into_iter().map(TryInto::try_into).collect() } async fn delete_scope(&self, id: &SystemId) -> Result<(), DomainError> { sqlx::query("DELETE FROM share_scopes WHERE scope_id = $1") .bind(*id.as_uuid()) .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(()) } // --- Targets --- async fn save_target(&self, target: &ShareTarget) -> Result<(), DomainError> { sqlx::query( "INSERT INTO share_targets (scope_id, target_type, target_id, role_id) VALUES ($1, $2, $3, $4) ON CONFLICT (scope_id, target_id) DO UPDATE SET target_type = EXCLUDED.target_type, role_id = EXCLUDED.role_id", ) .bind(*target.scope_id.as_uuid()) .bind(target_type_to_str(target.target_type)) .bind(*target.target_id.as_uuid()) .bind(*target.role_id.as_uuid()) .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(()) } async fn find_targets_for_scope( &self, scope_id: &SystemId, ) -> Result, DomainError> { let rows = sqlx::query_as::<_, ShareTargetRow>( "SELECT scope_id, target_type, target_id, role_id FROM share_targets WHERE scope_id = $1", ) .bind(*scope_id.as_uuid()) .fetch_all(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; rows.into_iter().map(TryInto::try_into).collect() } async fn find_targets_for_user( &self, user_id: &SystemId, ) -> Result, DomainError> { let rows = sqlx::query_as::<_, ShareTargetRow>( "SELECT scope_id, target_type, target_id, role_id FROM share_targets WHERE target_id = $1", ) .bind(*user_id.as_uuid()) .fetch_all(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; rows.into_iter().map(TryInto::try_into).collect() } // --- Links --- async fn save_link(&self, link: &ShareLink) -> Result<(), DomainError> { sqlx::query( "INSERT INTO share_links (scope_id, token, expires_at, access_level, is_active, max_uses, use_count) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (scope_id) DO UPDATE SET token = EXCLUDED.token, expires_at = EXCLUDED.expires_at, access_level = EXCLUDED.access_level, is_active = EXCLUDED.is_active, max_uses = EXCLUDED.max_uses, use_count = EXCLUDED.use_count", ) .bind(*link.scope_id.as_uuid()) .bind(&link.token) .bind(link.expires_at.as_ref().map(|d| d.as_datetime()).copied()) .bind(access_level_to_str(link.access_level)) .bind(link.is_active) .bind(link.max_uses.map(|v| v as i32)) .bind(link.use_count as i32) .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(()) } async fn find_link_by_token(&self, token: &str) -> Result, DomainError> { let row = sqlx::query_as::<_, ShareLinkRow>( "SELECT scope_id, token, expires_at, access_level, is_active, max_uses, use_count FROM share_links WHERE token = $1", ) .bind(token) .fetch_optional(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; row.map(TryInto::try_into).transpose() } // --- Invites --- async fn save_invite(&self, invite: &InviteCode) -> Result<(), DomainError> { sqlx::query( "INSERT INTO invite_codes (code_id, scope_id, created_by_user_id, expires_at, max_uses, use_count, assigned_role_id) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (code_id) DO UPDATE SET expires_at = EXCLUDED.expires_at, max_uses = EXCLUDED.max_uses, use_count = EXCLUDED.use_count", ) .bind(*invite.code_id.as_uuid()) .bind(*invite.scope_id.as_uuid()) .bind(*invite.created_by_user_id.as_uuid()) .bind(invite.expires_at.as_ref().map(|d| d.as_datetime()).copied()) .bind(invite.max_uses.map(|v| v as i32)) .bind(invite.use_count as i32) .bind(*invite.assigned_role_id.as_uuid()) .execute(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(()) } async fn find_invite_by_id(&self, id: &SystemId) -> Result, DomainError> { let row = sqlx::query_as::<_, InviteCodeRow>( "SELECT code_id, scope_id, created_by_user_id, expires_at, max_uses, use_count, assigned_role_id FROM invite_codes WHERE code_id = $1", ) .bind(*id.as_uuid()) .fetch_optional(&self.pool) .await .map_err(|e| DomainError::Internal(e.to_string()))?; row.map(TryInto::try_into).transpose() } }