feat: 업무 공동 지시자·검토자 표기·마감 임박/초과 알림 추가

- 공동 지시자(coIssuers): 업무에 공동 지시자를 지정해 원 지시자와 함께
  완료 검토(승인/수정요청) 권한을 부여. 생성/수정 UI, 검토 권한 체크,
  보고 열람, 승인요청·시작 알림 수신, '내가 지시한 업무' 목록에 포함.
- 검토자 표기(reviewedBy): 승인 완료/수정 요청 카드에 실제 검토를 수행한
  사람(원 지시자 또는 공동 지시자)을 표기하도록 추적·노출.
- 마감 알림: 매일 오전 9시(KST) cron으로 미완료 업무의 마감 임박(24h 이내)
  ·초과를 담당자에게 1회 알림(task.due_soon/task.overdue). 발송 플래그로
  중복 방지(다중 인스턴스 대비 원자적 선점), 마감 변경 시 초기화.
  웹푸시·인앱 문구 동시 지원(커버리지 스펙 갱신).

DB: task_co_issuers 조인테이블, tasks.reviewed_by,
    tasks.due_soon_notified_at/overdue_notified_at 마이그레이션 추가
    (dev synchronize 자동, 운영 migrationsRun).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 00:40:04 +09:00
parent fb0669990d
commit 5ff9bf0df8
17 changed files with 585 additions and 21 deletions
@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 공동 지시자 기능 — 업무별 공동 지시자(task_co_issuers) 다대다 조인 테이블 추가.
// 원 지시자(tasks.issuer_id)와 함께 완료 검토(승인/수정요청) 권한을 가진다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskCoIssuers1783400000000 implements MigrationInterface {
name = 'AddTaskCoIssuers1783400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 공동 지시자 조인 테이블(task_assignees 와 동일 구조)
await queryRunner.query(
`CREATE TABLE "task_co_issuers" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_task_co_issuers" PRIMARY KEY ("task_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_task_co_issuers_task" ON "task_co_issuers" ("task_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_task_co_issuers_user" ON "task_co_issuers" ("user_id")`,
);
// FK — 업무/사용자 삭제 시 조인 행도 함께 삭제
await queryRunner.query(
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_user"`,
);
await queryRunner.query(
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_task"`,
);
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_user"`);
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_task"`);
await queryRunner.query(`DROP TABLE "task_co_issuers"`);
}
}
@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 검토자 기록 — 업무의 최근 검토(승인/수정요청) 수행자(tasks.reviewed_by) 컬럼 추가.
// 원 지시자·공동 지시자 중 실제로 검토한 사람을 저장해, 완료/수정요청 카드에 정확한 검토자를 표기한다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskReviewedBy1783500000000 implements MigrationInterface {
name = 'AddTaskReviewedBy1783500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "tasks" ADD "reviewed_by" uuid`);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_tasks_reviewed_by" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_tasks_reviewed_by"`,
);
await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "reviewed_by"`);
}
}
@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 마감 임박·초과 알림(cron) 중복 발송 방지 플래그 — tasks 에 발송 시각 컬럼 2개 추가.
// 마감이 변경되면 서비스에서 다시 null 로 초기화해 새 마감 기준으로 재알림한다.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskDueNotifyFlags1783600000000 implements MigrationInterface {
name = 'AddTaskDueNotifyFlags1783600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "tasks" ADD "due_soon_notified_at" TIMESTAMP WITH TIME ZONE`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD "overdue_notified_at" TIMESTAMP WITH TIME ZONE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "tasks" DROP COLUMN "overdue_notified_at"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP COLUMN "due_soon_notified_at"`,
);
}
}
@@ -18,6 +18,8 @@ export type NotificationType =
| 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨
| 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
| 'task.due_soon' // 내가 담당한 업무의 마감이 하루 앞으로 다가옴
| 'task.overdue' // 내가 담당한 업무의 마감이 지남(미완료)
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
| 'task.removed' // 내 관련 업무가 삭제됨
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
@@ -14,6 +14,8 @@ const ALL_TYPES: NotificationType[] = [
'task.changes_requested',
'task.started',
'task.due_changed',
'task.due_soon',
'task.overdue',
'task.checkpoint_reported',
'task.removed',
'task.commented',
@@ -58,9 +60,9 @@ describe('WebPushService.notificationContent', () => {
expect(content.body.length).toBeGreaterThan(0);
});
it('타입 목록이 17종(현재 정의)과 일치해야 한다', () => {
it('타입 목록이 19종(현재 정의)과 일치해야 한다', () => {
// NotificationType 추가 시 이 테스트가 알려주도록 개수를 고정한다.
expect(ALL_TYPES).toHaveLength(17);
expect(ALL_TYPES).toHaveLength(19);
expect(new Set(ALL_TYPES).size).toBe(ALL_TYPES.length);
});
});
@@ -140,6 +140,22 @@ export class WebPushService implements OnModuleInit {
: `'${taskTitle}' 업무의 마감기한을 해제했습니다`;
break;
}
case 'task.due_soon': {
const label = fmtDueKo(pstr(payload, 'dueDate'));
title = '마감 임박';
body = label
? `'${taskTitle}' 업무의 마감이 다가옵니다 (마감: ${label})`
: `'${taskTitle}' 업무의 마감이 다가옵니다`;
break;
}
case 'task.overdue': {
const label = fmtDueKo(pstr(payload, 'dueDate'));
title = '마감 초과';
body = label
? `'${taskTitle}' 업무가 마감기한을 넘겼습니다 (마감: ${label})`
: `'${taskTitle}' 업무가 마감기한을 넘겼습니다`;
break;
}
case 'task.checkpoint_reported':
body = `'${taskTitle}' 업무의 중간 점검을 보고했습니다`;
break;
@@ -73,6 +73,19 @@ export class CreateTaskDto {
@IsUUID('4', { each: true })
assigneeIds!: string[];
@ApiProperty({
description:
'공동 지시자 사용자 ID 배열(선택, 프로젝트 멤버만 가능) — 원 지시자와 함께 완료 검토 권한을 가진다',
type: [String],
required: false,
example: ['b2c3d4e5-...'],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(20, { message: '공동 지시자가 너무 많습니다.' })
@IsUUID('4', { each: true })
coIssuerIds?: string[];
@ApiProperty({
description: '마감 기한(ISO8601, 선택)',
required: false,
@@ -80,6 +80,18 @@ export class UpdateTaskDto {
@IsUUID('4', { each: true })
assigneeIds?: string[];
@ApiProperty({
description:
'공동 지시자 사용자 ID 배열(프로젝트 멤버만 가능). 제공 시 전체 교체, 빈 배열이면 모두 해제',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@ArrayMaxSize(20, { message: '공동 지시자가 너무 많습니다.' })
@IsUUID('4', { each: true })
coIssuerIds?: string[];
@ApiProperty({
description: '마감 기한(ISO8601). null 이면 마감 해제',
required: false,
@@ -60,6 +60,16 @@ export class Task {
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
issuer!: User | null;
// 공동 지시자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증).
// 원 지시자와 동일하게 완료 검토(승인/수정요청) 권한을 가진다.
@ManyToMany(() => User)
@JoinTable({
name: 'task_co_issuers',
joinColumn: { name: 'task_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
})
coIssuers!: User[];
// 담당자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증)
@ManyToMany(() => User)
@JoinTable({
@@ -96,6 +106,23 @@ export class Task {
@Column({ name: 'changes_note', type: 'text', nullable: true })
changesNote!: string | null;
// 최근 검토(승인/수정요청)를 수행한 사용자 — 원 지시자 또는 공동 지시자.
// 완료/수정요청 카드의 '검토자' 표기에 사용(사용자 삭제 시 SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'reviewed_by' })
reviewedBy!: User | null;
// --- 마감 임박·초과 알림(cron) 발송 여부 플래그 ---
// 중복 발송을 막기 위해 발송 시각을 기록하고, 마감일이 변경되면 다시 null 로 초기화한다.
// 마감 임박(하루 앞) 알림 발송 시각 — 미발송이면 null
@Column({ name: 'due_soon_notified_at', type: 'timestamptz', nullable: true })
dueSoonNotifiedAt!: Date | null;
// 마감 초과 알림 발송 시각 — 미발송이면 null
@Column({ name: 'overdue_notified_at', type: 'timestamptz', nullable: true })
overdueNotifiedAt!: Date | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
+204 -13
View File
@@ -2,10 +2,18 @@ import {
ForbiddenException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository, type SelectQueryBuilder } from 'typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import {
Brackets,
In,
IsNull,
Repository,
type SelectQueryBuilder,
} from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Project } from '../project/entities/project.entity';
@@ -161,6 +169,8 @@ export interface TaskDetailResponse {
checklist: ChecklistItemResponse[];
assignees: PublicUser[];
issuer: PublicUser | null;
// 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다
coIssuers: PublicUser[];
dueDate: string | null;
createdAt: string;
comments: CommentResponse[];
@@ -168,6 +178,8 @@ export interface TaskDetailResponse {
files: AttachmentResponse[];
approval: ApprovalInfo | null;
changesNote: string | null;
// 최근 검토(승인/수정요청)를 수행한 사용자 — 완료/수정요청 카드의 검토자 표기용
reviewer: PublicUser | null;
// 중간 점검일 + 작업자 보고 이력
checkpoints: CheckpointResponse[];
checkpointReports: CheckpointReportResponse[];
@@ -194,6 +206,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
// 업무 비즈니스 로직
@Injectable()
export class TaskService {
private readonly logger = new Logger(TaskService.name);
// 마감 임박 판정 창(시간) — 미완료 업무의 마감이 이 시간 이내로 다가오면 임박 알림 1회 발송
private static readonly DUE_SOON_WINDOW_HOURS = 24;
constructor(
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@@ -230,6 +247,99 @@ export class TaskService {
};
}
// 마감 알림 payload — 행위자 없는 시스템 알림. 마감 라벨 표시를 위해 dueDate 동봉
private dueNotifyPayload(task: Task): Record<string, unknown> {
return {
projectId: task.project.id,
projectName: task.project.name,
taskSeq: task.seq,
taskTitle: task.title,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
};
}
// 마감 임박·초과 알림 — 매일 오전 9시. 미완료(done 아님) 업무 중
// (1) 마감이 DUE_SOON_WINDOW_HOURS 이내로 다가온 건 → 담당자에게 '마감 임박' 1회
// (2) 마감이 이미 지난 건 → 담당자에게 '마감 초과' 1회
// 각각 발송 플래그(dueSoonNotifiedAt/overdueNotifiedAt)로 중복 발송을 막는다.
// best-effort: 실패해도 서비스에 영향 없음(다음 주기 재시도).
// 서버 타임존과 무관하게 한국 시간 오전 9시에 발송되도록 timeZone 고정.
@Cron(CronExpression.EVERY_DAY_AT_9AM, { timeZone: 'Asia/Seoul' })
async notifyDueTasks(): Promise<void> {
const now = new Date();
try {
await this.notifyDueSoon(now);
} catch (err) {
this.logger.warn(
`마감 임박 알림 실패: ${err instanceof Error ? err.message : String(err)}`,
);
}
try {
await this.notifyOverdue(now);
} catch (err) {
this.logger.warn(
`마감 초과 알림 실패: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// 마감 임박(하루 앞) 미발송 업무 → 담당자에게 알림. 다중 인스턴스 중복 발송을 막기 위해
// 플래그를 원자적으로 선점(UPDATE ... WHERE ... IS NULL RETURNING)한 인스턴스만 발송한다.
private async notifyDueSoon(now: Date): Promise<void> {
const soonEnd = new Date(
now.getTime() + TaskService.DUE_SOON_WINDOW_HOURS * 60 * 60 * 1000,
);
const claimed = await this.taskRepo
.createQueryBuilder()
.update(Task)
.set({ dueSoonNotifiedAt: now })
.where('status != :done', { done: 'done' })
.andWhere('due_date BETWEEN :now AND :soonEnd', { now, soonEnd })
.andWhere('due_soon_notified_at IS NULL')
.returning('id')
.execute();
await this.notifyClaimedDue(claimed, 'task.due_soon', '마감 임박');
}
// 마감 초과 미발송 업무 → 담당자에게 알림(위와 동일하게 선점 후 발송)
private async notifyOverdue(now: Date): Promise<void> {
const claimed = await this.taskRepo
.createQueryBuilder()
.update(Task)
.set({ overdueNotifiedAt: now })
.where('status != :done', { done: 'done' })
.andWhere('due_date < :now', { now })
.andWhere('overdue_notified_at IS NULL')
.returning('id')
.execute();
await this.notifyClaimedDue(claimed, 'task.overdue', '마감 초과');
}
// 선점(UPDATE RETURNING id)한 업무들을 로드해 담당자에게 마감 알림을 발송
private async notifyClaimedDue(
claimed: { raw: unknown },
type: 'task.due_soon' | 'task.overdue',
label: string,
): Promise<void> {
const ids = Array.isArray(claimed.raw)
? (claimed.raw as { id: string }[]).map((r) => r.id)
: [];
if (ids.length === 0) return;
const tasks = await this.taskRepo.find({
where: { id: In(ids) },
relations: ['assignees', 'project'],
});
for (const task of tasks) {
await this.notification.notify({
recipientIds: (task.assignees ?? []).map((a) => a.id),
actorId: null,
type,
payload: this.dueNotifyPayload(task),
});
}
this.logger.log(`${label} 알림 ${tasks.length}건 발송`);
}
// 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트
async list(
projectId: string,
@@ -369,6 +479,13 @@ export class TaskService {
dto.assigneeIds,
);
// 공동 지시자(선택) — 프로젝트 멤버만 가능, 원 지시자 본인은 제외
const coIssuers = await this.resolveCoIssuers(
project.id,
dto.coIssuerIds,
issuer.user.id,
);
// 프로젝트 내 다음 순번 채번
const last = await this.taskRepo.findOne({
where: { project: { id: project.id } },
@@ -384,6 +501,7 @@ export class TaskService {
status: 'todo',
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
issuer: issuer.user,
coIssuers,
assignees,
checklist: (dto.checklist ?? []).map((c, idx) =>
this.checklistRepo.create({
@@ -443,7 +561,15 @@ export class TaskService {
if (dto.title !== undefined) task.title = dto.title;
if (dto.content !== undefined) task.content = dto.content;
if (dto.dueDate !== undefined) {
task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
const newDue = dto.dueDate ? new Date(dto.dueDate) : null;
const changed =
(task.dueDate?.getTime() ?? null) !== (newDue?.getTime() ?? null);
task.dueDate = newDue;
// 마감일이 실제로 바뀌면 임박·초과 알림을 새 마감 기준으로 다시 보낼 수 있게 플래그 초기화
if (changed) {
task.dueSoonNotifiedAt = null;
task.overdueNotifiedAt = null;
}
}
if (dto.assigneeIds !== undefined) {
task.assignees = await this.resolveMemberAssignees(
@@ -451,6 +577,13 @@ export class TaskService {
dto.assigneeIds,
);
}
if (dto.coIssuerIds !== undefined) {
task.coIssuers = await this.resolveCoIssuers(
project.id,
dto.coIssuerIds,
task.issuer?.id ?? null,
);
}
await this.taskRepo.save(task);
@@ -1072,7 +1205,7 @@ export class TaskService {
);
}
// 내가 지시(생성)한 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
// 내가 지시(생성)한 업무 — 원 지시자 또는 공동 지시자. 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
async listIssued(
userId: string,
page?: number,
@@ -1086,7 +1219,16 @@ export class TaskService {
.leftJoinAndSelect('task.assignees', 'assignee')
.leftJoinAndSelect('task.issuer', 'issuer')
.leftJoinAndSelect('task.checklist', 'checklist')
.where('task.issuer = :userId', { userId });
// 원 지시자이거나 공동 지시자(task_co_issuers)면 '내가 지시한 업무'에 포함.
// 공동 지시자는 EXISTS 서브쿼리로 판정해 조인으로 인한 행 중복(페이지네이션 왜곡)을 피한다.
.where(
new Brackets((w) => {
w.where('task.issuer = :userId').orWhere(
'EXISTS (SELECT 1 FROM task_co_issuers tci WHERE tci.task_id = task.id AND tci.user_id = :userId)',
);
}),
)
.setParameter('userId', userId);
this.applyMyTaskFilters(qb, filters);
const [tasks, total] = await qb
.skip(pg.skip)
@@ -1141,17 +1283,26 @@ export class TaskService {
const isAdmin = membership.roleType === 'admin';
const isIssuer = task.issuer?.id === actingUserId;
// 공동 지시자도 원 지시자와 동일한 검토 권한을 가진다
const isCoIssuer = (task.coIssuers ?? []).some(
(u) => u.id === actingUserId,
);
const isAssignee = task.assignees.some((a) => a.id === actingUserId);
// 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
// 승인/수정요청(review 출발)은 지시자·공동 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
const permitted =
from === 'review'
? isAdmin || isIssuer
: isAdmin || isIssuer || isAssignee;
? isAdmin || isIssuer || isCoIssuer
: isAdmin || isIssuer || isCoIssuer || isAssignee;
if (!permitted) {
throw new ForbiddenException();
}
task.status = next;
// 검토 전이(승인→done / 수정요청→changes) 시 실제 검토자 기록 — 완료/수정요청 카드의 검토자 표기용.
// 원 지시자·공동 지시자·admin 누구든 검토한 사람이 남는다.
if (next === 'done' || next === 'changes') {
task.reviewedBy = membership.user;
}
await this.taskRepo.save(task);
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속)
@@ -1172,11 +1323,16 @@ export class TaskService {
taskSeq: task.seq,
});
// 알림 — 전이 종류별 수신자(승인요청→지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
// 알림 — 전이 종류별 수신자(승인요청→지시자·공동지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
const actorName = membership.user.name;
// 검토자(원 지시자 + 공동 지시자) — 승인요청·시작 알림 수신 대상. 행위자는 notify 에서 자동 제외
const reviewerIds = [
...(task.issuer ? [task.issuer.id] : []),
...(task.coIssuers ?? []).map((u) => u.id),
];
if (next === 'review') {
await this.notification.notify({
recipientIds: task.issuer ? [task.issuer.id] : [],
recipientIds: reviewerIds,
actorId: actingUserId,
type: 'task.approval_requested',
payload: this.taskNotifyPayload(project, task, actorName),
@@ -1196,10 +1352,10 @@ export class TaskService {
payload: this.taskNotifyPayload(project, task, actorName),
});
} else if (next === 'prog') {
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게.
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자·공동 지시자에게.
// resumed=true 면 '재개', 아니면 '시작'으로 문구 분기(수정 요청 후 재개 구분).
await this.notification.notify({
recipientIds: task.issuer ? [task.issuer.id] : [],
recipientIds: reviewerIds,
actorId: actingUserId,
type: 'task.started',
payload: {
@@ -1225,7 +1381,14 @@ export class TaskService {
private async getTaskOrThrow(projectId: string, seq: number): Promise<Task> {
const task = await this.taskRepo.findOne({
where: { project: { id: projectId }, seq },
relations: ['assignees', 'issuer', 'checklist', 'approvalRequestedBy'],
relations: [
'assignees',
'issuer',
'coIssuers',
'checklist',
'approvalRequestedBy',
'reviewedBy',
],
});
if (!task) {
throw new NotFoundException();
@@ -1300,6 +1463,31 @@ export class TaskService {
return memberships.map((m) => m.user);
}
// 공동 지시자 ID 목록 → User[] 로 변환. 프로젝트 멤버만 허용하며 원 지시자(excludeUserId)는 제외.
// 목록이 없거나 비면 빈 배열.
private async resolveCoIssuers(
projectId: string,
coIssuerIds: string[] | undefined,
excludeUserId: string | null,
): Promise<ProjectMember['user'][]> {
const uniqueIds = [...new Set(coIssuerIds ?? [])].filter(
(id) => id !== excludeUserId,
);
if (uniqueIds.length === 0) return [];
const memberships = await this.memberRepo.find({
where: { project: { id: projectId }, user: { id: In(uniqueIds) } },
relations: ['user'],
});
if (memberships.length !== uniqueIds.length) {
throw new BusinessException(
'BIZ_001',
'공동 지시자는 프로젝트 멤버만 지정할 수 있습니다.',
HttpStatus.BAD_REQUEST,
);
}
return memberships.map((m) => m.user);
}
// 업무별 댓글 수(답글 포함) 집계 — 목록 commentCount 용
private async commentCountsByTask(
taskIds: string[],
@@ -1558,9 +1746,10 @@ export class TaskService {
reported: reportedCheckpointIds.has(c.id),
}));
// 보고 '내용'은 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
// 보고 '내용'은 지시자 + 공동 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
const canViewReports =
task.issuer?.id === viewerId ||
(task.coIssuers ?? []).some((u) => u.id === viewerId) ||
(task.assignees ?? []).some((a) => a.id === viewerId);
const checkpointReports: CheckpointReportResponse[] = canViewReports
? reportRows.map((r) => ({
@@ -1598,6 +1787,7 @@ export class TaskService {
checklist,
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
coIssuers: (task.coIssuers ?? []).map((u) => UserService.toPublic(u)),
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
createdAt: task.createdAt.toISOString(),
comments,
@@ -1605,6 +1795,7 @@ export class TaskService {
files,
approval,
changesNote: task.status === 'changes' ? task.changesNote : null,
reviewer: task.reviewedBy ? UserService.toPublic(task.reviewedBy) : null,
checkpoints,
checkpointReports,
};