From 0acacbf57cf997b86744a0df9bfb66e475cf8c00 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Tue, 28 Apr 2026 13:12:29 +0800 Subject: [PATCH] feat(search): add room-scoped message search with project name filter Add room parameter (UUID) and pn (project name) to search/messages API. Service layer supports filtering messages by room and project scope. Frontend search page updated with room-scoped search support. --- libs/api/search/service.rs | 2 + libs/service/search/service.rs | 105 +++++++++++++++++++++++++++++++-- src/app/search/page.tsx | 79 ++++++++++++------------- 3 files changed, 140 insertions(+), 46 deletions(-) diff --git a/libs/api/search/service.rs b/libs/api/search/service.rs index aed67bc..4b93710 100644 --- a/libs/api/search/service.rs +++ b/libs/api/search/service.rs @@ -37,6 +37,8 @@ pub async fn search( ("q" = String, Query, description = "Search keyword", min_length = 1, max_length = 200), ("page" = Option, Query, description = "Page number, default 1"), ("per_page" = Option, Query, description = "Results per page, default 20, max 100"), + ("room" = Option, Query, description = "Scope search to a specific room by UUID"), + ("pn" = Option, Query, description = "Scope search to a specific project by name"), ), responses( (status = 200, description = "Message search results across all accessible rooms", body = ApiResponse), diff --git a/libs/service/search/service.rs b/libs/service/search/service.rs index 5b9a052..02f9e1a 100644 --- a/libs/service/search/service.rs +++ b/libs/service/search/service.rs @@ -122,6 +122,12 @@ pub struct GlobalMessageSearchQuery { 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)] @@ -540,25 +546,112 @@ impl AppService { .await .map_err(|_| AppError::InternalError)?; - // 2. Public rooms in projects the user is a member of - let project_ids = accessible_project_ids(&self.db, Some(user_id)).await?; - let public_rooms: Vec = room::Entity::find() - .filter(room::Column::Project.is_in(project_ids.clone())) - .filter(room::Column::Public.eq(true)) + // 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(room::Column::Id) + .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 = direct_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() { diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index abfc4ad..c38deb0 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -11,7 +11,7 @@ import { Loader2, } from 'lucide-react'; import { client } from '@/client/client.gen'; -import { messageSearch, search, searchMessages } from '@/client/sdk.gen'; +import { search, searchMessages } from '@/client/sdk.gen'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -23,7 +23,6 @@ import type { RepoSearchItem, IssueSearchItem, UserSearchItem, - MessageSearchResponse, RoomMessageResponse, SearchResponse, GlobalMessageSearchResponse, @@ -255,6 +254,7 @@ function ResultSection({ export default function SearchPage() { const [searchParams, setSearchParams] = useSearchParams(); const [roomIdInput, setRoomIdInput] = useState(''); + const [projectNameInput, setProjectNameInput] = useState(''); const q = searchParams.get('q') ?? ''; const typeParam = searchParams.get('type') ?? ''; @@ -267,7 +267,6 @@ export default function SearchPage() { : [...ALL_TYPES]; const showMessages = activeTypes.includes('messages'); - const useRoomScoped = roomIdInput.trim().length > 0; const { data, isLoading, error } = useQuery({ queryKey: ['search', q, typeParam, page], @@ -287,33 +286,23 @@ export default function SearchPage() { }); // Global message search across all accessible rooms - const { data: globalMessagesData, isLoading: globalMessagesLoading } = useQuery({ - queryKey: ['search-messages-global', q], + const { data: messagesData, isLoading: messagesLoading } = useQuery({ + queryKey: ['search-messages-global', q, roomIdInput, projectNameInput], queryFn: async () => { const resp = await searchMessages({ - query: { q, page: 1, per_page: 20 }, + query: { + q, + page: 1, + per_page: 20, + ...(roomIdInput.trim() ? { room: roomIdInput.trim() } : {}), + ...(projectNameInput.trim() ? { pn: projectNameInput.trim() } : {}), + }, }); return resp.data?.data as GlobalMessageSearchResponse; }, - enabled: q.trim().length > 0 && showMessages && !useRoomScoped, + enabled: q.trim().length > 0 && showMessages, }); - // Room-scoped message search (when room ID is explicitly provided) - const { data: roomMessagesData, isLoading: roomMessagesLoading } = useQuery({ - queryKey: ['search-messages-room', q, roomIdInput], - queryFn: async () => { - const resp = await messageSearch({ - path: { room_id: roomIdInput.trim() }, - query: { q, limit: 20 }, - }); - return resp.data?.data as MessageSearchResponse; - }, - enabled: q.trim().length > 0 && showMessages && useRoomScoped, - }); - - const messagesData = useRoomScoped ? roomMessagesData : globalMessagesData; - const messagesLoading = useRoomScoped ? roomMessagesLoading : globalMessagesLoading; - const results = data ?? null; function handleSearchSubmit(e: React.FormEvent) { @@ -375,14 +364,20 @@ export default function SearchPage() { })} - {/* Room ID input for messages search */} + {/* Message scoping inputs */} {showMessages && ( -
+
setProjectNameInput(e.target.value)} + className="h-8 text-xs flex-1 min-w-[160px]" + /> + setRoomIdInput(e.target.value)} - className="h-8 text-xs" + className="h-8 text-xs flex-1 min-w-[200px]" />
)} @@ -419,7 +414,11 @@ export default function SearchPage() { {results && getTotal(results) > 0 ? `${getTotal(results)} results for "${q}"` : showMessages && messagesData - ? `${messagesData.total} message${messagesData.total === 1 ? '' : 's'} for "${q}"${useRoomScoped ? ` in room ${roomIdInput}` : ' across all accessible rooms'}` + ? `${messagesData.total} message${messagesData.total === 1 ? '' : 's'} for "${q}"${ + projectNameInput ? ` in project "${projectNameInput}"` : + roomIdInput ? ` in room "${roomIdInput}"` : + ' across all accessible rooms' + }` : `No results for "${q}"`}

@@ -470,15 +469,13 @@ export default function SearchPage() { {messagesData.total} - {useRoomScoped ? ( -

- in room {roomIdInput} -

- ) : ( -

- across all accessible rooms -

- )} +

+ {projectNameInput + ? `project "${projectNameInput}"` + : roomIdInput + ? `room "${roomIdInput}"` + : 'across all accessible rooms'} +

{messagesData.messages.map((msg) => ( @@ -497,9 +494,11 @@ export default function SearchPage() {

- {useRoomScoped - ? `No messages found in room "${roomIdInput}" matching "${q}"` - : `No messages found matching "${q}" across accessible rooms`} + No messages found matching "{q}"{ + projectNameInput ? ` in project "${projectNameInput}"` : + roomIdInput ? ` in room "${roomIdInput}"` : + ' across accessible rooms' + }