//! CRUD over the `group_members` table — explicit per-(user, group) role //! grants. A `group_admin` here is implicitly app_admin on every app and //! subgroup beneath the group; that inheritance is resolved by the authz //! ancestor walk (`app_members_repo::effective_app_role`), not here. //! //! Mirrors `app_members_repo` — same three role literals, same shapes. use async_trait::async_trait; use chrono::{DateTime, Utc}; use picloud_shared::{AdminUserId, AppRole, GroupId, InstanceRole}; use sqlx::PgPool; #[derive(Debug, thiserror::Error)] pub enum GroupMembersRepositoryError { #[error("database error: {0}")] Db(#[from] sqlx::Error), #[error("invalid app_role stored in DB: {0}")] InvalidRole(String), } #[derive(Debug, Clone)] pub struct GroupMembershipRow { pub group_id: GroupId, pub user_id: AdminUserId, pub role: AppRole, pub created_at: DateTime, } /// `group_members` row joined with `admin_users` for the dashboard's /// per-group Members tab. #[derive(Debug, Clone)] pub struct GroupMembershipDetail { 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 GroupMembersRepository: Send + Sync { /// Atomic insert. `None` if a membership already exists (handler → 409). async fn try_insert( &self, group_id: GroupId, user_id: AdminUserId, role: AppRole, ) -> Result, GroupMembersRepositoryError>; /// Atomic role update. `None` if no row exists (handler → 404). async fn update_role( &self, group_id: GroupId, user_id: AdminUserId, role: AppRole, ) -> Result, GroupMembersRepositoryError>; /// Remove a membership. No-op when the row doesn't exist. async fn remove( &self, group_id: GroupId, user_id: AdminUserId, ) -> Result<(), GroupMembersRepositoryError>; /// Per-group member list joined with `admin_users`, ordered by username. async fn list_for_group_enriched( &self, group_id: GroupId, ) -> Result, GroupMembersRepositoryError>; } pub struct PostgresGroupMembersRepository { pool: PgPool, } impl PostgresGroupMembersRepository { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl GroupMembersRepository for PostgresGroupMembersRepository { async fn try_insert( &self, group_id: GroupId, user_id: AdminUserId, role: AppRole, ) -> Result, GroupMembersRepositoryError> { let row = sqlx::query_as::<_, GroupMembershipRecord>( "INSERT INTO group_members (group_id, user_id, role) \ VALUES ($1, $2, $3) \ ON CONFLICT (group_id, user_id) DO NOTHING \ RETURNING group_id, user_id, role, created_at", ) .bind(group_id.into_inner()) .bind(user_id.into_inner()) .bind(role.as_str()) .fetch_optional(&self.pool) .await?; row.map(TryInto::try_into).transpose() } async fn update_role( &self, group_id: GroupId, user_id: AdminUserId, role: AppRole, ) -> Result, GroupMembersRepositoryError> { let row = sqlx::query_as::<_, GroupMembershipRecord>( "UPDATE group_members SET role = $1 \ WHERE group_id = $2 AND user_id = $3 \ RETURNING group_id, user_id, role, created_at", ) .bind(role.as_str()) .bind(group_id.into_inner()) .bind(user_id.into_inner()) .fetch_optional(&self.pool) .await?; row.map(TryInto::try_into).transpose() } async fn remove( &self, group_id: GroupId, user_id: AdminUserId, ) -> Result<(), GroupMembersRepositoryError> { sqlx::query("DELETE FROM group_members WHERE group_id = $1 AND user_id = $2") .bind(group_id.into_inner()) .bind(user_id.into_inner()) .execute(&self.pool) .await?; Ok(()) } async fn list_for_group_enriched( &self, group_id: GroupId, ) -> Result, GroupMembersRepositoryError> { let rows = sqlx::query_as::<_, GroupMembershipDetailRecord>( "SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \ gm.role, gm.created_at \ FROM group_members gm \ JOIN admin_users au ON au.id = gm.user_id \ WHERE gm.group_id = $1 \ ORDER BY au.username", ) .bind(group_id.into_inner()) .fetch_all(&self.pool) .await?; rows.into_iter().map(TryInto::try_into).collect() } } #[derive(sqlx::FromRow)] struct GroupMembershipRecord { group_id: uuid::Uuid, user_id: uuid::Uuid, role: String, created_at: DateTime, } impl TryFrom for GroupMembershipRow { type Error = GroupMembersRepositoryError; fn try_from(r: GroupMembershipRecord) -> Result { Ok(Self { group_id: r.group_id.into(), user_id: r.user_id.into(), role: AppRole::from_db_str(&r.role) .ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?, created_at: r.created_at, }) } } #[derive(sqlx::FromRow)] struct GroupMembershipDetailRecord { id: uuid::Uuid, username: String, email: Option, instance_role: String, is_active: bool, role: String, created_at: DateTime, } impl TryFrom for GroupMembershipDetail { type Error = GroupMembersRepositoryError; fn try_from(r: GroupMembershipDetailRecord) -> 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(GroupMembersRepositoryError::InvalidRole(r.instance_role))?, is_active: r.is_active, role: AppRole::from_db_str(&r.role) .ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?, created_at: r.created_at, }) } }