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:
parent
bbeaea6614
commit
0acacbf57c
@ -37,6 +37,8 @@ pub async fn search(
|
|||||||
("q" = String, Query, description = "Search keyword", min_length = 1, max_length = 200),
|
("q" = String, Query, description = "Search keyword", min_length = 1, max_length = 200),
|
||||||
("page" = Option<u32>, Query, description = "Page number, default 1"),
|
("page" = Option<u32>, Query, description = "Page number, default 1"),
|
||||||
("per_page" = Option<u32>, Query, description = "Results per page, default 20, max 100"),
|
("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(
|
responses(
|
||||||
(status = 200, description = "Message search results across all accessible rooms", body = ApiResponse<GlobalMessageSearchResponse>),
|
(status = 200, description = "Message search results across all accessible rooms", body = ApiResponse<GlobalMessageSearchResponse>),
|
||||||
|
|||||||
@ -122,6 +122,12 @@ pub struct GlobalMessageSearchQuery {
|
|||||||
pub q: String,
|
pub q: String,
|
||||||
pub page: Option<u32>,
|
pub page: Option<u32>,
|
||||||
pub per_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)]
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
@ -540,23 +546,110 @@ impl AppService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::InternalError)?;
|
.map_err(|_| AppError::InternalError)?;
|
||||||
|
|
||||||
// 2. Public rooms in projects the user is a member of
|
// 2. Rooms in projects the user belongs to:
|
||||||
let project_ids = accessible_project_ids(&self.db, Some(user_id)).await?;
|
// - For projects where user is a member: ALL rooms
|
||||||
let public_rooms: Vec<Uuid> = room::Entity::find()
|
// - For public-only projects: only public rooms
|
||||||
.filter(room::Column::Project.is_in(project_ids.clone()))
|
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))
|
.filter(room::Column::Public.eq(true))
|
||||||
.select_only()
|
.select_only()
|
||||||
.column(room::Column::Id)
|
.column(room::Column::Id)
|
||||||
.into_tuple::<Uuid>()
|
.into_tuple::<Uuid>()
|
||||||
.all(&self.db)
|
.all(&self.db)
|
||||||
.await
|
.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)?;
|
.map_err(|_| AppError::InternalError)?;
|
||||||
|
|
||||||
// Merge and deduplicate accessible room IDs using a HashSet
|
accessible_set = project_rooms.into_iter().collect();
|
||||||
use std::collections::HashSet;
|
|
||||||
let mut accessible_set: HashSet<Uuid> = direct_rooms.into_iter().collect();
|
|
||||||
for rid in public_rooms {
|
|
||||||
accessible_set.insert(rid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let accessible_rooms: Vec<Uuid> = accessible_set.iter().cloned().collect();
|
let accessible_rooms: Vec<Uuid> = accessible_set.iter().cloned().collect();
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { client } from '@/client/client.gen';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -23,7 +23,6 @@ import type {
|
|||||||
RepoSearchItem,
|
RepoSearchItem,
|
||||||
IssueSearchItem,
|
IssueSearchItem,
|
||||||
UserSearchItem,
|
UserSearchItem,
|
||||||
MessageSearchResponse,
|
|
||||||
RoomMessageResponse,
|
RoomMessageResponse,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
GlobalMessageSearchResponse,
|
GlobalMessageSearchResponse,
|
||||||
@ -255,6 +254,7 @@ function ResultSection<T>({
|
|||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [roomIdInput, setRoomIdInput] = useState('');
|
const [roomIdInput, setRoomIdInput] = useState('');
|
||||||
|
const [projectNameInput, setProjectNameInput] = useState('');
|
||||||
|
|
||||||
const q = searchParams.get('q') ?? '';
|
const q = searchParams.get('q') ?? '';
|
||||||
const typeParam = searchParams.get('type') ?? '';
|
const typeParam = searchParams.get('type') ?? '';
|
||||||
@ -267,7 +267,6 @@ export default function SearchPage() {
|
|||||||
: [...ALL_TYPES];
|
: [...ALL_TYPES];
|
||||||
|
|
||||||
const showMessages = activeTypes.includes('messages');
|
const showMessages = activeTypes.includes('messages');
|
||||||
const useRoomScoped = roomIdInput.trim().length > 0;
|
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['search', q, typeParam, page],
|
queryKey: ['search', q, typeParam, page],
|
||||||
@ -287,33 +286,23 @@ export default function SearchPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Global message search across all accessible rooms
|
// Global message search across all accessible rooms
|
||||||
const { data: globalMessagesData, isLoading: globalMessagesLoading } = useQuery({
|
const { data: messagesData, isLoading: messagesLoading } = useQuery({
|
||||||
queryKey: ['search-messages-global', q],
|
queryKey: ['search-messages-global', q, roomIdInput, projectNameInput],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const resp = await searchMessages({
|
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;
|
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;
|
const results = data ?? null;
|
||||||
|
|
||||||
function handleSearchSubmit(e: React.FormEvent<HTMLFormElement>) {
|
function handleSearchSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
@ -375,14 +364,20 @@ export default function SearchPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Room ID input for messages search */}
|
{/* Message scoping inputs */}
|
||||||
{showMessages && (
|
{showMessages && (
|
||||||
<div className="mt-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
<Input
|
<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}
|
value={roomIdInput}
|
||||||
onChange={(e) => setRoomIdInput(e.target.value)}
|
onChange={(e) => setRoomIdInput(e.target.value)}
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs flex-1 min-w-[200px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -419,7 +414,11 @@ export default function SearchPage() {
|
|||||||
{results && getTotal(results) > 0
|
{results && getTotal(results) > 0
|
||||||
? `${getTotal(results)} results for "${q}"`
|
? `${getTotal(results)} results for "${q}"`
|
||||||
: showMessages && messagesData
|
: 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}"`}
|
: `No results for "${q}"`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -470,15 +469,13 @@ export default function SearchPage() {
|
|||||||
{messagesData.total}
|
{messagesData.total}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{useRoomScoped ? (
|
|
||||||
<p className="text-xs text-muted-foreground -mt-1">
|
<p className="text-xs text-muted-foreground -mt-1">
|
||||||
in room <span className="font-mono font-medium">{roomIdInput}</span>
|
{projectNameInput
|
||||||
|
? `project "${projectNameInput}"`
|
||||||
|
: roomIdInput
|
||||||
|
? `room "${roomIdInput}"`
|
||||||
|
: 'across all accessible rooms'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground -mt-1">
|
|
||||||
across all accessible rooms
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="divide-y">
|
<CardContent className="divide-y">
|
||||||
{messagesData.messages.map((msg) => (
|
{messagesData.messages.map((msg) => (
|
||||||
@ -497,9 +494,11 @@ export default function SearchPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
{useRoomScoped
|
No messages found matching "{q}"{
|
||||||
? `No messages found in room "${roomIdInput}" matching "${q}"`
|
projectNameInput ? ` in project "${projectNameInput}"` :
|
||||||
: `No messages found matching "${q}" across accessible rooms`}
|
roomIdInput ? ` in room "${roomIdInput}"` :
|
||||||
|
' across accessible rooms'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user