diff --git a/backend/src/migrations/1783400000000-AddTaskCoIssuers.ts b/backend/src/migrations/1783400000000-AddTaskCoIssuers.ts new file mode 100644 index 0000000..08329b7 --- /dev/null +++ b/backend/src/migrations/1783400000000-AddTaskCoIssuers.ts @@ -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 { + // 공동 지시자 조인 테이블(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 { + 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"`); + } +} diff --git a/backend/src/migrations/1783500000000-AddTaskReviewedBy.ts b/backend/src/migrations/1783500000000-AddTaskReviewedBy.ts new file mode 100644 index 0000000..566ef56 --- /dev/null +++ b/backend/src/migrations/1783500000000-AddTaskReviewedBy.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE "tasks" DROP CONSTRAINT "FK_tasks_reviewed_by"`, + ); + await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "reviewed_by"`); + } +} diff --git a/backend/src/migrations/1783600000000-AddTaskDueNotifyFlags.ts b/backend/src/migrations/1783600000000-AddTaskDueNotifyFlags.ts new file mode 100644 index 0000000..e86aa94 --- /dev/null +++ b/backend/src/migrations/1783600000000-AddTaskDueNotifyFlags.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE "tasks" DROP COLUMN "overdue_notified_at"`, + ); + await queryRunner.query( + `ALTER TABLE "tasks" DROP COLUMN "due_soon_notified_at"`, + ); + } +} diff --git a/backend/src/modules/notification/entities/notification.entity.ts b/backend/src/modules/notification/entities/notification.entity.ts index 240836e..ea2d4cd 100644 --- a/backend/src/modules/notification/entities/notification.entity.ts +++ b/backend/src/modules/notification/entities/notification.entity.ts @@ -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' // 내 관련 업무에 댓글/답글이 달림 diff --git a/backend/src/modules/notification/web-push.service.spec.ts b/backend/src/modules/notification/web-push.service.spec.ts index 38c75a1..d2a5d7c 100644 --- a/backend/src/modules/notification/web-push.service.spec.ts +++ b/backend/src/modules/notification/web-push.service.spec.ts @@ -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); }); }); diff --git a/backend/src/modules/notification/web-push.service.ts b/backend/src/modules/notification/web-push.service.ts index 7e69240..2b615a7 100644 --- a/backend/src/modules/notification/web-push.service.ts +++ b/backend/src/modules/notification/web-push.service.ts @@ -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; diff --git a/backend/src/modules/task/dto/create-task.dto.ts b/backend/src/modules/task/dto/create-task.dto.ts index 3bb2d02..b6697c6 100644 --- a/backend/src/modules/task/dto/create-task.dto.ts +++ b/backend/src/modules/task/dto/create-task.dto.ts @@ -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, diff --git a/backend/src/modules/task/dto/update-task.dto.ts b/backend/src/modules/task/dto/update-task.dto.ts index 4d61d81..30ed078 100644 --- a/backend/src/modules/task/dto/update-task.dto.ts +++ b/backend/src/modules/task/dto/update-task.dto.ts @@ -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, diff --git a/backend/src/modules/task/entities/task.entity.ts b/backend/src/modules/task/entities/task.entity.ts index 9777e05..fbcf4ff 100644 --- a/backend/src/modules/task/entities/task.entity.ts +++ b/backend/src/modules/task/entities/task.entity.ts @@ -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; diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index d3f28b1..dad80df 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -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 = { // 업무 비즈니스 로직 @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, @@ -230,6 +247,99 @@ export class TaskService { }; } + // 마감 알림 payload — 행위자 없는 시스템 알림. 마감 라벨 표시를 위해 dueDate 동봉 + private dueNotifyPayload(task: Task): Record { + 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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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, }; diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 957c79b..559ff1b 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -171,6 +171,8 @@ export interface TaskDetail { activities: Activity[] assignees: User[] issuer: User + /** 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다 */ + coIssuers: User[] dueLabel: string dday: string /** 원본 마감 ISO(중간 점검 보고 가능 구간 계산용) — 없으면 null */ @@ -184,6 +186,8 @@ export interface TaskDetail { } /** 수정 요청 메시지(수정 요청 상태일 때) */ changesNote?: string + /** 최근 검토(승인/수정요청)를 수행한 사용자 — 없으면(레거시) 원 지시자로 대체 표기 */ + reviewer: User | null issuedLabel: string /** 중간 점검일 목록 */ checkpoints: TaskCheckpointView[] diff --git a/frontend/src/pages/relay/DrivePage.vue b/frontend/src/pages/relay/DrivePage.vue index 714b481..c2e4cdd 100644 --- a/frontend/src/pages/relay/DrivePage.vue +++ b/frontend/src/pages/relay/DrivePage.vue @@ -364,10 +364,7 @@ function openActivity(): void { -
+
불러오는 중… @@ -686,12 +683,8 @@ function openActivity(): void { flex: 1 1 auto; /* 파일 영역이 페이지의 남는 공간 전체를 차지 → 드래그 드롭 영역도 그만큼 */ min-height: 16rem; /* 짧은 뷰포트에서도 최소 드롭 영역 보장 */ } -/* 비어있음/검색없음일 때 안내문을 영역 중앙에 배치(넓은 빈 영역이 자연스럽게 보이도록) */ -.dr-listwrap.is-empty { - display: flex; - align-items: center; - justify-content: center; -} +/* 비어있음/검색없음일 때도 안내문 카드는 상단에 폭을 꽉 채워 배치(프로젝트·회의 화면과 통일). + 남는 아래 공간은 그대로 드래그 드롭 영역으로 유지된다. */ .dr-row { display: flex; align-items: center; diff --git a/frontend/src/pages/relay/TaskCreatePage.vue b/frontend/src/pages/relay/TaskCreatePage.vue index b3bc399..20107a4 100644 --- a/frontend/src/pages/relay/TaskCreatePage.vue +++ b/frontend/src/pages/relay/TaskCreatePage.vue @@ -96,6 +96,7 @@ async function loadTaskForEdit() { contentText.value = t.content.join('\n') dueDate.value = t.dueDate ? toDateInput(t.dueDate) : '' assignees.value = t.assignees.map(toUserView) + coIssuers.value = (t.coIssuers ?? []).map(toUserView) // id 를 보존해 저장 시 기존 항목을 식별(완료 상태 유지) checklist.value = t.checklist.map((c) => ({ id: c.id, @@ -169,6 +170,34 @@ function onAssigneeBlur() { setTimeout(() => (showAssigneeDropdown.value = false), 120) } +// --- 공동 지시자 선택 (프로젝트 멤버 중, 원 지시자 본인 제외) --- +// 원 지시자와 함께 완료 검토(승인/수정요청) 권한을 가진다. +const coIssuers = ref([]) +const coIssuerQuery = ref('') +const showCoIssuerDropdown = ref(false) +function removeCoIssuer(id: string) { + coIssuers.value = coIssuers.value.filter((c) => c.id !== id) +} +// 아직 선택되지 않은 멤버 후보(이름 부분일치) — 원 지시자 본인은 제외 +const coIssuerCandidates = computed(() => { + const selected = new Set(coIssuers.value.map((c) => c.id)) + const kw = coIssuerQuery.value.trim() + return memberStore.members + .filter((m) => m.user.id !== issuer.value.id && !selected.has(m.user.id)) + .filter((m) => !kw || m.user.name.includes(kw) || m.email.includes(kw)) +}) +function addCoIssuer(userId: string) { + const m = memberStore.members.find((x) => x.user.id === userId) + if (m && !coIssuers.value.some((c) => c.id === userId)) { + coIssuers.value.push(m.user) + } + coIssuerQuery.value = '' + showCoIssuerDropdown.value = false +} +function onCoIssuerBlur() { + setTimeout(() => (showCoIssuerDropdown.value = false), 120) +} + // --- 마감기한 --- const dueDate = ref('') // yyyy-MM-dd (input[type=date]) @@ -446,6 +475,7 @@ async function submitTask() { .map((p) => p.trim()) .filter((p) => p.length > 0) const assigneeIds = assignees.value.map((a) => a.id) + const coIssuerIds = coIssuers.value.map((c) => c.id) const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null const checkpointIsos = checkpoints.value.map((d) => new Date(d).toISOString()) @@ -456,6 +486,7 @@ async function submitTask() { title: title.value.trim(), content, assigneeIds, + coIssuerIds, dueDate: dueIso, checklist: checklist.value.map((c) => c.id @@ -470,6 +501,7 @@ async function submitTask() { title: title.value.trim(), content: content.length ? content : undefined, assigneeIds, + coIssuerIds: coIssuerIds.length ? coIssuerIds : undefined, dueDate: dueIso ?? undefined, checklist: checklist.value.length ? checklist.value.map((c) => ({ text: c.text, category: c.category })) @@ -1232,6 +1264,86 @@ function goBack() {
{{ issuer.name }}
+ + +
+
+ 공동 지시자 · 선택 +
+
+
+ + + + +
+ +
+ +
+ {{ memberStore.members.length === 0 ? '프로젝트 멤버가 없습니다.' : '추가할 멤버가 없습니다.' }} +
+
+
+ +
+ + + {{ c.name }} + × + +
+
+ 지정하면 원 지시자와 함께 완료된 할 일을 검토(승인·수정요청)할 수 있습니다. +
+
+
{{ isEdit ? '변경 사항을 저장하면 담당자에게 반영됩니다.' @@ -2444,4 +2556,20 @@ function goBack() { line-height: 1.6; margin-top: 0.75rem; } + +/* 공동 지시자 블록 — 지시자 카드 안에서 상단 구분선으로 분리 */ +.co-issuer-block { + margin-top: 0.875rem; + padding-top: 0.875rem; + border-top: 1px solid var(--border); +} +.co-label { + margin-bottom: 0.5rem; +} +.co-issuer-hint { + font-size: 0.719rem; + color: var(--text-3); + line-height: 1.6; + margin-top: 0.5rem; +} diff --git a/frontend/src/pages/relay/TaskDetailPage.vue b/frontend/src/pages/relay/TaskDetailPage.vue index d8f414f..67db345 100644 --- a/frontend/src/pages/relay/TaskDetailPage.vue +++ b/frontend/src/pages/relay/TaskDetailPage.vue @@ -309,6 +309,20 @@ const me = computed(() => { const isIssuer = computed( () => !!task.value && !!me.value.id && task.value.issuer.id === me.value.id, ) +// 공동 지시자 — 원 지시자와 함께 완료 검토(승인/수정요청) 권한을 가진다(편집 권한은 없음) +const isCoIssuer = computed( + () => + !!task.value && + !!me.value.id && + task.value.coIssuers.some((c) => c.id === me.value.id), +) +// 검토자 = 원 지시자 또는 공동 지시자. 검토 UI/보고 열람 권한은 이 값으로 판단 +const isReviewer = computed(() => isIssuer.value || isCoIssuer.value) +// 실제 검토(승인/수정요청)를 수행한 사람 이름 — 완료/수정요청 카드 표기용. +// 공동 지시자가 검토했으면 그 이름을, 없으면(레거시 데이터) 원 지시자로 대체. +const reviewerName = computed( + () => task.value?.reviewer?.name ?? task.value?.issuer.name ?? '', +) const isAssignee = computed( () => !!task.value && task.value.assignees.some((a) => a.id === me.value.id), ) @@ -346,8 +360,8 @@ const CP_STATE_LABEL: Record = { reported: '보고 완료', missed: '누락', } -// 보고 '내용' 열람 권한 — 지시자 또는 해당 업무 담당자(백엔드도 동일하게 제한) -const canViewReports = computed(() => isIssuer.value || isAssignee.value) +// 보고 '내용' 열람 권한 — 지시자·공동 지시자 또는 해당 업무 담당자(백엔드도 동일하게 제한) +const canViewReports = computed(() => isReviewer.value || isAssignee.value) // 체크포인트 상태 — 보고됨 / 당일 / 지남(누락) / 예정. 보고됨은 reported 플래그(전원 공유) 기준 function checkpointState(cp: TaskCheckpointView): CheckpointState { @@ -996,9 +1010,9 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
+ + +
+
+ 공동 지시자 +
+
+ +
{{ c.name }}
{{ c.role }}
+
+
@@ -3265,6 +3300,21 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { white-space: nowrap; } +/* 공동 지시자 — 지시자 아래 구분선으로 분리, 여러 명 세로 나열 */ +.co-issuers { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.co-issuers-label { + font-size: 0.719rem; + font-weight: 600; + color: var(--text-3); +} + /* 승인 요청 카드 */ .ap-card { padding: 1rem 1.125rem 1.125rem; diff --git a/frontend/src/stores/notification.store.ts b/frontend/src/stores/notification.store.ts index ea267e1..2bacbba 100644 --- a/frontend/src/stores/notification.store.ts +++ b/frontend/src/stores/notification.store.ts @@ -93,6 +93,24 @@ function toView(n: ApiNotification): NotificationView { html = `${actor}님이 ${taskTitle}의 ${body}` break } + case 'task.due_soon': { + const due = pstr(p, 'dueDate') + const label = due ? escapeHtml(taskDueInfo(due, false).dateLabel) : '' + tone = 'amber' + html = label + ? `${taskTitle} 업무의 마감이 다가옵니다 (마감 ${label})` + : `${taskTitle} 업무의 마감이 다가옵니다` + break + } + case 'task.overdue': { + const due = pstr(p, 'dueDate') + const label = due ? escapeHtml(taskDueInfo(due, false).dateLabel) : '' + tone = 'red' + html = label + ? `${taskTitle} 업무가 마감기한을 넘겼습니다 (마감 ${label})` + : `${taskTitle} 업무가 마감기한을 넘겼습니다` + break + } case 'task.checkpoint_reported': tone = 'blue' html = `${actor}님이 ${taskTitle}의 중간 점검 상태를 보고했습니다` diff --git a/frontend/src/stores/task.store.ts b/frontend/src/stores/task.store.ts index e1dd542..dcf527a 100644 --- a/frontend/src/stores/task.store.ts +++ b/frontend/src/stores/task.store.ts @@ -192,6 +192,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView { activities: api.activities.map(toTaskActivityView), assignees: api.assignees.map(toUserView), issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER, + coIssuers: (api.coIssuers ?? []).map(toUserView), dueLabel: due.dateLabel, dday: due.dday, dueAt: api.dueDate, @@ -206,6 +207,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView { } : undefined, changesNote: api.changesNote ?? undefined, + reviewer: api.reviewer ? toUserView(api.reviewer) : null, issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`, checkpoints: api.checkpoints.map((c) => ({ id: c.id, diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts index e89ab20..8b8f693 100644 --- a/frontend/src/types/notification.ts +++ b/frontend/src/types/notification.ts @@ -11,6 +11,8 @@ export type NotificationType = | 'task.changes_requested' | 'task.started' | 'task.due_changed' + | 'task.due_soon' + | 'task.overdue' | 'task.checkpoint_reported' | 'task.removed' | 'task.commented' diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index a40b977..e43c0d6 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -110,6 +110,8 @@ export interface ApiTaskDetail { checklist: ApiChecklistItem[] assignees: ApiMember[] issuer: ApiMember | null + // 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다 + coIssuers: ApiMember[] dueDate: string | null createdAt: string comments: ApiComment[] @@ -117,6 +119,8 @@ export interface ApiTaskDetail { files: ApiAttachment[] approval: ApiApproval | null changesNote: string | null + // 최근 검토(승인/수정요청)를 수행한 사용자 — 완료/수정요청 카드의 검토자 표기용 + reviewer: ApiMember | null checkpoints: ApiCheckpoint[] checkpointReports: ApiCheckpointReport[] } @@ -153,6 +157,8 @@ export interface CreateTaskPayload { title: string content?: string[] assigneeIds: string[] + // 공동 지시자 사용자 ID(선택) + coIssuerIds?: string[] dueDate?: string checklist?: { text: string; category?: ChecklistCategory }[] // 중간 점검일(ISO8601 목록) @@ -164,6 +170,8 @@ export interface UpdateTaskPayload { title?: string content?: string[] assigneeIds?: string[] + // 공동 지시자 사용자 ID(제공 시 전체 교체) + coIssuerIds?: string[] dueDate?: string | null // 체크리스트 전체 동기화(id 있으면 기존 유지, 없으면 신규, 빠지면 삭제) checklist?: { id?: string; text: string; category?: ChecklistCategory }[]