Skip to content

Commit a1a3bd1

Browse files
author
Diocrafts
committed
fix: folder trash/delete operations & frontend refactoring
- Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' does not exist) - Fix folder deletion: delete descendant files before folder to avoid 'duplicate key violates unique constraint idx_files_unique_name_at_root' - Simplify trash model: only mark the folder as trashed, not child files (implicit trash via parent) - Update trash_items view: filter to show only top-level trashed items - Update schema.sql: change files.folder_id FK from ON DELETE SET NULL to ON DELETE CASCADE - Fix trash view icons: folders and files now show correct visual icons (folder-icon, pdf-icon, etc.) in trash view - Frontend refactoring: extract inline CSS/JS from admin.html and profile.html into dedicated external files - Frontend cleanup: replace all inline style attributes with CSS classes - Frontend cleanup: replace style.display JS - Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' d
1 parent 27eb7b1 commit a1a3bd1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3304
-3416
lines changed

db/schema.sql

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ CREATE OR REPLACE TRIGGER trg_folders_cascade_path
424424
CREATE TABLE IF NOT EXISTS storage.files (
425425
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
426426
name TEXT NOT NULL,
427-
folder_id UUID REFERENCES storage.folders(id) ON DELETE SET NULL,
427+
folder_id UUID REFERENCES storage.folders(id) ON DELETE CASCADE,
428428
user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
429429
blob_hash VARCHAR(64) NOT NULL,
430430
size BIGINT NOT NULL DEFAULT 0,
@@ -448,15 +448,27 @@ CREATE INDEX IF NOT EXISTS idx_files_blob_hash ON storage.files(blob_hash);
448448
CREATE INDEX IF NOT EXISTS idx_files_trashed ON storage.files(user_id, is_trashed);
449449
CREATE INDEX IF NOT EXISTS idx_files_name_search ON storage.files(user_id, name text_pattern_ops);
450450

451-
-- Trash view combining trashed files and folders for the TrashRepository
451+
-- Trash view combining trashed files and folders for the TrashRepository.
452+
-- Only shows top-level trashed items: excludes files/folders whose parent
453+
-- is also trashed (they are implicitly in trash as children of a trashed folder).
452454
CREATE OR REPLACE VIEW storage.trash_items AS
453-
SELECT id, name, 'file' AS item_type, user_id, trashed_at,
454-
original_folder_id AS original_parent_id, created_at
455-
FROM storage.files WHERE is_trashed = TRUE
455+
SELECT f.id, f.name, 'file' AS item_type, f.user_id, f.trashed_at,
456+
f.original_folder_id AS original_parent_id, f.created_at
457+
FROM storage.files f
458+
WHERE f.is_trashed = TRUE
459+
AND (f.folder_id IS NULL
460+
OR NOT EXISTS (
461+
SELECT 1 FROM storage.folders p
462+
WHERE p.id = f.folder_id AND p.is_trashed = TRUE))
456463
UNION ALL
457-
SELECT id, name, 'folder' AS item_type, user_id, trashed_at,
458-
original_parent_id, created_at
459-
FROM storage.folders WHERE is_trashed = TRUE;
464+
SELECT fo.id, fo.name, 'folder' AS item_type, fo.user_id, fo.trashed_at,
465+
fo.original_parent_id, fo.created_at
466+
FROM storage.folders fo
467+
WHERE fo.is_trashed = TRUE
468+
AND (fo.parent_id IS NULL
469+
OR NOT EXISTS (
470+
SELECT 1 FROM storage.folders p
471+
WHERE p.id = fo.parent_id AND p.is_trashed = TRUE));
460472

461473
COMMENT ON TABLE storage.folders IS 'Virtual folder hierarchy with ltree — no physical directories on disk';
462474
COMMENT ON TABLE storage.files IS 'File metadata pointing to content-addressable blobs';

src/infrastructure/repositories/pg/folder_db_repository.rs

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,24 @@ impl FolderRepository for FolderDbRepository {
457457
}
458458

459459
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
460-
// Hard delete folder and all descendants (CASCADE handles children)
460+
// First, delete all files in this folder and descendant folders
461+
// to avoid constraint violations from ON DELETE SET NULL
462+
sqlx::query(
463+
r#"
464+
WITH RECURSIVE descendants AS (
465+
SELECT id FROM storage.folders WHERE id = $1::uuid
466+
UNION ALL
467+
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
468+
)
469+
DELETE FROM storage.files WHERE folder_id IN (SELECT id FROM descendants)
470+
"#,
471+
)
472+
.bind(id)
473+
.execute(self.pool())
474+
.await
475+
.map_err(|e| DomainError::internal_error("FolderDb", format!("delete files: {e}")))?;
476+
477+
// Then delete the folder (CASCADE will remove descendant folders)
461478
let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid")
462479
.bind(id)
463480
.execute(self.pool())
@@ -501,9 +518,10 @@ impl FolderRepository for FolderDbRepository {
501518
// ── Trash operations ──
502519

503520
async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> {
504-
// Atomic CTE: trash folder + all descendant files in a single statement.
505-
// PostgreSQL executes the entire CTE as one atomic operation — no
506-
// intermediate state where the folder is trashed but files are not.
521+
// Only mark the folder itself as trashed.
522+
// Child files and sub-folders are implicitly hidden because their
523+
// ancestor is trashed — list queries already filter NOT is_trashed,
524+
// and folder navigation won't reach a trashed folder's children.
507525
let result = sqlx::query_scalar::<_, i64>(
508526
r#"
509527
WITH trash_folder AS (
@@ -513,17 +531,6 @@ impl FolderRepository for FolderDbRepository {
513531
original_parent_id = parent_id,
514532
updated_at = NOW()
515533
WHERE id = $1::uuid AND NOT is_trashed
516-
RETURNING id
517-
),
518-
descendants AS (
519-
SELECT id FROM trash_folder
520-
UNION ALL
521-
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
522-
),
523-
trash_files AS (
524-
UPDATE storage.files
525-
SET is_trashed = TRUE, trashed_at = NOW(), original_folder_id = folder_id
526-
WHERE folder_id IN (SELECT id FROM descendants) AND NOT is_trashed
527534
RETURNING 1
528535
)
529536
SELECT COUNT(*) FROM trash_folder
@@ -546,7 +553,9 @@ impl FolderRepository for FolderDbRepository {
546553
folder_id: &str,
547554
_original_path: &str,
548555
) -> Result<(), DomainError> {
549-
// Atomic CTE: restore folder + all descendant files in a single statement.
556+
// Only restore the folder itself.
557+
// Child files were never marked as trashed — they become visible
558+
// again automatically once their parent folder is un-trashed.
550559
// The BEFORE UPDATE trigger on parent_id will recompute path/lpath
551560
// automatically when original_parent_id is restored.
552561
let result = sqlx::query_scalar::<_, i64>(
@@ -559,20 +568,6 @@ impl FolderRepository for FolderDbRepository {
559568
original_parent_id = NULL,
560569
updated_at = NOW()
561570
WHERE id = $1::uuid AND is_trashed
562-
RETURNING id
563-
),
564-
descendants AS (
565-
SELECT id FROM restore_folder
566-
UNION ALL
567-
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
568-
),
569-
restore_files AS (
570-
UPDATE storage.files
571-
SET is_trashed = FALSE,
572-
trashed_at = NULL,
573-
folder_id = COALESCE(original_folder_id, folder_id),
574-
original_folder_id = NULL
575-
WHERE folder_id IN (SELECT id FROM descendants) AND is_trashed
576571
RETURNING 1
577572
)
578573
SELECT COUNT(*) FROM restore_folder
@@ -591,7 +586,23 @@ impl FolderRepository for FolderDbRepository {
591586
}
592587

593588
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> {
594-
// Permanently delete — CASCADE handles children
589+
// First, delete all files in this folder and descendant folders
590+
sqlx::query(
591+
r#"
592+
WITH RECURSIVE descendants AS (
593+
SELECT id FROM storage.folders WHERE id = $1::uuid
594+
UNION ALL
595+
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
596+
)
597+
DELETE FROM storage.files WHERE folder_id IN (SELECT id FROM descendants)
598+
"#,
599+
)
600+
.bind(folder_id)
601+
.execute(self.pool())
602+
.await
603+
.map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete files: {e}")))?;
604+
605+
// Then permanently delete folder — CASCADE handles descendant folders
595606
let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid")
596607
.bind(folder_id)
597608
.execute(self.pool())

0 commit comments

Comments
 (0)