feat(drive): plug trash to drives

This commit is contained in:
Edouard Vanbelle
2026-06-23 21:08:55 +02:00
parent 062bcb701b
commit 184520c17a
10 changed files with 324 additions and 69 deletions
+8
View File
@@ -120,6 +120,14 @@ export interface TrashResourceItem {
resource_type: ItemType;
trashed_at: string;
deletion_date: string;
/**
* Drive the trashed item belongs to (D2b). Enables client-side
* group-by-drive in the `/trash` UI without resolving the drive from
* `resource.drive_id` per row. The drive's display name resolves
* against `drives.svelte` (the in-memory store already populated by
* the sidebar picker / config pages — no extra round-trip).
*/
drive_id: string;
resource: FileItem | FolderItem;
}
+45 -4
View File
@@ -10,7 +10,7 @@
restoreTrashItem
} from '$lib/api/endpoints/trash';
import { dateBucket, sizeBucket, typeLabel } from '$lib/api/endpoints/favorites';
import type { FileItem, TrashResourceItem } from '$lib/api/types';
import type { Drive, FileItem, TrashResourceItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import ResourceList, {
type GroupByDef,
@@ -18,6 +18,7 @@
} from '$lib/components/ResourceList.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { drives as drivesStore } from '$lib/stores/drives.svelte';
import { ui } from '$lib/stores/ui.svelte';
let raw = $state<TrashResourceItem[]>([]);
@@ -41,13 +42,48 @@
// `date` carries the deletion date — rendered as an expiry chip.
date: it.deletion_date,
category: isFile ? it.resource.category : 'Folder',
modifiedAt: it.trashed_at
modifiedAt: it.trashed_at,
// D2b: surface drive_id so the Drive group-by can bucket by it.
// Reuses the existing `ownerId` slot on ResourceEntry — both
// represent a UUID the listing pivots on; no new field needed.
ownerId: it.drive_id
};
})
);
// "Drive" group rank: default-personal first, then secondary personal, then
// shared — matches `DrivePicker.svelte::sortedDrives` so the sidebar and
// trash sections agree on ordering. Used as the bucket sort key.
function driveRank(d: Drive | null): number {
if (!d) return 99;
if (d.default_for_user) return 0;
return d.kind === 'personal' ? 1 : 2;
}
function driveLabel(driveId: string): string {
const d = drivesStore.findById(driveId);
return d?.name ?? driveId;
}
function driveBucketKey(driveId: string): string {
// Bucket key has to be a string but we want ordering; prefix with
// rank so the natural lexical sort puts buckets in the picker's order.
const d = drivesStore.findById(driveId);
const rank = driveRank(d).toString().padStart(2, '0');
return `${rank}:${driveId}`;
}
function driveBucketLabel(key: string): string {
const driveId = key.split(':')[1] ?? key;
return driveLabel(driveId);
}
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name' },
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
{
key: 'drive',
label: t('trash.groupby.drive', 'Drive'),
orderBy: 'name',
bucketOf: (e) => (e.ownerId ? driveBucketKey(e.ownerId) : null),
labelOf: driveBucketLabel
},
{
key: 'remainingDays',
label: t('trash.groupby.remaining_days', 'Remaining days'),
@@ -148,7 +184,12 @@
}
}
onMount(() => load(true));
onMount(() => {
// Drive names for the "Drive" group-by labels — `drivesStore.load()` is
// idempotent (cached on the singleton) so this is essentially free.
void drivesStore.load();
void load(true);
});
</script>
<svelte:head><title>{t('nav.trash', 'Trash')} · OxiCloud</title></svelte:head>
@@ -0,0 +1,51 @@
-- D2b — surface `drive_id` on the unified trash view.
--
-- `storage.trash_items` is the read-side projection of soft-deleted files and
-- folders. D0 added `drive_id` to both `storage.files` and `storage.folders`,
-- but the view shipped before that and still reflects only `user_id`. D2b's
-- per-drive trash authorisation needs `drive_id` per row so:
--
-- 1. Listing can filter to drives the caller can read (the storage
-- precheck — `pg_acl_engine.caller_role_on_drive_cached` — replaces
-- the legacy `WHERE user_id = caller_id` scope).
-- 2. The UI can group trash items by drive (per the D2b spec).
-- 3. The trash sweeper / orphan reclamation paths (added later in D2b)
-- can operate per-drive instead of per-user.
--
-- The view is `CREATE OR REPLACE`, so this is a pure schema-shape change —
-- no data migration needed. The two source columns (`storage.files.drive_id`,
-- `storage.folders.drive_id`) are both `NOT NULL` after D0's
-- `20260802100002_drives_not_null.sql`, so the projection inherits NOT NULL
-- semantics automatically (no `COALESCE` fallback needed).
-- `CREATE OR REPLACE VIEW` is restrictive: it can ADD columns at the END
-- but never re-order or rename existing ones. `drive_id` goes after every
-- pre-existing column so PG doesn't read this as renaming `trashed_at` →
-- `drive_id`. Column ORDER on the view changes; consumers SELECT by name
-- so they're unaffected.
CREATE OR REPLACE VIEW storage.trash_items AS
SELECT f.id, f.name, 'file' AS item_type, f.user_id, f.trashed_at,
f.original_folder_id AS original_parent_id, f.created_at,
f.drive_id
FROM storage.files f
WHERE f.is_trashed = TRUE
AND (f.folder_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders p
WHERE p.id = f.folder_id AND p.is_trashed = TRUE))
UNION ALL
SELECT fo.id, fo.name, 'folder' AS item_type, fo.user_id, fo.trashed_at,
fo.original_parent_id, fo.created_at,
fo.drive_id
FROM storage.folders fo
WHERE fo.is_trashed = TRUE
AND (fo.parent_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM storage.folders p
WHERE p.id = fo.parent_id AND p.is_trashed = TRUE));
COMMENT ON VIEW storage.trash_items IS
'Unified view of all trashed files and folders. `drive_id` added in D2b '
'so callers can scope by accessible drives (the legacy per-user scope '
'is being phased out alongside the user_id column in D7).';
+11
View File
@@ -61,6 +61,11 @@ pub struct TrashResourceRow {
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Drive the trashed item belongs to. Surfaced verbatim on the wire
/// (`TrashResourceItemDto.drive_id`) so the `/trash` UI can group by
/// drive without an extra lookup per row. D2b: filtering by drive is
/// done in SQL via `WHERE drive_id = ANY($accessible_drive_ids)`.
pub drive_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Feeds `File::compute_etag` so the trash listing's
/// `etag` matches what GET/HEAD/PROPFIND would return for the
@@ -162,6 +167,12 @@ pub struct TrashResourceItemDto {
pub trashed_at: DateTime<Utc>,
/// When the item will be permanently deleted by the retention sweeper.
pub deletion_date: DateTime<Utc>,
/// The drive the trashed item belongs to. Enables client-side
/// group-by-drive in the `/trash` UI (D2b spec — see
/// `project_trash_groupbys_d2b` memory). The drive's display name
/// resolves through the `/api/drives` listing the client already
/// holds in `drives.svelte` — no extra round-trip needed.
pub drive_id: Uuid,
/// Full resource details — shape determined by `resource_type`.
pub resource: ResourceContentDto,
}
+106 -20
View File
@@ -19,6 +19,7 @@ use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::file::File;
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
use crate::domain::services::authorization::ResourceKind;
@@ -70,6 +71,11 @@ pub struct TrashService {
/// Authz engine
authz: Arc<PgAclEngine>,
/// Drive repository — D2b uses it to resolve "drives the caller can read"
/// so trash listings filter by drive membership instead of the legacy
/// per-user scope.
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
/// Number of days items should be kept in trash before automatic cleanup
retention_days: u32,
}
@@ -85,6 +91,7 @@ impl TrashService {
dedup_service: Arc<DedupService>,
content_cache: Option<Arc<FileContentCache>>,
authz: Arc<PgAclEngine>,
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
) -> Self {
Self {
trash_repository,
@@ -95,6 +102,7 @@ impl TrashService {
file_deleted_hook: None,
content_cache,
authz,
drive_repo,
retention_days,
}
}
@@ -366,10 +374,7 @@ impl TrashUseCase for TrashService {
// Get the trash item
info!("Retrieving trash item from repository: ID={}", trash_id);
let item_result = self
.trash_repository
.get_trash_item(&trash_uuid, &user_uuid)
.await;
let item_result = self.trash_repository.get_trash_item(&trash_uuid).await;
match item_result {
Ok(Some(item)) => {
@@ -380,6 +385,21 @@ impl TrashUseCase for TrashService {
item.original_id()
);
// D2b stage 3: gate the restore on Delete permission against
// the item's original resource. The drive precheck in
// `pg_acl_engine` resolves Owner-on-drive → Delete-permission,
// so a drive Owner can restore items they didn't originally
// trash (per `drive.md §12`). 404-on-deny via `authz.require`
// matches the lookup-NotFound shape.
let original = item.original_id();
let resource = match item.item_type() {
TrashedItemType::File => Resource::File(original),
TrashedItemType::Folder => Resource::Folder(original),
};
self.authz
.require(Subject::User(user_id), Permission::Delete, resource)
.await?;
// Restore based on type
match item.item_type() {
TrashedItemType::File => {
@@ -540,10 +560,7 @@ impl TrashUseCase for TrashService {
// Get the trash item
info!("Retrieving trash item from repository: ID={}", trash_id);
let item_result = self
.trash_repository
.get_trash_item(&trash_uuid, &user_uuid)
.await;
let item_result = self.trash_repository.get_trash_item(&trash_uuid).await;
match item_result {
Ok(Some(item)) => {
@@ -554,6 +571,19 @@ impl TrashUseCase for TrashService {
item.original_id()
);
// D2b stage 3: gate the permanent-delete on Delete permission
// against the item's original resource (mirrors `restore_item`
// above). Drive Owners can hard-delete shared-drive items
// they didn't trash.
let original = item.original_id();
let resource = match item.item_type() {
TrashedItemType::File => Resource::File(original),
TrashedItemType::Folder => Resource::Folder(original),
};
self.authz
.require(Subject::User(user_id), Permission::Delete, resource)
.await?;
// Permanently delete based on type
match item.item_type() {
TrashedItemType::File => {
@@ -688,6 +718,41 @@ impl TrashUseCase for TrashService {
async fn empty_trash(&self, user_id: Uuid) -> Result<()> {
info!("Emptying trash for user {}", user_id);
// D2b stage 3: resolve the set of drives the caller can permanently
// delete content in. "Empty trash" means "drop every trashed item
// I have Delete on" — Owner role's bundle includes Delete; Editor /
// Viewer / Contributor / Commenter do not. So this filter picks out
// the drives where the caller is effectively Owner (direct or via a
// group). Single-drive users: this resolves to just their personal
// drive, identical to the legacy `WHERE user_id = $1` scope.
let (subject_types, subject_ids) = self
.authz
.expand_subject_for_listing(Subject::User(user_id))
.await?;
let drives = self
.drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
.map_err(|e| {
DomainError::internal_error(
"Trash",
format!("Failed to resolve accessible drives: {e:?}"),
)
})?;
let drive_ids: Vec<Uuid> = drives
.iter()
.filter(|d| {
d.caller_role
.is_some_and(|r| r.expand().contains(&Permission::Delete))
})
.map(|d| d.drive.id)
.collect();
if drive_ids.is_empty() {
info!("empty_trash: caller has Delete on no drive — nothing to do");
return Ok(());
}
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks (thumbnail
// cleanup, etc.) can run afterward. We use get_all_trashed_file_ids (not
// get_trash_items) because the trash_items view excludes files inside a
@@ -696,7 +761,7 @@ impl TrashUseCase for TrashService {
let trashed_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
match self
.trash_repository
.get_all_trashed_file_ids(&user_id)
.get_all_trashed_file_ids(&drive_ids)
.await
{
Ok(ids) => ids,
@@ -709,16 +774,14 @@ impl TrashUseCase for TrashService {
Vec::new()
};
// clear_trash() performs bulk SQL DELETEs in 2 queries:
// 1. DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE
// 2. DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE
// clear_trash() performs bulk SQL DELETEs in 2 queries (post D2b stage 3):
// 1. DELETE FROM storage.files WHERE drive_id = ANY($1) AND is_trashed = TRUE
// 2. DELETE FROM storage.folders WHERE drive_id = ANY($1) AND is_trashed = TRUE
//
// Folder deletion cascades (FK ON DELETE CASCADE) to child folders and
// their files. The PG trigger `trg_files_decrement_blob_ref` automatically
// decrements blob ref_counts for every deleted file row.
//
// Finally it clears the trash_items index for the user.
self.trash_repository.clear_trash(&user_id).await?;
self.trash_repository.clear_trash(&drive_ids).await?;
// The PG trigger decremented ref_counts but cannot delete disk files or
// thumbnails. Run garbage_collect() to remove any blobs whose ref_count
@@ -766,11 +829,32 @@ impl TrashService {
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<(Vec<TrashResourceItemDto>, Option<String>)> {
// D2b: scope by drives the caller can read (resolved through
// role_grants on resource_type='drive', including group-mediated
// grants). Empty set → empty page without a SQL round-trip.
let (subject_types, subject_ids) = self
.authz
.expand_subject_for_listing(Subject::User(user_id))
.await?;
let drive_ids: Vec<Uuid> = match self
.drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
{
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
Err(e) => {
return Err(DomainError::internal_error(
"Trash",
format!("Failed to resolve accessible drives: {e:?}"),
));
}
};
// Fetch one extra row to detect whether a next page exists.
let mut rows = self
.trash_repository
.list_resources_paged(
user_id,
&drive_ids,
limit + 1,
cursor.as_ref(),
order_by,
@@ -823,10 +907,10 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Trash listing — drive_id is informational and the trash
// row doesn't currently SELECT it. Path-based lookups
// never enter this code path.
drive_id: uuid::Uuid::nil(),
// D2b: the trash listing query now SELECTs `drive_id` (the
// unified view exposes it). Surfaced so per-drive grouping
// in the `/trash` UI doesn't need an extra lookup per row.
drive_id: row.drive_id,
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
@@ -841,6 +925,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
resource_type: ResourceTypeDto::Folder,
trashed_at: row.trashed_at,
deletion_date: row.deletion_date,
drive_id: row.drive_id,
resource: ResourceContentDto::Folder(dto),
}
} else {
@@ -884,6 +969,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
resource_type: ResourceTypeDto::File,
trashed_at: row.trashed_at,
deletion_date: row.deletion_date,
drive_id: row.drive_id,
resource: ResourceContentDto::File(dto),
}
}
+17 -18
View File
@@ -203,10 +203,7 @@ where
let trash_uuid = Uuid::parse_str(trash_id)
.map_err(|e| DomainError::validation_error(format!("Invalid trash ID: {}", e)))?;
let item = self
.trash_repository
.get_trash_item(&trash_uuid, &user_id)
.await?;
let item = self.trash_repository.get_trash_item(&trash_uuid).await?;
match item {
Some(item) => {
match item.item_type() {
@@ -265,10 +262,7 @@ where
let trash_uuid = Uuid::parse_str(trash_id)
.map_err(|e| DomainError::validation_error(format!("Invalid trash ID: {}", e)))?;
let item = self
.trash_repository
.get_trash_item(&trash_uuid, &user_id)
.await?;
let item = self.trash_repository.get_trash_item(&trash_uuid).await?;
match item {
Some(item) => {
match item.item_type() {
@@ -319,7 +313,11 @@ where
}
async fn empty_trash(&self, user_id: Uuid) -> Result<()> {
self.trash_repository.clear_trash(&user_id).await
// Test mock — treats `user_id` as a stand-in for the single accessible
// drive (the mock storage is single-user / single-drive). The
// production `TrashService::empty_trash` resolves the real drive set
// via `drive_repo.list_for_subjects` + role-bundle filter.
self.trash_repository.clear_trash(&[user_id]).await
}
}
@@ -364,13 +362,11 @@ impl TrashRepository for MockTrashRepository {
Ok(user_items)
}
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
async fn get_trash_item(&self, id: &Uuid) -> Result<Option<TrashedItem>> {
// Mock mirrors the production repo's no-filter lookup — the
// user-scoped check has moved into the service's `authz.require`.
let items = self.trash_items.lock().unwrap();
let item = items
.get(id)
.filter(|item| item.user_id() == *user_id)
.cloned();
Ok(item)
Ok(items.get(id).cloned())
}
async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()> {
@@ -393,16 +389,19 @@ impl TrashRepository for MockTrashRepository {
Ok(())
}
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()> {
// Test mock: the single-user/single-drive mock uses `user_id` as
// the drive proxy (see `empty_trash` in the service mock). Filter
// trash items whose owner is in the passed-in set.
let mut items = self.trash_items.lock().unwrap();
items.retain(|_, item| item.user_id() != *user_id);
items.retain(|_, item| !drive_ids.contains(&item.user_id()));
// Simulate PG CASCADE: clear trashed file/folder storage too
self.trashed_files.lock().unwrap().clear();
self.trashed_folders.lock().unwrap().clear();
Ok(())
}
async fn get_all_trashed_file_ids(&self, _user_id: &Uuid) -> Result<Vec<String>> {
async fn get_all_trashed_file_ids(&self, _drive_ids: &[Uuid]) -> Result<Vec<String>> {
let files = self.trashed_files.lock().unwrap();
Ok(files.keys().cloned().collect())
}
+3 -1
View File
@@ -768,6 +768,7 @@ impl AppServiceFactory {
repos: &RepositoryServices,
core: &CoreServices,
authz: &Arc<PgAclEngine>,
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
) -> Option<Arc<TrashService>> {
if !self.config.features.enable_trash {
tracing::info!("Trash service is disabled in configuration");
@@ -787,6 +788,7 @@ impl AppServiceFactory {
core.dedup_service.clone(),
Some(core.file_content_cache.clone()),
authz.clone(),
drive_repo.clone(),
)
.with_file_deleted_hook(core.file_lifecycle.clone()),
);
@@ -1137,7 +1139,7 @@ impl AppServiceFactory {
// 3b. Trash service (needed before application services)
let trash_service = self
.create_trash_service(&repos, &core, &authorization)
.create_trash_service(&repos, &core, &authorization, &drive_repo)
.await;
// 3c. Storage usage / quota service (needed by the instant-upload
+28 -6
View File
@@ -6,15 +6,37 @@ use crate::domain::entities::trashed_item::TrashedItem;
pub trait TrashRepository: Send + Sync {
async fn add_to_trash(&self, item: &TrashedItem) -> Result<()>;
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>>;
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>>;
/// Fetch a trashed item by its trash-row id.
///
/// **Caller contract**: this method does NO authorization check. Callers
/// MUST follow with
/// `authz.require(Permission::Delete, Resource::File|Folder(item.original_id()))`
/// before acting on the result. The trash service's `restore_item` and
/// `delete_permanently` are the canonical examples.
///
/// **Implementor contract**: do NOT re-introduce a `user_id` (or
/// `drive_id`) scope filter at the SQL layer. Authorization is the
/// service's job, not the repository's — adding a scope here would
/// silently break drive-Owner-restores-another-user's-trashed-item
/// (the canonical D2 use case). A direct-by-id lookup is the
/// intended shape.
async fn get_trash_item(&self, id: &Uuid) -> Result<Option<TrashedItem>>;
async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()>;
async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()>;
async fn clear_trash(&self, user_id: &Uuid) -> Result<()>;
/// Bulk-delete all trashed files and folders in the given drives.
///
/// **Caller contract**: pass only drive UUIDs the caller has
/// `Permission::Delete` on (resolved by the service via
/// `DriveRepository::list_for_subjects` + role-bundle filter). This
/// repository performs no authorization — see
/// `TrashService::empty_trash` for the canonical call site.
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()>;
/// All trashed file IDs for this user, regardless of parent folder trash status.
/// Used by empty_trash for thumbnail cleanup — the view used by get_trash_items
/// excludes files inside trashed folders, which would miss their ext thumbnails.
async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result<Vec<String>>;
/// All trashed file IDs across the given drives, regardless of parent
/// folder trash status. Used by `empty_trash` for thumbnail cleanup —
/// the trash_items view excludes files inside trashed folders, which
/// would miss their thumbnails. Same caller contract as `clear_trash`.
async fn get_all_trashed_file_ids(&self, drive_ids: &[Uuid]) -> Result<Vec<String>>;
/// Bulk-delete all expired trash items (files + folders) in a single
/// transaction. Returns `(files_deleted, folders_deleted)`.
@@ -147,18 +147,22 @@ impl TrashRepository for TrashDbRepository {
.collect())
}
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
async fn get_trash_item(&self, id: &Uuid) -> Result<Option<TrashedItem>> {
// D2b stage 3: lookup by id only — the `user_id` filter that used to
// gate this query is replaced by an explicit `authz.require(Delete,
// …)` in the service callers (`restore_item`, `delete_permanently`).
// The drive precheck in `pg_acl_engine` then resolves Owner-on-drive
// → Delete-permission for items in shared drives.
let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
r#"
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
COALESCE(p.path || '/' || t.name, t.name) AS original_path
FROM storage.trash_items t
LEFT JOIN storage.folders p ON p.id = t.original_parent_id
WHERE t.id = $1 AND t.user_id = $2
WHERE t.id = $1
"#,
)
.bind(id)
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
@@ -182,17 +186,22 @@ impl TrashRepository for TrashDbRepository {
Ok(())
}
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
// Delete all trashed files for this user
sqlx::query("DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE")
.bind(user_id)
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()> {
if drive_ids.is_empty() {
return Ok(());
}
// Delete all trashed files in the given drives.
sqlx::query("DELETE FROM storage.files WHERE drive_id = ANY($1) AND is_trashed = TRUE")
.bind(drive_ids)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("clear files: {e}")))?;
// Delete all trashed folders for this user
sqlx::query("DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE")
.bind(user_id)
// Delete all trashed folders in the given drives. FK ON DELETE CASCADE
// sweeps descendant rows; the `trg_files_decrement_blob_ref` trigger
// handles blob refcount drops automatically.
sqlx::query("DELETE FROM storage.folders WHERE drive_id = ANY($1) AND is_trashed = TRUE")
.bind(drive_ids)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("clear folders: {e}")))?;
@@ -200,11 +209,14 @@ impl TrashRepository for TrashDbRepository {
Ok(())
}
async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result<Vec<String>> {
async fn get_all_trashed_file_ids(&self, drive_ids: &[Uuid]) -> Result<Vec<String>> {
if drive_ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query_scalar::<_, String>(
"SELECT id::text FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE",
"SELECT id::text FROM storage.files WHERE drive_id = ANY($1) AND is_trashed = TRUE",
)
.bind(user_id)
.bind(drive_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("all_trashed_files: {e}")))?;
@@ -253,23 +265,34 @@ impl TrashRepository for TrashDbRepository {
// Cursor-paginated trash listing (used by GET /api/trash/resources)
// ════════════════════════════════════════════════════════════════════════════
impl TrashDbRepository {
/// Cursor-paginated list of the user's trashed resources.
/// Cursor-paginated list of trashed resources the caller can read.
///
/// D2b: scope is drive-membership-based — pass the set of drive UUIDs
/// the caller can read (resolved upstream by `DriveRepository::list_for_subjects`
/// or equivalent). Items in drives outside this set drop out at the
/// WHERE clause. The legacy `WHERE user_id = $1::uuid` filter is gone;
/// for single-drive users this returns exactly the same items as before
/// because the caller's own personal drive is always in `drive_ids`.
///
/// Mirrors the favorites/grants pattern: a UNION-ALL CTE over folder and
/// file branches (each pre-computing sort columns), then a per-dimension
/// keyset WHERE + ORDER BY.
///
/// Returns rows in caller-requested sort order. The caller is expected to
/// fetch `limit + 1` to detect end-of-results.
/// fetch `limit + 1` to detect end-of-results. Empty `drive_ids` returns
/// an empty page without hitting PG.
pub async fn list_resources_paged(
&self,
user_id: Uuid,
drive_ids: &[Uuid],
limit: usize,
cursor: Option<&TrashCursor>,
order_by: &str,
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<Vec<TrashResourceRow>> {
if drive_ids.is_empty() {
return Ok(Vec::new());
}
let include_folders =
kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder)));
let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File)));
@@ -291,6 +314,7 @@ impl TrashDbRepository {
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
@@ -299,7 +323,7 @@ impl TrashDbRepository {
0::bigint AS type_order,
0::int AS folder_first
FROM storage.folders fld
WHERE fld.user_id = $1::uuid
WHERE fld.drive_id = ANY($1)
AND fld.is_trashed = TRUE
AND (fld.parent_id IS NULL
OR NOT EXISTS (
@@ -317,6 +341,7 @@ impl TrashDbRepository {
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
@@ -327,7 +352,7 @@ impl TrashDbRepository {
FROM storage.files f
LEFT JOIN storage.folders pfld
ON pfld.id = f.folder_id
WHERE f.user_id = $1::uuid
WHERE f.drive_id = ANY($1)
AND f.is_trashed = TRUE
AND (f.folder_id IS NULL
OR NOT EXISTS (
@@ -447,7 +472,7 @@ impl TrashDbRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.trashed_at, r.deletion_date, r.resource_path,
r.owner_id, r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first
FROM resources r
{keyset}
@@ -456,7 +481,7 @@ LIMIT $6"
);
let rows = sqlx::query(&sql)
.bind(user_id) // $1
.bind(drive_ids) // $1 (was user_id pre-D2b)
.bind(cur_str) // $2
.bind(cur_int) // $3
.bind(cur_ts) // $4
@@ -505,6 +530,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
trashed_at,
deletion_date,
+9
View File
@@ -153,6 +153,10 @@ HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 7 – Default listing: 4 items, all carry trashed_at + deletion_date
# + drive_id (D2b — enables per-drive grouping in the UI).
# The listing is also drive-membership scoped (D2b stage 2): the
# admin sees their own personal drive's trashed items, just as
# before, because a personal drive Owner has all roles needed.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/trash/resources
Authorization: Bearer {{token}}
@@ -162,6 +166,11 @@ HTTP 200
jsonpath "$.items" count == 4
jsonpath "$.items[0].trashed_at" isString
jsonpath "$.items[0].deletion_date" isString
# D2b: drive_id is non-null on every trashed item.
jsonpath "$.items[0].drive_id" isString
jsonpath "$.items[1].drive_id" isString
jsonpath "$.items[2].drive_id" isString
jsonpath "$.items[3].drive_id" isString
jsonpath "$.items[0].resource_type" matches "^(file|folder)$"
jsonpath "$.items[*].resource.id" contains {{folder_a_id}}
jsonpath "$.items[*].resource.id" contains {{folder_b_id}}