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:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user