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:
Edouard Vanbelle
2026-07-26 21:31:04 +02:00
parent 0efbf0ff85
commit 3b31b8911b
33 changed files with 1342 additions and 221 deletions
+105
View File
@@ -368,3 +368,108 @@ pub struct FolderResourceItemDto {
/// Response envelope for `GET /api/folders/{id}/resources`.
pub type FolderResourcesDto = CursorListResponse<FolderResourceItemDto>;
// ═══════════════════════════════════════════════════════════════════════════
// Folder ancestor chain (`GET /api/folders/{id}/ancestors`)
// ═══════════════════════════════════════════════════════════════════════════
//
// Serves the shared breadcrumb component on `/files` (and, when re-wired,
// `/search`). One round-trip returns the whole caller-visible parent chain
// plus an `access_source` describing HOW the caller reached the topmost
// accessible ancestor (own drive / shared drive / direct folder share).
// See docs/plan/… — added 2026-07-26.
/// Single crumb in the walk from the drive root (or share-boundary) down
/// to the leaf. Present only for ancestors the caller has Read on; the
/// walk stops at the first inaccessible parent.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct FolderAncestorDto {
pub id: Uuid,
pub name: String,
/// `None` on the drive-root folder. On boundary crumbs it's the id
/// of the (invisible-to-caller) parent — clients don't render it
/// but the field is preserved for debugging.
pub parent_id: Option<Uuid>,
/// Drive the folder belongs to. Always populated (every folder has
/// a drive_id in the D0+ schema). Lets clients derive the current
/// drive from `ancestors.at(-1).drive_id` without a second
/// `GET /api/folders/{id}` round-trip — the ancestors response is
/// the authoritative "everything I need for the folder-context
/// header" call. See 2026-07-26 UX pass on /files load traffic.
pub drive_id: Uuid,
}
/// How the caller reached the topmost accessible ancestor. Drives the
/// breadcrumb's root icon + tooltip.
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AccessSourceKind {
/// Caller reached the topmost ancestor via drive membership (own
/// personal drive OR a shared drive they are a member of). The
/// `drive` field carries the drive info; render its `kind`-specific
/// icon + name.
Drive,
/// Caller reached the topmost ancestor via a direct folder-level
/// `role_grants` row (share). No drive-membership Read on any
/// ancestor. The `subject` field (if known) says who was granted
/// (self or a group); render the share icon.
DirectShare,
/// Reserved for public/token access. Not emitted by the MVP
/// endpoint — no live UI code path drives an authenticated /files
/// request via token yet.
#[allow(dead_code)]
Token,
}
/// Drive info for `AccessSourceKind::Drive`. Split out so serde can drop
/// it (`skip_serializing_if = "Option::is_none"`) when the kind isn't drive.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AccessSourceDriveDto {
pub id: Uuid,
pub name: String,
pub kind: crate::application::dtos::drive_dto::DriveKindDto,
}
/// Access-source detail returned alongside the ancestors chain.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AccessSourceDto {
pub kind: AccessSourceKind,
/// Populated when `kind == Drive`. Null otherwise.
#[serde(skip_serializing_if = "Option::is_none")]
pub drive: Option<AccessSourceDriveDto>,
/// Populated when a `role_grants` row identifies the grantee (self
/// or a group). MVP leaves this null — subject enrichment (grantor
/// name / group name lookup) is a follow-up. Once populated the FE
/// tooltip becomes "shared with **your team**" / "shared with **you
/// by X**" instead of the generic "shared with you".
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<AccessSourceSubjectDto>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AccessSourceSubjectKind {
User,
Group,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct AccessSourceSubjectDto {
pub kind: AccessSourceSubjectKind,
pub id: Uuid,
/// Display name (username / group name). MVP leaves this out — the
/// endpoint returns `subject: None` entirely rather than emitting a
/// half-populated `{id, name: null}`.
pub name: Option<String>,
}
/// Response envelope for `GET /api/folders/{id}/ancestors`.
///
/// `ancestors` is root-first (drive root or share boundary as element
/// 0), leaf-last. Length ≥ 1 (the leaf itself is always included).
/// `access_source` describes the boundary at element 0.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct FolderAncestorsDto {
pub ancestors: Vec<FolderAncestorDto>,
pub access_source: AccessSourceDto,
}
+118 -1
View File
@@ -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.