//! Per-repo collaborator access queries. use chrono::{DateTime, Utc}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; use super::{GitRepoId, UserId}; use crate::error::Result; #[derive(Debug, FromRow)] pub struct DbRepoCollaborator { pub id: Uuid, pub repo_id: GitRepoId, pub user_id: UserId, pub can_push: bool, pub created_at: DateTime, } /// Collaborator with joined username for display. #[derive(Debug, FromRow)] pub struct CollaboratorWithUsername { pub id: Uuid, pub user_id: UserId, pub username: String, pub can_push: bool, pub created_at: DateTime, } /// Add a collaborator to a repo. Returns the new record. #[tracing::instrument(skip_all)] pub async fn add_collaborator( pool: &PgPool, repo_id: GitRepoId, user_id: UserId, can_push: bool, ) -> Result { let row = sqlx::query_as::<_, DbRepoCollaborator>( r" INSERT INTO repo_collaborators (repo_id, user_id, can_push) VALUES ($1, $2, $3) RETURNING * ", ) .bind(repo_id) .bind(user_id) .bind(can_push) .fetch_one(pool) .await?; Ok(row) } /// Remove a collaborator from a repo. Returns true if a row was deleted. #[tracing::instrument(skip_all)] pub async fn remove_collaborator( pool: &PgPool, repo_id: GitRepoId, user_id: UserId, ) -> Result { let result = sqlx::query("DELETE FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2") .bind(repo_id) .bind(user_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// List collaborators for a repo, with usernames. #[tracing::instrument(skip_all)] pub async fn list_collaborators( pool: &PgPool, repo_id: GitRepoId, ) -> Result> { let rows = sqlx::query_as::<_, CollaboratorWithUsername>( r" SELECT rc.id, rc.user_id, u.username, rc.can_push, rc.created_at FROM repo_collaborators rc JOIN users u ON u.id = rc.user_id WHERE rc.repo_id = $1 ORDER BY rc.created_at ASC ", ) .bind(repo_id) .fetch_all(pool) .await?; Ok(rows) } /// A collaborator row tagged with its `repo_id`, for batched listing across /// many repos in a single query (see [`list_collaborators_for_repos`]). #[derive(Debug, FromRow)] pub struct CollaboratorForRepo { pub repo_id: GitRepoId, pub user_id: UserId, pub username: String, pub can_push: bool, } /// List collaborators for many repos in ONE query, each row tagged with its /// `repo_id` so the caller can group them. Replaces a per-repo `list_collaborators` /// loop (the N+1 in the project code tab). Returns rows ordered by repo then /// join time; an empty input short-circuits without a query. #[tracing::instrument(skip_all)] pub async fn list_collaborators_for_repos( pool: &PgPool, repo_ids: &[GitRepoId], ) -> Result> { if repo_ids.is_empty() { return Ok(Vec::new()); } let rows = sqlx::query_as::<_, CollaboratorForRepo>( r" SELECT rc.repo_id, rc.user_id, u.username, rc.can_push FROM repo_collaborators rc JOIN users u ON u.id = rc.user_id WHERE rc.repo_id = ANY($1) ORDER BY rc.repo_id, rc.created_at ASC ", ) .bind(repo_ids) .fetch_all(pool) .await?; Ok(rows) } /// Check if a user has push access to a repo (either owner or collaborator with can_push). #[tracing::instrument(skip_all)] pub async fn can_user_push(pool: &PgPool, repo_id: GitRepoId, user_id: UserId) -> Result { let row: (bool,) = sqlx::query_as( r" SELECT EXISTS( SELECT 1 FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2 AND can_push = true ) ", ) .bind(repo_id) .bind(user_id) .fetch_one(pool) .await?; Ok(row.0) } /// Check if a user has read access to a repo (any collaborator record, regardless of can_push). #[tracing::instrument(skip_all)] pub async fn is_collaborator(pool: &PgPool, repo_id: GitRepoId, user_id: UserId) -> Result { let row: (bool,) = sqlx::query_as( r" SELECT EXISTS( SELECT 1 FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2 ) ", ) .bind(repo_id) .bind(user_id) .fetch_one(pool) .await?; Ok(row.0) }