748 lines
26 KiB
Rust
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,
|
|
})
|
|
}
|
|
}
|