Compare commits

...

7 Commits

Author SHA1 Message Date
ttipo 01132d2ac3 Merge branch 'feat/task-coissuer-review-due-notify'
업무 공동 지시자·검토자 표기·마감 임박/초과 알림 및 드라이브 빈 상태 UI 통일 반영.
2026-07-10 00:41:18 +09:00
ttipo 5ff9bf0df8 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>
2026-07-10 00:40:04 +09:00
ttipo fb0669990d fix: 드라이브 빈 상태 안내를 상단 전체폭으로 통일
프로젝트·회의 화면과 달리 드라이브의 빈/검색없음 상태 안내문이 페이지
정중앙에 떠 있던 문제를 수정한다. 목록 컨테이너(.dr-listwrap)의 중앙
정렬을 제거해 안내 카드가 다른 화면과 동일하게 상단에 폭을 꽉 채워
배치되며, 남는 아래 영역은 그대로 드래그-드롭 업로드 영역으로 유지된다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 00:39:32 +09:00
ttipo de92fc01b4 docs: env 예시 파일의 웹 푸시(VAPID) 섹션 주석 구조 통일
VAPID 섹션만 설명 주석을 배너 박스 안에 넣거나(dev) 배너 자체가
없어(prod) 다른 섹션과 어긋나던 것을, 다른 섹션과 동일하게
'배너(제목만) → 아래 설명 주석' 구조로 맞춘다. 값 변화 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:42:17 +09:00
ttipo a65b5aac05 fix: 멤버 초대 자동완성 드롭다운이 모달 밖에서 잘리는 문제 수정
드롭다운을 body 로 Teleport + fixed 위치로 띄워(DropdownSelect 와 동일 패턴)
모달의 overflow 에 잘리지 않게 한다. 입력창 기준 좌표를 계산하고 뷰포트
하단을 넘지 않도록 최대높이를 제한하며 스크롤/리사이즈 시 위치를 갱신한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:31:34 +09:00
ttipo 9a79e169c8 feat: 알림 신뢰성·내용 보강 (SSE 재동기화·웹푸시·전직원 일정·파일첨부)
[신뢰성]
- SSE 재연결 시 fetchInitial 로 재동기화: 끊긴 사이 놓친 알림 복구 +
  access 토큰 자동 refresh 로 만료 토큰 재연결 무한루프 해소
- 서비스워커 pushsubscriptionchange 처리로 구독 회전 시 자동 재등록
- 읽은 알림 90일 retention 정리 배치(@Cron)
- 웹푸시 다기기 병렬 발송(allSettled) + TTL/urgency 지정
- 알림 문구 SSOT 상호참조 주석 + web-push 커버리지 테스트 추가

