feat(conversations): add search endpoint — M1-006 #299
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Brain } from '@mosaic/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
CreateConversationDto,
|
CreateConversationDto,
|
||||||
UpdateConversationDto,
|
UpdateConversationDto,
|
||||||
SendMessageDto,
|
SendMessageDto,
|
||||||
|
SearchMessagesDto,
|
||||||
} from './conversations.dto.js';
|
} from './conversations.dto.js';
|
||||||
|
|
||||||
@Controller('api/conversations')
|
@Controller('api/conversations')
|
||||||
@@ -33,6 +36,16 @@ export class ConversationsController {
|
|||||||
return this.brain.conversations.findAll(user.id);
|
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')
|
@Get(':id')
|
||||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||||
const conversation = await this.brain.conversations.findById(id, user.id);
|
const conversation = await this.brain.conversations.findById(id, user.id);
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsInt,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
} from 'class-validator';
|
} 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 {
|
export class CreateConversationDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -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. */
|
/** Maximum number of conversations returned per list query. */
|
||||||
const MAX_CONVERSATIONS = 200;
|
const MAX_CONVERSATIONS = 200;
|
||||||
@@ -10,6 +10,15 @@ export type NewConversation = typeof conversations.$inferInsert;
|
|||||||
export type Message = typeof messages.$inferSelect;
|
export type Message = typeof messages.$inferSelect;
|
||||||
export type NewMessage = typeof messages.$inferInsert;
|
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) {
|
export function createConversationsRepo(db: Db) {
|
||||||
return {
|
return {
|
||||||
async findAll(userId: string): Promise<Conversation[]> {
|
async findAll(userId: string): Promise<Conversation[]> {
|
||||||
@@ -87,6 +96,35 @@ export function createConversationsRepo(db: Db) {
|
|||||||
.limit(MAX_MESSAGES);
|
.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.
|
* Add a message to a conversation, scoped to the given user.
|
||||||
* Verifies the parent conversation belongs to the user before inserting.
|
* Verifies the parent conversation belongs to the user before inserting.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export {
|
|||||||
type NewConversation,
|
type NewConversation,
|
||||||
type Message,
|
type Message,
|
||||||
type NewMessage,
|
type NewMessage,
|
||||||
|
type MessageSearchResult,
|
||||||
} from './conversations.js';
|
} from './conversations.js';
|
||||||
export {
|
export {
|
||||||
createAgentsRepo,
|
createAgentsRepo,
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export {
|
|||||||
lt,
|
lt,
|
||||||
gte,
|
gte,
|
||||||
lte,
|
lte,
|
||||||
|
ilike,
|
||||||
} from 'drizzle-orm';
|
} from 'drizzle-orm';
|
||||||
|
|||||||
Reference in New Issue
Block a user