418 lines
14 KiB
Rust
418 lines
14 KiB
Rust
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<DateTime<Utc>>,
|
|
created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[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<DateTime<Utc>>,
|
|
access_level: String,
|
|
is_active: bool,
|
|
max_uses: Option<i32>,
|
|
use_count: i32,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct InviteCodeRow {
|
|
code_id: Uuid,
|
|
scope_id: Uuid,
|
|
created_by_user_id: Uuid,
|
|
expires_at: Option<DateTime<Utc>>,
|
|
max_uses: Option<i32>,
|
|
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<ScopeType, DomainError> {
|
|
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<ShareableType, DomainError> {
|
|
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<TargetType, DomainError> {
|
|
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<LinkAccessLevel, DomainError> {
|
|
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<ShareScopeRow> for ShareScope {
|
|
type Error = DomainError;
|
|
|
|
fn try_from(r: ShareScopeRow) -> Result<Self, Self::Error> {
|
|
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<ShareTargetRow> for ShareTarget {
|
|
type Error = DomainError;
|
|
|
|
fn try_from(r: ShareTargetRow) -> Result<Self, Self::Error> {
|
|
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<ShareLinkRow> for ShareLink {
|
|
type Error = DomainError;
|
|
|
|
fn try_from(r: ShareLinkRow) -> Result<Self, Self::Error> {
|
|
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<InviteCodeRow> for InviteCode {
|
|
type Error = DomainError;
|
|
|
|
fn try_from(r: InviteCodeRow) -> Result<Self, Self::Error> {
|
|
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<Option<ShareScope>, 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<Vec<ShareScope>, 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<Vec<ShareTarget>, 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<Vec<ShareTarget>, 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<Option<ShareLink>, 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<Option<InviteCode>, 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()
|
|
}
|
|
}
|