diff --git a/crates/manager-core/src/app_members_repo.rs b/crates/manager-core/src/app_members_repo.rs index d8af940..9d157d8 100644 --- a/crates/manager-core/src/app_members_repo.rs +++ b/crates/manager-core/src/app_members_repo.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use picloud_shared::{AdminUserId, AppId, AppRole}; +use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole}; use sqlx::PgPool; use crate::authz::{AuthzError, AuthzRepo}; @@ -36,6 +36,20 @@ pub struct AppMembershipRow { pub created_at: DateTime, } +/// `app_members` row joined with `admin_users` so the dashboard's +/// Members tab can render usernames / emails / status without an N+1 +/// fetch per row. Drives `GET /apps/{id}/members`. +#[derive(Debug, Clone)] +pub struct AppMembershipDetail { + pub user_id: AdminUserId, + pub username: String, + pub email: Option, + pub instance_role: InstanceRole, + pub is_active: bool, + pub role: AppRole, + pub created_at: DateTime, +} + #[async_trait] pub trait AppMembersRepository: Send + Sync { /// Single (user, app) lookup. Returns `None` for non-members and @@ -78,6 +92,14 @@ pub trait AppMembersRepository: Send + Sync { &self, app_id: AppId, ) -> Result, AppMembersRepositoryError>; + + /// Like `list_for_app` but joined with `admin_users` so the + /// dashboard can render member rows in one round-trip. Ordered by + /// username for a stable list. + async fn list_for_app_enriched( + &self, + app_id: AppId, + ) -> Result, AppMembersRepositoryError>; } pub struct PostgresAppMembersRepository { @@ -172,6 +194,24 @@ impl AppMembersRepository for PostgresAppMembersRepository { .await?; rows.into_iter().map(TryInto::try_into).collect() } + + async fn list_for_app_enriched( + &self, + app_id: AppId, + ) -> Result, AppMembersRepositoryError> { + let rows = sqlx::query_as::<_, AppMembershipDetailRecord>( + "SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \ + am.role, am.created_at \ + FROM app_members am \ + JOIN admin_users au ON au.id = am.user_id \ + WHERE am.app_id = $1 \ + ORDER BY au.username", + ) + .bind(app_id.into_inner()) + .fetch_all(&self.pool) + .await?; + rows.into_iter().map(TryInto::try_into).collect() + } } /// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly @@ -210,3 +250,31 @@ impl TryFrom for AppMembershipRow { }) } } + +#[derive(sqlx::FromRow)] +struct AppMembershipDetailRecord { + id: uuid::Uuid, + username: String, + email: Option, + instance_role: String, + is_active: bool, + role: String, + created_at: DateTime, +} + +impl TryFrom for AppMembershipDetail { + type Error = AppMembersRepositoryError; + fn try_from(r: AppMembershipDetailRecord) -> Result { + Ok(Self { + user_id: r.id.into(), + username: r.username, + email: r.email, + instance_role: InstanceRole::from_db_str(&r.instance_role) + .ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?, + is_active: r.is_active, + role: AppRole::from_db_str(&r.role) + .ok_or(AppMembersRepositoryError::InvalidRole(r.role))?, + created_at: r.created_at, + }) + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index d24fd0b..e2e2df5 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -46,7 +46,8 @@ pub use api_keys_api::{api_keys_router, ApiKeysState}; pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome}; pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository}; pub use app_members_repo::{ - AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository, + AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow, + PostgresAppMembersRepository, }; pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository}; pub use apps_api::{apps_router, AppsState};