use crate::AppService; use crate::error::AppError; use chrono::{DateTime, Utc}; use db::database::AppDatabase; use models::issues::issue; use models::projects::{project, project_members}; use models::repos::repo; use models::users::user; use sea_orm::*; use sea_query::{Expr as SqExpr, extension::postgres::PgExpr}; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; use uuid::Uuid; // ─── Request / Response types ──────────────────────────────────────────────── #[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] pub struct SearchQuery { /// Search keyword (matches against name, title, description, etc.) #[param(min_length = 1, max_length = 200)] pub q: String, /// Comma-separated list of entity types to search. /// Supported: projects, repos, issues, users. /// Default: all types. pub r#type: Option, /// Page number (1-indexed). Default: 1. pub page: Option, /// Results per page per type. Default: 20, max: 100. pub per_page: Option, } fn parse_types(types: Option) -> Vec { match types { None => vec![ "projects".into(), "repos".into(), "issues".into(), "users".into(), ], Some(s) => { let s = s.to_lowercase(); let mut out = vec![]; for t in s.split(',') { let t = t.trim(); if t == "projects" || t == "repos" || t == "issues" || t == "users" { out.push(t.into()); } } if out.is_empty() { vec![ "projects".into(), "repos".into(), "issues".into(), "users".into(), ] } else { out } } } } fn build_like_pattern(q: &str) -> String { format!("%{}%", q.trim()) } // ─── Per-type result items ─────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, ToSchema)] pub struct ProjectSearchItem { pub uid: Uuid, pub name: String, pub display_name: String, pub description: Option, pub avatar_url: Option, pub is_public: bool, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct RepoSearchItem { pub uid: Uuid, pub name: String, pub description: Option, pub project_uid: Uuid, pub project_name: String, pub is_private: bool, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct IssueSearchItem { pub uid: Uuid, pub number: i64, pub title: String, pub body: Option, pub state: String, pub project_uid: Uuid, pub project_name: String, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct UserSearchItem { pub uid: Uuid, pub username: String, pub display_name: Option, pub avatar_url: Option, pub organization: Option, pub created_at: DateTime, } // ─── Per-type result set ───────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, ToSchema)] pub struct SearchResultSet { pub items: Vec, pub total: i64, pub page: u32, pub per_page: u32, } impl SearchResultSet { fn new(items: Vec, total: i64, page: u32, per_page: u32) -> Self { Self { items, total, page, per_page, } } } // ─── Aggregated response ────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, ToSchema)] pub struct SearchResponse { pub query: String, #[serde(skip_serializing_if = "Option::is_none")] pub projects: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub repos: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub issues: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub users: Option>, } // ─── Permission helpers ─────────────────────────────────────────────────────── /// Returns the set of project IDs the current user can access (for filtering). /// If user is None (anonymous), returns only public project IDs. async fn accessible_project_ids( db: &AppDatabase, user_id: Option, ) -> Result, AppError> { let public_projects: Vec = project::Entity::find() .filter(project::Column::IsPublic.eq(true)) .select_only() .column(project::Column::Id) .into_tuple::() .all(db) .await .map_err(|_| AppError::InternalError)?; let Some(user_id) = user_id else { // Anonymous: only public projects return Ok(public_projects); }; let memberships: Vec = project_members::Entity::find() .filter(project_members::Column::User.eq(user_id)) .select_only() .column(project_members::Column::Project) .into_tuple::() .all(db) .await .map_err(|_| AppError::InternalError)?; let mut all: Vec = public_projects; for id in memberships { if !all.iter().any(|x| x == &id) { all.push(id); } } Ok(all) } impl AppService { pub async fn search( &self, ctx: &Session, params: SearchQuery, ) -> Result { let page = Ord::max(params.page.unwrap_or(1), 1u32); let per_page = Ord::min(params.per_page.unwrap_or(20), 100u32); let query = params.q.trim(); if query.is_empty() { return Err(AppError::BadRequest("q is required".to_string())); } let types = parse_types(params.r#type); let user_id = ctx.user(); let accessible = accessible_project_ids(&self.db, user_id).await?; let mut resp = SearchResponse { query: query.to_string(), projects: None, repos: None, issues: None, users: None, }; if types.iter().any(|t| t == "projects") { match self .search_projects(query, &accessible, page, per_page) .await { Ok(set) => resp.projects = Some(set), Err(_) => {} } } if types.iter().any(|t| t == "repos") { if let Ok(set) = self.search_repos(query, &accessible, page, per_page).await { resp.repos = Some(set); } } if types.iter().any(|t| t == "issues") { if let Ok(set) = self.search_issues(query, &accessible, page, per_page).await { resp.issues = Some(set); } } if types.iter().any(|t| t == "users") { if let Ok(users) = self.search_users(query, page, per_page).await { resp.users = Some(users); } } Ok(resp) } async fn search_projects( &self, query: &str, accessible: &[Uuid], page: u32, per_page: u32, ) -> Result, AppError> { let offset = (page - 1) * per_page; let pattern = build_like_pattern(query); // OR filter: Name ILIKE q OR DisplayName ILIKE q OR Description ILIKE q let or_filter = SqExpr::col(project::Column::Name) .ilike(&pattern) .or(SqExpr::col(project::Column::DisplayName).ilike(&pattern)) .or(SqExpr::col(project::Column::Description).ilike(&pattern)); // Count let total: i64 = project::Entity::find() .filter(project::Column::Id.is_in(accessible.iter().cloned().collect::>())) .filter(or_filter.clone()) .count(&self.db) .await .map_err(|_| AppError::InternalError)? as i64; // Fetch let items: Vec = project::Entity::find() .filter(project::Column::Id.is_in(accessible.iter().cloned().collect::>())) .filter(or_filter) .order_by_desc(project::Column::UpdatedAt) .offset(Some(offset as u64)) .limit(Some(per_page as u64)) .all(&self.db) .await .map_err(|_| AppError::InternalError)? .into_iter() .map(|p| ProjectSearchItem { uid: p.id, name: p.name, display_name: p.display_name, description: p.description, avatar_url: p.avatar_url, is_public: p.is_public, created_at: p.created_at, updated_at: p.updated_at, }) .collect(); Ok(SearchResultSet::new(items, total, page, per_page)) } async fn search_repos( &self, query: &str, accessible: &[Uuid], page: u32, per_page: u32, ) -> Result, AppError> { let offset = (page - 1) * per_page; let pattern = build_like_pattern(query); // OR filter: RepoName ILIKE q OR Description ILIKE q let repo_or = SqExpr::col(repo::Column::RepoName) .ilike(&pattern) .or(SqExpr::col(repo::Column::Description).ilike(&pattern)); // Count let total: i64 = repo::Entity::find() .filter(repo::Column::Project.is_in(accessible.iter().cloned().collect::>())) .filter(repo_or.clone()) .count(&self.db) .await .map_err(|_| AppError::InternalError)? as i64; // Fetch let repos: Vec = repo::Entity::find() .filter(repo::Column::Project.is_in(accessible.iter().cloned().collect::>())) .filter(repo_or) .order_by_desc(repo::Column::UpdatedAt) .offset(Some(offset as u64)) .limit(Some(per_page as u64)) .all(&self.db) .await .map_err(|_| AppError::InternalError)?; // Batch-fetch project names let project_ids: Vec = repos.iter().map(|r| r.project).collect(); let project_names: std::collections::HashMap = project::Entity::find() .filter(project::Column::Id.is_in(project_ids.clone())) .into_tuple::<(Uuid, String)>() .all(&self.db) .await .map_err(|_| AppError::InternalError)? .into_iter() .collect(); let items: Vec = repos .into_iter() .map(|r| RepoSearchItem { uid: r.id, name: r.repo_name, description: r.description, project_uid: r.project, project_name: project_names .get(&r.project) .cloned() .unwrap_or_else(|| r.project.to_string()), is_private: r.is_private, created_at: r.created_at, }) .collect(); Ok(SearchResultSet::new(items, total, page, per_page)) } async fn search_issues( &self, query: &str, accessible: &[Uuid], page: u32, per_page: u32, ) -> Result, AppError> { let offset = (page - 1) * per_page; let pattern = build_like_pattern(query); // OR filter: Title ILIKE q OR Body ILIKE q let issue_or = SqExpr::col(issue::Column::Title) .ilike(&pattern) .or(SqExpr::col(issue::Column::Body).ilike(&pattern)); // Count let total: i64 = issue::Entity::find() .filter(issue::Column::Project.is_in(accessible.iter().cloned().collect::>())) .filter(issue_or.clone()) .count(&self.db) .await .map_err(|_| AppError::InternalError)? as i64; // Fetch let issues: Vec = issue::Entity::find() .filter(issue::Column::Project.is_in(accessible.iter().cloned().collect::>())) .filter(issue_or) .order_by_desc(issue::Column::UpdatedAt) .offset(Some(offset as u64)) .limit(Some(per_page as u64)) .all(&self.db) .await .map_err(|_| AppError::InternalError)?; // Batch-fetch project names let project_ids: Vec = issues.iter().map(|i| i.project).collect(); let project_names: std::collections::HashMap = project::Entity::find() .filter(project::Column::Id.is_in(project_ids.clone())) .into_tuple::<(Uuid, String)>() .all(&self.db) .await .map_err(|_| AppError::InternalError)? .into_iter() .collect(); let items: Vec = issues .into_iter() .map(|i| IssueSearchItem { uid: i.id, number: i.number, title: i.title, body: i.body, state: i.state, project_uid: i.project, project_name: project_names .get(&i.project) .cloned() .unwrap_or_else(|| i.project.to_string()), created_at: i.created_at, updated_at: i.updated_at, }) .collect(); Ok(SearchResultSet::new(items, total, page, per_page)) } async fn search_users( &self, query: &str, page: u32, per_page: u32, ) -> Result, AppError> { let offset = (page - 1) * per_page; let pattern = build_like_pattern(query); // OR filter: Username ILIKE q OR DisplayName ILIKE q let user_or = SqExpr::col(user::Column::Username) .ilike(&pattern) .or(SqExpr::col(user::Column::DisplayName).ilike(&pattern)); // Count let total: i64 = user::Entity::find() .filter(user_or.clone()) .count(&self.db) .await .map_err(|_| AppError::InternalError)? as i64; // Fetch let items: Vec = user::Entity::find() .filter(user_or) .order_by_desc(user::Column::LastSignInAt) .offset(Some(offset as u64)) .limit(Some(per_page as u64)) .all(&self.db) .await .map_err(|_| AppError::InternalError)? .into_iter() .map(|u| UserSearchItem { uid: u.uid, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, organization: u.organization, created_at: u.created_at, }) .collect(); Ok(SearchResultSet::new(items, total, page, per_page)) } }