//! BaseInfo structs — stable, minimal projections of core entities for API/WS responses. //! //! Every raw UUID foreign key in API / WebSocket responses must be expanded //! to its corresponding `*BaseInfo` object so frontend consumers never //! receive bare identifiers. //! //! Batch resolvers are provided to avoid N+1 queries when enriching //! collections of entities. use std::collections::HashMap; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::models::common::{ChannelType, State, Visibility}; use crate::models::db::AppDatabase; // Section: BaseInfo structs #[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserBaseInfo { pub id: Uuid, pub username: String, pub display_name: Option, pub avatar_url: Option, pub is_bot: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceBaseInfo { pub id: Uuid, pub name: String, pub avatar_url: Option, pub visibility: Visibility, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct RepoBaseInfo { pub id: Uuid, pub name: String, pub workspace_id: Uuid, pub visibility: Visibility, pub is_fork: bool, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ChannelBaseInfo { pub id: Uuid, pub name: String, pub channel_type: ChannelType, pub workspace_id: Uuid, pub archived: bool, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct IssueBaseInfo { pub id: Uuid, pub number: i64, pub title: String, pub state: State, pub workspace_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct PullRequestBaseInfo { pub id: Uuid, pub number: i64, pub title: String, pub state: State, pub repo_id: Uuid, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WikiPageBaseInfo { pub id: Uuid, pub title: String, pub slug: String, pub repo_id: Uuid, } // Section: DB row structs for batch queries #[derive(Debug, sqlx::FromRow)] struct UserBaseInfoRow { id: Uuid, username: String, display_name: Option, avatar_url: Option, is_bot: bool, } impl From for UserBaseInfo { fn from(r: UserBaseInfoRow) -> Self { UserBaseInfo { id: r.id, username: r.username, display_name: r.display_name, avatar_url: r.avatar_url, is_bot: r.is_bot, } } } #[derive(Debug, sqlx::FromRow)] struct WorkspaceBaseInfoRow { id: Uuid, name: String, avatar_url: Option, visibility: Visibility, } impl From for WorkspaceBaseInfo { fn from(r: WorkspaceBaseInfoRow) -> Self { WorkspaceBaseInfo { id: r.id, name: r.name, avatar_url: r.avatar_url, visibility: r.visibility, } } } #[derive(Debug, sqlx::FromRow)] struct RepoBaseInfoRow { id: Uuid, name: String, workspace_id: Uuid, visibility: Visibility, is_fork: bool, } impl From for RepoBaseInfo { fn from(r: RepoBaseInfoRow) -> Self { RepoBaseInfo { id: r.id, name: r.name, workspace_id: r.workspace_id, visibility: r.visibility, is_fork: r.is_fork, } } } #[derive(Debug, sqlx::FromRow)] struct ChannelBaseInfoRow { id: Uuid, name: String, channel_type: ChannelType, workspace_id: Uuid, archived: bool, } impl From for ChannelBaseInfo { fn from(r: ChannelBaseInfoRow) -> Self { ChannelBaseInfo { id: r.id, name: r.name, channel_type: r.channel_type, workspace_id: r.workspace_id, archived: r.archived, } } } #[derive(Debug, sqlx::FromRow)] struct IssueBaseInfoRow { id: Uuid, number: i64, title: String, state: State, workspace_id: Uuid, } impl From for IssueBaseInfo { fn from(r: IssueBaseInfoRow) -> Self { IssueBaseInfo { id: r.id, number: r.number, title: r.title, state: r.state, workspace_id: r.workspace_id, } } } #[derive(Debug, sqlx::FromRow)] struct PullRequestBaseInfoRow { id: Uuid, number: i64, title: String, state: State, repo_id: Uuid, } impl From for PullRequestBaseInfo { fn from(r: PullRequestBaseInfoRow) -> Self { PullRequestBaseInfo { id: r.id, number: r.number, title: r.title, state: r.state, repo_id: r.repo_id, } } } impl UserBaseInfo { pub fn placeholder(id: Uuid) -> Self { Self { id, username: "unknown".into(), display_name: Some("Unknown User".into()), avatar_url: None, is_bot: false, } } } // Section: Batch resolvers /// Resolve multiple users to `UserBaseInfo`. Always do a single /// `SELECT … WHERE id = ANY($1)` to avoid N+1. pub async fn resolve_users( db: &AppDatabase, ids: &[Uuid], ) -> Result, AppError> { if ids.is_empty() { return Ok(HashMap::new()); } let rows = sqlx::query_as::<_, UserBaseInfoRow>( r#"SELECT id, username, display_name, avatar_url, is_bot FROM "user" WHERE id = ANY($1) AND deleted_at IS NULL"#, ) .bind(ids) .fetch_all(db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(|r| (r.id, r.into())).collect()) } /// Resolve multiple workspaces to `WorkspaceBaseInfo`. pub async fn resolve_workspaces( db: &AppDatabase, ids: &[Uuid], ) -> Result, AppError> { if ids.is_empty() { return Ok(HashMap::new()); } let rows = sqlx::query_as::<_, WorkspaceBaseInfoRow>( "SELECT id, name, avatar_url, visibility FROM workspace WHERE id = ANY($1) AND deleted_at IS NULL", ) .bind(ids) .fetch_all(db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(|r| (r.id, r.into())).collect()) } /// Resolve multiple repos to `RepoBaseInfo`. pub async fn resolve_repos( db: &AppDatabase, ids: &[Uuid], ) -> Result, AppError> { if ids.is_empty() { return Ok(HashMap::new()); } let rows = sqlx::query_as::<_, RepoBaseInfoRow>( "SELECT id, name, workspace_id, visibility, is_fork FROM repo WHERE id = ANY($1) AND deleted_at IS NULL", ) .bind(ids) .fetch_all(db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(|r| (r.id, r.into())).collect()) } /// Resolve multiple channels to `ChannelBaseInfo`. pub async fn resolve_channels( db: &AppDatabase, ids: &[Uuid], ) -> Result, AppError> { if ids.is_empty() { return Ok(HashMap::new()); } let rows = sqlx::query_as::<_, ChannelBaseInfoRow>( "SELECT id, name, channel_type, workspace_id, archived FROM channel WHERE id = ANY($1) AND deleted_at IS NULL", ) .bind(ids) .fetch_all(db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(|r| (r.id, r.into())).collect()) } /// Resolve multiple issues to `IssueBaseInfo`. pub async fn resolve_issues( db: &AppDatabase, ids: &[Uuid], ) -> Result, AppError> { if ids.is_empty() { return Ok(HashMap::new()); } let rows = sqlx::query_as::<_, IssueBaseInfoRow>( "SELECT id, number, title, state, workspace_id FROM issue WHERE id = ANY($1) AND deleted_at IS NULL", ) .bind(ids) .fetch_all(db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(|r| (r.id, r.into())).collect()) } /// Resolve multiple pull requests to `PullRequestBaseInfo`. pub async fn resolve_pull_requests( db: &AppDatabase, ids: &[Uuid], ) -> Result, AppError> { if ids.is_empty() { return Ok(HashMap::new()); } let rows = sqlx::query_as::<_, PullRequestBaseInfoRow>( "SELECT id, number, title, state, repo_id FROM pull_request WHERE id = ANY($1) AND deleted_at IS NULL", ) .bind(ids) .fetch_all(db.reader()) .await .map_err(AppError::Database)?; Ok(rows.into_iter().map(|r| (r.id, r.into())).collect()) }