import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpException, HttpStatus, Inject, NotFoundException, Param, Patch, Post, Query, UseGuards, } from '@nestjs/common'; import type { Brain } from '@mosaicstack/brain'; import { BRAIN } from '../brain/brain.tokens.js'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js'; import { CreateConversationDto, UpdateConversationDto, SendMessageDto, SearchMessagesDto, } from './conversations.dto.js'; /** * Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the * legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before * the repository is touched — never a duplicate write. The `code` field is exposed at the top level * so callers can discriminate the refusal while the 503 status carries the browser-safe surface. */ class HarnessRuntimeWriteUnsupportedException extends HttpException { readonly code = 'runtime_unsupported' as const; constructor() { super( { code: 'runtime_unsupported', message: 'Conversation message writes are handled by the harness runtime on this deployment.', }, HttpStatus.SERVICE_UNAVAILABLE, ); } } @Controller('api/conversations') @UseGuards(AuthGuard) export class ConversationsController { /** * `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3). * The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at * request time — a single source of truth, so the controller cannot disagree with the router * about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode` * so this class depends on nothing else the router exposes. */ constructor( @Inject(BRAIN) private readonly brain: Brain, @Inject(ChatRuntimeRouter) private readonly router: Pick, ) {} @Get() async list(@CurrentUser() user: { id: string }) { 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); if (!conversation) throw new NotFoundException('Conversation not found'); return conversation; } @Post() async create(@CurrentUser() user: { id: string }, @Body() dto: CreateConversationDto) { return this.brain.conversations.create({ userId: user.id, title: dto.title, projectId: dto.projectId, }); } @Patch(':id') async update( @Param('id') id: string, @Body() dto: UpdateConversationDto, @CurrentUser() user: { id: string }, ) { const conversation = await this.brain.conversations.update(id, user.id, dto); if (!conversation) throw new NotFoundException('Conversation not found'); return conversation; } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { const deleted = await this.brain.conversations.remove(id, user.id); if (!deleted) throw new NotFoundException('Conversation not found'); } @Get(':id/messages') async listMessages(@Param('id') id: string, @CurrentUser() user: { id: string }) { // Verify ownership explicitly to return a clear 404 rather than an empty list. const conversation = await this.brain.conversations.findById(id, user.id); if (!conversation) throw new NotFoundException('Conversation not found'); return this.brain.conversations.findMessages(id, user.id); } @Post(':id/messages') async addMessage( @Param('id') id: string, @Body() dto: SendMessageDto, @CurrentUser() user: { id: string }, ) { // Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the // harness path owns persistence there, so a direct write would duplicate the message. The mode // comes from the router's init-time resolution, not a request-time env read. if (this.router.runtimeMode === 'pi-rpc') { throw new HarnessRuntimeWriteUnsupportedException(); } const message = await this.brain.conversations.addMessage( { conversationId: id, role: dto.role, content: dto.content, metadata: dto.metadata, }, user.id, ); if (!message) throw new ForbiddenException('Conversation not found or access denied'); return message; } }