From 29a8ef841fa70e36b2781ab5cb329018e40caf49 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Tue, 21 Jul 2026 00:23:32 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EA=B0=9C=EC=9D=B8=20=ED=95=A0=20?= =?UTF-8?q?=EC=9D=BC(=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8)=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이드바에 '할 일' 메뉴를 추가하고, 라벨 1개에 할 일 N개를 담는 개인 전용 체크리스트를 구현한다. - todo_labels / todo_items 엔티티 + 마이그레이션 (소유자·라벨 CASCADE) - 라벨·항목 CRUD API — 항목 변경 응답은 '변경된 라벨' 단위라 카드 하나만 갱신 - 모든 라벨·항목은 소유자 본인만 접근하며 타인 것은 존재를 숨김(404) - 화면: 라벨 카드 세로 나열, 인라인 추가·수정, 완료 항목은 취소선 후 하단 정렬 - 기존 .list / .toolbar / BaseModal 등 공용 디자인 규격 사용 Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/app.module.ts | 2 + .../src/migrations/1783800000000-AddTodo.ts | 45 ++ backend/src/modules/todo/dto/todo-item.dto.ts | 32 ++ .../src/modules/todo/dto/todo-label.dto.ts | 21 + .../modules/todo/entities/todo-item.entity.ts | 43 ++ .../todo/entities/todo-label.entity.ts | 51 +++ backend/src/modules/todo/todo.controller.ts | 106 +++++ backend/src/modules/todo/todo.module.ts | 14 + backend/src/modules/todo/todo.service.ts | 205 +++++++++ .../src/components/todo/TodoLabelCard.vue | 413 ++++++++++++++++++ .../src/components/todo/TodoLabelModal.vue | 165 +++++++ frontend/src/composables/useTodo.ts | 58 +++ frontend/src/layouts/AppShell.vue | 25 +- frontend/src/pages/relay/TodoPage.vue | 175 ++++++++ frontend/src/router/index.ts | 7 + frontend/src/stores/todo.store.ts | 98 +++++ frontend/src/types/todo.ts | 31 ++ 17 files changed, 1490 insertions(+), 1 deletion(-) create mode 100644 backend/src/migrations/1783800000000-AddTodo.ts create mode 100644 backend/src/modules/todo/dto/todo-item.dto.ts create mode 100644 backend/src/modules/todo/dto/todo-label.dto.ts create mode 100644 backend/src/modules/todo/entities/todo-item.entity.ts create mode 100644 backend/src/modules/todo/entities/todo-label.entity.ts create mode 100644 backend/src/modules/todo/todo.controller.ts create mode 100644 backend/src/modules/todo/todo.module.ts create mode 100644 backend/src/modules/todo/todo.service.ts create mode 100644 frontend/src/components/todo/TodoLabelCard.vue create mode 100644 frontend/src/components/todo/TodoLabelModal.vue create mode 100644 frontend/src/composables/useTodo.ts create mode 100644 frontend/src/pages/relay/TodoPage.vue create mode 100644 frontend/src/stores/todo.store.ts create mode 100644 frontend/src/types/todo.ts 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/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/frontend/src/components/todo/TodoLabelCard.vue b/frontend/src/components/todo/TodoLabelCard.vue new file mode 100644 index 0000000..055445b --- /dev/null +++ b/frontend/src/components/todo/TodoLabelCard.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/frontend/src/components/todo/TodoLabelModal.vue b/frontend/src/components/todo/TodoLabelModal.vue new file mode 100644 index 0000000..235d533 --- /dev/null +++ b/frontend/src/components/todo/TodoLabelModal.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/frontend/src/composables/useTodo.ts b/frontend/src/composables/useTodo.ts new file mode 100644 index 0000000..858fdd5 --- /dev/null +++ b/frontend/src/composables/useTodo.ts @@ -0,0 +1,58 @@ +import { useApi } from './useApi' +import type { + ApiTodoLabel, + TodoItemPayload, + TodoLabelPayload, +} from '@/types/todo' + +// 할 일 도메인 API Composable — 인터셉터가 success/data 를 언래핑. +// 항목 관련 응답은 모두 '변경된 라벨' 한 건이라 화면이 카드 하나만 교체하면 된다. +export function useTodo() { + const api = useApi() + + // 라벨 + async function listLabels(): Promise { + return (await api.get('/todos/labels')) as unknown as ApiTodoLabel[] + } + async function createLabel(payload: TodoLabelPayload): Promise { + return (await api.post('/todos/labels', payload)) as unknown as ApiTodoLabel + } + async function updateLabel( + id: string, + payload: Partial, + ): Promise { + return (await api.patch(`/todos/labels/${id}`, payload)) as unknown as ApiTodoLabel + } + async function deleteLabel(id: string): Promise { + await api.delete(`/todos/labels/${id}`) + } + + // 항목 + async function createItem( + labelId: string, + title: string, + ): Promise { + return (await api.post(`/todos/labels/${labelId}/items`, { + title, + })) as unknown as ApiTodoLabel + } + async function updateItem( + id: string, + payload: TodoItemPayload, + ): Promise { + return (await api.patch(`/todos/items/${id}`, payload)) as unknown as ApiTodoLabel + } + async function deleteItem(id: string): Promise { + return (await api.delete(`/todos/items/${id}`)) as unknown as ApiTodoLabel + } + + return { + listLabels, + createLabel, + updateLabel, + deleteLabel, + createItem, + updateItem, + deleteItem, + } +} diff --git a/frontend/src/layouts/AppShell.vue b/frontend/src/layouts/AppShell.vue index 3e04204..15c70a3 100644 --- a/frontend/src/layouts/AppShell.vue +++ b/frontend/src/layouts/AppShell.vue @@ -31,9 +31,12 @@ function toggleCollapse() { const mobileOpen = ref(false) // 현재 경로 기준으로 사이드바 네비 활성 항목을 판별 -const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting' | 'drive'>(() => { +const activeNav = computed< + 'tasks' | 'todos' | 'projects' | 'schedule' | 'meeting' | 'drive' +>(() => { if (route.path.startsWith('/projects')) return 'projects' if (route.path.startsWith('/schedule')) return 'schedule' + if (route.path.startsWith('/todos')) return 'todos' if (route.path.startsWith('/meeting')) return 'meeting' if (route.path.startsWith('/drive')) return 'drive' return 'tasks' @@ -204,6 +207,26 @@ async function onLogout() { 내 업무 + + + + + + + 할 일 + +// 할 일 — 개인이 직접 적어 관리하는 체크리스트(라벨 1개에 할 일 N개). +// (프로젝트에서 배정·지시되는 '내 업무'와는 별개의 개인용 목록) +import { onMounted, ref } from 'vue' +import AppShell from '@/layouts/AppShell.vue' +import EmptyState from '@/components/common/EmptyState.vue' +import TodoLabelCard from '@/components/todo/TodoLabelCard.vue' +import TodoLabelModal from '@/components/todo/TodoLabelModal.vue' +import { useTodoStore } from '@/stores/todo.store' +import { useDialogStore } from '@/stores/dialog.store' +import type { ApiTodoItem, ApiTodoLabel, TodoLabelPayload } from '@/types/todo' + +const store = useTodoStore() +const dialog = useDialogStore() + +// 라벨 모달 — init 이 null 이면 생성, 있으면 편집 +const labelModal = ref<{ init: ApiTodoLabel | null } | null>(null) + +onMounted(() => { + void store.load().catch(() => { + // 인터셉터가 에러 토스트 처리 + }) +}) + +// ----- 라벨 ----- +function openNewLabel(): void { + labelModal.value = { init: null } +} +function openEditLabel(label: ApiTodoLabel): void { + labelModal.value = { init: label } +} +async function onSaveLabel(payload: TodoLabelPayload): Promise { + const init = labelModal.value?.init + if (init) await store.updateLabel(init.id, payload) + else await store.createLabel(payload) + labelModal.value = null +} +async function onDeleteLabel(label: ApiTodoLabel): Promise { + const msg = label.totalCount + ? `'${label.name}' 라벨과 여기에 등록된 할 일 ${label.totalCount}건이 함께 삭제됩니다. 계속할까요?` + : `'${label.name}' 라벨을 삭제할까요?` + const ok = await dialog.confirm(msg, { + title: '라벨 삭제', + confirmText: '삭제', + variant: 'danger', + }) + if (!ok) return + await store.deleteLabel(label.id) +} + +// ----- 할 일 ----- +async function onAddItem(labelId: string, title: string): Promise { + await store.addItem(labelId, title) +} +async function onToggleItem(item: ApiTodoItem): Promise { + await store.toggleItem(item.id, !item.done) +} +async function onRenameItem(item: ApiTodoItem, title: string): Promise { + await store.renameItem(item.id, title) +} +async function onRemoveItem(item: ApiTodoItem): Promise { + await store.removeItem(item.id) +} + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3ea606f..a58f728 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -101,6 +101,13 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/pages/relay/MyTasksPage.vue'), meta: { requiresAuth: true }, }, + { + // 할 일 — 개인 체크리스트('내 업무'와 달리 스스로 등록해 관리) + path: '/todos', + name: 'todos', + component: () => import('@/pages/relay/TodoPage.vue'), + meta: { requiresAuth: true }, + }, { // 일정 — 전사 공유 주간 캘린더(타임라인) path: '/schedule', diff --git a/frontend/src/stores/todo.store.ts b/frontend/src/stores/todo.store.ts new file mode 100644 index 0000000..97b8769 --- /dev/null +++ b/frontend/src/stores/todo.store.ts @@ -0,0 +1,98 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { useTodo } from '@/composables/useTodo' +import type { ApiTodoLabel, TodoLabelPayload } from '@/types/todo' + +// 할 일 스토어 — 라벨(항목 포함) 목록 + 검색어 + CRUD. +// 항목 변경 API 는 변경된 라벨을 그대로 돌려주므로 해당 카드만 교체한다. +export const useTodoStore = defineStore('todo', () => { + const api = useTodo() + + const labels = ref([]) + const keyword = ref('') + const loading = ref(false) + + // 검색 — 라벨 이름 또는 할 일 내용에 걸리면 남긴다. + // 라벨 이름이 걸린 경우는 항목을 그대로 두고, 항목만 걸린 경우는 걸린 항목만 보여준다. + const displayLabels = computed(() => { + const kw = keyword.value.trim().toLowerCase() + if (!kw) return labels.value + const result: ApiTodoLabel[] = [] + for (const l of labels.value) { + if (l.name.toLowerCase().includes(kw)) { + result.push(l) + continue + } + const hit = l.items.filter((i) => i.title.toLowerCase().includes(kw)) + if (hit.length) result.push({ ...l, items: hit }) + } + return result + }) + + const isFiltered = computed(() => keyword.value.trim() !== '') + // 전체 할 일 수(헤더 count-pill 표기용) + const totalCount = computed(() => + labels.value.reduce((sum, l) => sum + l.totalCount, 0), + ) + + // 변경된 라벨 한 건을 목록에 반영 + function replaceLabel(next: ApiTodoLabel): void { + labels.value = labels.value.map((l) => (l.id === next.id ? next : l)) + } + + async function load(): Promise { + loading.value = true + try { + labels.value = await api.listLabels() + } finally { + loading.value = false + } + } + + // --- 라벨 --- + async function createLabel(payload: TodoLabelPayload): Promise { + const label = await api.createLabel(payload) + labels.value = [...labels.value, label] + } + async function updateLabel( + id: string, + payload: Partial, + ): Promise { + replaceLabel(await api.updateLabel(id, payload)) + } + async function deleteLabel(id: string): Promise { + await api.deleteLabel(id) + labels.value = labels.value.filter((l) => l.id !== id) + } + + // --- 항목 --- + async function addItem(labelId: string, title: string): Promise { + replaceLabel(await api.createItem(labelId, title)) + } + async function toggleItem(id: string, done: boolean): Promise { + replaceLabel(await api.updateItem(id, { done })) + } + async function renameItem(id: string, title: string): Promise { + replaceLabel(await api.updateItem(id, { title })) + } + async function removeItem(id: string): Promise { + replaceLabel(await api.deleteItem(id)) + } + + return { + labels, + displayLabels, + keyword, + loading, + isFiltered, + totalCount, + load, + createLabel, + updateLabel, + deleteLabel, + addItem, + toggleItem, + renameItem, + removeItem, + } +}) diff --git a/frontend/src/types/todo.ts b/frontend/src/types/todo.ts new file mode 100644 index 0000000..db6cbc0 --- /dev/null +++ b/frontend/src/types/todo.ts @@ -0,0 +1,31 @@ +// 할 일(개인 체크리스트) 도메인 타입 — 백엔드 Todo 응답과 1:1 + +// 할 일 항목 +export interface ApiTodoItem { + id: string + title: string + done: boolean + sortOrder: number +} + +// 라벨(할 일 묶음) — 항목과 진행 집계를 함께 내려받는다 +export interface ApiTodoLabel { + id: string + name: string + color: string + sortOrder: number + items: ApiTodoItem[] + doneCount: number + totalCount: number +} + +// 생성/수정 payload +export interface TodoLabelPayload { + name: string + color: string +} + +export interface TodoItemPayload { + title?: string + done?: boolean +} From f550c4d46e3458d18adc8736ed721c0b307cba44 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Tue, 21 Jul 2026 00:23:46 +0900 Subject: [PATCH 2/4] =?UTF-8?q?style:=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=B5=9C=EB=8C=80=20=ED=8F=AD=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .page 의 max-width(67.5rem)와 가운데 정렬을 제거해 콘텐츠가 화면 전체 폭을 사용하도록 한다. 좌우 여백(1.5rem)은 그대로 유지한다. 프로젝트 생성·설정처럼 좁은 단이 필요한 폼 화면은 각 페이지에서 자체 max-width 를 유지한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/assets/styles/relay.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; } From 38b3762b2c9e05a9b59a9651437aac0c309879b9 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Tue, 21 Jul 2026 00:24:14 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=ED=97=A4=EB=8D=94=20AI=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EC=96=B4=EC=8B=9C=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용하지 않는 기능이라 헤더 버튼과 관련 로직을 모두 걷어낸다. 업무 생성 화면의 AI 할 일 추천(/ai/suggest-checklist)은 그대로 유지한다. - 프론트: AgentChatPanel, ai.store, 헤더 버튼·패널 마킹, useAi 의 대화 함수와 types/ai 의 대화 타입 제거. 채팅 전용이던 shared/utils/markdown.ts 도 함께 삭제 - 백엔드: AiController(대화 5개 엔드포인트), AiConversation·AiMessage 엔티티, SendMessageDto, AiService 의 채팅 로직 제거. AiModule 의 TaskModule 의존도 해제 - ai_conversations·ai_messages 테이블 삭제 마이그레이션 추가(down 은 초기 스키마 복원) - 문서: api-contract 의 채팅 API 절 삭제, integration-progress 8-6 은 이력이라 삭제 대신 제거됨 표기 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../1783900000000-DropAiConversations.ts | 35 + backend/src/modules/ai/ai.controller.ts | 97 --- backend/src/modules/ai/ai.module.ts | 17 +- backend/src/modules/ai/ai.service.ts | 279 +------ .../src/modules/ai/dto/send-message.dto.ts | 11 - .../ai/entities/ai-conversation.entity.ts | 33 - .../modules/ai/entities/ai-message.entity.ts | 30 - docs/api-contract.md | 17 +- docs/integration-progress.md | 7 +- frontend/src/components/AgentChatPanel.vue | 718 ------------------ frontend/src/composables/useAi.ts | 52 +- frontend/src/layouts/AppShell.vue | 41 - frontend/src/shared/utils/markdown.ts | Bin 3057 -> 0 bytes frontend/src/stores/ai.store.ts | 119 --- frontend/src/types/ai.ts | 27 - 15 files changed, 55 insertions(+), 1428 deletions(-) create mode 100644 backend/src/migrations/1783900000000-DropAiConversations.ts delete mode 100644 backend/src/modules/ai/ai.controller.ts delete mode 100644 backend/src/modules/ai/dto/send-message.dto.ts delete mode 100644 backend/src/modules/ai/entities/ai-conversation.entity.ts delete mode 100644 backend/src/modules/ai/entities/ai-message.entity.ts delete mode 100644 frontend/src/components/AgentChatPanel.vue delete mode 100644 frontend/src/shared/utils/markdown.ts delete mode 100644 frontend/src/stores/ai.store.ts 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/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/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/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 @@ - - -