feat(conversations): add full-text message search endpoint (M1-006)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed

Adds GET /api/conversations/search?q=<query> endpoint that searches
message content across all authenticated user's conversations using
PostgreSQL ILIKE. Supports limit/offset pagination (default limit=20)
and returns results sorted by recency with conversation context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 15:42:59 -05:00
parent 1d14ddcfe7
commit 06c3e51290
5 changed files with 77 additions and 1 deletions

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -11,6 +12,7 @@ import {
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import type { Brain } from '@mosaic/brain';
@@ -21,6 +23,7 @@ import {
CreateConversationDto,
UpdateConversationDto,
SendMessageDto,
SearchMessagesDto,
} from './conversations.dto.js';
@Controller('api/conversations')
@@ -33,6 +36,16 @@ export class ConversationsController {
return this.brain.conversations.findAll(user.id);
}
@Get('search')
async search(@Query() dto: SearchMessagesDto, @CurrentUser() user: { id: string }) {
if (!dto.q || dto.q.trim().length === 0) {
throw new BadRequestException('Query parameter "q" is required and must not be empty');
}
const limit = dto.limit ?? 20;
const offset = dto.offset ?? 0;
return this.brain.conversations.searchMessages(user.id, dto.q.trim(), limit, offset);
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
const conversation = await this.brain.conversations.findById(id, user.id);

View File

@@ -1,12 +1,35 @@
import {
IsBoolean,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
export class SearchMessagesDto {
@IsString()
@MaxLength(500)
q!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number = 0;
}
export class CreateConversationDto {
@IsOptional()

View File

@@ -1,4 +1,4 @@
import { eq, and, asc, desc, type Db, conversations, messages } from '@mosaic/db';
import { eq, and, asc, desc, ilike, type Db, conversations, messages } from '@mosaic/db';
/** Maximum number of conversations returned per list query. */
const MAX_CONVERSATIONS = 200;
@@ -10,6 +10,15 @@ export type NewConversation = typeof conversations.$inferInsert;
export type Message = typeof messages.$inferSelect;
export type NewMessage = typeof messages.$inferInsert;
export interface MessageSearchResult {
messageId: string;
conversationId: string;
conversationTitle: string | null;
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: Date;
}
export function createConversationsRepo(db: Db) {
return {
async findAll(userId: string): Promise<Conversation[]> {
@@ -87,6 +96,35 @@ export function createConversationsRepo(db: Db) {
.limit(MAX_MESSAGES);
},
/**
* Search messages by content across all conversations belonging to the user.
* Uses ILIKE for case-insensitive substring matching.
*/
async searchMessages(
userId: string,
query: string,
limit: number,
offset: number,
): Promise<MessageSearchResult[]> {
const rows = await db
.select({
messageId: messages.id,
conversationId: conversations.id,
conversationTitle: conversations.title,
role: messages.role,
content: messages.content,
createdAt: messages.createdAt,
})
.from(messages)
.innerJoin(conversations, eq(messages.conversationId, conversations.id))
.where(and(eq(conversations.userId, userId), ilike(messages.content, `%${query}%`)))
.orderBy(desc(messages.createdAt))
.limit(limit)
.offset(offset);
return rows;
},
/**
* Add a message to a conversation, scoped to the given user.
* Verifies the parent conversation belongs to the user before inserting.

View File

@@ -25,6 +25,7 @@ export {
type NewConversation,
type Message,
type NewMessage,
type MessageSearchResult,
} from './conversations.js';
export {
createAgentsRepo,

View File

@@ -15,4 +15,5 @@ export {
lt,
gte,
lte,
ilike,
} from 'drizzle-orm';