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.
@@ -91,6 +91,24 @@ fn build_folders_with_flags(
Ok((folders, flags))
}
/// Row projected by `fetch_ancestor_walk`. One per folder in the
/// leaf→root walk (root order is reversed to root-first by the caller).
/// `has_folder_grant` = caller has a `role_grants` row on THIS folder;
/// `has_drive_grant` = caller has drive-membership on the containing drive.
/// Either grant satisfies Read; the split lets the service pick the right
/// `AccessSource` kind (`Drive` vs `DirectShare`).
#[derive(Debug, sqlx::FromRow)]
pub struct AncestorRow {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub drive_id: Uuid,
#[allow(dead_code)]
pub depth: i32,
pub has_folder_grant: bool,
pub has_drive_grant: bool,
}
/// Type alias for paginated folder rows (includes total_count as
/// the last element after the §14 provenance columns). Same
/// column set as [`FolderRow`] plus the trailing count.
@@ -1467,6 +1485,94 @@ impl FolderDbRepository {
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
}
/// Raw ancestor row returned by the recursive walk. `depth` is 0 at
/// the leaf, growing as we move up. `has_drive_grant` / `has_folder_grant`
/// are the two Read predicates the service uses to identify the
/// share/drive boundary and choose the access-source kind.
pub async fn fetch_ancestor_walk(
&self,
caller_id: Uuid,
leaf_id: Uuid,
) -> Result<Vec<AncestorRow>, DomainError> {
// Recursive CTE walks `parent_id` from the leaf upward. Group ids
// are hoisted into a one-row CTE so `caller_group_ids($1)` fires
// once per query instead of per ancestor row (perf: the function
// is `RECURSIVE` and non-trivial). Grant EXISTS are unions over
// user + group subjects; the drive-grant subquery matches the
// ambient `CALLER_CAN_READ_DRIVE` predicate used elsewhere so
// access decisions stay consistent across the repo.
let sql = r#"
WITH RECURSIVE
groups AS (
SELECT ARRAY(SELECT storage.caller_group_ids($1)) AS ids
),
chain AS (
SELECT id, name, parent_id, drive_id, 0::int AS depth
FROM storage.folders WHERE id = $2::uuid
UNION ALL
SELECT f.id, f.name, f.parent_id, f.drive_id, c.depth + 1
FROM storage.folders f
JOIN chain c ON f.id = c.parent_id
WHERE c.parent_id IS NOT NULL
AND c.depth < 64
)
SELECT
c.id,
c.name,
c.parent_id,
c.drive_id,
c.depth,
EXISTS (
SELECT 1 FROM storage.role_grants g, groups
WHERE g.resource_type = 'folder'
AND g.resource_id = c.id
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND ((g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id = ANY(groups.ids)))
) AS has_folder_grant,
EXISTS (
SELECT 1 FROM storage.role_grants g, groups
WHERE g.resource_type = 'drive'
AND g.resource_id = c.drive_id
AND (g.expires_at IS NULL OR g.expires_at > NOW())
AND ((g.subject_type = 'user' AND g.subject_id = $1)
OR (g.subject_type = 'group' AND g.subject_id = ANY(groups.ids)))
) AS has_drive_grant
FROM chain c
ORDER BY c.depth DESC
"#;
sqlx::query_as::<_, AncestorRow>(sql)
.bind(caller_id)
.bind(leaf_id)
.fetch_all(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("ancestor walk: {e}")))
}
/// Drive header (`id + name + kind`) for the drive-source arm of
/// `AccessSourceDto`. Read-only; no authz gate — the caller already
/// proved drive-membership via the ancestor walk before invoking.
///
/// Drive name lives on the drive's root folder, not the drive row
/// itself (`docs/plan/drive.md §3`). The JOIN resolves it; a drive
/// with a NULL `root_folder_id` returns None (backfill invariant
/// violation — surfaced as "drive vanished mid-query" in the caller).
pub async fn fetch_drive_header(
&self,
drive_id: Uuid,
) -> Result<Option<(Uuid, String, String)>, DomainError> {
sqlx::query_as::<_, (Uuid, String, String)>(
"SELECT d.id, fo.name, d.kind::text \
FROM storage.drives d \
JOIN storage.folders fo ON fo.id = d.root_folder_id \
WHERE d.id = $1::uuid",
)
.bind(drive_id)
.fetch_optional(self.pool())
.await
.map_err(|e| DomainError::internal_error("FolderDb", format!("drive header lookup: {e}")))
}
/// Cursor-paginated combined listing of sub-folders and files inside
/// `parent_id`, sorted by `order_by`.
///
+36 -2
View File
@@ -12,8 +12,8 @@ use crate::application::dtos::display_helpers::{
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery,
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
CreateFolderDto, FolderAncestorsDto, FolderDto, FolderResourceItemDto, FolderResourcesDto,
FolderResourcesQuery, ListResourcesOptions, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
use crate::application::ports::external_mount_ports::MountEntry;
@@ -112,6 +112,21 @@ impl FolderHandler {
}
}
/// `GET /api/folders/{id}/ancestors` — parent-chain + access-source
/// for the shared breadcrumb component. See `FolderAncestorsDto`
/// for the response shape. Anti-enum via `NotFound` on Read denial.
pub(super) async fn get_folder_ancestors_impl(
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.get_ancestors_with_perms(&id, auth_user.id).await {
Ok(dto) => (StatusCode::OK, Json(dto)).into_response(),
Err(err) => AppError::from(err).into_response(),
}
}
/// Lists root folders for the authenticated user.
/// Only returns folders owned by this user — no information disclosure.
pub(super) async fn list_root_folders_impl(
@@ -367,6 +382,25 @@ pub async fn get_folder(
FolderHandler::get_folder_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders/{id}/ancestors",
params(("id" = String, Path, description = "Leaf folder ID — the walk starts here and climbs the parent chain up to the drive root or the caller's share/drive-membership boundary.")),
responses(
(status = 200, description = "Ancestor chain + access-source. `ancestors` is root-first, leaf-last (length ≥ 1). See `FolderAncestorsDto`.", body = FolderAncestorsDto),
(status = 404, description = "Folder not found or caller lacks Read (anti-enum)"),
),
security(("bearerAuth" = [])),
tag = "folders"
)]
pub async fn get_folder_ancestors(
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> impl IntoResponse {
FolderHandler::get_folder_ancestors_impl(state, auth_user, path).await
}
#[utoipa::path(
get,
path = "/api/folders",
+12 -1
View File
@@ -20,7 +20,9 @@ use crate::application::dtos::favorites_dto::{
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{
CreateFolderDto, FolderDto, FolderResourceItemDto, MoveFolderDto, RenameFolderDto,
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, AccessSourceSubjectDto,
AccessSourceSubjectKind, CreateFolderDto, FolderAncestorDto, FolderAncestorsDto, FolderDto,
FolderResourceItemDto, MoveFolderDto, RenameFolderDto,
};
use crate::application::dtos::folder_listing_dto::FolderListingDto;
use crate::application::dtos::grant_dto::{
@@ -96,6 +98,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
// Folder handlers (free functions — see folder_handler.rs for why)
handlers::folder_handler::create_folder,
handlers::folder_handler::get_folder,
handlers::folder_handler::get_folder_ancestors,
handlers::folder_handler::list_root_folders,
handlers::folder_handler::list_folder_resources,
handlers::folder_handler::rename_folder,
@@ -264,6 +267,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
FolderListingDto,
FolderResourceItemDto,
ResourceContentDto,
// Folder ancestor chain (breadcrumb endpoint)
FolderAncestorsDto,
FolderAncestorDto,
AccessSourceDto,
AccessSourceKind,
AccessSourceDriveDto,
AccessSourceSubjectDto,
AccessSourceSubjectKind,
// File schemas
FileDto,
// Delta-upload schemas
+6 -1
View File
@@ -82,7 +82,7 @@ use crate::interfaces::api::handlers::file_handler::{
list_files_query, move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
};
use crate::interfaces::api::handlers::folder_handler::{
create_folder, delete_folder_with_trash, download_folder_zip, get_folder,
create_folder, delete_folder_with_trash, download_folder_zip, get_folder, get_folder_ancestors,
list_folder_resources, list_root_folders, move_folder, rename_folder,
};
use crate::interfaces::api::handlers::i18n_handler::{
@@ -218,6 +218,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let folders_crud_router = Router::new()
.route("/", post(create_folder))
.route("/{id}", get(get_folder))
// Ancestor chain for the shared breadcrumb component — one
// round-trip vs the pre-2026-07-26 per-segment `getFolder` walk
// on the /files client. Returns caller-visible parents (walk
// stops at share/drive-membership boundary) + access_source.
.route("/{id}/ancestors", get(get_folder_ancestors))
.route("/{id}/rename", put(rename_folder))
.route("/{id}/move", put(move_folder))
.with_state(app_state.clone());