diff --git a/backend/src/migrations/1782200000000-AddTaskCheckpoints.ts b/backend/src/migrations/1782200000000-AddTaskCheckpoints.ts new file mode 100644 index 0000000..a0aca12 --- /dev/null +++ b/backend/src/migrations/1782200000000-AddTaskCheckpoints.ts @@ -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 { + // 체크리스트 작업자 진행상태(자가보고) — 미착수/진행중/완료 + 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 { + 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"`, + ); + } +} diff --git a/backend/src/modules/notification/entities/notification.entity.ts b/backend/src/modules/notification/entities/notification.entity.ts index 194b227..313ed31 100644 --- a/backend/src/modules/notification/entities/notification.entity.ts +++ b/backend/src/modules/notification/entities/notification.entity.ts @@ -18,6 +18,7 @@ export type NotificationType = | 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨 | 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함 | 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨 + | 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴 | 'task.removed' // 내 관련 업무가 삭제됨 | 'task.commented' // 내 관련 업무에 댓글/답글이 달림 | 'member.invited' // 프로젝트에 멤버로 초대됨 diff --git a/backend/src/modules/task/dto/checkpoint-report.dto.ts b/backend/src/modules/task/dto/checkpoint-report.dto.ts new file mode 100644 index 0000000..2c95e71 --- /dev/null +++ b/backend/src/modules/task/dto/checkpoint-report.dto.ts @@ -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; +} diff --git a/backend/src/modules/task/dto/create-task.dto.ts b/backend/src/modules/task/dto/create-task.dto.ts index 1cde5f7..3bb2d02 100644 --- a/backend/src/modules/task/dto/create-task.dto.ts +++ b/backend/src/modules/task/dto/create-task.dto.ts @@ -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[]; } diff --git a/backend/src/modules/task/dto/update-task.dto.ts b/backend/src/modules/task/dto/update-task.dto.ts index 52b92bf..4d61d81 100644 --- a/backend/src/modules/task/dto/update-task.dto.ts +++ b/backend/src/modules/task/dto/update-task.dto.ts @@ -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[]; } diff --git a/backend/src/modules/task/dto/update-work-status.dto.ts b/backend/src/modules/task/dto/update-work-status.dto.ts new file mode 100644 index 0000000..768b025 --- /dev/null +++ b/backend/src/modules/task/dto/update-work-status.dto.ts @@ -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[]; +} diff --git a/backend/src/modules/task/entities/checklist-item.entity.ts b/backend/src/modules/task/entities/checklist-item.entity.ts index 02ace26..9103440 100644 --- a/backend/src/modules/task/entities/checklist-item.entity.ts +++ b/backend/src/modules/task/entities/checklist-item.entity.ts @@ -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; diff --git a/backend/src/modules/task/entities/checkpoint-report.entity.ts b/backend/src/modules/task/entities/checkpoint-report.entity.ts new file mode 100644 index 0000000..96b2216 --- /dev/null +++ b/backend/src/modules/task/entities/checkpoint-report.entity.ts @@ -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; + + // 대상 체크포인트 (점검일 삭제 시 보고도 함께 삭제) + @ManyToOne(() => TaskCheckpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'checkpoint_id' }) + checkpoint!: Relation; + + // 보고한 작업자 (사용자 삭제 시에도 보고는 유지 — 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; +} diff --git a/backend/src/modules/task/entities/task-checkpoint.entity.ts b/backend/src/modules/task/entities/task-checkpoint.entity.ts new file mode 100644 index 0000000..9d901ea --- /dev/null +++ b/backend/src/modules/task/entities/task-checkpoint.entity.ts @@ -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; + + // 점검 예정일 + @Column({ name: 'check_at', type: 'timestamptz' }) + checkAt!: Date; + + // 표시 순서(입력 순) + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder!: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/task/task.controller.ts b/backend/src/modules/task/task.controller.ts index e93465f..c6b8d10 100644 --- a/backend/src/modules/task/task.controller.ts +++ b/backend/src/modules/task/task.controller.ts @@ -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 { + 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 { + return this.taskService.submitCheckpointReport( + projectId, + user.id, + taskId, + checkpointId, + dto, + ); + } + /* ===================== 댓글 / 답글 (5단계) ===================== */ @Post(':taskId/comments') diff --git a/backend/src/modules/task/task.module.ts b/backend/src/modules/task/task.module.ts index b42c101..7d2e222 100644 --- a/backend/src/modules/task/task.module.ts +++ b/backend/src/modules/task/task.module.ts @@ -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, diff --git a/backend/src/modules/task/task.service.spec.ts b/backend/src/modules/task/task.service.spec.ts index a71fa76..fdb79eb 100644 --- a/backend/src/modules/task/task.service.spec.ts +++ b/backend/src/modules/task/task.service.spec.ts @@ -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, commentRepo as unknown as Repository, attachmentRepo as unknown as Repository, + checkpointRepo as unknown as Repository, + reportRepo as unknown as Repository, activity as unknown as ActivityService, notification as unknown as NotificationService, ); diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 43765a3..8d9c42d 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -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, @InjectRepository(Attachment) private readonly attachmentRepo: Repository, + @InjectRepository(TaskCheckpoint) + private readonly checkpointRepo: Repository, + @InjectRepository(CheckpointReport) + private readonly reportRepo: Repository, 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 { + 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 { + 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 { + 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 { - 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, }; }