469 lines
16 KiB
Rust
469 lines
16 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::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 {
|
|
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<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>,
|
|
}
|
|
|
|
// ─── 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();
|
|
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<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));
|
|
|
|
// Count
|
|
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;
|
|
|
|
// Fetch
|
|
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));
|
|
|
|
// Count
|
|
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;
|
|
|
|
// Fetch
|
|
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));
|
|
|
|
// Count
|
|
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;
|
|
|
|
// Fetch
|
|
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));
|
|
|
|
// 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<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))
|
|
}
|
|
}
|