feat: 중간 점검(체크포인트) 백엔드 — 진행상태/점검일/보고

체크리스트에 작업자 진행상태(work_status: 미착수/진행중/완료) 필드 추가(지시자 검토와 분리).
업무당 중간 점검일(task_checkpoints) 여러 개 + 작업자 보고 스냅샷(checkpoint_reports) 도입.
- 생성/수정 DTO에 checkpoints(날짜 목록) 추가, 날짜 기준 동기화(보고 보존)
- PATCH .../work-status(담당자 진행상태 갱신), POST .../checkpoints/:id/report(점검일~마감 구간, 스냅샷+지시자 알림 task.checkpoint_reported)
- 상세 응답에 checkpoints/checkpointReports/항목 workStatus 포함
- 운영용 마이그레이션 추가(dev 는 synchronize 자동 반영)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:46:07 +09:00
parent c550d66826
commit 20664dab0f
13 changed files with 514 additions and 6 deletions
@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 중간 점검(체크포인트) 기능 — 체크리스트 작업자 진행상태 + 점검일/보고 테이블 추가.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskCheckpoints1782200000000 implements MigrationInterface {
name = 'AddTaskCheckpoints1782200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 체크리스트 작업자 진행상태(자가보고) — 미착수/진행중/완료
await queryRunner.query(
`ALTER TABLE "checklist_items" ADD "work_status" character varying NOT NULL DEFAULT 'todo'`,
);
// 중간 점검일
await queryRunner.query(
`CREATE TABLE "task_checkpoints" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "check_at" TIMESTAMP WITH TIME ZONE NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, CONSTRAINT "PK_task_checkpoints" PRIMARY KEY ("id"))`,
);
// 중간 점검 보고(스냅샷)
await queryRunner.query(
`CREATE TABLE "checkpoint_reports" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "note" text, "items_snapshot" jsonb NOT NULL DEFAULT '[]', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "checkpoint_id" uuid, "reporter_id" uuid, CONSTRAINT "PK_checkpoint_reports" PRIMARY KEY ("id"))`,
);
// FK
await queryRunner.query(
`ALTER TABLE "task_checkpoints" ADD CONSTRAINT "FK_task_checkpoints_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_checkpoint" FOREIGN KEY ("checkpoint_id") REFERENCES "task_checkpoints"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_reporter" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_reporter"`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_checkpoint"`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_task"`,
);
await queryRunner.query(
`ALTER TABLE "task_checkpoints" DROP CONSTRAINT "FK_task_checkpoints_task"`,
);
await queryRunner.query(`DROP TABLE "checkpoint_reports"`);
await queryRunner.query(`DROP TABLE "task_checkpoints"`);
await queryRunner.query(
`ALTER TABLE "checklist_items" DROP COLUMN "work_status"`,
);
}
}
@@ -18,6 +18,7 @@ export type NotificationType =
| 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨
| 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
| 'task.removed' // 내 관련 업무가 삭제됨
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
| 'member.invited' // 프로젝트에 멤버로 초대됨
@@ -0,0 +1,11 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 중간 점검 보고 — 보고 시점의 현재 진행 상태(서버가 스냅샷)와 함께 선택 메모를 전송
export class CheckpointReportDto {
@ApiProperty({ description: '보고 메모(선택)', required: false })
@IsOptional()
@IsString()
@MaxLength(2000, { message: '메모가 너무 깁니다.' })
note?: string;
}
@@ -93,4 +93,20 @@ export class CreateTaskDto {
@ValidateNested({ each: true })
@Type(() => ChecklistInputDto)
checklist?: ChecklistInputDto[];
@ApiProperty({
description:
'중간 점검일 목록(ISO8601, 선택) — 작업자가 진행 상태를 보고하는 기준일',
type: [String],
required: false,
example: ['2026-06-15T00:00:00Z'],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(20, { message: '중간 점검일이 너무 많습니다.' })
@IsISO8601(
{ strict: false },
{ each: true, message: '올바른 날짜 형식을 입력해 주세요.' },
)
checkpoints?: string[];
}
@@ -101,4 +101,19 @@ export class UpdateTaskDto {
@ValidateNested({ each: true })
@Type(() => UpdateChecklistItemInput)
checklist?: UpdateChecklistItemInput[];
@ApiProperty({
description:
'중간 점검일 목록(ISO8601). 제공 시 전체 교체, 빈 배열이면 모두 삭제',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@ArrayMaxSize(20, { message: '중간 점검일이 너무 많습니다.' })
@IsISO8601(
{ strict: false },
{ each: true, message: '올바른 날짜 형식을 입력해 주세요.' },
)
checkpoints?: string[];
}
@@ -0,0 +1,36 @@
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsIn,
IsUUID,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import {
CHECKLIST_WORK_STATUSES,
type ChecklistWorkStatus,
} from '../entities/checklist-item.entity';
// 항목별 진행 상태 입력(작업자 자가보고)
export class WorkStatusItemInput {
@ApiProperty({ description: '체크리스트 항목 ID' })
@IsUUID('4')
id!: string;
@ApiProperty({ description: '진행 상태', enum: CHECKLIST_WORK_STATUSES })
@IsIn(CHECKLIST_WORK_STATUSES)
workStatus!: ChecklistWorkStatus;
}
// 진행 상태 일괄 갱신 — 담당자만 호출
export class UpdateWorkStatusDto {
@ApiProperty({ description: '갱신할 항목 목록', type: [WorkStatusItemInput] })
@IsArray()
@ArrayNotEmpty({ message: '갱신할 항목이 없습니다.' })
@ArrayMaxSize(100, { message: '항목이 너무 많습니다.' })
@ValidateNested({ each: true })
@Type(() => WorkStatusItemInput)
items!: WorkStatusItemInput[];
}
@@ -12,6 +12,11 @@ import { Task } from './task.entity';
export const CHECKLIST_CATEGORIES = ['필수', '관리', '보안', '부가'] as const;
export type ChecklistCategory = (typeof CHECKLIST_CATEGORIES)[number];
// 작업자 진행 상태(자가보고) — 지시자의 검토(done/reviewNote)와는 별개.
// 작업자가 항목별로 직접 설정하며, 중간 점검 보고 시 스냅샷으로 기록된다.
export const CHECKLIST_WORK_STATUSES = ['todo', 'doing', 'done'] as const;
export type ChecklistWorkStatus = (typeof CHECKLIST_WORK_STATUSES)[number];
// 업무 체크리스트 항목
@Entity('checklist_items')
export class ChecklistItem {
@@ -31,10 +36,14 @@ export class ChecklistItem {
@Column({ type: 'varchar', default: '필수' })
category!: ChecklistCategory;
// 완료 여부
// 완료 여부(지시자 검토 = 승인). 보완거절은 done:false + reviewNote 로 표현
@Column({ default: false })
done!: boolean;
// 작업자 진행 상태(자가보고) — 미착수/진행중/완료. 검토(done)와 독립
@Column({ name: 'work_status', type: 'varchar', default: 'todo' })
workStatus!: ChecklistWorkStatus;
// 보완 내용(수정 요청 시 항목별로 입력) — 완료 처리 시 비워짐
@Column({ name: 'review_note', type: 'text', nullable: true })
reviewNote!: string | null;
@@ -0,0 +1,53 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Task } from './task.entity';
import { TaskCheckpoint } from './task-checkpoint.entity';
import type { ChecklistWorkStatus } from './checklist-item.entity';
// 보고 스냅샷의 항목 단위(보고 시점의 항목별 진행 상태를 동결 보관)
export interface ReportItemSnapshot {
itemId: string;
text: string;
workStatus: ChecklistWorkStatus;
}
// 중간 점검 보고 — 작업자가 점검일에 현재 진행 상태를 지시자에게 보낸 기록(스냅샷).
@Entity('checkpoint_reports')
export class CheckpointReport {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'task_id' })
task!: Relation<Task>;
// 대상 체크포인트 (점검일 삭제 시 보고도 함께 삭제)
@ManyToOne(() => TaskCheckpoint, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'checkpoint_id' })
checkpoint!: Relation<TaskCheckpoint>;
// 보고한 작업자 (사용자 삭제 시에도 보고는 유지 — SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'reporter_id' })
reporter!: User | null;
// 보고 메모(선택)
@Column({ type: 'text', nullable: true })
note!: string | null;
// 보고 시점의 항목별 진행 상태 스냅샷
@Column({ name: 'items_snapshot', type: 'jsonb', default: () => "'[]'" })
itemsSnapshot!: ReportItemSnapshot[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,34 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { Task } from './task.entity';
// 중간 점검일 — 지시자가 업무 배정 시 지정. 한 업무에 여러 개 가능.
// 작업자는 점검일~마감 구간에 진행 상태를 보고할 수 있다.
@Entity('task_checkpoints')
export class TaskCheckpoint {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'task_id' })
task!: Relation<Task>;
// 점검 예정일
@Column({ name: 'check_at', type: 'timestamptz' })
checkAt!: Date;
// 표시 순서(입력 순)
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -38,6 +38,8 @@ import {
} from './task.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { UpdateWorkStatusDto } from './dto/update-work-status.dto';
import { CheckpointReportDto } from './dto/checkpoint-report.dto';
import { ChangeStatusDto } from './dto/change-status.dto';
import { CreateCommentDto, CreateReplyDto } from './dto/create-comment.dto';
import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
@@ -160,6 +162,44 @@ export class TaskController {
);
}
/* ===================== 중간 점검(체크포인트) ===================== */
@Patch(':taskId/work-status')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '체크리스트 진행 상태 갱신 (담당자)' })
@ApiResponse({ status: 200, description: '갱신 성공' })
@ApiResponse({ status: 403, description: '담당자 아님 (AUTH_003)' })
updateWorkStatus(
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Body() dto: UpdateWorkStatusDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.updateWorkStatus(projectId, user.id, taskId, dto);
}
@Post(':taskId/checkpoints/:checkpointId/report')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '중간 점검 보고 (담당자) — 점검일~마감 구간' })
@ApiResponse({ status: 201, description: '보고 접수' })
@ApiResponse({ status: 403, description: '담당자 아님 (AUTH_003)' })
@ApiResponse({ status: 404, description: '점검일을 찾을 수 없음 (RES_001)' })
submitCheckpointReport(
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Param('checkpointId', ParseUUIDPipe) checkpointId: string,
@Body() dto: CheckpointReportDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.submitCheckpointReport(
projectId,
user.id,
taskId,
checkpointId,
dto,
);
}
/* ===================== 댓글 / 답글 (5단계) ===================== */
@Post(':taskId/comments')
+4
View File
@@ -4,6 +4,8 @@ import { Project } from '../project/entities/project.entity';
import { ProjectMember } from '../project/entities/project-member.entity';
import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
import { CheckpointReport } from './entities/checkpoint-report.entity';
import { Comment } from './entities/comment.entity';
import { Attachment } from './entities/attachment.entity';
import { ActivityModule } from '../activity/activity.module';
@@ -18,6 +20,8 @@ import { MyTaskController } from './my-task.controller';
TypeOrmModule.forFeature([
Task,
ChecklistItem,
TaskCheckpoint,
CheckpointReport,
Comment,
Attachment,
Project,
@@ -8,6 +8,8 @@ import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { Comment } from './entities/comment.entity';
import { Attachment } from './entities/attachment.entity';
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
import { CheckpointReport } from './entities/checkpoint-report.entity';
import { ActivityService } from '../activity/activity.service';
import { NotificationService } from '../notification/notification.service';
@@ -26,6 +28,19 @@ function makeService() {
// 상세 조립(buildDetail)에서 댓글/첨부는 빈 목록으로 모킹
const commentRepo = { find: jest.fn().mockResolvedValue([]) };
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
// 중간 점검(체크포인트)·보고 — buildDetail 에서 빈 목록으로 모킹
const checkpointRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
create: jest.fn(),
};
const reportRepo = {
find: jest.fn().mockResolvedValue([]),
save: jest.fn(),
create: jest.fn(),
};
// 활동 서비스 — 적재는 no-op, 업무 활동 조회는 빈 목록
const activity = {
record: jest.fn().mockResolvedValue(undefined),
@@ -40,6 +55,8 @@ function makeService() {
memberRepo as unknown as Repository<ProjectMember>,
commentRepo as unknown as Repository<Comment>,
attachmentRepo as unknown as Repository<Attachment>,
checkpointRepo as unknown as Repository<TaskCheckpoint>,
reportRepo as unknown as Repository<CheckpointReport>,
activity as unknown as ActivityService,
notification as unknown as NotificationService,
);
+219 -5
View File
@@ -14,11 +14,19 @@ import { Task, type TaskStatus } from './entities/task.entity';
import {
ChecklistItem,
type ChecklistCategory,
type ChecklistWorkStatus,
} from './entities/checklist-item.entity';
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
import {
CheckpointReport,
type ReportItemSnapshot,
} from './entities/checkpoint-report.entity';
import { Comment } from './entities/comment.entity';
import { Attachment, type AttachmentKind } from './entities/attachment.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { UpdateWorkStatusDto } from './dto/update-work-status.dto';
import { CheckpointReportDto } from './dto/checkpoint-report.dto';
import {
ActivityService,
type ActivityResponse,
@@ -58,6 +66,25 @@ export interface ChecklistItemResponse {
category: ChecklistCategory;
// 보완 내용(수정 요청 시 항목별 입력) — 없으면 null
reviewNote: string | null;
// 작업자 진행 상태(자가보고) — 미착수/진행중/완료
workStatus: ChecklistWorkStatus;
}
// 중간 점검일 응답
export interface CheckpointResponse {
id: string;
checkAt: string;
}
// 중간 점검 보고 응답(스냅샷 포함)
export interface CheckpointReportResponse {
id: string;
checkpointId: string;
checkAt: string | null;
reporter: PublicUser | null;
note: string | null;
items: ReportItemSnapshot[];
createdAt: string;
}
// 프로젝트 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영
@@ -140,6 +167,9 @@ export interface TaskDetailResponse {
files: AttachmentResponse[];
approval: ApprovalInfo | null;
changesNote: string | null;
// 중간 점검일 + 작업자 보고 이력
checkpoints: CheckpointResponse[];
checkpointReports: CheckpointReportResponse[];
}
// 상태 머신 전이 규칙(허용 목적지)
@@ -176,6 +206,10 @@ export class TaskService {
private readonly commentRepo: Repository<Comment>,
@InjectRepository(Attachment)
private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(TaskCheckpoint)
private readonly checkpointRepo: Repository<TaskCheckpoint>,
@InjectRepository(CheckpointReport)
private readonly reportRepo: Repository<CheckpointReport>,
private readonly activity: ActivityService,
private readonly notification: NotificationService,
) {}
@@ -357,6 +391,9 @@ export class TaskService {
});
const saved = await this.taskRepo.save(task);
// 중간 점검일 생성(있으면)
await this.syncCheckpoints(saved, dto.checkpoints);
// 활동 적재 — 업무 생성
await this.activity.record({
projectId: project.id,
@@ -417,6 +454,11 @@ export class TaskService {
checklistChanged = await this.syncChecklist(task, dto.checklist);
}
// 중간 점검일 동기화(제공된 경우에만) — 날짜 기준 추가/삭제(미변경 항목·보고 유지)
if (dto.checkpoints !== undefined) {
await this.syncCheckpoints(task, dto.checkpoints);
}
// 변경 항목별 활동 적재(담당자/마감/일반 수정)
await this.recordUpdateActivities(project, actingUserId, task, {
dto,
@@ -1293,15 +1335,166 @@ export class TaskService {
}
// 업무 상세 + 부속(댓글/첨부/승인) 조립
// --- 중간 점검(체크포인트) ---
// 작업자 진행 상태 갱신(자가보고) — 담당자만. 항목별 work_status 갱신
async updateWorkStatus(
projectId: string,
actingUserId: string,
seq: number,
dto: UpdateWorkStatusDto,
): Promise<TaskDetailResponse> {
const project = await this.getProjectOrThrow(projectId);
const task = await this.getTaskOrThrow(project.id, seq);
this.assertAssignee(task, actingUserId);
const byId = new Map((task.checklist ?? []).map((c) => [c.id, c]));
const changed: ChecklistItem[] = [];
for (const it of dto.items) {
const item = byId.get(it.id);
if (item && item.workStatus !== it.workStatus) {
item.workStatus = it.workStatus;
changed.push(item);
}
}
if (changed.length) {
await this.checklistRepo.save(changed);
}
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
);
}
// 중간 점검 보고 — 담당자만. 점검일~마감 구간에서 현재 진행 상태를 스냅샷 기록 + 지시자 알림
async submitCheckpointReport(
projectId: string,
actingUserId: string,
seq: number,
checkpointId: string,
dto: CheckpointReportDto,
): Promise<TaskDetailResponse> {
const project = await this.getProjectOrThrow(projectId);
const task = await this.getTaskOrThrow(project.id, seq);
this.assertAssignee(task, actingUserId);
const checkpoint = await this.checkpointRepo.findOne({
where: { id: checkpointId, task: { id: task.id } },
});
if (!checkpoint) {
throw new NotFoundException();
}
// 보고 가능 시점: 점검일 당일부터 마감 전까지
const now = Date.now();
if (checkpoint.checkAt.getTime() > now) {
throw new BusinessException(
'BIZ_001',
'아직 중간 점검일이 되지 않았습니다.',
HttpStatus.BAD_REQUEST,
);
}
if (task.dueDate && task.dueDate.getTime() <= now) {
throw new BusinessException(
'BIZ_001',
'마감이 지나 중간 점검 보고를 보낼 수 없습니다.',
HttpStatus.BAD_REQUEST,
);
}
// 현재 항목별 진행 상태 스냅샷(동결)
const snapshot: ReportItemSnapshot[] = (task.checklist ?? [])
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((c) => ({ itemId: c.id, text: c.text, workStatus: c.workStatus }));
const reporter =
(task.assignees ?? []).find((a) => a.id === actingUserId) ?? null;
const note = dto.note?.trim() ? dto.note.trim() : null;
await this.reportRepo.save(
this.reportRepo.create({
task: { id: task.id } as Task,
checkpoint: { id: checkpoint.id } as TaskCheckpoint,
reporter,
note,
itemsSnapshot: snapshot,
}),
);
// 지시자에게 알림(행위자=작업자는 자동 제외)
await this.notification.notify({
recipientIds: task.issuer ? [task.issuer.id] : [],
actorId: actingUserId,
type: 'task.checkpoint_reported',
payload: {
...this.taskNotifyPayload(project, task, reporter?.name ?? '담당자'),
checkpointAt: checkpoint.checkAt.toISOString(),
},
});
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
);
}
// 중간 점검일 동기화 — 날짜(시각) 기준 추가/삭제. 미변경 날짜는 그대로 두어 보고를 보존.
private async syncCheckpoints(task: Task, dates?: string[]): Promise<void> {
if (dates === undefined) return;
const existing = await this.checkpointRepo.find({
where: { task: { id: task.id } },
});
const existingTimes = existing.map((c) => c.checkAt.getTime());
const wantedTimes = [...new Set(dates.map((d) => new Date(d).getTime()))];
const toRemove = existing.filter(
(c) => !wantedTimes.includes(c.checkAt.getTime()),
);
if (toRemove.length) {
await this.checkpointRepo.remove(toRemove);
}
const toAdd = wantedTimes
.filter((t) => !existingTimes.includes(t))
.map((t, i) =>
this.checkpointRepo.create({
task: { id: task.id } as Task,
checkAt: new Date(t),
sortOrder: i,
}),
);
if (toAdd.length) {
await this.checkpointRepo.save(toAdd);
}
}
// 담당자(작업자) 확인 — 아니면 403
private assertAssignee(task: Task, userId: string): void {
const isAssignee = (task.assignees ?? []).some((a) => a.id === userId);
if (!isAssignee) {
throw new ForbiddenException();
}
}
private async buildDetail(
task: Task,
project: Project,
): Promise<TaskDetailResponse> {
const [comments, files, activities] = await Promise.all([
this.loadComments(task.id, project, task.seq),
this.loadFiles(task.id, project, task.seq),
this.activity.listForTask(project.id, task.seq),
]);
const [comments, files, activities, checkpointRows, reportRows] =
await Promise.all([
this.loadComments(task.id, project, task.seq),
this.loadFiles(task.id, project, task.seq),
this.activity.listForTask(project.id, task.seq),
this.checkpointRepo.find({
where: { task: { id: task.id } },
order: { checkAt: 'ASC' },
}),
this.reportRepo.find({
where: { task: { id: task.id } },
order: { createdAt: 'DESC' },
relations: ['reporter', 'checkpoint'],
}),
]);
const checklist = (task.checklist ?? [])
.slice()
@@ -1312,8 +1505,27 @@ export class TaskService {
done: c.done,
category: c.category,
reviewNote: c.reviewNote ?? null,
workStatus: c.workStatus,
}));
const checkpoints: CheckpointResponse[] = checkpointRows.map((c) => ({
id: c.id,
checkAt: c.checkAt.toISOString(),
}));
const checkpointReports: CheckpointReportResponse[] = reportRows.map(
(r) => ({
id: r.id,
checkpointId: r.checkpoint?.id ?? '',
checkAt: r.checkpoint?.checkAt
? r.checkpoint.checkAt.toISOString()
: null,
reporter: r.reporter ? UserService.toPublic(r.reporter) : null,
note: r.note,
items: r.itemsSnapshot ?? [],
createdAt: r.createdAt.toISOString(),
}),
);
// 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출
const approval: ApprovalInfo | null =
task.status === 'review' && task.approvalRequestedAt
@@ -1343,6 +1555,8 @@ export class TaskService {
files,
approval,
changesNote: task.status === 'changes' ? task.changesNote : null,
checkpoints,
checkpointReports,
};
}