feat(breadcrumb): build breadcrumb in 1 API call
add /api/folders/{id}/ancestors
this API to iterate parent up to the drive root or the shared folder
this will help UI to build the breadcrumb in 1 API call
and to identify the root element (is it a drive users has access to or
a shared folder ?)
ui: now only 1 API call is now required to build the breadcrumb
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::drive_dto::DriveKindDto;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
|
||||
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, CreateFolderDto, FolderAncestorDto,
|
||||
FolderAncestorsDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
|
||||
MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
@@ -1004,6 +1006,121 @@ fn cross_boundary_move_err() -> DomainError {
|
||||
// ── FolderService — cursor-paginated resource listing ────────────────────────
|
||||
|
||||
impl FolderService {
|
||||
/// Ancestor chain for the shared breadcrumb component. Returns the
|
||||
/// list of folders from the caller-visible root (drive root or
|
||||
/// share boundary) down to the leaf, plus an `access_source`
|
||||
/// describing HOW the caller reached that topmost ancestor.
|
||||
///
|
||||
/// AuthZ: requires `Read` on the leaf. Anti-enum via `NotFound` on
|
||||
/// denial (the `require` helper turns denials into 404 to match
|
||||
/// listing endpoints — same pattern used by `get_folder_with_perms`).
|
||||
///
|
||||
/// Boundary detection: the recursive SQL walks all the way to the
|
||||
/// drive root and reports two Read predicates per ancestor
|
||||
/// (`has_folder_grant`, `has_drive_grant`). We drop ancestors that
|
||||
/// have NEITHER — that's a folder the caller can't Read, which by
|
||||
/// definition means everything above it is also invisible to them.
|
||||
/// The last surviving ancestor is the "root of this caller's view."
|
||||
///
|
||||
/// Access-source kind: `Drive` when the topmost accessible ancestor's
|
||||
/// Read came (even in part) from drive-membership; `DirectShare`
|
||||
/// otherwise. `Token` is reserved for future public-link callers.
|
||||
/// Subject enrichment (grantor / group name) is deferred — MVP
|
||||
/// returns `subject: None` and the FE renders a generic tooltip.
|
||||
pub async fn get_ancestors_with_perms(
|
||||
&self,
|
||||
leaf_id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FolderAncestorsDto, DomainError> {
|
||||
// Gate: caller must have Read on the leaf. Denial → 404 (anti-enum).
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Self::folder_resource(leaf_id)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let leaf_uuid =
|
||||
Uuid::parse_str(leaf_id).map_err(|_| DomainError::not_found("Folder", leaf_id))?;
|
||||
|
||||
let mut rows = self
|
||||
.folder_storage
|
||||
.fetch_ancestor_walk(caller_id, leaf_uuid)
|
||||
.await?;
|
||||
if rows.is_empty() {
|
||||
return Err(DomainError::not_found("Folder", leaf_id));
|
||||
}
|
||||
// Repo returns root-first (ORDER BY depth DESC). Walk from index 0
|
||||
// (topmost) and drop entries with NO Read grant — that's the
|
||||
// share/drive boundary, everything above is invisible.
|
||||
let boundary = rows
|
||||
.iter()
|
||||
.position(|r| r.has_folder_grant || r.has_drive_grant)
|
||||
.unwrap_or(rows.len());
|
||||
rows.drain(..boundary);
|
||||
if rows.is_empty() {
|
||||
// Shouldn't happen: `authz.require(Read, leaf)` above passed,
|
||||
// so at least the leaf must have some Read source. Defensive
|
||||
// 404 rather than emit an empty chain.
|
||||
return Err(DomainError::not_found("Folder", leaf_id));
|
||||
}
|
||||
|
||||
// The topmost surviving row is the root of the caller's view.
|
||||
// Its grant profile drives `AccessSource`.
|
||||
let top = &rows[0];
|
||||
let access_source = if top.has_drive_grant {
|
||||
// Drive-membership Read — even if a direct folder grant also
|
||||
// exists, the drive channel is the more useful "how did I
|
||||
// get here" signal (it names the drive the caller sees in
|
||||
// their picker). Fetch the drive header for id/name/kind.
|
||||
// `.map` (not `match`) — the drive-vanished-mid-query fallback
|
||||
// is a straight `None`, no side effects; clippy's manual_map
|
||||
// lint prefers this shape.
|
||||
let drive = self
|
||||
.folder_storage
|
||||
.fetch_drive_header(top.drive_id)
|
||||
.await?
|
||||
.map(|(id, name, kind_str)| AccessSourceDriveDto {
|
||||
id,
|
||||
name,
|
||||
kind: match kind_str.as_str() {
|
||||
"personal" => DriveKindDto::Personal,
|
||||
_ => DriveKindDto::Shared,
|
||||
},
|
||||
});
|
||||
AccessSourceDto {
|
||||
kind: AccessSourceKind::Drive,
|
||||
drive,
|
||||
subject: None,
|
||||
}
|
||||
} else {
|
||||
// Direct folder-level grant (share). Subject enrichment is a
|
||||
// follow-up (see the DTO comment) — MVP surfaces the kind and
|
||||
// lets the FE render a generic "shared with you" tooltip.
|
||||
AccessSourceDto {
|
||||
kind: AccessSourceKind::DirectShare,
|
||||
drive: None,
|
||||
subject: None,
|
||||
}
|
||||
};
|
||||
|
||||
let ancestors = rows
|
||||
.into_iter()
|
||||
.map(|r| FolderAncestorDto {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
parent_id: r.parent_id,
|
||||
drive_id: r.drive_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(FolderAncestorsDto {
|
||||
ancestors,
|
||||
access_source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cursor-paginated listing of sub-folders **and** files inside `parent_id`.
|
||||
///
|
||||
/// Enforces `Permission::Read` on the parent folder before querying.
|
||||
|
||||
Reference in New Issue
Block a user