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:
@@ -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<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]
|
||||
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<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 {
|
||||
@@ -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<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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user