diff --git a/apps/gateway/src/conversations/conversations.controller.ts b/apps/gateway/src/conversations/conversations.controller.ts index 732edff..040be50 100644 --- a/apps/gateway/src/conversations/conversations.controller.ts +++ b/apps/gateway/src/conversations/conversations.controller.ts @@ -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); diff --git a/apps/gateway/src/conversations/conversations.dto.ts b/apps/gateway/src/conversations/conversations.dto.ts index 369ef33..7954df7 100644 --- a/apps/gateway/src/conversations/conversations.dto.ts +++ b/apps/gateway/src/conversations/conversations.dto.ts @@ -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() diff --git a/packages/brain/src/conversations.ts b/packages/brain/src/conversations.ts index f1bc88b..4508b96 100644 --- a/packages/brain/src/conversations.ts +++ b/packages/brain/src/conversations.ts @@ -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 { @@ -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 { + 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. diff --git a/packages/brain/src/index.ts b/packages/brain/src/index.ts index 4cde737..8bf5516 100644 --- a/packages/brain/src/index.ts +++ b/packages/brain/src/index.ts @@ -25,6 +25,7 @@ export { type NewConversation, type Message, type NewMessage, + type MessageSearchResult, } from './conversations.js'; export { createAgentsRepo, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f14fb70..72230ed 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -15,4 +15,5 @@ export { lt, gte, lte, + ilike, } from 'drizzle-orm';