2026-02-14 20:22:19 +01:00
|
|
|
|
//! PostgreSQL-backed folder repository.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! Implements `FolderRepository` (and thus `FolderStoragePort`) using the
|
|
|
|
|
|
//! `storage.folders` table. Folders are purely virtual — no physical
|
|
|
|
|
|
//! directories are created on the filesystem.
|
2026-02-16 13:10:44 +01:00
|
|
|
|
//!
|
|
|
|
|
|
//! Folder paths are **materialized** in a `path TEXT` column maintained by
|
|
|
|
|
|
//! database triggers, so reading a folder's full path is always O(1) — no
|
|
|
|
|
|
//! recursive CTEs or N+1 queries.
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
|
|
|
|
|
use async_trait::async_trait;
|
|
|
|
|
|
use sqlx::PgPool;
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
|
|
use crate::common::errors::DomainError;
|
|
|
|
|
|
use crate::domain::entities::folder::Folder;
|
|
|
|
|
|
use crate::domain::repositories::folder_repository::FolderRepository;
|
|
|
|
|
|
use crate::domain::services::path_service::StoragePath;
|
|
|
|
|
|
|
|
|
|
|
|
/// PostgreSQL-backed folder repository.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// All folder metadata lives in the `storage.folders` table. The physical
|
|
|
|
|
|
/// filesystem is never touched for folder operations.
|
|
|
|
|
|
pub struct FolderDbRepository {
|
|
|
|
|
|
pool: Option<Arc<PgPool>>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl FolderDbRepository {
|
|
|
|
|
|
pub fn new(pool: Arc<PgPool>) -> Self {
|
|
|
|
|
|
Self { pool: Some(pool) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Creates a stub instance for `AppState::default()`.
|
|
|
|
|
|
/// This is never called in production — only used for route scaffolding.
|
|
|
|
|
|
pub fn new_stub() -> Self {
|
|
|
|
|
|
Self { pool: None }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Get the pool, panicking if stub.
|
|
|
|
|
|
fn pool(&self) -> &PgPool {
|
|
|
|
|
|
self.pool
|
|
|
|
|
|
.as_deref()
|
|
|
|
|
|
.expect("FolderDbRepository: pool not available (stub instance)")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Convert a database row into a `Folder` domain entity.
|
2026-02-16 13:10:44 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// The `path` comes directly from the materialized `path` column — no
|
|
|
|
|
|
/// extra queries needed.
|
|
|
|
|
|
fn row_to_folder(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
id: String,
|
|
|
|
|
|
name: String,
|
2026-02-16 13:10:44 +01:00
|
|
|
|
path: String,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
parent_id: Option<String>,
|
2026-02-15 23:45:11 +01:00
|
|
|
|
user_id: Option<String>,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
created_at: i64,
|
|
|
|
|
|
modified_at: i64,
|
|
|
|
|
|
) -> Result<Folder, DomainError> {
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let storage_path = StoragePath::from_string(&path);
|
2026-02-15 23:45:11 +01:00
|
|
|
|
Folder::with_timestamps_and_owner(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
id,
|
|
|
|
|
|
name,
|
|
|
|
|
|
storage_path,
|
|
|
|
|
|
parent_id,
|
2026-02-15 23:45:11 +01:00
|
|
|
|
user_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
created_at as u64,
|
|
|
|
|
|
modified_at as u64,
|
|
|
|
|
|
)
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
|
|
impl FolderRepository for FolderDbRepository {
|
|
|
|
|
|
async fn create_folder(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
name: String,
|
|
|
|
|
|
parent_id: Option<String>,
|
|
|
|
|
|
) -> Result<Folder, DomainError> {
|
|
|
|
|
|
// Derive user_id from parent folder. Root-level folders require the
|
|
|
|
|
|
// caller to have set up the home folder beforehand (done during user
|
|
|
|
|
|
// registration).
|
|
|
|
|
|
let user_id: String = if let Some(ref pid) = parent_id {
|
|
|
|
|
|
sqlx::query_scalar::<_, String>(
|
|
|
|
|
|
"SELECT user_id FROM storage.folders WHERE id = $1::uuid",
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("parent lookup: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", pid))?
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return Err(DomainError::internal_error(
|
|
|
|
|
|
"FolderDb",
|
|
|
|
|
|
"Cannot create root folder without user_id — use create_home_folder instead",
|
|
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
r#"
|
|
|
|
|
|
INSERT INTO storage.folders (name, parent_id, user_id)
|
|
|
|
|
|
VALUES ($1, $2::uuid, $3)
|
|
|
|
|
|
RETURNING id::text,
|
2026-02-16 13:10:44 +01:00
|
|
|
|
path,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(&name)
|
|
|
|
|
|
.bind(&parent_id)
|
|
|
|
|
|
.bind(&user_id)
|
|
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
2026-02-15 17:53:25 +01:00
|
|
|
|
if let sqlx::Error::Database(ref db_err) = e
|
2026-02-15 18:04:32 +01:00
|
|
|
|
&& db_err.code().as_deref() == Some("23505")
|
|
|
|
|
|
{
|
|
|
|
|
|
return DomainError::already_exists(
|
|
|
|
|
|
"Folder",
|
|
|
|
|
|
format!("{name} already exists in parent"),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-02-14 20:22:19 +01:00
|
|
|
|
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
Self::row_to_folder(row.0, name, row.1, parent_id, Some(user_id), row.2, row.3)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE id = $1::uuid AND NOT is_trashed
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
|
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
|
|
|
|
|
let path_str = storage_path.to_string();
|
2026-02-16 13:10:44 +01:00
|
|
|
|
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
|
|
|
|
|
|
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
if lookup.is_empty() {
|
2026-02-14 20:22:19 +01:00
|
|
|
|
return Err(DomainError::not_found("Folder", "empty path"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
|
|
|
|
|
r#"
|
|
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
|
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE path = $1 AND NOT is_trashed
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(lookup)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
2026-02-21 13:33:18 +01:00
|
|
|
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> =
|
|
|
|
|
|
if let Some(pid) = parent_id {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id = $1::uuid AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
"#,
|
2026-02-21 13:33:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id IS NULL AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
"#,
|
2026-02-21 13:33:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
rows.into_iter()
|
|
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn list_folders_by_owner(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
owner_id: &str,
|
|
|
|
|
|
) -> Result<Vec<Folder>, DomainError> {
|
2026-02-21 13:33:18 +01:00
|
|
|
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> =
|
|
|
|
|
|
if let Some(pid) = parent_id {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-15 23:45:11 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
"#,
|
2026-02-21 13:33:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.bind(owner_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-15 23:45:11 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
"#,
|
2026-02-21 13:33:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(owner_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
2026-02-15 23:45:11 +01:00
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
rows.into_iter()
|
|
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
/// Paginated folder listing — single query with `COUNT(*) OVER()` window
|
|
|
|
|
|
/// function so the total matching count comes back alongside the data rows,
|
|
|
|
|
|
/// eliminating a separate COUNT round-trip.
|
2026-02-14 20:22:19 +01:00
|
|
|
|
async fn list_folders_paginated(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
offset: usize,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
include_total: bool,
|
|
|
|
|
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
2026-03-02 01:30:34 +01:00
|
|
|
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64, i64)> =
|
2026-02-21 13:33:18 +01:00
|
|
|
|
if let Some(pid) = parent_id {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-03-02 01:30:34 +01:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
|
|
|
|
|
COUNT(*) OVER() AS total_count
|
2026-02-14 20:22:19 +01:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id = $1::uuid AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
LIMIT $2 OFFSET $3
|
|
|
|
|
|
"#,
|
2026-02-21 13:33:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.bind(limit as i64)
|
|
|
|
|
|
.bind(offset as i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-03-02 01:30:34 +01:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
|
|
|
|
|
COUNT(*) OVER() AS total_count
|
2026-02-14 20:22:19 +01:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id IS NULL AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
LIMIT $1 OFFSET $2
|
|
|
|
|
|
"#,
|
2026-02-21 13:33:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(limit as i64)
|
|
|
|
|
|
.bind(offset as i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
// total_count is identical in every row; 0 when the result set is empty.
|
|
|
|
|
|
let total = if include_total {
|
|
|
|
|
|
Some(rows.first().map_or(0, |r| r.7) as usize)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let folders: Result<Vec<Folder>, DomainError> = rows
|
|
|
|
|
|
.into_iter()
|
2026-03-02 01:30:34 +01:00
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
|
2026-02-16 13:10:44 +01:00
|
|
|
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
Ok((folders?, total))
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
/// Paginated folder listing filtered by owner — single query with
|
|
|
|
|
|
/// `COUNT(*) OVER()` to avoid a separate COUNT round-trip.
|
2026-02-16 00:22:42 +01:00
|
|
|
|
async fn list_folders_by_owner_paginated(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
owner_id: &str,
|
|
|
|
|
|
offset: usize,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
include_total: bool,
|
|
|
|
|
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
2026-03-02 01:30:34 +01:00
|
|
|
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64, i64)> =
|
2026-02-21 13:33:18 +01:00
|
|
|
|
if let Some(pid) = parent_id {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-16 00:22:42 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-03-02 01:30:34 +01:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
|
|
|
|
|
COUNT(*) OVER() AS total_count
|
2026-02-16 00:22:42 +01:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
LIMIT $3 OFFSET $4
|
|
|
|
|
|
"#,
|
2026-02-21 13:33:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.bind(owner_id)
|
|
|
|
|
|
.bind(limit as i64)
|
|
|
|
|
|
.bind(offset as i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-02-16 13:10:44 +01:00
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
2026-02-16 00:22:42 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-03-02 01:30:34 +01:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
|
|
|
|
|
COUNT(*) OVER() AS total_count
|
2026-02-16 00:22:42 +01:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
LIMIT $2 OFFSET $3
|
|
|
|
|
|
"#,
|
2026-02-21 13:33:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(owner_id)
|
|
|
|
|
|
.bind(limit as i64)
|
|
|
|
|
|
.bind(offset as i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}"))
|
|
|
|
|
|
})?;
|
2026-02-16 00:22:42 +01:00
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
let total = if include_total {
|
|
|
|
|
|
Some(rows.first().map_or(0, |r| r.7) as usize)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let folders: Result<Vec<Folder>, DomainError> = rows
|
|
|
|
|
|
.into_iter()
|
2026-03-02 01:30:34 +01:00
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
|
2026-02-16 13:10:44 +01:00
|
|
|
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
Ok((folders?, total))
|
2026-02-16 00:22:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 20:22:19 +01:00
|
|
|
|
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
|
2026-02-24 17:15:36 +01:00
|
|
|
|
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
|
|
|
|
|
|
// the AFTER UPDATE cascade trigger then batch-updates all
|
|
|
|
|
|
// descendants in a single UPDATE using the GiST lpath index.
|
2026-03-02 01:30:34 +01:00
|
|
|
|
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
r#"
|
|
|
|
|
|
UPDATE storage.folders
|
|
|
|
|
|
SET name = $1, updated_at = NOW()
|
|
|
|
|
|
WHERE id = $2::uuid AND NOT is_trashed
|
2026-03-02 01:30:34 +01:00
|
|
|
|
RETURNING id::text, name, path, parent_id::text, user_id,
|
|
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
2026-02-14 20:22:19 +01:00
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(&new_name)
|
|
|
|
|
|
.bind(id)
|
2026-03-02 01:30:34 +01:00
|
|
|
|
.fetch_optional(self.pool())
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
2026-02-15 17:53:25 +01:00
|
|
|
|
if let sqlx::Error::Database(ref db_err) = e
|
2026-02-15 18:04:32 +01:00
|
|
|
|
&& db_err.code().as_deref() == Some("23505")
|
|
|
|
|
|
{
|
|
|
|
|
|
return DomainError::already_exists("Folder", format!("{new_name} already exists"));
|
|
|
|
|
|
}
|
2026-02-14 20:22:19 +01:00
|
|
|
|
DomainError::internal_error("FolderDb", format!("rename: {e}"))
|
2026-03-02 01:30:34 +01:00
|
|
|
|
})?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn move_folder(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
new_parent_id: Option<&str>,
|
|
|
|
|
|
) -> Result<Folder, DomainError> {
|
2026-02-24 17:15:36 +01:00
|
|
|
|
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
|
|
|
|
|
|
// the AFTER UPDATE cascade trigger then batch-updates all
|
|
|
|
|
|
// descendants in a single UPDATE using the GiST lpath index.
|
2026-03-02 01:30:34 +01:00
|
|
|
|
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
r#"
|
|
|
|
|
|
UPDATE storage.folders
|
|
|
|
|
|
SET parent_id = $1::uuid, updated_at = NOW()
|
|
|
|
|
|
WHERE id = $2::uuid AND NOT is_trashed
|
2026-03-02 01:30:34 +01:00
|
|
|
|
RETURNING id::text, name, path, parent_id::text, user_id,
|
|
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
2026-02-14 20:22:19 +01:00
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(new_parent_id)
|
|
|
|
|
|
.bind(id)
|
2026-03-02 01:30:34 +01:00
|
|
|
|
.fetch_optional(self.pool())
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.await
|
2026-03-02 01:30:34 +01:00
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
|
2026-02-26 00:08:38 +01:00
|
|
|
|
// Delete all files whose folder is anywhere in the subtree.
|
|
|
|
|
|
// Uses the GiST-indexed ltree `<@` operator — O(log N) vs the
|
|
|
|
|
|
// O(depth × N) recursive CTE it replaces.
|
2026-02-20 12:27:52 +01:00
|
|
|
|
sqlx::query(
|
2026-02-26 00:08:38 +01:00
|
|
|
|
"DELETE FROM storage.files \
|
|
|
|
|
|
WHERE folder_id IN ( \
|
|
|
|
|
|
SELECT id FROM storage.folders \
|
|
|
|
|
|
WHERE lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
|
|
|
|
|
)",
|
2026-02-20 12:27:52 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.execute(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("delete files: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
// Then delete the folder (CASCADE will remove descendant folders)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid")
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.execute(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("delete: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
if result.rows_affected() == 0 {
|
|
|
|
|
|
return Err(DomainError::not_found("Folder", id));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let path_str = storage_path.to_string();
|
|
|
|
|
|
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
|
|
|
|
|
|
|
|
|
|
|
let exists: bool = sqlx::query_scalar(
|
|
|
|
|
|
"SELECT EXISTS(SELECT 1 FROM storage.folders WHERE path = $1 AND NOT is_trashed)",
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(lookup)
|
|
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("exists: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
Ok(exists)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
2026-02-21 13:33:18 +01:00
|
|
|
|
let path: String =
|
|
|
|
|
|
sqlx::query_scalar("SELECT path FROM storage.folders WHERE id = $1::uuid")
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_path: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
2026-02-16 13:10:44 +01:00
|
|
|
|
|
|
|
|
|
|
Ok(StoragePath::from_string(&path))
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Trash operations ──
|
|
|
|
|
|
|
|
|
|
|
|
async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> {
|
2026-02-20 12:27:52 +01:00
|
|
|
|
// Only mark the folder itself as trashed.
|
|
|
|
|
|
// Child files and sub-folders are implicitly hidden because their
|
|
|
|
|
|
// ancestor is trashed — list queries already filter NOT is_trashed,
|
|
|
|
|
|
// and folder navigation won't reach a trashed folder's children.
|
2026-02-14 20:22:19 +01:00
|
|
|
|
let result = sqlx::query_scalar::<_, i64>(
|
|
|
|
|
|
r#"
|
|
|
|
|
|
WITH trash_folder AS (
|
|
|
|
|
|
UPDATE storage.folders
|
|
|
|
|
|
SET is_trashed = TRUE,
|
|
|
|
|
|
trashed_at = NOW(),
|
|
|
|
|
|
original_parent_id = parent_id,
|
|
|
|
|
|
updated_at = NOW()
|
|
|
|
|
|
WHERE id = $1::uuid AND NOT is_trashed
|
|
|
|
|
|
RETURNING 1
|
|
|
|
|
|
)
|
|
|
|
|
|
SELECT COUNT(*) FROM trash_folder
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("trash: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
if result == 0 {
|
|
|
|
|
|
return Err(DomainError::not_found("Folder", folder_id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn restore_from_trash(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: &str,
|
|
|
|
|
|
_original_path: &str,
|
|
|
|
|
|
) -> Result<(), DomainError> {
|
2026-02-20 12:27:52 +01:00
|
|
|
|
// Only restore the folder itself.
|
|
|
|
|
|
// Child files were never marked as trashed — they become visible
|
|
|
|
|
|
// again automatically once their parent folder is un-trashed.
|
2026-02-24 17:15:36 +01:00
|
|
|
|
// The BEFORE UPDATE trigger recomputes path/lpath when
|
|
|
|
|
|
// original_parent_id is restored; the cascade trigger
|
|
|
|
|
|
// batch-updates all descendants via the GiST lpath index.
|
2026-02-14 20:22:19 +01:00
|
|
|
|
let result = sqlx::query_scalar::<_, i64>(
|
|
|
|
|
|
r#"
|
|
|
|
|
|
WITH restore_folder AS (
|
|
|
|
|
|
UPDATE storage.folders
|
|
|
|
|
|
SET is_trashed = FALSE,
|
|
|
|
|
|
trashed_at = NULL,
|
|
|
|
|
|
parent_id = COALESCE(original_parent_id, parent_id),
|
|
|
|
|
|
original_parent_id = NULL,
|
|
|
|
|
|
updated_at = NOW()
|
|
|
|
|
|
WHERE id = $1::uuid AND is_trashed
|
|
|
|
|
|
RETURNING 1
|
|
|
|
|
|
)
|
|
|
|
|
|
SELECT COUNT(*) FROM restore_folder
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("restore: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
if result == 0 {
|
|
|
|
|
|
return Err(DomainError::not_found("Folder", folder_id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> {
|
2026-02-26 00:08:38 +01:00
|
|
|
|
// Delete all files whose folder is anywhere in the subtree
|
|
|
|
|
|
// (GiST ltree index, same pattern as delete_folder).
|
2026-02-20 12:27:52 +01:00
|
|
|
|
sqlx::query(
|
2026-02-26 00:08:38 +01:00
|
|
|
|
"DELETE FROM storage.files \
|
|
|
|
|
|
WHERE folder_id IN ( \
|
|
|
|
|
|
SELECT id FROM storage.folders \
|
|
|
|
|
|
WHERE lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
|
|
|
|
|
)",
|
2026-02-20 12:27:52 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.execute(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete files: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
// Then permanently delete folder — CASCADE handles descendant folders
|
2026-02-14 20:22:19 +01:00
|
|
|
|
let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid")
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.execute(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
if result.rows_affected() == 0 {
|
|
|
|
|
|
return Err(DomainError::not_found("Folder", folder_id));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 13:33:18 +01:00
|
|
|
|
async fn create_home_folder(&self, user_id: &str, name: String) -> Result<Folder, DomainError> {
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
r#"
|
|
|
|
|
|
INSERT INTO storage.folders (name, parent_id, user_id)
|
|
|
|
|
|
VALUES ($1, NULL, $2)
|
|
|
|
|
|
ON CONFLICT DO NOTHING
|
|
|
|
|
|
RETURNING id::text,
|
2026-02-16 13:10:44 +01:00
|
|
|
|
path,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
2026-02-16 16:18:39 +01:00
|
|
|
|
.bind(&name)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
match row {
|
2026-02-21 13:33:18 +01:00
|
|
|
|
Some((id, path, ca, ma)) => Self::row_to_folder(
|
|
|
|
|
|
id,
|
|
|
|
|
|
name.clone(),
|
|
|
|
|
|
path,
|
|
|
|
|
|
None,
|
|
|
|
|
|
Some(user_id.to_string()),
|
|
|
|
|
|
ca,
|
|
|
|
|
|
ma,
|
|
|
|
|
|
),
|
2026-02-14 20:22:19 +01:00
|
|
|
|
None => {
|
|
|
|
|
|
// Already exists — fetch it
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let existing = sqlx::query_as::<_, (String, String, i64, i64)>(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
r#"
|
|
|
|
|
|
SELECT id::text,
|
2026-02-16 13:10:44 +01:00
|
|
|
|
path,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE name = $1 AND user_id = $2 AND parent_id IS NULL
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
2026-02-16 16:18:39 +01:00
|
|
|
|
.bind(&name)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?;
|
2026-02-21 13:33:18 +01:00
|
|
|
|
Self::row_to_folder(
|
|
|
|
|
|
existing.0,
|
|
|
|
|
|
name,
|
|
|
|
|
|
existing.1,
|
|
|
|
|
|
None,
|
|
|
|
|
|
Some(user_id.to_string()),
|
|
|
|
|
|
existing.2,
|
|
|
|
|
|
existing.3,
|
|
|
|
|
|
)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-23 00:17:40 +01:00
|
|
|
|
|
2026-02-24 12:18:38 +01:00
|
|
|
|
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Single GiST-indexed query: `fo.lpath <@ (root's lpath)`.
|
|
|
|
|
|
/// Ordered by `fo.path` so callers can iterate in directory order.
|
2026-02-25 10:28:34 +01:00
|
|
|
|
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
|
2026-02-24 12:18:38 +01:00
|
|
|
|
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
|
|
|
|
|
fo.user_id::text, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
|
|
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.is_trashed = false \
|
|
|
|
|
|
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
|
|
|
|
|
ORDER BY fo.path";
|
|
|
|
|
|
|
2026-02-25 10:28:34 +01:00
|
|
|
|
let rows: Vec<(
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
Option<String>,
|
|
|
|
|
|
Option<String>,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
)> = sqlx::query_as(sql)
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("FolderDb", format!("subtree folders: {e}"))
|
|
|
|
|
|
})?;
|
2026-02-24 12:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
|
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
/// SQL-level folder search with name filter, user isolation, and
|
|
|
|
|
|
/// recursive / non-recursive modes.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// - Non-recursive: `WHERE parent_id = $1 AND user_id = $2 [AND LIKE]`
|
|
|
|
|
|
/// - Recursive + folder_id: delegates to `list_descendant_folders`
|
|
|
|
|
|
/// - Recursive + no folder_id: `WHERE user_id = $1 [AND LIKE]`
|
|
|
|
|
|
async fn search_folders(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
name_contains: Option<&str>,
|
|
|
|
|
|
user_id: &str,
|
|
|
|
|
|
recursive: bool,
|
|
|
|
|
|
) -> Result<Vec<Folder>, DomainError> {
|
|
|
|
|
|
// Recursive with folder scope → existing optimised ltree scan
|
2026-02-25 10:28:34 +01:00
|
|
|
|
if recursive && let Some(fid) = parent_id {
|
|
|
|
|
|
return self
|
|
|
|
|
|
.list_descendant_folders(fid, name_contains, user_id)
|
|
|
|
|
|
.await;
|
2026-02-24 17:15:36 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build optional name filter
|
|
|
|
|
|
let (name_clause, name_pattern) = match name_contains {
|
|
|
|
|
|
Some(name) if !name.is_empty() => (
|
|
|
|
|
|
if recursive {
|
|
|
|
|
|
" AND LOWER(fo.name) LIKE $2"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
" AND LOWER(fo.name) LIKE $3"
|
|
|
|
|
|
},
|
|
|
|
|
|
Some(format!("%{}%", name.to_lowercase())),
|
|
|
|
|
|
),
|
|
|
|
|
|
_ => ("", None),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if recursive {
|
|
|
|
|
|
// Recursive, no folder scope → ALL user folders
|
|
|
|
|
|
let sql = format!(
|
|
|
|
|
|
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
|
|
|
|
|
fo.user_id::text, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
|
|
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.user_id = $1 \
|
|
|
|
|
|
AND fo.is_trashed = false \
|
|
|
|
|
|
{name_clause} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-25 10:28:34 +01:00
|
|
|
|
let rows: Vec<(
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
Option<String>,
|
|
|
|
|
|
Option<String>,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
)> = if let Some(ref pattern) = name_pattern {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.bind(pattern)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
2026-02-24 17:15:36 +01:00
|
|
|
|
|
|
|
|
|
|
return rows
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Non-recursive: direct children of parent_id, filtered by user
|
|
|
|
|
|
let sql = if parent_id.is_some() {
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
|
|
|
|
|
fo.user_id::text, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
|
|
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.parent_id = $1::uuid \
|
|
|
|
|
|
AND fo.user_id = $2 \
|
|
|
|
|
|
AND fo.is_trashed = false \
|
|
|
|
|
|
{name_clause} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Root folders: parent_id IS NULL, reindex params ($1=user_id, $2=pattern)
|
|
|
|
|
|
let name_clause_root = match name_contains {
|
|
|
|
|
|
Some(name) if !name.is_empty() => " AND LOWER(fo.name) LIKE $2",
|
|
|
|
|
|
_ => "",
|
|
|
|
|
|
};
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
|
|
|
|
|
fo.user_id::text, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
|
|
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.parent_id IS NULL \
|
|
|
|
|
|
AND fo.user_id = $1 \
|
|
|
|
|
|
AND fo.is_trashed = false \
|
|
|
|
|
|
{name_clause_root} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-25 10:28:34 +01:00
|
|
|
|
let rows: Vec<(
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
Option<String>,
|
|
|
|
|
|
Option<String>,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
)> = if let Some(pid) = parent_id {
|
|
|
|
|
|
if let Some(ref pattern) = name_pattern {
|
2026-02-24 17:15:36 +01:00
|
|
|
|
sqlx::query_as(&sql)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(pid)
|
2026-02-24 17:15:36 +01:00
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.bind(pattern)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(pid)
|
2026-02-24 17:15:36 +01:00
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
2026-02-25 10:28:34 +01:00
|
|
|
|
} else if let Some(ref pattern) = name_pattern {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.bind(pattern)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
2026-02-24 17:15:36 +01:00
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
|
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 00:17:40 +01:00
|
|
|
|
/// Lists all descendant folders in a subtree using ltree GiST index.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Single SQL query: `fo.lpath <@ (root's lpath)` fetches the entire
|
|
|
|
|
|
/// subtree in one indexed scan. Optional name filter is pushed to SQL.
|
|
|
|
|
|
async fn list_descendant_folders(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: &str,
|
|
|
|
|
|
name_contains: Option<&str>,
|
|
|
|
|
|
user_id: &str,
|
|
|
|
|
|
) -> Result<Vec<Folder>, DomainError> {
|
|
|
|
|
|
let (where_extra, name_pattern) = match name_contains {
|
2026-02-25 10:28:34 +01:00
|
|
|
|
Some(name) if !name.is_empty() => (
|
|
|
|
|
|
" AND LOWER(fo.name) LIKE $3",
|
|
|
|
|
|
Some(format!("%{}%", name.to_lowercase())),
|
|
|
|
|
|
),
|
2026-02-23 00:17:40 +01:00
|
|
|
|
_ => ("", None),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let sql = format!(
|
|
|
|
|
|
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
|
|
|
|
|
fo.user_id::text, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
|
|
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.user_id = $1 \
|
|
|
|
|
|
AND fo.is_trashed = false \
|
|
|
|
|
|
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid) \
|
|
|
|
|
|
AND fo.id != $2::uuid \
|
|
|
|
|
|
{where_extra} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-02-25 10:28:34 +01:00
|
|
|
|
let rows: Vec<(
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
Option<String>,
|
|
|
|
|
|
Option<String>,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
)> = if let Some(ref pattern) = name_pattern {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.bind(pattern)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
|
2026-02-23 00:17:40 +01:00
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
|
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
2026-02-23 23:36:38 +01:00
|
|
|
|
|
|
|
|
|
|
async fn suggest_folders_by_name(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
query: &str,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
) -> Result<Vec<Folder>, DomainError> {
|
|
|
|
|
|
let pattern = format!("%{}%", query.to_lowercase());
|
|
|
|
|
|
let query_lower = query.to_lowercase();
|
|
|
|
|
|
let limit_i64 = limit as i64;
|
|
|
|
|
|
|
|
|
|
|
|
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> =
|
|
|
|
|
|
if let Some(pid) = parent_id {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
|
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
|
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id = $1::uuid
|
|
|
|
|
|
AND NOT is_trashed
|
|
|
|
|
|
AND LOWER(name) LIKE $2
|
|
|
|
|
|
ORDER BY CASE
|
|
|
|
|
|
WHEN LOWER(name) = $3 THEN 0
|
|
|
|
|
|
WHEN LOWER(name) LIKE $3 || '%' THEN 1
|
|
|
|
|
|
ELSE 2
|
|
|
|
|
|
END,
|
|
|
|
|
|
name
|
|
|
|
|
|
LIMIT $4
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.bind(&pattern)
|
|
|
|
|
|
.bind(&query_lower)
|
|
|
|
|
|
.bind(limit_i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
|
|
|
|
|
SELECT id::text, name, path, parent_id::text, user_id,
|
|
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
|
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id IS NULL
|
|
|
|
|
|
AND NOT is_trashed
|
|
|
|
|
|
AND LOWER(name) LIKE $1
|
|
|
|
|
|
ORDER BY CASE
|
|
|
|
|
|
WHEN LOWER(name) = $2 THEN 0
|
|
|
|
|
|
WHEN LOWER(name) LIKE $2 || '%' THEN 1
|
|
|
|
|
|
ELSE 2
|
|
|
|
|
|
END,
|
|
|
|
|
|
name
|
|
|
|
|
|
LIMIT $3
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(&pattern)
|
|
|
|
|
|
.bind(&query_lower)
|
|
|
|
|
|
.bind(limit_i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
|
|
|
|
|
.map(|(id, name, path, pid, uid, ca, ma)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
2026-02-16 16:18:39 +01:00
|
|
|
|
}
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-02-16 16:18:39 +01:00
|
|
|
|
// ── Extra helpers for blob-storage bootstrap ──
|
|
|
|
|
|
|
|
|
|
|
|
impl FolderDbRepository {
|
2026-02-14 20:22:19 +01:00
|
|
|
|
/// Returns user_id for a given folder. Used by file repositories.
|
|
|
|
|
|
pub async fn get_folder_user_id(&self, folder_id: &str) -> Result<String, DomainError> {
|
|
|
|
|
|
sqlx::query_scalar::<_, String>("SELECT user_id FROM storage.folders WHERE id = $1::uuid")
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|