import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, NotFoundException, Param, Patch, Post, Query, UseGuards, } from '@nestjs/common'; import fs from 'node:fs'; import path from 'node:path'; import { AuthGuard } from '../auth/auth.guard.js'; import { CurrentUser } from '../auth/current-user.decorator.js'; import { CoordService } from './coord.service.js'; import type { CreateDbMissionDto, UpdateDbMissionDto, CreateMissionTaskDto, UpdateMissionTaskDto, } from './coord.dto.js'; /** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */ function findMonorepoRoot(start: string): string { let dir = start; for (let i = 0; i < 5; i++) { try { fs.accessSync(path.join(dir, 'pnpm-workspace.yaml')); return dir; } catch { const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } } return start; } /** Only paths under these roots are allowed for coord queries. */ const WORKSPACE_ROOT = process.env['MOSAIC_WORKSPACE_ROOT'] ?? findMonorepoRoot(process.cwd()); const ALLOWED_ROOTS = [process.cwd(), WORKSPACE_ROOT]; function resolveAndValidatePath(raw: string | undefined): string { const resolved = path.resolve(raw ?? process.cwd()); const isAllowed = ALLOWED_ROOTS.some( (root) => resolved === root || resolved.startsWith(`${root}/`), ); if (!isAllowed) { throw new BadRequestException('projectPath is outside the allowed workspace'); } return resolved; } @Controller('api/coord') @UseGuards(AuthGuard) export class CoordController { constructor(@Inject(CoordService) private readonly coordService: CoordService) {} // ── File-based coord endpoints (legacy) ── @Get('status') async missionStatus(@Query('projectPath') projectPath?: string) { const resolvedPath = resolveAndValidatePath(projectPath); const status = await this.coordService.getMissionStatus(resolvedPath); if (!status) throw new NotFoundException('No active coord mission found'); return status; } @Get('tasks') async listTasks(@Query('projectPath') projectPath?: string) { const resolvedPath = resolveAndValidatePath(projectPath); return this.coordService.listTasks(resolvedPath); } @Get('tasks/:taskId') async taskStatus(@Param('taskId') taskId: string, @Query('projectPath') projectPath?: string) { const resolvedPath = resolveAndValidatePath(projectPath); const detail = await this.coordService.getTaskStatus(resolvedPath, taskId); if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`); return detail; } // ── DB-backed mission endpoints ── @Get('missions') async listDbMissions(@CurrentUser() user: { id: string }) { return this.coordService.getMissionsByUser(user.id); } @Get('missions/:id') async getDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) { const mission = await this.coordService.getMissionByIdAndUser(id, user.id); if (!mission) throw new NotFoundException('Mission not found'); return mission; } @Post('missions') async createDbMission(@Body() dto: CreateDbMissionDto, @CurrentUser() user: { id: string }) { return this.coordService.createDbMission({ name: dto.name, description: dto.description, projectId: dto.projectId, userId: user.id, phase: dto.phase, milestones: dto.milestones, config: dto.config, status: dto.status, }); } @Patch('missions/:id') async updateDbMission( @Param('id') id: string, @Body() dto: UpdateDbMissionDto, @CurrentUser() user: { id: string }, ) { const mission = await this.coordService.updateDbMission(id, user.id, dto); if (!mission) throw new NotFoundException('Mission not found'); return mission; } @Delete('missions/:id') @HttpCode(HttpStatus.NO_CONTENT) async deleteDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) { const deleted = await this.coordService.deleteDbMission(id, user.id); if (!deleted) throw new NotFoundException('Mission not found'); } // ── DB-backed mission task endpoints ── @Get('missions/:missionId/mission-tasks') async listMissionTasks( @Param('missionId') missionId: string, @CurrentUser() user: { id: string }, ) { const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); if (!mission) throw new NotFoundException('Mission not found'); return this.coordService.getMissionTasksByMissionAndUser(missionId, user.id); } @Get('missions/:missionId/mission-tasks/:taskId') async getMissionTask( @Param('missionId') missionId: string, @Param('taskId') taskId: string, @CurrentUser() user: { id: string }, ) { const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); if (!mission) throw new NotFoundException('Mission not found'); const task = await this.coordService.getMissionTaskByIdAndUser(taskId, user.id); if (!task) throw new NotFoundException('Mission task not found'); return task; } @Post('missions/:missionId/mission-tasks') async createMissionTask( @Param('missionId') missionId: string, @Body() dto: CreateMissionTaskDto, @CurrentUser() user: { id: string }, ) { const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); if (!mission) throw new NotFoundException('Mission not found'); return this.coordService.createMissionTask({ missionId, taskId: dto.taskId, userId: user.id, status: dto.status, description: dto.description, notes: dto.notes, pr: dto.pr, }); } @Patch('missions/:missionId/mission-tasks/:taskId') async updateMissionTask( @Param('missionId') missionId: string, @Param('taskId') taskId: string, @Body() dto: UpdateMissionTaskDto, @CurrentUser() user: { id: string }, ) { const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); if (!mission) throw new NotFoundException('Mission not found'); const updated = await this.coordService.updateMissionTask(taskId, user.id, dto); if (!updated) throw new NotFoundException('Mission task not found'); return updated; } @Delete('missions/:missionId/mission-tasks/:taskId') @HttpCode(HttpStatus.NO_CONTENT) async deleteMissionTask( @Param('missionId') missionId: string, @Param('taskId') taskId: string, @CurrentUser() user: { id: string }, ) { const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id); if (!mission) throw new NotFoundException('Mission not found'); const deleted = await this.coordService.deleteMissionTask(taskId, user.id); if (!deleted) throw new NotFoundException('Mission task not found'); } }