feat: 업무 상세에서 개인 할 일 만들기 추가

담당자가 업무를 자기 할 일 목록으로 옮길 수 있게 한다. 항목 출처는 두 갈래이며
확정 모달은 하나를 공유한다.

- 체크리스트가 있으면 그 항목을 카테고리 순으로 복사(완료 항목은 기본 해제)
- 없고 본문만 있으면 AI 가 본문을 분석해 담당자 관점의 실행 단계를 제안
- 둘 다 비어 있으면 버튼을 비활성화

- todo_labels.source_task_id 추가 — 같은 업무로 다시 만들면 새 라벨 대신 병합
- 병합 시 판정 기준은 '보완 내용을 뗀 원문', 반려된 항목은 완료를 되돌린다
- 수정 요청된 항목은 보완 내용을 제목에 함께 담는다
- 라벨 이름은 '프로젝트 - 업무 제목', 길면 프로젝트명부터 줄인다
- AI 프롬프트는 근거 없는 항목을 만들지 않도록 개수 하한 없이 제약

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:03:07 +09:00
parent 53f1eeddc7
commit 8d2436cd10
17 changed files with 1237 additions and 8 deletions
@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 할 일 라벨의 원본 업무 참조(source_task_id) 추가.
// 업무 상세의 '할 일로 만들기'로 생성된 라벨을 식별해, 같은 업무로 다시 만들 때
// 새 라벨을 만들지 않고 기존 라벨에 항목을 병합하기 위한 연결이다.
// 업무가 삭제돼도 개인 할 일은 유지해야 하므로 SET NULL(연결만 끊는다).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTodoLabelSourceTask1784100000000 implements MigrationInterface {
name = 'AddTodoLabelSourceTask1784100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "todo_labels" ADD "source_task_id" uuid`,
);
await queryRunner.query(
`CREATE INDEX "IDX_todo_labels_source_task" ON "todo_labels" ("source_task_id")`,
);
await queryRunner.query(
`ALTER TABLE "todo_labels" ADD CONSTRAINT "FK_todo_labels_source_task" FOREIGN KEY ("source_task_id") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "todo_labels" DROP CONSTRAINT "FK_todo_labels_source_task"`,
);
await queryRunner.query(`DROP INDEX "IDX_todo_labels_source_task"`);
await queryRunner.query(
`ALTER TABLE "todo_labels" DROP COLUMN "source_task_id"`,
);
}
}
@@ -13,6 +13,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { AiService, type ChecklistSuggestionGroup } from './ai.service';
import { SuggestChecklistDto } from './dto/suggest-checklist.dto';
import { SuggestTaskTodosDto } from './dto/suggest-task-todos.dto';
// AI 보조 — 업무 작성 시 할 일(체크리스트) 추천
@ApiTags('AI')
@@ -37,4 +38,23 @@ export class AiSuggestController {
content: dto.content,
});
}
@Post('suggest-task-todos')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 1000, ttl: 60_000 } })
@ApiOperation({
summary: '개인 할 일 추천 — 업무 본문 분석(체크리스트 없는 업무용)',
})
@ApiResponse({ status: 200, description: '실행 단계 목록' })
@ApiResponse({ status: 400, description: '업무 내용 없음 (BIZ_001)' })
@ApiResponse({ status: 404, description: '프로젝트·업무 없음 (RES_001)' })
suggestTaskTodos(
@CurrentUser() user: PublicUser,
@Body() dto: SuggestTaskTodosDto,
): Promise<{ items: string[] }> {
return this.aiService.suggestTaskTodos(user.id, {
projectId: dto.projectId,
taskSeq: dto.taskSeq,
});
}
}
+4 -2
View File
@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { ProjectModule } from '../project/project.module';
import { TaskModule } from '../task/task.module';
import { AiSuggestController } from './ai-suggest.controller';
import { AiService } from './ai.service';
// AI 모듈 — Claude(Anthropic) 기반 업무 할 일(체크리스트) 추천.
// ProjectModule 을 가져와 프로젝트 정보·첨부 문서 텍스트를 프롬프트에 주입한다.
// ProjectModule 을 가져와 프로젝트 정보·첨부 문서 텍스트를 프롬프트에 주입하고,
// TaskModule 로 개인 할 일 추천의 원본 업무(제목·본문)를 읽는다.
@Module({
imports: [ProjectModule],
imports: [ProjectModule, TaskModule],
controllers: [AiSuggestController],
providers: [AiService],
})
+129
View File
@@ -2,6 +2,7 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { BusinessException } from '../../common/exceptions/business.exception';
import { ProjectService } from '../project/project.service';
import { TaskService } from '../task/task.service';
// Anthropic Messages API 응답(필요 필드만)
interface AnthropicResponse {
@@ -37,6 +38,36 @@ const SUGGEST_SYSTEM_PROMPT = [
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
].join('\n');
// 개인 할 일 추천용 시스템 프롬프트 — '산출물'이 아니라 담당자의 '실행 단계'를 뽑는다.
// (체크리스트 추천과 층위가 다르므로 프롬프트를 분리한다)
const TASK_TODO_SYSTEM_PROMPT = [
'당신은 업무 실행 보조자입니다.',
"주어진 업무를 담당자가 실제로 수행할 '개인 할 일' 목록으로 분해합니다.",
'',
'[규칙]',
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
'- 출력은 완결된 단일 JSON 객체여야 합니다. 배열 []·문자열 "" 을 정확히 닫고 중간에 끊지 마세요.',
'- 각 항목은 한국어로 된 간결한 한 문장이며, 담당자가 바로 착수할 수 있는 행동이어야 합니다.',
'- 항목은 수행 순서대로 나열하고, 최대 12개까지만 제안하세요.',
'- 각 항목은 60자를 넘기지 마세요.',
"- 기능 명세나 산출물 목록이 아니라 '내가 다음에 할 일' 관점으로 작성하세요.",
'',
'[근거 규칙 — 가장 중요]',
'- 모든 항목은 업무 내용에 실제로 적힌 서술에서 직접 도출되어야 합니다.',
'- 업무 내용에 없는 작업·기한·담당자·도구·산출물·검수 절차를 추가하지 마세요.',
'- 일반적인 업무 절차라는 이유로 항목을 채워 넣지 마세요. 추측해서 보완하지 마세요.',
'- 개수를 맞추기 위해 항목을 만들어내지 마세요. 내용에서 도출되는 만큼만 제안하며, 1~2개여도 괜찮습니다.',
'- 도출할 항목이 전혀 없으면 빈 배열({"items":[]})로 응답하세요.',
'',
'[형식]',
'{"items":["...","..."]}',
].join('\n');
// 할 일 항목 최대 길이 — 백엔드 CreateTodoItemDto(200자)보다 짧게 잡아 화면 가독성을 지킨다
const TODO_ITEM_MAX_LEN = 100;
// 개인 할 일 추천 최대 개수
const TODO_ITEM_MAX_COUNT = 12;
// AI(Claude) 서비스 — 업무 생성 화면의 할 일(체크리스트) 추천을 담당한다.
// 키(CLAUDE_API_KEY)는 서버 env 에만 두고 클라이언트에 노출하지 않는다.
@Injectable()
@@ -49,6 +80,7 @@ export class AiService {
constructor(
config: ConfigService,
private readonly projectService: ProjectService,
private readonly taskService: TaskService,
) {
this.apiKey = (config.get<string>('CLAUDE_API_KEY') ?? '').trim();
this.model = (
@@ -101,6 +133,103 @@ export class AiService {
return { groups: this.parseSuggestions(raw) };
}
// 개인 할 일 추천 — 업무 제목/본문을 담당자 관점의 실행 단계로 분해한다.
// 체크리스트가 있는 업무는 호출자(프론트)가 이 API 대신 체크리스트를 그대로 쓴다.
async suggestTaskTodos(
userId: string,
input: { projectId: string; taskSeq: number },
): Promise<{ items: string[] }> {
this.ensureEnabled();
const project = await this.projectService.findOne(input.projectId, userId);
const { task } = await this.taskService.findEntity(
input.projectId,
input.taskSeq,
);
const content = (task.content ?? []).join('\n').trim();
if (!content) {
throw new BusinessException(
'BIZ_001',
'업무 내용이 없어 할 일을 만들 수 없습니다.',
HttpStatus.BAD_REQUEST,
);
}
const userMsg = [
`[프로젝트] ${project.name}`,
`설명: ${project.description ?? '(없음)'}`,
'',
`[업무] 제목: ${task.title}`,
'내용:',
content,
].join('\n');
const raw = await this.callClaude(
[{ role: 'user', content: userMsg }],
TASK_TODO_SYSTEM_PROMPT,
2000,
);
return { items: this.parseTodoItems(raw) };
}
// 개인 할 일 추천 응답 파싱 — 표준 파싱 실패 시 문자열 배열만 정규식으로 복구한다.
// 빈 배열은 '내용에서 도출할 항목이 없음'이라는 정당한 응답이므로 오류로 보지 않고,
// 응답 자체를 해석하지 못한 경우에만 실패로 처리한다.
private parseTodoItems(raw: string): string[] {
const text = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/, '')
.trim();
let items: unknown[] = [];
let parsed = false;
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
try {
const obj = JSON.parse(text.slice(start, end + 1)) as {
items?: unknown;
};
if (Array.isArray(obj.items)) {
items = obj.items;
parsed = true;
}
} catch {
// 표준 파싱 실패 → 아래 견고 파싱으로 폴백
}
}
// 폴백 — "items":[ ... ] 블록에서 문자열만 추출
if (!parsed) {
const m = /"items"\s*:\s*\[([\s\S]*?)\]/.exec(text);
if (m) {
parsed = true;
items = (m[1].match(/"(?:[^"\\]|\\.)*"/g) ?? [])
.map((s) => {
try {
return JSON.parse(s) as unknown;
} catch {
return null;
}
})
.filter((s) => s !== null);
}
}
if (!parsed) {
this.logger.warn(
`할 일 추천 응답을 해석하지 못함 — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
);
throw this.suggestFailed();
}
return items
.filter((x): x is string => typeof x === 'string')
.map((s) => s.trim().slice(0, TODO_ITEM_MAX_LEN))
.filter((s) => s.length > 0)
.slice(0, TODO_ITEM_MAX_COUNT);
}
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
const text = raw
@@ -0,0 +1,19 @@
import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 개인 할 일 추천 요청 — 업무 본문을 분석해 담당자 관점의 실행 단계를 제안한다.
// 제목·본문은 서버가 업무에서 직접 읽으므로 식별자만 받는다.
// (체크리스트가 이미 있는 업무는 이 API 를 호출하지 않고 그 항목을 그대로 쓴다)
export class SuggestTaskTodosDto {
@ApiProperty({ description: '프로젝트 식별자' })
@IsString()
@IsNotEmpty({ message: '프로젝트가 필요합니다.' })
projectId!: string;
@ApiProperty({ description: '업무 순번(seq)', example: 9 })
@Type(() => Number)
@IsInt()
@Min(1)
taskSeq!: number;
}
+11
View File
@@ -469,6 +469,17 @@ export class TaskService {
return this.buildDetail(task, project, viewerId);
}
// 업무 엔티티(+체크리스트)와 소속 프로젝트 — 업무를 원본으로 삼는 다른 모듈(할 일 생성)용.
// 열람 권한은 상세 조회(findOne)와 동일하게 인증 사용자면 가능하다.
async findEntity(
projectId: string,
seq: number,
): Promise<{ task: Task; project: Project }> {
const project = await this.getProjectOrThrow(projectId);
const task = await this.getTaskOrThrow(project.id, seq);
return { task, project };
}
// 업무 생성 — admin(지시자) 권한. 담당자는 프로젝트 멤버만 지정 가능
async create(
projectId: string,
@@ -0,0 +1,41 @@
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsInt,
IsNotEmpty,
IsString,
MaxLength,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
// 업무 → 개인 할 일 생성 요청.
// 항목은 화면 모달에서 사용자가 확정한 것만 담기며(체크리스트 복사분·AI 추천분 공통),
// 라벨 이름·색은 서버가 원본 업무에서 파생하므로 받지 않는다.
export class CreateTodoFromTaskDto {
@ApiProperty({ description: '프로젝트 식별자' })
@IsString()
@IsNotEmpty({ message: '프로젝트가 필요합니다.' })
projectId!: string;
@ApiProperty({ description: '업무 순번(seq)', example: 9 })
@Type(() => Number)
@IsInt()
@Min(1)
taskSeq!: number;
@ApiProperty({
description: '생성할 할 일 내용 목록',
type: [String],
example: ['견적서 검토', '담당자 회신'],
})
@IsArray()
@ArrayNotEmpty({ message: '추가할 할 일을 하나 이상 선택해 주세요.' })
@ArrayMaxSize(50, { message: '할 일은 한 번에 최대 50개까지 추가합니다.' })
@IsString({ each: true })
@IsNotEmpty({ each: true, message: '할 일 내용을 입력해 주세요.' })
@MaxLength(200, { each: true, message: '할 일은 최대 200자입니다.' })
items!: string[];
}
@@ -11,6 +11,7 @@ import {
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Task } from '../../task/entities/task.entity';
import { TodoItem } from './todo-item.entity';
// 할 일 라벨 — 개인 체크리스트의 묶음 단위(라벨 1개 : 할 일 N개).
@@ -38,6 +39,15 @@ export class TodoLabel {
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
// 원본 업무 — '할 일로 만들기'로 생성된 라벨만 값을 갖는다(수동 생성은 null).
// 같은 업무로 다시 만들면 새 라벨 대신 이 라벨에 항목을 병합한다.
// 업무가 삭제돼도 개인 할 일은 남아야 하므로 SET NULL(연결만 끊는다).
// Relation<> 래퍼: 엔티티 순환참조 회피
@Index()
@ManyToOne(() => Task, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'source_task_id' })
sourceTask!: Relation<Task> | null;
// 소속 할 일 — 라벨 삭제 시 함께 삭제(FK CASCADE)
// Relation<> 래퍼: 엔티티 순환참조 회피
@OneToMany(() => TodoItem, (item) => item.label)
@@ -18,6 +18,7 @@ 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';
import { CreateTodoFromTaskDto } from './dto/create-todo-from-task.dto';
// 할 일(개인 체크리스트) 컨트롤러 — 라벨 1개에 항목 N개.
// 모든 응답은 라벨 단위라 화면이 카드 하나만 갈아끼우면 된다.
@@ -69,6 +70,20 @@ export class TodoController {
return { success: true };
}
@Post('labels/from-task')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '업무에서 할 일 생성 — 같은 업무의 라벨이 있으면 항목을 병합',
})
@ApiResponse({ status: 200, description: '생성·병합 성공' })
@ApiResponse({ status: 404, description: '프로젝트·업무 없음 (RES_001)' })
createFromTask(
@CurrentUser() user: PublicUser,
@Body() dto: CreateTodoFromTaskDto,
): Promise<TodoLabelResponse> {
return this.todoService.createFromTask(dto, user.id);
}
// ----- 항목 -----
@Post('labels/:labelId/items')
+4 -2
View File
@@ -4,10 +4,12 @@ import { TodoLabel } from './entities/todo-label.entity';
import { TodoItem } from './entities/todo-item.entity';
import { TodoService } from './todo.service';
import { TodoController } from './todo.controller';
import { TaskModule } from '../task/task.module';
// 할 일(개인 체크리스트) 모듈
// 할 일(개인 체크리스트) 모듈.
// TaskModule 은 '업무 → 할 일 만들기'에서 원본 업무를 읽기 위해 가져온다.
@Module({
imports: [TypeOrmModule.forFeature([TodoLabel, TodoItem])],
imports: [TypeOrmModule.forFeature([TodoLabel, TodoItem]), TaskModule],
controllers: [TodoController],
providers: [TodoService],
})
+174 -2
View File
@@ -4,8 +4,39 @@ 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 { Task } from '../task/entities/task.entity';
import { TaskService } from '../task/task.service';
import { CreateTodoLabelDto } from './dto/todo-label.dto';
import { CreateTodoItemDto, UpdateTodoItemDto } from './dto/todo-item.dto';
import { CreateTodoFromTaskDto } from './dto/create-todo-from-task.dto';
// 업무에서 파생한 라벨의 이름 최대 길이 — CreateTodoLabelDto 의 제한과 동일
const LABEL_NAME_MAX_LEN = 40;
// 라벨 이름이 길어질 때 프로젝트명에 허용하는 최대 길이(나머지는 업무 제목 몫)
const LABEL_PROJECT_MAX_LEN = 14;
// 할 일 제목 최대 길이 — CreateTodoItemDto 의 제한과 동일
const ITEM_TITLE_MAX_LEN = 200;
// 할 일 제목에 보완 내용을 덧붙일 때 쓰는 구분자.
// 이 형식은 서버가 만들고 서버가 되읽으므로(재실행 시 원문 판정) 한곳에서만 정의한다.
const NOTE_SEPARATOR = ' — ';
// 라벨 색의 고정 채도/명도 — 프론트 TodoLabelModal 과 동일한 값
const LABEL_SAT = 62;
const LABEL_LIG = 46;
// HSL → hex(#RRGGBB). 라벨 색 규격이 hex 라서 변환해 저장한다.
function hslToHex(h: number, s: number, l: number): string {
const sn = s / 100;
const ln = l / 100;
const k = (n: number) => (n + h / 30) % 12;
const a = sn * Math.min(ln, 1 - ln);
const f = (n: number) =>
ln - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const to = (x: number) =>
Math.round(255 * x)
.toString(16)
.padStart(2, '0');
return `#${to(f(0))}${to(f(8))}${to(f(4))}`;
}
// 응답(원시) — 표시 음영색은 프론트가 color 로 파생
export interface TodoItemResponse {
@@ -15,6 +46,13 @@ export interface TodoItemResponse {
sortOrder: number;
}
// 라벨을 만들어 낸 원본 업무 — 업무 상세로 돌아가는 링크에 쓴다
export interface TodoSourceTaskResponse {
projectId: string;
seq: number;
title: string;
}
export interface TodoLabelResponse {
id: string;
name: string;
@@ -24,6 +62,8 @@ export interface TodoLabelResponse {
// 진행 표기(2/3)용 집계 — 프론트가 items 로 다시 세지 않도록 함께 내려준다
doneCount: number;
totalCount: number;
// 원본 업무 — 직접 만든 라벨이거나 원본 업무가 삭제됐으면 null
sourceTask: TodoSourceTaskResponse | null;
}
// 할 일(개인 체크리스트) 비즈니스 로직 — 라벨 1개에 항목 N개.
@@ -36,6 +76,7 @@ export class TodoService {
private readonly labelRepo: Repository<TodoLabel>,
@InjectRepository(TodoItem)
private readonly itemRepo: Repository<TodoItem>,
private readonly taskService: TaskService,
) {}
// ----- 라벨 -----
@@ -44,7 +85,7 @@ export class TodoService {
async listLabels(ownerId: string): Promise<TodoLabelResponse[]> {
const rows = await this.labelRepo.find({
where: { owner: { id: ownerId } },
relations: ['items'],
relations: ['items', 'sourceTask', 'sourceTask.project'],
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
return rows.map((l) => this.toLabelResponse(l));
@@ -87,6 +128,98 @@ export class TodoService {
await this.labelRepo.delete({ id });
}
// 업무 → 개인 할 일 생성(병합).
// 같은 업무로 이미 만든 라벨이 있으면 새로 만들지 않고 그 라벨에 항목을 덧붙인다.
// 이미 같은 내용의 항목이 있으면 건너뛰어 중복 누적을 막는다.
async createFromTask(
dto: CreateTodoFromTaskDto,
ownerId: string,
): Promise<TodoLabelResponse> {
const { task, project } = await this.taskService.findEntity(
dto.projectId,
dto.taskSeq,
);
// 체크리스트 원문 → 검토 상태. 보완 내용을 할 일 제목에 덧붙이고,
// 반려된(미완료) 항목은 기존 할 일의 완료를 되돌리는 판단에 쓴다.
const review = new Map(
(task.checklist ?? []).map((c) => [
c.text.trim(),
{ done: c.done, note: c.reviewNote?.trim() || null },
]),
);
let label = await this.labelRepo.findOne({
where: { owner: { id: ownerId }, sourceTask: { id: task.id } },
relations: ['items', 'sourceTask', 'sourceTask.project'],
});
if (!label) {
label = await this.labelRepo.save(
this.labelRepo.create({
owner: { id: ownerId } as User,
name: this.taskLabelName(project.name, task.title),
color: this.taskLabelColor(task.seq),
sortOrder: await this.nextSortOrder(this.labelRepo, {
owner: { id: ownerId },
}),
sourceTask: { id: task.id } as Task,
}),
);
label.items = [];
}
let sortOrder = await this.nextSortOrder(this.itemRepo, {
label: { id: label.id },
});
const fresh: TodoItem[] = [];
const touched: TodoItem[] = [];
for (const raw of dto.items) {
const base = raw.trim();
if (!base) continue;
const reviewed = review.get(base);
// 보완 내용이 있으면 제목에 덧붙인다(왜 다시 해야 하는지 할 일에 남긴다)
const title = reviewed?.note
? this.clip(
`${base}${NOTE_SEPARATOR}${reviewed.note}`,
ITEM_TITLE_MAX_LEN,
)
: this.clip(base, ITEM_TITLE_MAX_LEN);
// 같은 항목인지 판정은 '보완 내용을 뗀 원문' 기준 — 보완 내용이 바뀌어도 중복 생성하지 않는다
const dup = (label.items ?? []).find(
(i) => this.itemBaseTitle(i.title) === base,
);
if (dup) {
// 지시자가 반려한 항목은 이미 완료 처리해 뒀더라도 다시 열어 준다
if (reviewed && !reviewed.done) {
if (dup.done || dup.title !== title) {
dup.done = false;
dup.title = title;
touched.push(dup);
}
}
continue;
}
const item = this.itemRepo.create({
label: { id: label.id } as TodoLabel,
title,
sortOrder: sortOrder++,
});
fresh.push(item);
// 같은 요청 안에서의 중복도 막는다
label.items = [...(label.items ?? []), item];
}
if (fresh.length > 0) await this.itemRepo.save(fresh);
if (touched.length > 0) await this.itemRepo.save(touched);
return this.findLabelOrThrow(label.id, ownerId);
}
// ----- 항목 -----
async createItem(
@@ -155,6 +288,39 @@ export class TodoService {
return item;
}
// 긴 문자열을 최대 길이로 자르되, 잘렸음을 말줄임표로 표시
private clip(text: string, max: number): string {
const t = text.trim();
return t.length <= max ? t : `${t.slice(0, max - 1)}`;
}
// 업무 기반 라벨 이름 — '프로젝트 - 업무 제목'.
// 전체가 제한을 넘으면 식별력이 큰 업무 제목을 살리고 프로젝트명을 먼저 줄인다.
private taskLabelName(projectName: string, taskTitle: string): string {
const sep = ' - ';
const project = projectName.trim();
const title = taskTitle.trim();
if (project.length + sep.length + title.length <= LABEL_NAME_MAX_LEN) {
return `${project}${sep}${title}`;
}
const head = this.clip(project, LABEL_PROJECT_MAX_LEN);
const rest = LABEL_NAME_MAX_LEN - head.length - sep.length;
return `${head}${sep}${this.clip(title, rest)}`;
}
// 할 일 제목에서 보완 내용을 뗀 원문 — 재실행 시 같은 항목인지 판정하는 기준
private itemBaseTitle(title: string): string {
const at = title.indexOf(NOTE_SEPARATOR);
return (at >= 0 ? title.slice(0, at) : title).trim();
}
// 업무 기반 라벨의 색 — 업무 순번으로 hue 를 분산시켜 카드가 서로 구분되게 한다.
// 채도/명도는 라벨 모달(TodoLabelModal)과 동일한 값으로 고정한다.
private taskLabelColor(seq: number): string {
const hue = (seq * 47) % 360;
return hslToHex(hue, LABEL_SAT, LABEL_LIG);
}
// 다음 정렬값 = 같은 묶음 안의 최대 + 1
private async nextSortOrder<T extends { sortOrder: number }>(
repo: Repository<T>,
@@ -174,7 +340,7 @@ export class TodoService {
): Promise<TodoLabelResponse> {
const label = await this.labelRepo.findOne({
where: { id, owner: { id: ownerId } },
relations: ['items'],
relations: ['items', 'sourceTask', 'sourceTask.project'],
});
if (!label) throw new NotFoundException('라벨을 찾을 수 없습니다.');
return this.toLabelResponse(label);
@@ -192,6 +358,9 @@ export class TodoService {
done: i.done,
sortOrder: i.sortOrder,
}));
// 원본 업무는 '업무에서 만든 라벨'에만 있다. 직접 만든 라벨이거나
// 원본 업무가 삭제된 경우(FK SET NULL) null 이며, 화면은 링크를 숨긴다.
const src = l.sourceTask;
return {
id: l.id,
name: l.name,
@@ -200,6 +369,9 @@ export class TodoService {
items,
doneCount: items.filter((i) => i.done).length,
totalCount: items.length,
sourceTask: src?.project
? { projectId: src.project.id, seq: src.seq, title: src.title }
: null,
};
}
}