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.
This commit is contained in:
ZhenYi 2026-04-28 13:12:29 +08:00
parent bbeaea6614
commit 0acacbf57c
3 changed files with 140 additions and 46 deletions

View File

@ -37,6 +37,8 @@ pub async fn search(
("q" = String, Query, description = "Search keyword", min_length = 1, max_length = 200),
("page" = Option<u32>, Query, description = "Page number, default 1"),
("per_page" = Option<u32>, Query, description = "Results per page, default 20, max 100"),
("room" = Option<Uuid>, Query, description = "Scope search to a specific room by UUID"),
("pn" = Option<String>, Query, description = "Scope search to a specific project by name"),
),
responses(
(status = 200, description = "Message search results across all accessible rooms", body = ApiResponse<GlobalMessageSearchResponse>),

View File

@ -122,6 +122,12 @@ pub struct GlobalMessageSearchQuery {
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)]
@ -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<Uuid> = 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<Uuid> = 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::<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> = 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<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() {

View File

@ -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<T>({
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<HTMLFormElement>) {
@ -375,14 +364,20 @@ export default function SearchPage() {
})}
</div>
{/* Room ID input for messages search */}
{/* Message scoping inputs */}
{showMessages && (
<div className="mt-2">
<div className="mt-2 flex flex-wrap gap-2">
<Input
placeholder="Room ID to search messages in (e.g. workspace:general)..."
placeholder="Project name (e.g. my-project)..."
value={projectNameInput}
onChange={(e) => setProjectNameInput(e.target.value)}
className="h-8 text-xs flex-1 min-w-[160px]"
/>
<Input
placeholder="Room UUID (optional)..."
value={roomIdInput}
onChange={(e) => setRoomIdInput(e.target.value)}
className="h-8 text-xs"
className="h-8 text-xs flex-1 min-w-[200px]"
/>
</div>
)}
@ -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}"`}
</p>
</div>
@ -470,15 +469,13 @@ export default function SearchPage() {
{messagesData.total}
</Badge>
</CardTitle>
{useRoomScoped ? (
<p className="text-xs text-muted-foreground -mt-1">
in room <span className="font-mono font-medium">{roomIdInput}</span>
</p>
) : (
<p className="text-xs text-muted-foreground -mt-1">
across all accessible rooms
</p>
)}
<p className="text-xs text-muted-foreground -mt-1">
{projectNameInput
? `project "${projectNameInput}"`
: roomIdInput
? `room "${roomIdInput}"`
: 'across all accessible rooms'}
</p>
</CardHeader>
<CardContent className="divide-y">
{messagesData.messages.map((msg) => (
@ -497,9 +494,11 @@ export default function SearchPage() {
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-4">
{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'
}
</p>
</CardContent>
</Card>