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
@@ -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`.
///