[내용]
- 전 직원(allHands) 일정도 전 사용자에게 알림(생성/수정/삭제)
- task.file_attached 신규 타입: 파일 첨부 시 지시자·담당자 알림
- task.started 재개(수정요청 후) 문구 '재개/시작' 구분
- task.due_changed 푸시에 날짜/해제 상세 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:31:25 +09:00
ttipo 2d4f2e2c19 fix: 드라이브 검색바 너비·안내문을 프로젝트/회의 화면과 통일
검색바가 전체 너비로 늘어나던 오버라이드를 제거해 전역 고정폭(17.5rem)을
사용하고 버튼 그룹을 우측 정렬한다. 상단 안내문도 다른 목록 화면과 같은
한 줄 문구로 맞춘다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:31:07 +09:00
27 changed files with 969 additions and 129 deletions
+4 -3
View File
@@ -82,9 +82,10 @@ OBJECT_STORAGE_REGION=kr-standard
OBJECT_STORAGE_BUCKET=comrelay-dev
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID) — 비밀 아닌 값만 루트에서 주입
# 키페어 생성: node -e "console.log(require('web-push').generateVAPIDKeys())"
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트에도 주입됨.
# 웹 푸시 알림 (Web Push / VAPID)
# ==========================================
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
# node -e "console.log(require('web-push').generateVAPIDKeys())"
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:admin@example.com
+6 -2
View File
@@ -84,7 +84,11 @@ OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
OBJECT_STORAGE_REGION=kr-standard
OBJECT_STORAGE_BUCKET=comrelay-prod
# 웹 푸시 알림 (Web Push / VAPID) — 비밀 아닌 값만 루트에서 주입
# 키페어 생성: node -e "console.log(require('web-push').generateVAPIDKeys())"
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID)
# ==========================================
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
# node -e "console.log(require('web-push').generateVAPIDKeys())"
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:admin@example.com
+1 -1
View File
@@ -54,6 +54,6 @@ OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시만 비활성.
# ==========================================
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
VAPID_PRIVATE_KEY=
+4 -1
View File
@@ -51,5 +51,8 @@ CLAUDE_MODEL=claude-sonnet-4-6
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키). 미설정 시 푸시만 비활성.
# ==========================================
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
# ==========================================
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
VAPID_PRIVATE_KEY=
@@ -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,9 +18,12 @@ export type NotificationType =
| 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨
| 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
| 'task.due_soon' // 내가 담당한 업무의 마감이 하루 앞으로 다가옴
| 'task.overdue' // 내가 담당한 업무의 마감이 지남(미완료)
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
| 'task.removed' // 내 관련 업무가 삭제됨
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
| 'task.file_attached' // 내 관련 업무에 파일이 첨부됨
| 'member.invited' // 프로젝트에 멤버로 초대됨
| 'member.role_changed' // 프로젝트 내 내 역할이 변경됨
| 'member.removed' // 프로젝트에서 제외됨
@@ -5,7 +5,8 @@ import {
type OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { LessThan, Repository } from 'typeorm';
import { Subject, merge, interval, type Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import Redis from 'ioredis';
@@ -65,6 +66,8 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
private subscriber: Redis | null = null;
// 실시간 알림 채널명
private static readonly CHANNEL = 'relay:notifications';
// 읽은 알림 보관 기간(일) — 이보다 오래된 '읽음' 알림은 주기적으로 정리해 무한 증가를 막는다.
private static readonly READ_RETENTION_DAYS = 90;
constructor(
@InjectRepository(Notification)
@@ -224,6 +227,29 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
return { unreadCount: 0 };
}
// 오래된 '읽음' 알림 정리 — 매일 새벽 4시. 무한 증가 방지(미읽음은 보존).
// best-effort: 실패해도 서비스에 영향 없음(다음 주기에 재시도).
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async cleanupOldRead(): Promise<void> {
try {
const cutoff = new Date(
Date.now() -
NotificationService.READ_RETENTION_DAYS * 24 * 60 * 60 * 1000,
);
const result = await this.notificationRepo.delete({
read: true,
createdAt: LessThan(cutoff),
});
if (result.affected) {
this.logger.log(`오래된 읽음 알림 ${result.affected}건 정리`);
}
} catch (err) {
this.logger.warn(
`읽음 알림 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// 사용자별 실시간 스트림(SSE) — 본인 알림만 필터 + 주기적 하트비트(프록시 idle 차단 방지)
streamFor(userId: string): Observable<MessageEvent> {
const events = this.stream$.asObservable().pipe(
@@ -0,0 +1,68 @@
import { WebPushService } from './web-push.service';
import type { NotificationType } from './entities/notification.entity';
// 모든 NotificationType 에 대해 푸시 문구가 조립되는지 검증한다.
// (NotificationType 을 추가하고 notificationContent 의 case 를 빠뜨리면 기본 문구가 남아 실패)
//
// SSOT 주의: 인앱 표시 문구는 프론트 notification.store.ts 의 toView() 가 별도로 담당한다.
// 새 타입을 추가하면 이 목록과 양쪽(백엔드 notificationContent, 프론트 toView)을 함께 갱신할 것.
const ALL_TYPES: NotificationType[] = [
'task.assigned',
'task.unassigned',
'task.approval_requested',
'task.approved',
'task.changes_requested',
'task.started',
'task.due_changed',
'task.due_soon',
'task.overdue',
'task.checkpoint_reported',
'task.removed',
'task.commented',
'task.file_attached',
'member.invited',
'member.role_changed',
'member.removed',
'schedule.invited',
'schedule.updated',
'schedule.removed',
];
// 서버 기본 문구(케이스 누락 시 남는 값) — 이 문구가 남으면 매핑이 빠진 것.
const DEFAULT_BODY = '새 알림이 도착했습니다';
describe('WebPushService.notificationContent', () => {
// notificationContent 는 repo/config 를 쓰지 않으므로 의존성 없이 인스턴스화한다.
const service = new WebPushService(
null as unknown as never,
null as unknown as never,
);
// 표시 문구 조립에 쓰이는 대표 payload(모든 필드 채워 어떤 case 든 자연스럽게)
const payload = {
actorName: '홍길동',
projectName: '테스트 프로젝트',
projectId: 'p-1',
taskTitle: '샘플 업무',
taskSeq: 12,
eventTitle: '주간 회의',
roleType: 'admin',
isReply: true,
fileName: '보고서.pdf',
dueDate: '2026-07-10T00:00:00.000Z',
resumed: true,
};
it.each(ALL_TYPES)('%s 타입의 본문이 기본 문구가 아니어야 한다', (type) => {
const content = service.notificationContent(type, payload);
expect(content.title).toBeTruthy();
expect(content.body).not.toBe(DEFAULT_BODY);
expect(content.body.length).toBeGreaterThan(0);
});
it('타입 목록이 19종(현재 정의)과 일치해야 한다', () => {
// NotificationType 추가 시 이 테스트가 알려주도록 개수를 고정한다.
expect(ALL_TYPES).toHaveLength(19);
expect(new Set(ALL_TYPES).size).toBe(ALL_TYPES.length);
});
});
@@ -22,6 +22,19 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
const v = p[k];
return typeof v === 'number' ? v : null;
}
function pbool(p: Record<string, unknown>, k: string): boolean {
return p[k] === true;
}
// 마감일(ISO) → 한국식 날짜 라벨(한국 시간대 고정). 파싱 실패 시 빈 문자열.
function fmtDueKo(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'long',
timeZone: 'Asia/Seoul',
}).format(d);
}
// 웹 푸시 발송 + 구독 관리.
// VAPID 키 미설정 시 비활성(발송 skip) — storage 미설정 패턴과 동일한 graceful degrade.
@@ -29,6 +42,8 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
export class WebPushService implements OnModuleInit {
private readonly logger = new Logger(WebPushService.name);
private enabled = false;
// 푸시 서비스가 오프라인 기기에 보관·재시도하는 최대 시간(초). 하루 지나면 폐기.
private static readonly PUSH_TTL_SECONDS = 60 * 60 * 24;
constructor(
@InjectRepository(PushSubscription)
@@ -82,6 +97,10 @@ export class WebPushService implements OnModuleInit {
// type+payload → 푸시 표시 내용(제목=행위자/맥락, 본문=행위, url=이동 경로).
// 프론트 toView 의 푸시 전용 plain 버전(서비스워커가 번들 모듈을 import 못하므로 서버에서 완성).
//
// SSOT 주의: 인앱 표시 문구는 프론트 `notification.store.ts` 의 `toView()` 가 별도로 조립한다
// (인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **두 곳 모두** case 를 넣어야 하며,
// 여기 커버리지는 `web-push.service.spec.ts` 가 기본 문구 잔존 여부로 검증한다.
notificationContent(
type: NotificationType,
payload: Record<string, unknown>,
@@ -111,11 +130,32 @@ export class WebPushService implements OnModuleInit {
body = `'${taskTitle}' 업무 담당에서 회원님을 제외했습니다`;
break;
case 'task.started':
body = `'${taskTitle}' 업무를 시작했습니다`;
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
body = `'${taskTitle}' 업무를 ${pbool(payload, 'resumed') ? '재개' : '시작'}했습니다`;
break;
case 'task.due_changed':
body = `'${taskTitle}' 업무의 마감기한이 변경되었습니다`;
case 'task.due_changed': {
const label = fmtDueKo(pstr(payload, 'dueDate'));
body = label
? `'${taskTitle}' 업무의 마감기한을 ${label}(으)로 변경했습니다`
: `'${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;
@@ -136,6 +176,13 @@ export class WebPushService implements OnModuleInit {
case 'task.commented':
body = `'${taskTitle}'에 ${payload.isReply === true ? '답글' : '댓글'}을 남겼습니다`;
break;
case 'task.file_attached': {
const fileName = pstr(payload, 'fileName');
body = fileName
? `'${taskTitle}'에 파일 '${fileName}'을(를) 첨부했습니다`
: `'${taskTitle}'에 파일을 첨부했습니다`;
break;
}
case 'member.invited':
body = `'${projectName}' 프로젝트에 초대했습니다`;
url = projectUrl;
@@ -164,28 +211,34 @@ export class WebPushService implements OnModuleInit {
return { title, body, url };
}
// 한 사용자의 모든 구독에 발송 — 만료(404/410) 구독은 정리. best-effort.
// 한 사용자의 모든 구독(기기)에 발송 — 만료(404/410) 구독은 정리. best-effort.
// 다기기 병렬 발송(allSettled) — 하나가 실패/지연돼도 다른 기기 발송을 막지 않는다.
async sendToUser(userId: string, content: PushContent): Promise<void> {
if (!this.enabled) return;
const subs = await this.subRepo.find({ where: { user: { id: userId } } });
if (subs.length === 0) return;
const json = JSON.stringify(content);
for (const s of subs) {
try {
await webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
json,
await Promise.allSettled(subs.map((s) => this.sendOne(s, json)));
}
// 단일 구독 발송 — 만료 구독 정리 포함. 예외는 흡수(호출부 흐름 보호).
private async sendOne(s: PushSubscription, json: string): Promise<void> {
try {
await webpush.sendNotification(
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
json,
// 오프라인 기기용 보관 시간 + 일반 우선순위(배터리 절약 정책 존중)
{ TTL: WebPushService.PUSH_TTL_SECONDS, urgency: 'normal' },
);
} catch (err: unknown) {
const statusCode = (err as { statusCode?: number })?.statusCode;
if (statusCode === 404 || statusCode === 410) {
// 만료/해지된 구독 — 정리
await this.subRepo.delete({ id: s.id });
} else {
this.logger.warn(
`푸시 발송 실패(무시): ${statusCode ?? ''} ${err instanceof Error ? err.message : String(err)}`,
);
} catch (err: unknown) {
const statusCode = (err as { statusCode?: number })?.statusCode;
if (statusCode === 404 || statusCode === 410) {
// 만료/해지된 구독 — 정리
await this.subRepo.delete({ id: s.id });
} else {
this.logger.warn(
`푸시 발송 실패(무시): ${statusCode ?? ''} ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
}
@@ -145,16 +145,14 @@ export class ScheduleService {
});
const saved = await this.eventRepo.save(event);
// 참석자로 지정된 사용자에게 알림(전 직원 일정은 개별 알림 생략)
await this.notifyEvent(
'schedule.invited',
this.idsOf(dto.attendeeIds),
actor,
{
eventTitle: event.title,
start: event.startDate,
},
);
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
const recipients = event.allHands
? await this.userService.findAllIds()
: this.idsOf(dto.attendeeIds);
await this.notifyEvent('schedule.invited', recipients, actor, {
eventTitle: event.title,
start: event.startDate,
});
return this.findEventOrThrow(saved.id);
}
@@ -169,8 +167,9 @@ export class ScheduleService {
});
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
// 수정 전 참석자(알림 대상 비교용)
// 수정 전 참석자·전직원 여부(알림 대상 비교용) — 아래 필드 변경 전에 캡처
const oldIds = new Set((event.attendees ?? []).map((a) => a.id));
const wasAllHands = event.allHands;
if (dto.categoryId !== undefined) {
const category = await this.categoryRepo.findOne({
@@ -195,10 +194,16 @@ export class ScheduleService {
}
await this.eventRepo.save(event);
// 알림: 새로 추가된 참석자 → 초대, 기존 참석자 → 수정됨
const newIds = (event.attendees ?? []).map((a) => a.id);
const invited = newIds.filter((i) => !oldIds.has(i));
const stillThere = newIds.filter((i) => oldIds.has(i));
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로 신규→초대, 기존→수정됨.
// (전 직원 일정이면 전 사용자가 실효 참석자 — 수정 전/후 어느 쪽이든 반영)
const allIds =
wasAllHands || event.allHands ? await this.userService.findAllIds() : [];
const oldEffective = new Set(wasAllHands ? allIds : [...oldIds]);
const newEffective = event.allHands
? allIds
: (event.attendees ?? []).map((a) => a.id);
const invited = newEffective.filter((i) => !oldEffective.has(i));
const stillThere = newEffective.filter((i) => oldEffective.has(i));
const payload = { eventTitle: event.title, start: event.startDate };
await this.notifyEvent('schedule.invited', invited, actor, payload);
await this.notifyEvent('schedule.updated', stillThere, actor, payload);
@@ -213,7 +218,10 @@ export class ScheduleService {
relations: ['attendees'],
});
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
const recipients = (event.attendees ?? []).map((a) => a.id);
// 전 직원 일정이면 전 사용자에게 삭제 알림
const recipients = event.allHands
? await this.userService.findAllIds()
: (event.attendees ?? []).map((a) => a.id);
await this.eventRepo.delete({ id });
@@ -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;
+223 -14
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);
@@ -851,6 +984,20 @@ export class TaskService {
taskSeq: task.seq,
});
// 알림 — 업무 관련자(지시자 + 담당자, 업로더 본인 제외)에게 '파일 첨부'
await this.notification.notify({
recipientIds: [
...(task.issuer ? [task.issuer.id] : []),
...task.assignees.map((a) => a.id),
],
actorId: actingUserId,
type: 'task.file_attached',
payload: {
...this.taskNotifyPayload(project, task, membership.user.name),
fileName: name,
},
});
return this.toAttachmentResponse(
{ ...saved, uploader: membership.user },
project,
@@ -1058,7 +1205,7 @@ export class TaskService {
);
}
// 내가 지시(생성)한 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
// 내가 지시(생성)한 업무 — 원 지시자 또는 공동 지시자. 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
async listIssued(
userId: string,
page?: number,
@@ -1072,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)
@@ -1127,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) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속)
@@ -1158,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),
@@ -1182,12 +1352,16 @@ 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: this.taskNotifyPayload(project, task, actorName),
payload: {
...this.taskNotifyPayload(project, task, actorName),
resumed: from === 'changes',
},
});
}
}
@@ -1207,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();
@@ -1282,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[],
@@ -1540,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) => ({
@@ -1580,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,
@@ -1587,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,
};
+6
View File
@@ -76,6 +76,12 @@ export class UserService implements OnApplicationBootstrap {
});
}
// 전체 사용자 id 목록 — 전사 알림(예: 전 직원 일정)의 수신자 산출용
async findAllIds(): Promise<string[]> {
const rows = await this.userRepository.find({ select: { id: true } });
return rows.map((u) => u.id);
}
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
findByEmailWithPassword(email: string): Promise<User | null> {
return this.userRepository
+43
View File
@@ -41,6 +41,49 @@ self.addEventListener('push', (event) => {
)
})
// 브라우저가 푸시 구독을 자체 회전(만료·교체)시키면 발생 — 새 구독으로 재등록해
// 사용자가 앱을 다시 열지 않아도 푸시가 끊기지 않게 한다. best-effort(실패 시 앱 재진입의
// enablePush(true) 가 최신화). 백엔드 경로는 기본 프록시('/api') 기준.
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
(async () => {
try {
// 브라우저가 새 구독을 넘겨줬으면 그대로, 아니면 이전 구독의 VAPID 키로 재구독
let sub = event.newSubscription || null
if (!sub) {
const opts =
event.oldSubscription && event.oldSubscription.options
? event.oldSubscription.options
: null
const appServerKey = opts && opts.applicationServerKey
// 키가 없으면 재구독 불가 — 앱 재진입 때 복구되도록 조용히 종료
if (!appServerKey) return
sub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: appServerKey,
})
}
if (!sub) return
const json = sub.toJSON()
await fetch('/api/notifications/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
endpoint: sub.endpoint,
keys: {
p256dh: (json.keys && json.keys.p256dh) || '',
auth: (json.keys && json.keys.auth) || '',
},
}),
})
} catch (e) {
/* 재구독/재등록 실패 무시 — 앱 재진입 시 최신화 */
}
})(),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = (event.notification.data && event.notification.data.url) || '/'
+4
View File
@@ -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[]
+7 -14
View File
@@ -231,7 +231,7 @@ function openActivity(): void {
<span class="count-pill">{{ itemCount }}</span>
</div>
<p class="lede">
전사 공유 드라이브입니다. 모든 구성원 함께 보고 관리하며, 추가·이름변경·삭제 기록은 기록에서 확인할 있습니다.
파일을 올리고 폴더로 정리해 모든 구성원 함께 관리하세요.
</p>
<!-- 툴바 -->
@@ -364,10 +364,7 @@ function openActivity(): void {
</div>
<!-- 목록 -->
<div
class="dr-listwrap"
:class="{ 'is-empty': isEmpty || noResult }"
>
<div class="dr-listwrap">
<EmptyState v-if="loading && isEmpty">
불러오는
</EmptyState>
@@ -597,9 +594,9 @@ function openActivity(): void {
margin: 0.25rem 0 1.125rem;
}
/* 툴바 — 검색은 늘리고 버튼은 우측 */
.toolbar .search {
flex: 1 1 auto;
/* 툴바 — 검색은 전역 고정폭(17.5rem) 사용(프로젝트·회의 화면과 통일), 버튼 그룹은 우측 정렬 */
.toolbar > .btn:first-of-type {
margin-left: auto;
}
.dr-file-input {
display: none;
@@ -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;
+80 -44
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 프로젝트 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달 (백엔드 연동)
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
@@ -140,6 +140,29 @@ const usersLoading = ref(false)
const showUserDropdown = ref(false)
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
// 자동완성 드롭다운은 body 로 Teleport + fixed 위치로 띄운다 — 모달의 overflow 에 잘리지 않도록.
const emailInputRef = ref<HTMLInputElement | null>(null)
const ddStyle = ref<Record<string, string>>({})
// 입력창 기준으로 드롭다운 좌표/최대높이 계산(뷰포트 하단을 넘지 않게 제한)
function positionDropdown() {
const el = emailInputRef.value
if (!el) return
const r = el.getBoundingClientRect()
const gap = 6
const margin = 12 // 뷰포트 하단 여백
const below = window.innerHeight - r.bottom - gap - margin
ddStyle.value = {
top: `${r.bottom + gap}px`,
left: `${r.left}px`,
width: `${r.width}px`,
maxHeight: `${Math.min(256, Math.max(120, below))}px`,
}
}
// 스크롤/리사이즈 시 열려 있으면 위치 재계산
function onDropdownReposition() {
if (showUserDropdown.value) positionDropdown()
}
// 현재 프로젝트에 이미 속한 사용자 id (자동완성에서 제외)
const memberIds = computed(() => new Set(members.value.map((m) => m.user.id)))
const userOptions = computed(() =>
@@ -163,10 +186,12 @@ function scheduleUserSearch() {
}
function onEmailFocus() {
showUserDropdown.value = true
void nextTick(positionDropdown)
void runUserSearch() // 포커스 시 전체(또는 현재 입력 기준) 목록 표시
}
function onEmailInput() {
showUserDropdown.value = true
void nextTick(positionDropdown)
scheduleUserSearch()
}
function onEmailBlur() {
@@ -216,8 +241,17 @@ function onKeydown(e: KeyboardEvent) {
openRoleFor.value = null
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
onMounted(() => {
window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', onDropdownReposition)
// capture=true — 모달 본문 등 내부 스크롤도 포착해 위치를 따라가게 한다
window.addEventListener('scroll', onDropdownReposition, true)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', onDropdownReposition)
window.removeEventListener('scroll', onDropdownReposition, true)
})
</script>
<template>
@@ -472,6 +506,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<div class="form-field uc-field">
<label class="form-label">초대할 사람 (이메일)</label>
<input
ref="emailInputRef"
v-model="inviteEmail"
class="form-input"
type="email"
@@ -481,40 +516,43 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
@input="onEmailInput"
@blur="onEmailBlur"
>
<!-- 사용자 자동완성 드롭다운 -->
<div
v-if="showUserDropdown"
class="user-dd"
>
<!-- 사용자 자동완성 드롭다운 body Teleport(모달 overflow 밖에서 잘리지 않도록) -->
<Teleport to="body">
<div
v-if="usersLoading"
class="user-dd-msg"
v-if="showUserDropdown"
class="user-dd"
:style="ddStyle"
>
불러오는
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
v-if="usersLoading"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
불러오는
</div>
</template>
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
</div>
</template>
</div>
</Teleport>
</div>
<div class="form-field">
@@ -833,21 +871,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
.invite-body .uc-field {
position: relative;
}
.invite-body .user-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
/* 자동완성 드롭다운 — body 로 Teleport 되므로 .invite-body 후손 선택자를 쓰지 않는다.
위치/최대높이(top/left/width/maxHeight)는 컴포넌트가 인라인 style 로 지정(fixed). */
.user-dd {
position: fixed;
z-index: 1000; /* 모달 backdrop(100) 위 */
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
padding: 0.375rem;
max-height: 16rem;
overflow-y: auto;
}
.invite-body .user-dd-item {
.user-dd-item {
display: flex;
align-items: center;
gap: 0.625rem;
@@ -860,22 +896,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
font-family: inherit;
text-align: left;
}
.invite-body .user-dd-item:hover {
.user-dd-item:hover {
background: #f5f6f8;
}
.invite-body .user-av {
.user-dd .user-av {
width: 1.625rem;
height: 1.625rem;
font-size: 0.6875rem;
flex-shrink: 0;
}
.invite-body .user-nm {
.user-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-body .user-em {
.user-em {
font-size: 0.75rem;
color: var(--text-3);
margin-left: auto;
@@ -885,7 +921,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
max-width: 14rem;
padding-left: 0.5rem;
}
.invite-body .user-dd-msg {
.user-dd-msg {
padding: 0.75rem 0.5rem;
font-size: 0.781rem;
color: var(--text-3);
+128
View File
@@ -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<User[]>([])
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() {
<div class="nm">{{ issuer.name }}</div>
</span>
</div>
<!-- 공동 지시자(선택) 지시자와 함께 완료 검토 권한을 가진다 -->
<div class="co-issuer-block">
<div class="side-label co-label">
공동 지시자 <span class="muted">· 선택</span>
</div>
<div class="combo">
<div class="combo-field">
<input
v-model="coIssuerQuery"
class="combo-input"
placeholder="멤버 검색…"
autocomplete="off"
@focus="showCoIssuerDropdown = true"
@blur="onCoIssuerBlur"
>
<span class="combo-caret">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</span>
</div>
<!-- 멤버 후보 드롭다운 -->
<div
v-if="showCoIssuerDropdown"
class="assignee-dd"
>
<button
v-for="m in coIssuerCandidates"
:key="m.user.id"
type="button"
class="assignee-dd-item"
@mousedown.prevent="addCoIssuer(m.user.id)"
>
<span class="avatar"><UserAvatar
:url="m.user.avatarUrl"
:name="m.user.name"
/></span>
<span class="ad-nm">{{ m.user.name }}</span>
<span class="ad-em">{{ m.email }}</span>
</button>
<div
v-if="coIssuerCandidates.length === 0"
class="assignee-dd-msg"
>
{{ memberStore.members.length === 0 ? '프로젝트 멤버가 없습니다.' : '추가할 멤버가 없습니다.' }}
</div>
</div>
</div>
<!-- 선택된 공동 지시자 -->
<div
v-if="coIssuers.length"
class="assignee-chips"
>
<span
v-for="c in coIssuers"
:key="c.id"
class="chip"
>
<span class="avatar"><UserAvatar
:url="c.avatarUrl"
:name="c.name"
/></span>
{{ c.name }}
<span
class="x"
@click="removeCoIssuer(c.id)"
>×</span>
</span>
</div>
<div class="co-issuer-hint">
지정하면 지시자와 함께 완료된 일을 검토(승인·수정요청) 있습니다.
</div>
</div>
<div class="side-foot">
{{ 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;
}
</style>
+56 -6
View File
@@ -309,6 +309,20 @@ const me = computed<User>(() => {
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<CheckpointState, string> = {
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 } {
<!-- 사이드바 -->
<aside class="side">
<!-- 지시자: 승인 대기(review) 승인/수정요청 -->
<!-- 지시자·공동 지시자: 승인 대기(review) 승인/수정요청 -->
<div
v-if="task.status === 'review' && isIssuer"
v-if="task.status === 'review' && isReviewer"
class="card ap-card review"
>
<div class="ap-card-head">
@@ -1139,7 +1153,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
<span class="ap-card-title">수정 요청됨</span>
</div>
<div class="ap-card-meta">
지시자 <b>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 다시 승인을 요청하세요.
지시자 <b>{{ reviewerName }}</b>님이 수정을 요청했습니다. 보완 다시 승인을 요청하세요.
</div>
<div
v-if="task.changesNote"
@@ -1204,7 +1218,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
<span class="ap-card-title">승인 완료</span>
</div>
<div class="ap-card-meta">
지시자 <b>{{ task.issuer.name }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
지시자 <b>{{ reviewerName }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
</div>
</template>
</div>
@@ -1362,6 +1376,27 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
/></span>
<span><div class="nm">{{ task.issuer.name }}</div><div class="rl">{{ task.issuer.role }}</div></span>
</div>
<!-- 공동 지시자 있을 때만 노출. 지시자와 함께 검토 권한을 가진다 -->
<div
v-if="task.coIssuers.length"
class="co-issuers"
>
<div class="co-issuers-label">
공동 지시자
</div>
<div
v-for="c in task.coIssuers"
:key="c.id"
class="issuer"
>
<span class="avatar"><UserAvatar
:url="c.avatarUrl"
:name="c.name"
/></span>
<span><div class="nm">{{ c.name }}</div><div class="rl">{{ c.role }}</div></span>
</div>
</div>
</div>
</div>
</aside>
@@ -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;
+59 -8
View File
@@ -49,6 +49,10 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
}
// API 알림 → 화면 뷰(type+payload → 톤/문구/이동경로). 사용자 입력은 escapeHtml 후 신뢰 태그만 조립
//
// SSOT 주의: 푸시(사이트 닫힘) 표시 문구는 백엔드 web-push.service.ts 의 notificationContent() 가
// 별도로 조립한다(인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **양쪽 모두** case 를 넣을 것.
// (백엔드 커버리지는 web-push.service.spec.ts 가 검증)
function toView(n: ApiNotification): NotificationView {
const p = n.payload
const actor = escapeHtml(pstr(p, 'actorName') || '누군가')
@@ -77,7 +81,8 @@ function toView(n: ApiNotification): NotificationView {
break
case 'task.started':
tone = 'blue'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 시작했습니다`
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 ${p.resumed === true ? '재개' : '시작'}했습니다`
break
case 'task.due_changed': {
const due = pstr(p, 'dueDate')
@@ -88,6 +93,24 @@ function toView(n: ApiNotification): NotificationView {
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 ${body}`
break
}
case 'task.due_soon': {
const due = pstr(p, 'dueDate')
const label = due ? escapeHtml(taskDueInfo(due, false).dateLabel) : ''
tone = 'amber'
html = label
? `<b>${taskTitle}</b> 업무의 마감이 다가옵니다 (마감 <b>${label}</b>)`
: `<b>${taskTitle}</b> 업무의 마감이 다가옵니다`
break
}
case 'task.overdue': {
const due = pstr(p, 'dueDate')
const label = due ? escapeHtml(taskDueInfo(due, false).dateLabel) : ''
tone = 'red'
html = label
? `<b>${taskTitle}</b> 업무가 마감기한을 넘겼습니다 (마감 <b>${label}</b>)`
: `<b>${taskTitle}</b> 업무가 마감기한을 넘겼습니다`
break
}
case 'task.checkpoint_reported':
tone = 'blue'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 중간 점검 상태를 보고했습니다`
@@ -113,6 +136,14 @@ function toView(n: ApiNotification): NotificationView {
tone = ''
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>에 ${p.isReply === true ? '답글' : '댓글'}을 남겼습니다`
break
case 'task.file_attached': {
const fileName = escapeHtml(pstr(p, 'fileName'))
tone = ''
html = fileName
? `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일 <b>${fileName}</b>을(를) 첨부했습니다`
: `<b>${actor}</b>님이 <b>${taskTitle}</b>에 파일을 첨부했습니다`
break
}
case 'member.invited':
tone = 'accent'
to = projectId ? `/projects/${projectId}` : null
@@ -204,20 +235,40 @@ export const useNotificationStore = defineStore('notification', () => {
es.close()
es = null
}
if (wantConnected && !reconnectTimer) {
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (wantConnected) openStream()
}, RECONNECT_DELAY)
}
scheduleReconnect()
}
}
// 재연결 예약(중복 방지) — 지연 후 '재동기화 → 스트림 오픈' 순으로 복구한다.
// 재동기화(fetchInitial)는 axios 인터셉터를 타므로,
// 1) 끊긴 사이 놓친 알림을 수신함에 복구하고(미읽음 수도 서버값으로 보정)
// 2) access 토큰이 만료됐다면 자동 refresh 되어 쿠키가 갱신된다
// → 만료된 토큰으로 SSE 를 여는 무한 실패 루프를 방지.
function scheduleReconnect(): void {
if (!wantConnected || reconnectTimer) return
reconnectTimer = setTimeout(async () => {
reconnectTimer = null
if (!wantConnected) return
try {
await fetchInitial()
} catch {
// 재동기화 실패(세션 만료/네트워크 지속) — 스트림은 열지 않고 다음 주기 재시도
scheduleReconnect()
return
}
if (wantConnected) openStream()
}, RECONNECT_DELAY)
}
// 연결 시작(멱등) — 최초 1회 목록 로드 + 스트림 오픈. 로그인 화면 진입마다 호출돼도 안전
async function connect(): Promise<void> {
if (wantConnected) return
wantConnected = true
await fetchInitial()
try {
await fetchInitial()
} catch {
// 초기 로드 실패해도 스트림은 열어 둔다 — 실패 시 onerror→재연결이 복구
}
openStream()
}
+2
View File
@@ -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,
+3
View File
@@ -11,9 +11,12 @@ export type NotificationType =
| 'task.changes_requested'
| 'task.started'
| 'task.due_changed'
| 'task.due_soon'
| 'task.overdue'
| 'task.checkpoint_reported'
| 'task.removed'
| 'task.commented'
| 'task.file_attached'
| 'member.invited'
| 'member.role_changed'
| 'member.removed'
+8
View File
@@ -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 }[]