feat(drive): tell UI the caller role per Drive
This commit is contained in:
@@ -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<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
/// 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<RoleDto>,
|
||||
}
|
||||
|
||||
impl From<DriveWithRootName> for DriveDto {
|
||||
@@ -77,6 +96,7 @@ impl From<DriveWithRootName> 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<crate::domain::services::authorization::Role>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -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<DriveWithRootName, DriveRepositoryError> {
|
||||
@@ -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<DriveWithRootName, DriveRepositoryError> {
|
||||
use crate::domain::services::authorization::Role;
|
||||
let mut dwr = Self::row_to_drive_with_name(row)?;
|
||||
let role_str: Option<String> = 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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user