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::rooms::{room, room_access}; 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 { let escaped = q .trim() .replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_"); format!("%{}%", escaped) } // ─── 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, } // ─── Global message search ──────────────────────────────────────────────────── #[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] pub struct GlobalMessageSearchQuery { #[param(min_length = 1, max_length = 200)] pub q: String, pub page: Option, pub per_page: Option, /// Scope search to a specific room (by UUID). #[param(value_type = Option, example = "550e8400-e29b-41d4-a716-446655440000")] pub room: Option, /// Scope search to a specific project (by project name, e.g. "my-team/frontend"). #[param(value_type = Option, example = "my-team/frontend")] pub pn: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct GlobalMessageSearchItem { pub id: Uuid, pub room_id: Uuid, pub room_name: String, pub sender_id: Option, pub sender_type: String, pub display_name: Option, pub content: String, pub content_type: String, pub send_at: DateTime, pub highlighted_content: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct GlobalMessageSearchResponse { pub query: String, pub messages: Vec, pub total: i64, pub page: u32, pub per_page: u32, } // ─── 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(); 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)); 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; 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)); 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; 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)); 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; 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)); let total: i64 = user::Entity::find() .filter(user_or.clone()) .count(&self.db) .await .map_err(|_| AppError::InternalError)? as i64; 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)) } /// Search messages across all rooms the current user can access. /// Uses PostgreSQL full-text search with ts_headline for result highlighting. pub async fn global_message_search( &self, ctx: &Session, params: GlobalMessageSearchQuery, ) -> Result { let user_id = ctx.user(); // Anonymous users cannot search messages let Some(user_id) = user_id else { return Err(AppError::Unauthorized); }; if params.q.trim().is_empty() { return Ok(GlobalMessageSearchResponse { query: params.q.clone(), messages: Vec::new(), total: 0, page: params.page.unwrap_or(1), per_page: params.per_page.unwrap_or(20), }); } let page = std::cmp::max(1, params.page.unwrap_or(1)); let per_page = std::cmp::min(100, std::cmp::max(1, params.per_page.unwrap_or(20))); let offset = (page - 1) * per_page; let q = params.q.trim(); // Build the set of room IDs the user can access: // 1. Private rooms where user has explicit access let private_rooms: Vec = room_access::Entity::find() .filter(room_access::Column::User.eq(user_id)) .select_only() .column(room_access::Column::Room) .into_tuple::() .all(&self.db) .await .map_err(|_| AppError::InternalError)?; // 2. Rooms in projects the user belongs to: // - For projects where user is a member: ALL rooms // - For public-only projects: only public rooms let member_project_ids: Vec = project_members::Entity::find() .filter(project_members::Column::User.eq(user_id)) .select_only() .column(project_members::Column::Project) .into_tuple::() .all(&self.db) .await .map_err(|_| AppError::InternalError)?; // Public-only projects = accessible projects minus member projects let accessible_ids = accessible_project_ids(&self.db, Some(user_id)).await?; let public_only_ids: Vec = accessible_ids .into_iter() .filter(|pid| !member_project_ids.iter().any(|m| m == pid)) .collect(); // ALL rooms from member projects let member_rooms: Vec = if member_project_ids.is_empty() { Vec::new() } else { room::Entity::find() .filter(room::Column::Project.is_in(member_project_ids)) .select_only() .column(room::Column::Id) .into_tuple::() .all(&self.db) .await .map_err(|_| AppError::InternalError)? }; // Only public rooms from public-only projects let public_rooms: Vec = if public_only_ids.is_empty() { Vec::new() } else { room::Entity::find() .filter(room::Column::Project.is_in(public_only_ids)) .filter(room::Column::Public.eq(true)) .select_only() .column(room::Column::Id) .into_tuple::() .all(&self.db) .await .map_err(|_| AppError::InternalError)? }; // Merge and deduplicate accessible room IDs using a HashSet use std::collections::HashSet; let mut accessible_set: HashSet = private_rooms.into_iter().collect(); for rid in member_rooms { accessible_set.insert(rid); } for rid in public_rooms { accessible_set.insert(rid); } // Apply room/project scoping if let Some(room_id) = params.room { // Scope to a specific room (must be accessible) if !accessible_set.contains(&room_id) { return Ok(GlobalMessageSearchResponse { query: q.to_string(), messages: Vec::new(), total: 0, page, per_page, }); } accessible_set.clear(); accessible_set.insert(room_id); } if let Some(ref project_name) = params.pn { // Scope to rooms in a specific project let project_row = project::Entity::find() .filter(project::Column::Name.eq(project_name)) .one(&self.db) .await .map_err(|_| AppError::InternalError)?; let Some(project_row) = project_row else { return Ok(GlobalMessageSearchResponse { query: q.to_string(), messages: Vec::new(), total: 0, page, per_page, }); }; // Get all room IDs in this project that are in the accessible set let project_rooms: Vec = room::Entity::find() .filter(room::Column::Project.eq(project_row.id)) .filter(room::Column::Id.is_in(accessible_set.iter().copied().collect::>())) .select_only() .column(room::Column::Id) .into_tuple::() .all(&self.db) .await .map_err(|_| AppError::InternalError)?; accessible_set = project_rooms.into_iter().collect(); } let accessible_rooms: Vec = accessible_set.iter().cloned().collect(); if accessible_rooms.is_empty() { return Ok(GlobalMessageSearchResponse { query: q.to_string(), messages: Vec::new(), total: 0, page, per_page, }); } // Fetch room names for the accessible rooms let room_names_map: std::collections::HashMap = room::Entity::find() .filter(room::Column::Id.is_in(accessible_rooms.clone())) .all(&self.db) .await .map_err(|_| AppError::InternalError)? .into_iter() .map(|r| (r.id, r.room_name)) .collect(); let tsquery = format!("plainto_tsquery('simple', $1)"); let sql = format!( r#" SELECT m.id, m.room, m.sender_type, m.sender_id, m.content, m.content_type, m.send_at, ts_headline('simple', m.content, {}, 'StartSel=, StopSel=, MaxWords=50, MinWords=15') AS highlighted_content FROM room_message m WHERE m.room = ANY($2) AND m.content_tsv @@ {} AND m.revoked IS NULL ORDER BY m.send_at DESC LIMIT $3 OFFSET $4"#, tsquery, tsquery ); // Results query let results_sql = Statement::from_sql_and_values( DbBackend::Postgres, &sql, vec![ q.into(), accessible_rooms.clone().into(), per_page.into(), offset.into(), ], ); let rows = self.db.query_all_raw(results_sql).await?; let mut messages: Vec = Vec::new(); for row in rows { let room_id: Uuid = row.try_get::("", "room").unwrap_or_default(); let sender_type_str = row.try_get::("", "sender_type").unwrap_or_default(); let content_type_str = row .try_get::("", "content_type") .unwrap_or_default(); let highlighted = row.try_get::("", "highlighted_content").ok(); messages.push(GlobalMessageSearchItem { id: row.try_get::("", "id").unwrap_or_default(), room_id, room_name: room_names_map.get(&room_id).cloned().unwrap_or_default(), sender_id: row.try_get::>("", "sender_id").ok().flatten(), sender_type: sender_type_str, display_name: None, content: row.try_get::("", "content").unwrap_or_default(), content_type: content_type_str, send_at: row .try_get::>("", "send_at") .unwrap_or_default(), highlighted_content: highlighted, }); } // Count total across all accessible rooms let count_sql = format!( "SELECT COUNT(*) AS count FROM room_message WHERE room = ANY($1) AND content_tsv @@ {} AND revoked IS NULL", tsquery ); let count_stmt = Statement::from_sql_and_values( DbBackend::Postgres, &count_sql, vec![accessible_rooms.into(), q.into()], ); let count_row = self.db.query_one_raw(count_stmt).await?; let total: i64 = count_row .and_then(|r| r.try_get::("", "count").ok()) .unwrap_or(0); Ok(GlobalMessageSearchResponse { query: q.to_string(), messages, total, page, per_page, }) } }