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 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,
})
}
}