feat(repo): join app_members with admin_users via list_for_app_enriched

Adds `AppMembershipDetail` (membership row + joined username, email,
instance_role, is_active) and `list_for_app_enriched` on
`AppMembersRepository`. The Postgres impl does a single JOIN on
admin_users ordered by username, so the upcoming `GET
/apps/{id}/members` handler can render its table without an N+1 fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 21:27:02 +02:00
parent 33697a2766
commit 1314420fca
2 changed files with 71 additions and 2 deletions

View File

@@ -8,7 +8,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, AppRole}; use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
use sqlx::PgPool; use sqlx::PgPool;
use crate::authz::{AuthzError, AuthzRepo}; use crate::authz::{AuthzError, AuthzRepo};
@@ -36,6 +36,20 @@ pub struct AppMembershipRow {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// `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<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
#[async_trait] #[async_trait]
pub trait AppMembersRepository: Send + Sync { pub trait AppMembersRepository: Send + Sync {
/// Single (user, app) lookup. Returns `None` for non-members and /// Single (user, app) lookup. Returns `None` for non-members and
@@ -78,6 +92,14 @@ pub trait AppMembersRepository: Send + Sync {
&self, &self,
app_id: AppId, app_id: AppId,
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>; ) -> Result<Vec<AppMembershipRow>, 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<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
} }
pub struct PostgresAppMembersRepository { pub struct PostgresAppMembersRepository {
@@ -172,6 +194,24 @@ impl AppMembersRepository for PostgresAppMembersRepository {
.await?; .await?;
rows.into_iter().map(TryInto::try_into).collect() rows.into_iter().map(TryInto::try_into).collect()
} }
async fn list_for_app_enriched(
&self,
app_id: AppId,
) -> Result<Vec<AppMembershipDetail>, 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 /// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
@@ -210,3 +250,31 @@ impl TryFrom<AppMembershipRecord> for AppMembershipRow {
}) })
} }
} }
#[derive(sqlx::FromRow)]
struct AppMembershipDetailRecord {
id: uuid::Uuid,
username: String,
email: Option<String>,
instance_role: String,
is_active: bool,
role: String,
created_at: DateTime<Utc>,
}
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
type Error = AppMembersRepositoryError;
fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
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,
})
}
}

View File

@@ -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_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository}; pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
pub use app_members_repo::{ pub use app_members_repo::{
AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository, AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
PostgresAppMembersRepository,
}; };
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository}; pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
pub use apps_api::{apps_router, AppsState}; pub use apps_api::{apps_router, AppsState};