diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 293416a..85381f2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,6 +22,7 @@ import { ProjectModule } from './modules/project/project.module'; import { ScheduleModule } from './modules/schedule/schedule.module'; import { MeetingModule } from './modules/meeting/meeting.module'; import { DriveModule } from './modules/drive/drive.module'; +import { TodoModule } from './modules/todo/todo.module'; @Module({ imports: [ @@ -106,6 +107,7 @@ import { DriveModule } from './modules/drive/drive.module'; ScheduleModule, MeetingModule, DriveModule, + TodoModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/migrations/1783800000000-AddTodo.ts b/backend/src/migrations/1783800000000-AddTodo.ts new file mode 100644 index 0000000..b7918e0 --- /dev/null +++ b/backend/src/migrations/1783800000000-AddTodo.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 할 일(개인 체크리스트) — 라벨/항목 테이블 추가. +// 라벨은 소유자 전용이고, 사용자 삭제 시 라벨과 항목이 연쇄 삭제된다. +// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다. +export class AddTodo1783800000000 implements MigrationInterface { + name = 'AddTodo1783800000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 라벨(묶음) + await queryRunner.query( + `CREATE TABLE "todo_labels" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "color" character varying NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid NOT NULL, CONSTRAINT "PK_todo_labels" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_todo_labels_owner" ON "todo_labels" ("owner_id")`, + ); + + // 항목(할 일) + await queryRunner.query( + `CREATE TABLE "todo_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "done" boolean NOT NULL DEFAULT false, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "label_id" uuid, CONSTRAINT "PK_todo_items" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_todo_items_label" ON "todo_items" ("label_id")`, + ); + + // FK + await queryRunner.query( + `ALTER TABLE "todo_labels" ADD CONSTRAINT "FK_todo_labels_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "todo_items" ADD CONSTRAINT "FK_todo_items_label" FOREIGN KEY ("label_id") REFERENCES "todo_labels"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "todo_items" DROP CONSTRAINT "FK_todo_items_label"`, + ); + await queryRunner.query( + `ALTER TABLE "todo_labels" DROP CONSTRAINT "FK_todo_labels_owner"`, + ); + await queryRunner.query(`DROP TABLE "todo_items"`); + await queryRunner.query(`DROP TABLE "todo_labels"`); + } +} diff --git a/backend/src/migrations/1783900000000-DropAiConversations.ts b/backend/src/migrations/1783900000000-DropAiConversations.ts new file mode 100644 index 0000000..836529b --- /dev/null +++ b/backend/src/migrations/1783900000000-DropAiConversations.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// AI 채팅(헤더 어시스턴트) 기능 제거 — 대화/메시지 테이블 삭제. +// 업무 생성 화면의 할 일 추천(/ai/suggest-checklist)은 DB 를 쓰지 않으므로 영향이 없다. +// down() 은 초기 스키마(CreateInitialSchema)의 정의를 그대로 되살린다(데이터는 복구되지 않음). +export class DropAiConversations1783900000000 implements MigrationInterface { + name = 'DropAiConversations1783900000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 메시지 → 대화 순서로 FK 를 먼저 끊고 테이블을 지운다 + await queryRunner.query( + `ALTER TABLE "ai_messages" DROP CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d"`, + ); + await queryRunner.query( + `ALTER TABLE "ai_conversations" DROP CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb"`, + ); + await queryRunner.query(`DROP TABLE "ai_messages"`); + await queryRunner.query(`DROP TABLE "ai_conversations"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "ai_conversations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(120) NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_60db12765b82858ba00c8aa4ae2" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "ai_messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role" character varying NOT NULL, "content" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "conversation_id" uuid NOT NULL, CONSTRAINT "PK_a390434d4a515ba18a41bc996c2" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "ai_conversations" ADD CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "ai_messages" ADD CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d" FOREIGN KEY ("conversation_id") REFERENCES "ai_conversations"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/backend/src/migrations/1784000000000-AddTaskCompletedAt.ts b/backend/src/migrations/1784000000000-AddTaskCompletedAt.ts new file mode 100644 index 0000000..dbf8110 --- /dev/null +++ b/backend/src/migrations/1784000000000-AddTaskCompletedAt.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 업무 완료 시각(completed_at) 추가. +// 종전에는 목록의 '완료' 날짜를 마감일(due_date)로 표기해 실제 완료일과 달랐다. +// 기존 완료 업무는 활동 로그(task.status_changed, statusTo=done)의 시각으로 백필한다. +// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다. +export class AddTaskCompletedAt1784000000000 implements MigrationInterface { + name = 'AddTaskCompletedAt1784000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "tasks" ADD "completed_at" TIMESTAMP WITH TIME ZONE`, + ); + + // 백필 — 업무별 '가장 최근' 완료 전이 시각을 사용한다. + // (수정 요청 후 재승인된 업무는 마지막 승인 시점이 잡힌다) + // activities 는 task FK 대신 (project_id, task_seq) 를 갖고, tasks 는 (project_id, seq) 가 유일하다. + // 활동 기록이 없는 과거 업무는 null 로 남고, 프론트는 날짜 없이 '완료'만 표시한다. + // activities.created_at 은 timestamp(무시간대, UTC 저장)이므로 + // AT TIME ZONE 'UTC' 로 명시 변환해 세션 타임존과 무관하게 정확히 옮긴다. + await queryRunner.query(` + UPDATE "tasks" t SET "completed_at" = a."created_at" AT TIME ZONE 'UTC' + FROM ( + SELECT DISTINCT ON ("project_id", "task_seq") + "project_id", "task_seq", "created_at" + FROM "activities" + WHERE "type" = 'task.status_changed' + AND "payload"->>'statusTo' = 'done' + ORDER BY "project_id", "task_seq", "created_at" DESC + ) a + WHERE t."project_id" = a."project_id" + AND t."seq" = a."task_seq" + AND t."status" = 'done' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "completed_at"`); + } +} diff --git a/backend/src/modules/ai/ai.controller.ts b/backend/src/modules/ai/ai.controller.ts deleted file mode 100644 index 3f17fc5..0000000 --- a/backend/src/modules/ai/ai.controller.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - Body, - Controller, - DefaultValuePipe, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseIntPipe, - ParseUUIDPipe, - Post, - Query, - UseGuards, -} from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { CurrentUser } from '../auth/decorators/current-user.decorator'; -import type { PublicUser } from '../user/user.service'; -import type { PaginatedResult } from '../../common/pagination/pagination'; -import { - AiService, - type ConversationDetail, - type ConversationSummary, - type SendResult, -} from './ai.service'; -import { SendMessageDto } from './dto/send-message.dto'; - -// AI 채팅 컨트롤러 — 인증 필요. 대화는 본인 소유만 접근(서비스에서 스코프). -@ApiTags('AI') -@Controller('ai/conversations') -@UseGuards(JwtAuthGuard) -export class AiController { - constructor(private readonly aiService: AiService) {} - - @Get() - @ApiOperation({ summary: '내 대화 목록 (최근순, 페이지네이션)' }) - @ApiResponse({ status: 200, description: '목록 조회 성공' }) - list( - @CurrentUser() user: PublicUser, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number, - ): Promise> { - return this.aiService.listConversations(user.id, page, size); - } - - @Get(':id') - @ApiOperation({ summary: '대화 상세 (메시지 포함)' }) - @ApiResponse({ status: 200, description: '조회 성공' }) - @ApiResponse({ status: 404, description: '대화 없음 (RES_001)' }) - get( - @CurrentUser() user: PublicUser, - @Param('id', ParseUUIDPipe) id: string, - ): Promise { - return this.aiService.getConversation(user.id, id); - } - - @Post() - @HttpCode(HttpStatus.CREATED) - @Throttle({ default: { limit: 1000, ttl: 60_000 } }) - @ApiOperation({ summary: '새 대화 시작 (첫 메시지)' }) - @ApiResponse({ status: 201, description: '생성 + AI 응답' }) - start( - @CurrentUser() user: PublicUser, - @Body() dto: SendMessageDto, - ): Promise { - return this.aiService.startConversation(user.id, dto.content); - } - - @Post(':id/messages') - @HttpCode(HttpStatus.OK) - @Throttle({ default: { limit: 1000, ttl: 60_000 } }) - @ApiOperation({ summary: '대화에 메시지 추가' }) - @ApiResponse({ status: 200, description: 'AI 응답' }) - @ApiResponse({ status: 404, description: '대화 없음 (RES_001)' }) - send( - @CurrentUser() user: PublicUser, - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: SendMessageDto, - ): Promise { - return this.aiService.addMessage(user.id, id, dto.content); - } - - @Delete(':id') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '대화 삭제' }) - @ApiResponse({ status: 200, description: '삭제 성공' }) - @ApiResponse({ status: 404, description: '대화 없음 (RES_001)' }) - async remove( - @CurrentUser() user: PublicUser, - @Param('id', ParseUUIDPipe) id: string, - ): Promise { - await this.aiService.deleteConversation(user.id, id); - return null; - } -} diff --git a/backend/src/modules/ai/ai.module.ts b/backend/src/modules/ai/ai.module.ts index 9077339..9cf3aca 100644 --- a/backend/src/modules/ai/ai.module.ts +++ b/backend/src/modules/ai/ai.module.ts @@ -1,22 +1,13 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { TaskModule } from '../task/task.module'; import { ProjectModule } from '../project/project.module'; -import { AiController } from './ai.controller'; import { AiSuggestController } from './ai-suggest.controller'; import { AiService } from './ai.service'; -import { AiConversation } from './entities/ai-conversation.entity'; -import { AiMessage } from './entities/ai-message.entity'; -// AI 모듈 — Claude(Anthropic) 채팅 프록시 + 대화/메시지 영속. -// Task/ProjectModule 을 가져와 사용자 업무·프로젝트 컨텍스트를 시스템 프롬프트에 주입한다. +// AI 모듈 — Claude(Anthropic) 기반 업무 할 일(체크리스트) 추천. +// ProjectModule 을 가져와 프로젝트 정보·첨부 문서 텍스트를 프롬프트에 주입한다. @Module({ - imports: [ - TypeOrmModule.forFeature([AiConversation, AiMessage]), - TaskModule, - ProjectModule, - ], - controllers: [AiController, AiSuggestController], + imports: [ProjectModule], + controllers: [AiSuggestController], providers: [AiService], }) export class AiModule {} diff --git a/backend/src/modules/ai/ai.service.ts b/backend/src/modules/ai/ai.service.ts index 54ea142..23b018e 100644 --- a/backend/src/modules/ai/ai.service.ts +++ b/backend/src/modules/ai/ai.service.ts @@ -1,41 +1,13 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { BusinessException } from '../../common/exceptions/business.exception'; -import { - paginated, - resolvePage, - type PaginatedResult, -} from '../../common/pagination/pagination'; -import { TaskService } from '../task/task.service'; -import type { TaskStatus } from '../task/entities/task.entity'; import { ProjectService } from '../project/project.service'; -import { AiConversation } from './entities/ai-conversation.entity'; -import { AiMessage } from './entities/ai-message.entity'; // Anthropic Messages API 응답(필요 필드만) interface AnthropicResponse { content?: { type: string; text?: string }[]; } -// 외부 노출 형태 -export interface ConversationSummary { - id: string; - title: string; - updatedAt: Date; -} -export interface ConversationDetail { - id: string; - title: string; - messages: { role: 'user' | 'assistant'; content: string }[]; -} -export interface SendResult { - conversationId: string; - title: string; - reply: string; -} - // 할 일 추천 — 카테고리별 항목 export interface ChecklistSuggestionGroup { category: string; @@ -46,36 +18,6 @@ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages'; // AI 요청 타임아웃 기본값(ms) — env AI_TIMEOUT_MS 로 재정의. 프론트와 동일 값을 사용한다. const DEFAULT_AI_TIMEOUT_MS = 60_000; const MAX_TOKENS = 1024; -const TITLE_MAX = 60; -// 컨텍스트로 주입할 담당 업무 최대 건수(토큰 절약) -const CONTEXT_TASK_LIMIT = 30; -// 컨텍스트로 주입할 프로젝트 최대 개수 -const CONTEXT_PROJECT_LIMIT = 30; - -// 주제 한정(가드레일) + 데이터 근거 지시 -const SYSTEM_PROMPT = [ - '당신은 업무 협업 도구 "Relay" 의 AI 어시스턴트입니다.', - '', - '[역할]', - "- 업무 관리, 일정·마감, 협업, Relay 사용법, 그리고 아래 '사용자 데이터' 에 관한 질문에 답합니다.", - '- 업무와 무관한 주제(일반 상식, 시사, 번역, 코딩 과제, 잡담 등)는 정중히 거절하고 업무 관련 질문을 하도록 안내하세요.', - '', - '[규칙]', - '- 한국어로 간결하고 명확하게 답하세요. 불필요한 인사말·사과·이모지 남발을 피하고, 과도한 존칭("회원님" 등) 대신 자연스러운 존댓말을 쓰세요.', - '- 목록이나 강조가 필요하면 마크다운(굵게 **텍스트**, 목록 - 또는 1.)을 사용하세요. 표·복잡한 서식은 피하세요.', - "- 아래 '사용자 데이터' 에 근거해 답하고, 데이터에 없는 내용은 추측하지 말고 모른다고 하세요.", - "- 마감/일정은 제공된 '오늘 날짜' 를 기준으로 계산하세요.", - '- 프로젝트는 한글 표시 제목으로 설명하세요.', -].join('\n'); - -// 업무 상태 → 한국어 라벨(컨텍스트 표기용) -const STATUS_LABEL: Record = { - todo: '할 일', - prog: '진행 중', - review: '검토 대기', - changes: '수정 요청', - done: '완료', -}; // 할 일 추천용 시스템 프롬프트 — JSON 으로만 응답하도록 강제 const SUGGEST_SYSTEM_PROMPT = [ @@ -95,7 +37,7 @@ const SUGGEST_SYSTEM_PROMPT = [ '{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}', ].join('\n'); -// AI(Claude) 채팅 서비스 — 대화/메시지를 DB 에 보관하고 Anthropic Messages API 를 호출. +// AI(Claude) 서비스 — 업무 생성 화면의 할 일(체크리스트) 추천을 담당한다. // 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다. @Injectable() export class AiService { @@ -106,12 +48,7 @@ export class AiService { constructor( config: ConfigService, - private readonly taskService: TaskService, private readonly projectService: ProjectService, - @InjectRepository(AiConversation) - private readonly convRepo: Repository, - @InjectRepository(AiMessage) - private readonly msgRepo: Repository, ) { this.apiKey = (config.get('CLAUDE_API_KEY') ?? '').trim(); this.model = ( @@ -121,7 +58,7 @@ export class AiService { const t = Number(config.get('AI_TIMEOUT_MS')); this.timeoutMs = Number.isFinite(t) && t > 0 ? t : DEFAULT_AI_TIMEOUT_MS; if (!this.apiKey) { - this.logger.warn('CLAUDE_API_KEY 미설정 — AI 채팅 비활성화'); + this.logger.warn('CLAUDE_API_KEY 미설정 — AI 추천 비활성화'); } } @@ -129,69 +66,6 @@ export class AiService { return this.apiKey.length > 0; } - // 내 대화 목록(최근순, 페이지네이션) - async listConversations( - userId: string, - page?: number, - size?: number, - ): Promise> { - const params = resolvePage(page, size); - const [rows, total] = await this.convRepo.findAndCount({ - where: { user: { id: userId } }, - order: { updatedAt: 'DESC' }, - skip: params.skip, - take: params.take, - }); - return paginated( - rows.map((c) => ({ id: c.id, title: c.title, updatedAt: c.updatedAt })), - total, - params, - ); - } - - // 대화 상세(본인 소유만) — 메시지 시간순 - async getConversation( - userId: string, - conversationId: string, - ): Promise { - const conv = await this.getOwnedConversation(userId, conversationId); - const messages = await this.msgRepo.find({ - where: { conversation: { id: conv.id } }, - order: { createdAt: 'ASC' }, - }); - return { - id: conv.id, - title: conv.title, - messages: messages.map((m) => ({ role: m.role, content: m.content })), - }; - } - - // 새 대화 시작 — 첫 메시지로 대화 생성 후 응답 - async startConversation( - userId: string, - content: string, - ): Promise { - this.ensureEnabled(); - const conv = await this.convRepo.save( - this.convRepo.create({ - user: { id: userId }, - title: this.makeTitle(content), - }), - ); - return this.appendAndReply(conv, content, userId); - } - - // 기존 대화에 메시지 추가 후 응답(본인 소유만) - async addMessage( - userId: string, - conversationId: string, - content: string, - ): Promise { - this.ensureEnabled(); - const conv = await this.getOwnedConversation(userId, conversationId); - return this.appendAndReply(conv, content, userId); - } - // 할 일(체크리스트) 추천 — 프로젝트 설명 + 업무 제목/내용을 바탕으로 카테고리별 항목 제안 async suggestChecklist( userId: string, @@ -322,91 +196,6 @@ export class AiService { ); } - // 대화 삭제(본인 소유만) — 메시지 CASCADE - async deleteConversation( - userId: string, - conversationId: string, - ): Promise { - const conv = await this.getOwnedConversation(userId, conversationId); - await this.convRepo.remove(conv); - } - - // 사용자 메시지 저장 → 전체 이력으로 Claude 호출 → 응답 저장 → 대화 updatedAt 갱신. - // Claude 실패 시 방금 저장한 사용자 메시지를 롤백(빈 대화면 대화도 삭제)해 고아 데이터 방지. - private async appendAndReply( - conv: AiConversation, - content: string, - userId: string, - ): Promise { - const userMsg = await this.msgRepo.save( - this.msgRepo.create({ - conversation: { id: conv.id }, - role: 'user', - content, - }), - ); - const history = await this.msgRepo.find({ - where: { conversation: { id: conv.id } }, - order: { createdAt: 'ASC' }, - }); - - let reply: string; - try { - const system = await this.buildSystemPrompt(userId); - reply = await this.callClaude( - history.map((m) => ({ role: m.role, content: m.content })), - system, - ); - } catch (e) { - // 롤백 — 방금 사용자 메시지 제거, 남은 메시지가 없으면(첫 메시지였다면) 대화도 삭제 - await this.msgRepo.delete(userMsg.id); - const remaining = await this.msgRepo.count({ - where: { conversation: { id: conv.id } }, - }); - if (remaining === 0) { - await this.convRepo.delete(conv.id); - } - throw e; - } - - await this.msgRepo.save( - this.msgRepo.create({ - conversation: { id: conv.id }, - role: 'assistant', - content: reply, - }), - ); - // 최근 대화로 정렬되도록 updatedAt 갱신(@UpdateDateColumn) - await this.convRepo.save(conv); - return { conversationId: conv.id, title: conv.title, reply }; - } - - // 소유권 확인 — 본인 대화가 아니면 404(존재 비노출) - private async getOwnedConversation( - userId: string, - conversationId: string, - ): Promise { - const conv = await this.convRepo.findOne({ - where: { id: conversationId, user: { id: userId } }, - }); - if (!conv) { - throw new BusinessException( - 'RES_001', - '대화를 찾을 수 없습니다.', - HttpStatus.NOT_FOUND, - ); - } - return conv; - } - - private makeTitle(content: string): string { - const firstLine = content.trim().split('\n')[0].trim(); - if (!firstLine) return '새 대화'; - return firstLine.length > TITLE_MAX - ? `${firstLine.slice(0, TITLE_MAX)}…` - : firstLine; - } - private ensureEnabled(): void { if (!this.enabled) { throw new BusinessException( @@ -417,70 +206,6 @@ export class AiService { } } - // 시스템 프롬프트 = 가드레일 + 현재 사용자 데이터 컨텍스트 - private async buildSystemPrompt(userId: string): Promise { - const context = await this.buildUserContext(userId); - return `${SYSTEM_PROMPT}\n\n--- 사용자 데이터 ---\n${context}`; - } - - // 현재 사용자의 담당/지시 업무를 요약해 컨텍스트 문자열로 — 실패해도 채팅은 진행 - private async buildUserContext(userId: string): Promise { - const today = new Date(Date.now() + 9 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); // KST 기준 오늘 - try { - const assigned = await this.taskService.listAssigned( - userId, - 1, - CONTEXT_TASK_LIMIT, - ); - const issued = await this.taskService.listIssued(userId, 1, 1); - const projects = await this.projectService.findAll( - 1, - CONTEXT_PROJECT_LIMIT, - ); - - const lines = [`오늘 날짜: ${today} (KST)`, '']; - - // 담당 업무 - lines.push(`내가 담당한 업무 (${assigned.total}건):`); - if (assigned.items.length === 0) { - lines.push('- (없음)'); - } else { - for (const t of assigned.items) { - const due = t.dueDate ? t.dueDate.slice(0, 10) : '없음'; - const [done, totalC] = t.checklist; - lines.push( - `- [${t.projectName}] #${t.id} ${t.title} — 상태: ${STATUS_LABEL[t.status]}, 마감: ${due}, 체크리스트: ${done}/${totalC}`, - ); - } - if (assigned.total > assigned.items.length) { - lines.push(`- … 외 ${assigned.total - assigned.items.length}건`); - } - } - lines.push('', `내가 지시한 업무: ${issued.total}건`); - - // 프로젝트 목록(인증 사용자는 전체 열람 가능) - lines.push('', `프로젝트 목록 (${projects.total}개):`); - if (projects.items.length === 0) { - lines.push('- (없음)'); - } else { - for (const p of projects.items) { - lines.push(`- ${p.name} — 업무 ${p.doneCount}/${p.totalCount}건`); - } - if (projects.total > projects.items.length) { - lines.push(`- … 외 ${projects.total - projects.items.length}개`); - } - } - return lines.join('\n'); - } catch (e) { - this.logger.warn( - `사용자 컨텍스트 조회 실패: ${e instanceof Error ? e.message : String(e)}`, - ); - return `오늘 날짜: ${today} (KST)\n(사용자 업무 데이터를 불러오지 못했습니다.)`; - } - } - // Anthropic Messages API 호출 — 실패 시 내부/키 노출 없이 일반 문구 private async callClaude( messages: { role: 'user' | 'assistant'; content: string }[], diff --git a/backend/src/modules/ai/dto/send-message.dto.ts b/backend/src/modules/ai/dto/send-message.dto.ts deleted file mode 100644 index 11c863c..0000000 --- a/backend/src/modules/ai/dto/send-message.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -// 메시지 전송 — 대화 생성/이어가기 공통. 이력은 서버가 DB 에서 불러온다. -export class SendMessageDto { - @ApiProperty({ description: '사용자 메시지 내용' }) - @IsString() - @IsNotEmpty({ message: '메시지 내용을 입력해 주세요.' }) - @MaxLength(8000, { message: '메시지가 너무 깁니다.' }) - content!: string; -} diff --git a/backend/src/modules/ai/entities/ai-conversation.entity.ts b/backend/src/modules/ai/entities/ai-conversation.entity.ts deleted file mode 100644 index 1db268e..0000000 --- a/backend/src/modules/ai/entities/ai-conversation.entity.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - JoinColumn, - ManyToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, - type Relation, -} from 'typeorm'; -import { User } from '../../user/entities/user.entity'; - -// AI 대화 — 사용자별 채팅 스레드. 메시지는 AiMessage 로 분리(1:N). -@Entity('ai_conversations') -export class AiConversation { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false }) - @JoinColumn({ name: 'user_id' }) - user!: Relation; - - // 첫 사용자 메시지로 자동 생성되는 제목 - @Column({ type: 'varchar', length: 120 }) - title!: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt!: Date; - - // 새 메시지 추가 시 갱신(최근 대화 정렬용) - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt!: Date; -} diff --git a/backend/src/modules/ai/entities/ai-message.entity.ts b/backend/src/modules/ai/entities/ai-message.entity.ts deleted file mode 100644 index 3a5f395..0000000 --- a/backend/src/modules/ai/entities/ai-message.entity.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - JoinColumn, - ManyToOne, - PrimaryGeneratedColumn, - type Relation, -} from 'typeorm'; -import { AiConversation } from './ai-conversation.entity'; - -// AI 대화 메시지 1건 — 대화에 종속(CASCADE). -@Entity('ai_messages') -export class AiMessage { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @ManyToOne(() => AiConversation, { onDelete: 'CASCADE', nullable: false }) - @JoinColumn({ name: 'conversation_id' }) - conversation!: Relation; - - @Column({ type: 'varchar' }) - role!: 'user' | 'assistant'; - - @Column({ type: 'text' }) - content!: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt!: Date; -} diff --git a/backend/src/modules/task/entities/task.entity.ts b/backend/src/modules/task/entities/task.entity.ts index fbcf4ff..0c51e65 100644 --- a/backend/src/modules/task/entities/task.entity.ts +++ b/backend/src/modules/task/entities/task.entity.ts @@ -102,6 +102,11 @@ export class Task { }) approvalRequestedAt!: Date | null; + // 완료(done) 전이 시각 — 목록의 '완료' 날짜 표기용. + // done 에서 벗어나면(수정 요청 등) null 로 초기화하고, 재승인 시 최신 시각으로 갱신한다. + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt!: Date | null; + // 최근 수정 요청 메시지(지시자 → 담당자). 수정 요청(changes) 상태에서 노출 @Column({ name: 'changes_note', type: 'text', nullable: true }) changesNote!: string | null; diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index dad80df..0b7df97 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -59,6 +59,8 @@ export interface ProjectTaskResponse { title: string; status: TaskStatus; dueDate: string | null; + // 완료 시각(done 이 아니거나 기록이 없으면 null) + completedAt: string | null; checklist: [number, number]; // [완료, 전체] commentCount: number; fileCount: number; // 첨부 파일 수 @@ -146,6 +148,8 @@ export interface MyTaskRowResponse { title: string; status: TaskStatus; dueDate: string | null; + // 완료 시각(done 이 아니거나 기록이 없으면 null) + completedAt: string | null; checklist: [number, number]; // [완료, 전체] assignees: PublicUser[]; issuer: PublicUser | null; @@ -1252,6 +1256,7 @@ export class TaskService { title: task.title, status: task.status, dueDate: task.dueDate ? task.dueDate.toISOString() : null, + completedAt: task.completedAt ? task.completedAt.toISOString() : null, checklist: [done, checklist.length], assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)), issuer: task.issuer ? UserService.toPublic(task.issuer) : null, @@ -1303,6 +1308,13 @@ export class TaskService { if (next === 'done' || next === 'changes') { task.reviewedBy = membership.user; } + // 완료 시각 기록 — 완료로 들어가면 지금 시각, 완료에서 벗어나면(수정 요청 등) 초기화. + // 마감일(dueDate)과 별개의 값이므로 목록의 '완료' 날짜는 이 값을 쓴다. + if (next === 'done') { + task.completedAt = new Date(); + } else if (from === 'done') { + task.completedAt = null; + } await this.taskRepo.save(task); // 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속) @@ -1544,6 +1556,7 @@ export class TaskService { title: task.title, status: task.status, dueDate: task.dueDate ? task.dueDate.toISOString() : null, + completedAt: task.completedAt ? task.completedAt.toISOString() : null, checklist: [done, checklist.length], commentCount, fileCount, diff --git a/backend/src/modules/todo/dto/todo-item.dto.ts b/backend/src/modules/todo/dto/todo-item.dto.ts new file mode 100644 index 0000000..67de2ed --- /dev/null +++ b/backend/src/modules/todo/dto/todo-item.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; + +// 할 일 항목 생성 DTO +export class CreateTodoItemDto { + @ApiProperty({ description: '할 일 내용', example: '견적서 검토' }) + @IsString() + @IsNotEmpty({ message: '할 일 내용을 입력해 주세요.' }) + @MaxLength(200, { message: '할 일은 최대 200자입니다.' }) + title!: string; +} + +// 할 일 항목 수정 DTO — 내용 변경과 완료 토글에 함께 쓴다 +export class UpdateTodoItemDto { + @ApiPropertyOptional({ description: '할 일 내용', example: '견적서 재검토' }) + @IsOptional() + @IsString() + @IsNotEmpty({ message: '할 일 내용을 입력해 주세요.' }) + @MaxLength(200, { message: '할 일은 최대 200자입니다.' }) + title?: string; + + @ApiPropertyOptional({ description: '완료 여부', example: true }) + @IsOptional() + @IsBoolean() + done?: boolean; +} diff --git a/backend/src/modules/todo/dto/todo-label.dto.ts b/backend/src/modules/todo/dto/todo-label.dto.ts new file mode 100644 index 0000000..10e6a8d --- /dev/null +++ b/backend/src/modules/todo/dto/todo-label.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; + +// 할 일 라벨 생성 DTO +export class CreateTodoLabelDto { + @ApiProperty({ description: '라벨 이름', example: '업무 준비' }) + @IsString() + @IsNotEmpty({ message: '라벨 이름을 입력해 주세요.' }) + @MaxLength(40, { message: '라벨 이름은 최대 40자입니다.' }) + name!: string; + + @ApiProperty({ description: '라벨 색(hex)', example: '#1d4ed8' }) + @IsString() + @Matches(/^#[0-9a-fA-F]{6}$/, { + message: '색상은 #RRGGBB 형식의 hex 값이어야 합니다.', + }) + color!: string; +} + +// 할 일 라벨 수정 DTO — 생성 DTO 의 부분 집합 +export class UpdateTodoLabelDto extends PartialType(CreateTodoLabelDto) {} diff --git a/backend/src/modules/todo/entities/todo-item.entity.ts b/backend/src/modules/todo/entities/todo-item.entity.ts new file mode 100644 index 0000000..117e0ae --- /dev/null +++ b/backend/src/modules/todo/entities/todo-item.entity.ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, + type Relation, +} from 'typeorm'; +import { TodoLabel } from './todo-label.entity'; + +// 할 일 항목 — 제목과 완료 여부만 갖는 최소 구성. +// (마감일·담당자가 필요한 일은 '내 업무'가 담당하므로 여기서는 다루지 않는다) +@Entity('todo_items') +export class TodoItem { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 소속 라벨 — 라벨 삭제 시 항목도 함께 삭제 + // Relation<> 래퍼: 엔티티 순환참조 회피 + @Index() + @ManyToOne(() => TodoLabel, (label) => label.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'label_id' }) + label!: Relation; + + @Column({ type: 'varchar' }) + title!: string; + + @Column({ type: 'boolean', default: false }) + done!: boolean; + + // 라벨 안에서의 표시 순서 — 작을수록 위 + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder!: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/todo/entities/todo-label.entity.ts b/backend/src/modules/todo/entities/todo-label.entity.ts new file mode 100644 index 0000000..c51b9df --- /dev/null +++ b/backend/src/modules/todo/entities/todo-label.entity.ts @@ -0,0 +1,51 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, + type Relation, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; +import { TodoItem } from './todo-item.entity'; + +// 할 일 라벨 — 개인 체크리스트의 묶음 단위(라벨 1개 : 할 일 N개). +// 개인 전용이므로 소유자만 조회·수정할 수 있고, 사용자 삭제 시 함께 정리된다. +@Entity('todo_labels') +export class TodoLabel { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 소유자 — 항상 존재한다(공용 라벨 개념 없음) + @Index() + @ManyToOne(() => User, { onDelete: 'CASCADE', nullable: false }) + @JoinColumn({ name: 'owner_id' }) + owner!: User; + + // 표시 이름(예: 업무 준비, 개인) + @Column({ type: 'varchar' }) + name!: string; + + // 라벨 색(hex, 예: #1d4ed8) — 카드 헤더의 점 색으로 쓴다 + @Column({ type: 'varchar' }) + color!: string; + + // 표시 순서(카드 정렬) — 작을수록 위 + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder!: number; + + // 소속 할 일 — 라벨 삭제 시 함께 삭제(FK CASCADE) + // Relation<> 래퍼: 엔티티 순환참조 회피 + @OneToMany(() => TodoItem, (item) => item.label) + items!: Relation[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/todo/todo.controller.ts b/backend/src/modules/todo/todo.controller.ts new file mode 100644 index 0000000..df5cc1a --- /dev/null +++ b/backend/src/modules/todo/todo.controller.ts @@ -0,0 +1,106 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { PublicUser } from '../user/user.service'; +import { TodoService, type TodoLabelResponse } from './todo.service'; +import { CreateTodoLabelDto, UpdateTodoLabelDto } from './dto/todo-label.dto'; +import { CreateTodoItemDto, UpdateTodoItemDto } from './dto/todo-item.dto'; + +// 할 일(개인 체크리스트) 컨트롤러 — 라벨 1개에 항목 N개. +// 모든 응답은 라벨 단위라 화면이 카드 하나만 갈아끼우면 된다. +@ApiTags('Todo') +@Controller('todos') +@UseGuards(JwtAuthGuard) +export class TodoController { + constructor(private readonly todoService: TodoService) {} + + // ----- 라벨 ----- + + @Get('labels') + @ApiOperation({ summary: '내 할 일 라벨 목록(항목 포함)' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + listLabels(@CurrentUser() user: PublicUser): Promise { + return this.todoService.listLabels(user.id); + } + + @Post('labels') + @ApiOperation({ summary: '할 일 라벨 생성' }) + @ApiResponse({ status: 201, description: '생성 성공' }) + createLabel( + @CurrentUser() user: PublicUser, + @Body() dto: CreateTodoLabelDto, + ): Promise { + return this.todoService.createLabel(dto, user.id); + } + + @Patch('labels/:id') + @ApiOperation({ summary: '할 일 라벨 수정' }) + @ApiResponse({ status: 200, description: '수정 성공' }) + updateLabel( + @CurrentUser() user: PublicUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTodoLabelDto, + ): Promise { + return this.todoService.updateLabel(id, dto, user.id); + } + + @Delete('labels/:id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '할 일 라벨 삭제(소속 할 일도 함께 삭제)' }) + @ApiResponse({ status: 200, description: '삭제 성공' }) + async deleteLabel( + @CurrentUser() user: PublicUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ success: boolean }> { + await this.todoService.deleteLabel(id, user.id); + return { success: true }; + } + + // ----- 항목 ----- + + @Post('labels/:labelId/items') + @ApiOperation({ summary: '할 일 추가' }) + @ApiResponse({ status: 201, description: '생성 성공' }) + createItem( + @CurrentUser() user: PublicUser, + @Param('labelId', ParseUUIDPipe) labelId: string, + @Body() dto: CreateTodoItemDto, + ): Promise { + return this.todoService.createItem(labelId, dto, user.id); + } + + @Patch('items/:id') + @ApiOperation({ summary: '할 일 수정(내용 변경·완료 토글)' }) + @ApiResponse({ status: 200, description: '수정 성공' }) + updateItem( + @CurrentUser() user: PublicUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTodoItemDto, + ): Promise { + return this.todoService.updateItem(id, dto, user.id); + } + + @Delete('items/:id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '할 일 삭제' }) + @ApiResponse({ status: 200, description: '삭제 성공' }) + deleteItem( + @CurrentUser() user: PublicUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.todoService.deleteItem(id, user.id); + } +} diff --git a/backend/src/modules/todo/todo.module.ts b/backend/src/modules/todo/todo.module.ts new file mode 100644 index 0000000..9eb0fb4 --- /dev/null +++ b/backend/src/modules/todo/todo.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TodoLabel } from './entities/todo-label.entity'; +import { TodoItem } from './entities/todo-item.entity'; +import { TodoService } from './todo.service'; +import { TodoController } from './todo.controller'; + +// 할 일(개인 체크리스트) 모듈 +@Module({ + imports: [TypeOrmModule.forFeature([TodoLabel, TodoItem])], + controllers: [TodoController], + providers: [TodoService], +}) +export class TodoModule {} diff --git a/backend/src/modules/todo/todo.service.ts b/backend/src/modules/todo/todo.service.ts new file mode 100644 index 0000000..0aa47b1 --- /dev/null +++ b/backend/src/modules/todo/todo.service.ts @@ -0,0 +1,205 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../user/entities/user.entity'; +import { TodoLabel } from './entities/todo-label.entity'; +import { TodoItem } from './entities/todo-item.entity'; +import { CreateTodoLabelDto } from './dto/todo-label.dto'; +import { CreateTodoItemDto, UpdateTodoItemDto } from './dto/todo-item.dto'; + +// 응답(원시) — 표시 음영색은 프론트가 color 로 파생 +export interface TodoItemResponse { + id: string; + title: string; + done: boolean; + sortOrder: number; +} + +export interface TodoLabelResponse { + id: string; + name: string; + color: string; + sortOrder: number; + items: TodoItemResponse[]; + // 진행 표기(2/3)용 집계 — 프론트가 items 로 다시 세지 않도록 함께 내려준다 + doneCount: number; + totalCount: number; +} + +// 할 일(개인 체크리스트) 비즈니스 로직 — 라벨 1개에 항목 N개. +// 모든 라벨·항목은 개인 전용이라 소유자 본인만 접근할 수 있고, +// 남의 것은 존재 자체를 노출하지 않도록 404 로 처리한다. +@Injectable() +export class TodoService { + constructor( + @InjectRepository(TodoLabel) + private readonly labelRepo: Repository, + @InjectRepository(TodoItem) + private readonly itemRepo: Repository, + ) {} + + // ----- 라벨 ----- + + // 본인 라벨 전체 + 소속 항목을 한 번에 반환(화면이 단일 요청으로 그린다) + async listLabels(ownerId: string): Promise { + const rows = await this.labelRepo.find({ + where: { owner: { id: ownerId } }, + relations: ['items'], + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + }); + return rows.map((l) => this.toLabelResponse(l)); + } + + async createLabel( + dto: CreateTodoLabelDto, + ownerId: string, + ): Promise { + const sortOrder = await this.nextSortOrder(this.labelRepo, { + owner: { id: ownerId }, + }); + const saved = await this.labelRepo.save( + this.labelRepo.create({ + owner: { id: ownerId } as User, + name: dto.name.trim(), + color: dto.color, + sortOrder, + }), + ); + // 방금 만든 라벨은 항목이 없다 + return this.toLabelResponse({ ...saved, items: [] }); + } + + async updateLabel( + id: string, + dto: Partial, + ownerId: string, + ): Promise { + const label = await this.assertLabelOwned(id, ownerId); + if (dto.name !== undefined) label.name = dto.name.trim(); + if (dto.color !== undefined) label.color = dto.color; + await this.labelRepo.save(label); + return this.findLabelOrThrow(id, ownerId); + } + + // 라벨 삭제 — 소속 항목도 FK CASCADE 로 함께 삭제된다. + async deleteLabel(id: string, ownerId: string): Promise { + await this.assertLabelOwned(id, ownerId); + await this.labelRepo.delete({ id }); + } + + // ----- 항목 ----- + + async createItem( + labelId: string, + dto: CreateTodoItemDto, + ownerId: string, + ): Promise { + await this.assertLabelOwned(labelId, ownerId); + const sortOrder = await this.nextSortOrder(this.itemRepo, { + label: { id: labelId }, + }); + await this.itemRepo.save( + this.itemRepo.create({ + label: { id: labelId } as TodoLabel, + title: dto.title.trim(), + sortOrder, + }), + ); + // 화면이 카드 단위로 갱신할 수 있도록 라벨 전체를 돌려준다 + return this.findLabelOrThrow(labelId, ownerId); + } + + async updateItem( + id: string, + dto: UpdateTodoItemDto, + ownerId: string, + ): Promise { + const item = await this.assertItemOwned(id, ownerId); + if (dto.title !== undefined) item.title = dto.title.trim(); + if (dto.done !== undefined) item.done = dto.done; + await this.itemRepo.save(item); + return this.findLabelOrThrow(item.label.id, ownerId); + } + + async deleteItem(id: string, ownerId: string): Promise { + const item = await this.assertItemOwned(id, ownerId); + const labelId = item.label.id; + await this.itemRepo.delete({ id }); + return this.findLabelOrThrow(labelId, ownerId); + } + + // ----- 내부 헬퍼 ----- + + // 소유 라벨인지 검증하고 반환(남의 라벨은 존재를 숨긴다) + private async assertLabelOwned( + id: string, + ownerId: string, + ): Promise { + const label = await this.labelRepo.findOne({ + where: { id, owner: { id: ownerId } }, + }); + if (!label) throw new NotFoundException('라벨을 찾을 수 없습니다.'); + return label; + } + + // 소유 항목인지 검증하고 반환(라벨 소유자로 판정) + private async assertItemOwned( + id: string, + ownerId: string, + ): Promise { + const item = await this.itemRepo.findOne({ + where: { id, label: { owner: { id: ownerId } } }, + relations: ['label'], + }); + if (!item) throw new NotFoundException('할 일을 찾을 수 없습니다.'); + return item; + } + + // 다음 정렬값 = 같은 묶음 안의 최대 + 1 + private async nextSortOrder( + repo: Repository, + where: Record, + ): Promise { + const [last] = await repo.find({ + where: where as never, + order: { sortOrder: 'DESC' } as never, + take: 1, + }); + return (last?.sortOrder ?? -1) + 1; + } + + private async findLabelOrThrow( + id: string, + ownerId: string, + ): Promise { + const label = await this.labelRepo.findOne({ + where: { id, owner: { id: ownerId } }, + relations: ['items'], + }); + if (!label) throw new NotFoundException('라벨을 찾을 수 없습니다.'); + return this.toLabelResponse(label); + } + + private toLabelResponse(l: TodoLabel): TodoLabelResponse { + // 미완료 먼저, 그 안에서는 등록 순서대로 + const items = [...(l.items ?? [])] + .sort((a, b) => + a.done === b.done ? a.sortOrder - b.sortOrder : a.done ? 1 : -1, + ) + .map((i) => ({ + id: i.id, + title: i.title, + done: i.done, + sortOrder: i.sortOrder, + })); + return { + id: l.id, + name: l.name, + color: l.color, + sortOrder: l.sortOrder, + items, + doneCount: items.filter((i) => i.done).length, + totalCount: items.length, + }; + } +} diff --git a/docs/api-contract.md b/docs/api-contract.md index bc13b89..847bd86 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -336,26 +336,19 @@ todo ─시작→ prog ─승인요청→ review ─승인→ done Google/Kakao OAuth(passport, 키 미설정 시 자동 비활성) + 이메일 가입 인증(미인증 로그인 차단, 그랜드페더링). 메일 발송은 `MailService` 플레이스홀더(백엔드 로그). 상세는 §1 참조. -### AI 채팅 (`/api/ai/conversations`) — **✅ 완료 (DB 영속, 다중 대화)** +### AI 할 일 추천 (`/api/ai`) — **✅ 완료** | 메서드 | 경로 | 요청 | 응답 | 비고 | |---|---|---|---|---| -| GET | `/ai/conversations` | `?page&size` | `{items:[{id,title,updatedAt}],total,...}` | 내 대화 목록(최근순) | -| GET | `/ai/conversations/:id` | — | `{id,title,messages:[{role,content}]}` | 대화 상세(본인 소유만) | -| POST | `/ai/conversations` | `{ content }` | `{ conversationId, title, reply }` | 새 대화 시작(첫 메시지). throttle 20/분 | -| POST | `/ai/conversations/:id/messages` | `{ content }` | `{ conversationId, title, reply }` | 메시지 추가. throttle 20/분 | -| DELETE | `/ai/conversations/:id` | — | `null` | 대화 삭제(메시지 CASCADE) | | POST | `/ai/suggest-checklist` | `{ repoId, title, content? }` | `{ groups: [{category, items[]}] }` | 업무 작성 시 할 일(체크리스트) 추천. throttle 20/분 | -- **대화/메시지를 DB 보관**(`AiConversation`·`AiMessage`, user CASCADE). 모든 접근은 **본인 소유 스코프**(타 사용자 대화는 404=RES_001, IDOR 차단). 클라는 "이 대화에 새 메시지 1건"만 보내고 **서버가 DB 이력으로 Claude 호출**(이전의 클라 전체-이력 전송 방식 폐기). -- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). **Claude 실패 시 방금 사용자 메시지·빈 대화 롤백**(고아 데이터 방지). -- **주제 한정 + 데이터 인지(시스템 프롬프트)**: 업무/Relay 관련만 답하고 무관한 주제는 정중히 거절(가드레일, 프롬프트 레벨·소프트). 매 요청 시 **오늘 날짜(KST) + 담당 업무(최대 30건: 제목/상태/마감/체크리스트) + 지시 업무 수 + 저장소 목록(최대 30개: 이름/공개범위/업무수)** 을 시스템 프롬프트에 주입(`TaskService.listAssigned/listIssued` + `RepoService.findAll`). "내 마감 임박 업무"·"저장소 뭐 있어" 같은 질문에 실데이터로 응답. 조회 실패해도 채팅은 진행(베스트에포트). -- 프론트: `components/AgentChatPanel.vue`(우측 슬라이드, **채팅 ↔ 지난 대화 목록** 전환·열기·삭제), 버튼은 **AppShell 헤더 상시 노출(모든 화면)**. `stores/ai.store` 가 목록·현재 대화 관리 → **DB 영속이라 다른 기기·재로그인에도 기록 유지**. AI 응답은 **마크다운 렌더**(`shared/utils/markdown.ts` — escapeHtml 후 신뢰 태그만 조립, 외부 의존성 0, XSS 안전: 굵게/목록/코드/제목/링크). -- **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵). +- `AiService` 가 Anthropic Messages API(`x-api-key`) 호출 — **키(`CLAUDE_API_KEY`)는 서버 env 에만**. 모델 `CLAUDE_MODEL`(기본 `claude-sonnet-4-6`). 미설정 503 / 외부오류 502(내부·키 비노출). +- **할 일 추천**(`POST /ai/suggest-checklist`): `AiService.suggestChecklist` 가 저장소 표시제목/설명 + 업무 제목/내용(+첨부 문서 추출 텍스트)을 Claude 에 보내 **JSON(카테고리별 항목)** 으로 받아 파싱(코드펜스/잡텍스트 방어, 빈 결과 502). 프론트 **TaskCreatePage**: "취소 / **AI 추천** / 지시 보내기" 버튼 + 체크리스트 아래 추천 패널(카테고리별 항목 클릭 추가·전체 추가·중복 스킵). +- DB 를 쓰지 않는다(무상태). 헤더의 AI 채팅 어시스턴트는 제거되었으며 대화/메시지 테이블도 삭제됨(`DropAiConversations1783900000000`). ### 그 외(미착수) -메일 실제 발송(SMTP) — 현재 로그 대체. 보안 후속(계정잠금/CAPTCHA 등). (페이지네이션·실시간 알림·Redis 캐싱·소셜로그인·이메일인증·refresh 회전·AI 채팅은 완료.) +메일 실제 발송(SMTP) — 현재 로그 대체. 보안 후속(계정잠금/CAPTCHA 등). (페이지네이션·실시간 알림·Redis 캐싱·소셜로그인·이메일인증·refresh 회전은 완료.) --- diff --git a/docs/integration-progress.md b/docs/integration-progress.md index f641bf8..028da5e 100644 --- a/docs/integration-progress.md +++ b/docs/integration-progress.md @@ -309,7 +309,12 @@ - **L6** CORS `localhost:3000`·Swagger `/api-docs` 비운영 한정. 검증: 백엔드 build/lint 0·20 tests, 프론트 변경 없음. -#### 8-6. AI 채팅 패널 ✅ (2026-06-19) +#### 8-6. AI 채팅 패널 — **🗑 제거됨 (2026-07-21)** + +> **이 절은 과거 이력이다.** 헤더의 AI 채팅 어시스턴트는 "사용하지 않는다"는 판단으로 전부 제거되었다. +> 제거 범위: `AgentChatPanel.vue`·`stores/ai.store.ts`·`shared/utils/markdown.ts`·`AiController`·`AiConversation`/`AiMessage` 엔티티·`SendMessageDto`·`AiService` 의 채팅 로직, +> DB 테이블은 `DropAiConversations1783900000000` 마이그레이션으로 삭제. +> **업무 생성 화면의 AI 할 일 추천(`POST /ai/suggest-checklist`)은 그대로 유지된다** — 아래 설명 중 채팅 관련 내용만 폐기된 것이다. **지시**: 기존 목업 UI(가짜 "relay order draft" 터미널)는 버리고, **Claude API로 채팅만** 되도록. 키는 백엔드 env(`CLAUDE_API_KEY`)에 등록됨. diff --git a/frontend/src/assets/styles/relay.css b/frontend/src/assets/styles/relay.css index 9741477..bf3f470 100644 --- a/frontend/src/assets/styles/relay.css +++ b/frontend/src/assets/styles/relay.css @@ -101,9 +101,9 @@ body { /* ============================================================ * 3. 페이지 컨테이너 / 브레드크럼 * ============================================================ */ +/* 폭 제한 없이 화면 전체를 채운다 — 좌우 여백(1.5rem)만 유지. + 개별 폼 화면처럼 좁은 단이 필요한 곳은 각 페이지에서 max-width 를 따로 건다. */ .page { - max-width: 67.5rem; /* 1080px */ - margin: 0 auto; padding: 0 1.5rem 4.375rem; } diff --git a/frontend/src/components/AgentChatPanel.vue b/frontend/src/components/AgentChatPanel.vue deleted file mode 100644 index ee1e55b..0000000 --- a/frontend/src/components/AgentChatPanel.vue +++ /dev/null @@ -1,718 +0,0 @@ - - -