gitdataai/libs/service/search/service.rs

748 lines
26 KiB
Rust

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<String>,
/// Page number (1-indexed). Default: 1.
pub page: Option<u32>,
/// Results per page per type. Default: 20, max: 100.
pub per_page: Option<u32>,
}
fn parse_types(types: Option<String>) -> Vec<String> {
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<String>,
pub avatar_url: Option<String>,
pub is_public: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct RepoSearchItem {
pub uid: Uuid,
pub name: String,
pub description: Option<String>,
pub project_uid: Uuid,
pub project_name: String,
pub is_private: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct IssueSearchItem {
pub uid: Uuid,
pub number: i64,
pub title: String,
pub body: Option<String>,
pub state: String,
pub project_uid: Uuid,
pub project_name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct UserSearchItem {
pub uid: Uuid,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub organization: Option<String>,
pub created_at: DateTime<Utc>,
}
// ─── 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<u32>,
pub per_page: Option<u32>,
/// Scope search to a specific room (by UUID).
#[param(value_type = Option<String>, example = "550e8400-e29b-41d4-a716-446655440000")]
pub room: Option<Uuid>,
/// Scope search to a specific project (by project name, e.g. "my-team/frontend").
#[param(value_type = Option<String>, example = "my-team/frontend")]
pub pn: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GlobalMessageSearchItem {
pub id: Uuid,
pub room_id: Uuid,
pub room_name: String,
pub sender_id: Option<Uuid>,
pub sender_type: String,
pub display_name: Option<String>,
pub content: String,
pub content_type: String,
pub send_at: DateTime<Utc>,
pub highlighted_content: Option<String>,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct GlobalMessageSearchResponse {
pub query: String,
pub messages: Vec<GlobalMessageSearchItem>,
pub total: i64,
pub page: u32,
pub per_page: u32,
}
// ─── Per-type result set ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SearchResultSet<T> {
pub items: Vec<T>,
pub total: i64,
pub page: u32,
pub per_page: u32,
}
impl<T> SearchResultSet<T> {
fn new(items: Vec<T>, 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<SearchResultSet<ProjectSearchItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repos: Option<SearchResultSet<RepoSearchItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issues: Option<SearchResultSet<IssueSearchItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub users: Option<SearchResultSet<UserSearchItem>>,
}
// ─── 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<Uuid>,
) -> Result<Vec<Uuid>, AppError> {
let public_projects: Vec<Uuid> = project::Entity::find()
.filter(project::Column::IsPublic.eq(true))
.select_only()
.column(project::Column::Id)
.into_tuple::<Uuid>()
.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<Uuid> = project_members::Entity::find()
.filter(project_members::Column::User.eq(user_id))
.select_only()
.column(project_members::Column::Project)
.into_tuple::<Uuid>()
.all(db)
.await
.map_err(|_| AppError::InternalError)?;
let mut all: Vec<Uuid> = 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<SearchResponse, AppError> {
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<SearchResultSet<ProjectSearchItem>, 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::<Vec<_>>()))
.filter(or_filter.clone())
.count(&self.db)
.await
.map_err(|_| AppError::InternalError)? as i64;
let items: Vec<ProjectSearchItem> = project::Entity::find()
.filter(project::Column::Id.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.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<SearchResultSet<RepoSearchItem>, 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::<Vec<_>>()))
.filter(repo_or.clone())
.count(&self.db)
.await
.map_err(|_| AppError::InternalError)? as i64;
let repos: Vec<repo::Model> = repo::Entity::find()
.filter(repo::Column::Project.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.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<Uuid> = repos.iter().map(|r| r.project).collect();
let project_names: std::collections::HashMap<Uuid, String> = 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<RepoSearchItem> = 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<SearchResultSet<IssueSearchItem>, 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::<Vec<_>>()))
.filter(issue_or.clone())
.count(&self.db)
.await
.map_err(|_| AppError::InternalError)? as i64;
let issues: Vec<issue::Model> = issue::Entity::find()
.filter(issue::Column::Project.is_in(accessible.iter().cloned().collect::<Vec<_>>()))
.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<Uuid> = issues.iter().map(|i| i.project).collect();
let project_names: std::collections::HashMap<Uuid, String> = 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<IssueSearchItem> = 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<SearchResultSet<UserSearchItem>, 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<UserSearchItem> = 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<GlobalMessageSearchResponse, AppError> {
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<Uuid> = room_access::Entity::find()
.filter(room_access::Column::User.eq(user_id))
.select_only()
.column(room_access::Column::Room)
.into_tuple::<Uuid>()
.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<Uuid> = project_members::Entity::find()
.filter(project_members::Column::User.eq(user_id))
.select_only()
.column(project_members::Column::Project)
.into_tuple::<Uuid>()
.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<Uuid> = accessible_ids
.into_iter()
.filter(|pid| !member_project_ids.iter().any(|m| m == pid))
.collect();
// ALL rooms from member projects
let member_rooms: Vec<Uuid> = 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::<Uuid>()
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?
};
// Only public rooms from public-only projects
let public_rooms: Vec<Uuid> = 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::<Uuid>()
.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<Uuid> = 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<Uuid> = room::Entity::find()
.filter(room::Column::Project.eq(project_row.id))
.filter(room::Column::Id.is_in(accessible_set.iter().copied().collect::<Vec<_>>()))
.select_only()
.column(room::Column::Id)
.into_tuple::<Uuid>()
.all(&self.db)
.await
.map_err(|_| AppError::InternalError)?;
accessible_set = project_rooms.into_iter().collect();
}
let accessible_rooms: Vec<Uuid> = 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<Uuid, String> = 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=<mark>, StopSel=</mark>, 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<GlobalMessageSearchItem> = Vec::new();
for row in rows {
let room_id: Uuid = row.try_get::<Uuid>("", "room").unwrap_or_default();
let sender_type_str = row.try_get::<String>("", "sender_type").unwrap_or_default();
let content_type_str = row
.try_get::<String>("", "content_type")
.unwrap_or_default();
let highlighted = row.try_get::<String>("", "highlighted_content").ok();
messages.push(GlobalMessageSearchItem {
id: row.try_get::<Uuid>("", "id").unwrap_or_default(),
room_id,
room_name: room_names_map.get(&room_id).cloned().unwrap_or_default(),
sender_id: row.try_get::<Option<Uuid>>("", "sender_id").ok().flatten(),
sender_type: sender_type_str,
display_name: None,
content: row.try_get::<String>("", "content").unwrap_or_default(),
content_type: content_type_str,
send_at: row
.try_get::<DateTime<Utc>>("", "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::<i64>("", "count").ok())
.unwrap_or(0);
Ok(GlobalMessageSearchResponse {
query: q.to_string(),
messages,
total,
page,
per_page,
})
}
}