feat: 개인 할 일(체크리스트) 기능 추가

사이드바에 '할 일' 메뉴를 추가하고, 라벨 1개에 할 일 N개를 담는
개인 전용 체크리스트를 구현한다.

- todo_labels / todo_items 엔티티 + 마이그레이션 (소유자·라벨 CASCADE)
- 라벨·항목 CRUD API — 항목 변경 응답은 '변경된 라벨' 단위라 카드 하나만 갱신
- 모든 라벨·항목은 소유자 본인만 접근하며 타인 것은 존재를 숨김(404)
- 화면: 라벨 카드 세로 나열, 인라인 추가·수정, 완료 항목은 취소선 후 하단 정렬
- 기존 .list / .toolbar / BaseModal 등 공용 디자인 규격 사용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:23:32 +09:00
parent 74aa5a13bb
commit 29a8ef841f
17 changed files with 1490 additions and 1 deletions
+2
View File
@@ -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: [
@@ -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<void> {
// 라벨(묶음)
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<void> {
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"`);
}
}
@@ -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;
}
@@ -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) {}
@@ -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<TodoLabel>;
@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;
}
@@ -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<TodoItem>[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
+106
View File
@@ -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<TodoLabelResponse[]> {
return this.todoService.listLabels(user.id);
}
@Post('labels')
@ApiOperation({ summary: '할 일 라벨 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createLabel(
@CurrentUser() user: PublicUser,
@Body() dto: CreateTodoLabelDto,
): Promise<TodoLabelResponse> {
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<TodoLabelResponse> {
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<TodoLabelResponse> {
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<TodoLabelResponse> {
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<TodoLabelResponse> {
return this.todoService.deleteItem(id, user.id);
}
}
+14
View File
@@ -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 {}
+205
View File
@@ -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<TodoLabel>,
@InjectRepository(TodoItem)
private readonly itemRepo: Repository<TodoItem>,
) {}
// ----- 라벨 -----
// 본인 라벨 전체 + 소속 항목을 한 번에 반환(화면이 단일 요청으로 그린다)
async listLabels(ownerId: string): Promise<TodoLabelResponse[]> {
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<TodoLabelResponse> {
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<CreateTodoLabelDto>,
ownerId: string,
): Promise<TodoLabelResponse> {
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<void> {
await this.assertLabelOwned(id, ownerId);
await this.labelRepo.delete({ id });
}
// ----- 항목 -----
async createItem(
labelId: string,
dto: CreateTodoItemDto,
ownerId: string,
): Promise<TodoLabelResponse> {
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<TodoLabelResponse> {
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<TodoLabelResponse> {
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<TodoLabel> {
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<TodoItem> {
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<T extends { sortOrder: number }>(
repo: Repository<T>,
where: Record<string, unknown>,
): Promise<number> {
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<TodoLabelResponse> {
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,
};
}
}