From 2fe9c9ffeb680db47b416888690d873fe846dde2 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 23 Jun 2026 00:09:12 +0200 Subject: [PATCH] feat(drive): tell UI the caller role per Drive --- src/application/dtos/drive_dto.rs | 20 +++++++++++ src/domain/repositories/drive_repository.rs | 11 ++++++ .../repositories/pg/drive_pg_repository.rs | 34 +++++++++++++++++-- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/application/dtos/drive_dto.rs b/src/application/dtos/drive_dto.rs index 42fbfd01..ed464646 100644 --- a/src/application/dtos/drive_dto.rs +++ b/src/application/dtos/drive_dto.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; +use crate::application::dtos::grant_dto::RoleDto; use crate::domain::entities::drive::DriveKind; use crate::domain::repositories::drive_repository::DriveWithRootName; @@ -62,6 +63,24 @@ pub struct DriveDto { pub policies: serde_json::Value, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, + + /// Highest role the **calling** user holds on this drive (direct OR + /// group-mediated). Populated by `GET /api/drives` and the drive + /// grants in `/api/grants/incoming/resources`. Omitted (`None`) in + /// contexts where the caller isn't naturally a member — e.g. the + /// outgoing-grants listing where the caller is the granter and may + /// not have a current role on the drive themselves. + /// + /// **UI gating**: + /// - `Some(Owner)` → mutation controls (add/edit/remove members, change policies, delete drive) + /// - `Some(Editor/Contributor/Commenter)` → mutate drive contents, no membership UI + /// - `Some(Viewer)` → read-only + /// - `None` → show neither member list nor mutation controls + /// + /// See `project_caller_role_on_file_folder_dto` memory for the + /// pattern extension to FileDto / FolderDto (deferred, perf-sensitive). + #[serde(skip_serializing_if = "Option::is_none")] + pub caller_role: Option, } impl From for DriveDto { @@ -77,6 +96,7 @@ impl From for DriveDto { policies: d.drive.policies, created_at: d.drive.created_at, updated_at: d.drive.updated_at, + caller_role: d.caller_role.map(RoleDto::from), } } } diff --git a/src/domain/repositories/drive_repository.rs b/src/domain/repositories/drive_repository.rs index b047d692..acec14aa 100644 --- a/src/domain/repositories/drive_repository.rs +++ b/src/domain/repositories/drive_repository.rs @@ -51,6 +51,17 @@ pub struct DriveWithRootName { /// The drive's display name. Sourced from `storage.folders.name` /// of the root folder via JOIN at read time. pub root_folder_name: String, + /// Highest role the calling user holds on this drive (direct OR + /// group-mediated). Populated by `list_for_subjects` (which already + /// JOINs `role_grants` for accessibility, so the role is in scope at + /// query time). `None` for repo methods called without a caller + /// context (`get_by_id`, `get_by_ids`, `find_default_for_user`, + /// `create_personal_drive_atomic`) — the DTO layer omits the field + /// via `#[serde(skip_serializing_if = "Option::is_none")]`. + /// + /// See [[project-caller-role-on-file-folder-dto]] for the pattern + /// extension to FileDto/FolderDto with a perf warning. + pub caller_role: Option, } #[async_trait::async_trait] diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index 299e3f55..9376998a 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -44,6 +44,9 @@ impl DrivePgRepository { /// Map a row carrying both the drive's columns AND a `root_folder_name` /// column (sourced via JOIN with `storage.folders`) into the view-model. + /// `caller_role` is left `None` — only the listing path (which + /// JOINs `role_grants` for accessibility) has it in scope; see + /// `row_to_drive_with_name_and_role`. fn row_to_drive_with_name( row: &sqlx::postgres::PgRow, ) -> Result { @@ -63,8 +66,25 @@ impl DrivePgRepository { Ok(DriveWithRootName { drive, root_folder_name: row.get("root_folder_name"), + caller_role: None, }) } + + /// Same as `row_to_drive_with_name` but reads `caller_role` from the + /// listing query — `MIN(g.role)::text`. The `storage.grant_role` ENUM + /// is declared owner→viewer (strongest→weakest), so `MIN` picks the + /// strongest of the caller's grants on the drive (direct + + /// group-mediated collapsed by GROUP BY). Used only by + /// `list_for_subjects`. + fn row_to_drive_with_name_and_role( + row: &sqlx::postgres::PgRow, + ) -> Result { + use crate::domain::services::authorization::Role; + let mut dwr = Self::row_to_drive_with_name(row)?; + let role_str: Option = row.try_get("caller_role").ok(); + dwr.caller_role = role_str.as_deref().and_then(Role::parse); + Ok(dwr) + } } #[async_trait::async_trait] @@ -260,12 +280,20 @@ impl DriveRepository for DrivePgRepository { // group-mediated) and sidesteps PostgreSQL's "ORDER BY // expression must appear in select list" rule that SELECT // DISTINCT imposes. + // `MIN(g.role)` picks the caller's strongest role on each drive: + // `storage.grant_role` is declared `owner → viewer` (strongest → + // weakest), so MIN returns the strongest. Cast `::text` matches + // the codebase convention for reading enum columns into Rust + // (see `pg_acl_engine.rs`); `Role::parse` handles the trip back. + // Collapses direct + group-mediated grants on the same drive + // into one row alongside the existing GROUP BY. let rows = sqlx::query( r#" SELECT d.id, d.kind, d.default_for_user, d.root_folder_id, d.quota_bytes, d.used_bytes, d.policies, d.created_at, d.updated_at, - f.name AS root_folder_name + f.name AS root_folder_name, + MIN(g.role)::text AS caller_role FROM storage.drives d JOIN storage.folders f ON f.id = d.root_folder_id JOIN storage.role_grants g @@ -292,6 +320,8 @@ impl DriveRepository for DrivePgRepository { .await .map_err(|e| Self::map_sqlx_err("list_for_subjects", e))?; - rows.iter().map(Self::row_to_drive_with_name).collect() + rows.iter() + .map(Self::row_to_drive_with_name_and_role) + .collect() } }