Compare commits

...

24 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
ttipo 3adb596511 feat: 데스크톱 알림을 포커스 중에도 항상 표시하도록 변경
기존엔 받는 사람이 앱 화면을 보고 있으면(포커스) OS 토스트를 띄우지
않고 종 알림만 갱신했으나, 화면을 보고 있어도 토스트가 뜨도록 변경한다.
서비스워커의 중복 방지(열린 탭 있으면 표시 생략)는 그대로라 토스트는
여전히 1개다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:13:17 +09:00
ttipo bd4f3e70bf fix: 운영 프론트 빌드에 VITE_VAPID_PUBLIC_KEY 누락 수정
프론트 Dockerfile이 VAPID 공개키 build arg 를 받지 못해 운영 빌드
번들에 키가 비어 "푸시 키 미설정" 으로 동작하던 문제를 수정한다.
다른 VITE_* 변수와 동일하게 ARG/ENV 를 선언해 빌드에 주입한다.
(dev 는 런타임 env 주입이라 영향 없었음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:05:55 +09:00
ttipo 8d00d71072 feat: PC 데스크톱 알림(웹 푸시) 추가 — 인앱/SSE에 OS 토스트 연동
카카오톡처럼 PC에 OS 토스트가 뜨도록 Web Notifications(Level1) +
Web Push/서비스워커(Level2)를 기존 인앱·SSE 알림 위에 얹는다.
중복 방지: 페이지가 열려 있으면 SSE 로컬 알림(포커스면 인앱만),
닫혀 있으면 서비스워커 푸시가 담당한다.

- 백엔드: push_subscriptions 엔티티 + WebPushService(VAPID 설정·
  type→{title,body,url} 포매터·만료 구독 정리), notify()가 SSE와
  함께 푸시 발송, push/subscribe·unsubscribe 엔드포인트, 마이그레이션,
  web-push 의존성
- 프론트: public/sw.js(push/클릭), usePush 컴포저블(권한·구독, 결과
  안내), notification.store 로컬 토스트, AppShell 'PC 알림 켜기' 버튼
- env/compose: VAPID 키 주입(공개키→프론트, 비밀키→백엔드)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:49:33 +09:00
ttipo 26cad39857 refactor: 드라이브 경로 표기를 전역 .crumb 시스템으로 통일
드라이브 브레드크럼이 커스텀 .dr-crumb/.dr-sep(› 구분자)로 따로
구현돼 프로젝트 화면(.crumb/.lnk/.sep, / 구분자)과 달랐던 것을
전역 시스템으로 맞춘다. 루트 링크도 goRoot 핸들러 대신 RouterLink 로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:49:05 +09:00
ttipo 5ee5ad6973 fix: 드라이브 드래그 드롭 영역을 파일 영역 전체로 확장
드롭 오버레이가 목록 래퍼 안에 갇혀 빈 폴더에서는 안내 카드 한 장
높이만큼만 드롭 영역으로 보이던 문제를 개선한다. 페이지를 세로
플렉스로 만들고 목록 영역이 남는 높이를 모두 채우게 해, 파일이 적거나
비어 있어도 파일 영역 전체에 끌어다 놓을 수 있게 한다.

- .page: 세로 플렉스 + min-height(뷰포트 - 상단바)
- .dr-listwrap: flex 1 로 남는 공간 차지(+ min-height 하한)
- 빈 폴더/검색 결과 없음 안내문은 영역 중앙 정렬

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:31:02 +09:00
ttipo f7b8a4c42d refactor: 활동 피드 공용 컴포넌트 추출 및 드라이브 기록 디자인 통일
프로젝트 활동 페이지에 인라인 중복 구현돼 있던 활동 피드(원형 아이콘+
아바타+본문+날짜그룹) 마크업·아이콘맵·스타일을 공용 ActivityFeed로
추출한다. 드라이브 '기록' 패널도 단색 점 목록에서 이 공용 컴포넌트로
교체해 다른 화면과 동일한 디자인 시스템을 쓰도록 통일한다.

- 신규 components/common/ActivityFeed.vue (모달 임베드용 bare 옵션 포함)
- ProjectActivityPage: 인라인 피드 제거 → ActivityFeed 사용
- drive.store: 감사 로그를 ProjectActivityDay[]로 변환(activityDays),
  tone setting·repo/pencil 아이콘, escapeHtml로 XSS 방지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:03:43 +09:00
ttipo e42ccf7139 feat: 드라이브를 전사 공유 드라이브로 전환하고 전역 감사 로그 추가
개인별 격리(owner 기준)를 제거해 로그인 사용자 전원이 하나의 공유
트리를 보고 관리하도록 전환한다. 수정/삭제는 전원 허용하며, 행위
추적을 위해 전역 감사 로그(drive_activities, GET /drive/activity)를
추가한다. 업로더 탈퇴 시 공유 파일이 사라지지 않도록 owner FK를
CASCADE에서 SET NULL로 변경한다.

- 백엔드: 접근 권한 해석 제거(단순 로더화), 업로드/이름변경/삭제
  6지점에 감사 적재, DriveAuditService 신설, 운영 마이그레이션
  1783200000000 추가
- 프론트: 목록 행에 업로더 표시, 툴바 '기록' 패널 추가
- 프로젝트 폴더 연동의 '본인 소유 폴더만' 제한 해제(공유 모델 일관성)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:40:45 +09:00
ttipo ec54ba0e8d feat: 드라이브 업로드·삭제 행위자 추적 추가
연동 프로젝트 폴더의 파일 업로드/삭제와 폴더 삭제를 활동 피드에 기록하고,
파일 삭제를 소프트 삭제(status=deleted + deletedBy/deletedAt 묘비)로 전환해
누가·언제 지웠는지 추적할 수 있게 한다. 실제 객체는 즉시 영구 삭제(복구 제외).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:54:17 +09:00
ttipo 2cbd19e791 refactor: 프로젝트 상세 AI 검토 문서를 버튼+모달 리스트로 변경
인라인 wrap 나열 제거, 'AI 검토 문서' 버튼 클릭 시 모달에서 파일종류 배지·AI상태·크기를 세로 리스트로 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:56:01 +09:00
ttipo 65796a0893 fix: 드라이브 파일 배지를 확장자 기준으로 정확히 표기
kind(4종) 대신 확장자로 라벨 산출 — 엑셀 XLS, 파워포인트 PPT, CSV/JSON/TXT, 영상·오디오 등 구분 + 색상 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:41:59 +09:00
ttipo 5267b759be fix: 드라이브 전환 마이그레이션 멱등·견고화 (운영 적용 실패 방지)
ADD COLUMN IF NOT EXISTS, project_attachments 는 제약 이름 비의존 DROP TABLE IF EXISTS ... CASCADE 로 변경. 과거 synchronize 로 만들어진 운영 DB나 부분 적용 상태에서도 실패하지 않도록 함

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:43:23 +09:00
ttipo 524c6f6782 feat: 드라이브 연동 전환 운영 마이그레이션 추가
drive_files 에 extracted_text/extract_status 추가, project_attachments 폐기(완전 대체). dev 는 synchronize, 운영은 이 마이그레이션으로 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:15:02 +09:00
ttipo 5a92813a31 fix: AI 체크리스트 추천 응답 견고 파싱으로 502 방지
Claude가 깨진/잘린 JSON을 반환해도 정규식으로 카테고리·항목 복구(폴백), 프롬프트에 완결 JSON 규칙 추가, max_tokens 상향

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:21:37 +09:00
ttipo cf1f4db6af feat: 프로젝트 드라이브 폴더 연동 + AI 검토 문서 전환
프로젝트 직접 업로드(project_attachments) 폐기, AI 검토 문서를 드라이브 폴더 연동으로 전환. 폴더 연동 CRUD·연동 폴더 문서에서 텍스트 지연 추출(NCP 다운로드, xlsx 포함)·AI 추천 주입. 폴더 선택 피커 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:21:23 +09:00
ttipo f023bd0ed7 fix: 화상회의 로비 입장/링크복사 버튼 순서 변경
입장 버튼을 맨 왼쪽으로, 그다음 링크복사 순으로 배치

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:21:07 +09:00
ttipo 638d1265f6 feat: 드라이브(파일 저장소) 프론트엔드 UI 구현
폴더 탐색·드래그앤드롭 업로드(presigned 3단계)·다운로드·이름변경/삭제, 라우트(/drive)·사이드바 네비 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:20:51 +09:00
ttipo 9ce3158450 chore: 화상회의 네이티브 lk 시각/부하 테스트 러너 추가
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:20:33 +09:00
84 changed files with 5915 additions and 978 deletions
+9
View File
@@ -80,3 +80,12 @@ LIVEKIT_URL=wss://your-project.livekit.cloud
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
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> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:admin@example.com
+9
View File
@@ -83,3 +83,12 @@ LIVEKIT_URL=http://livekit:7880
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())"
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:admin@example.com
+6
View File
@@ -51,3 +51,9 @@ 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) — 시크릿(개인키)
# ==========================================
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
VAPID_PRIVATE_KEY=
+6
View File
@@ -50,3 +50,9 @@ 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) — 시크릿(개인키)
# ==========================================
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
VAPID_PRIVATE_KEY=
+792 -11
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -41,11 +41,13 @@
"@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"@types/web-push": "^3.6.4",
"bcryptjs": "^3.0.3",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"cookie-parser": "^1.4.7",
"exceljs": "^4.4.0",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"ioredis": "^5.11.1",
@@ -61,6 +63,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.28",
"web-push": "^3.6.7",
"winston": "^3.17.0"
},
"devDependencies": {
@@ -123,6 +123,25 @@ export class NcpObjectStorageService extends StorageService {
return getSignedUrl(client, command, { expiresIn });
}
async getObjectBuffer(key: string): Promise<Buffer | null> {
const client = this.ensureConfigured();
try {
const res = await client.send(
new GetObjectCommand({ Bucket: this.bucketName, Key: key }),
);
const body = res.Body as
| { transformToByteArray?: () => Promise<Uint8Array> }
| undefined;
// AWS SDK v3 의 스트림 → 바이트 배열(transformToByteArray)로 수집
if (!body?.transformToByteArray) return null;
const bytes = await body.transformToByteArray();
return Buffer.from(bytes);
} catch (err: unknown) {
if (this.isNotFound(err)) return null;
throw err;
}
}
async headObject(key: string): Promise<ObjectHead | null> {
const client = this.ensureConfigured();
try {
@@ -21,6 +21,9 @@ export abstract class StorageService {
// 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용.
abstract headObject(key: string): Promise<ObjectHead | null>;
// 객체 바이트 다운로드 — 서버 측 텍스트 추출(AI 검토) 등에 사용. 미존재 시 null.
abstract getObjectBuffer(key: string): Promise<Buffer | null>;
// 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과.
abstract deleteObject(key: string): Promise<void>;
}
@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 프로젝트 AI 검토 문서를 "드라이브 폴더 연동"으로 전환.
// - drive_files 에 AI 추출 캐시 컬럼(extracted_text, extract_status) 추가
// → 연동 폴더 문서를 NCP 에서 받아 텍스트 추출·캐싱(AI 업무 추천 주입)
// - 프로젝트 직접 업로드(project_attachments) 폐기 → 테이블 제거(완전 대체)
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
//
// 멱등·견고하게 작성: 과거 synchronize 로 만들어진 운영 DB(제약 이름 상이)나
// 부분 적용 상태에서도 실패하지 않도록 IF [NOT] EXISTS / CASCADE 를 사용한다.
export class AddDriveFileExtractDropProjectAttachments1783000000000 implements MigrationInterface {
name = 'AddDriveFileExtractDropProjectAttachments1783000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1) drive_files AI 추출 캐시 컬럼 (이미 있으면 건너뜀)
await queryRunner.query(
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "extracted_text" text`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "extract_status" character varying NOT NULL DEFAULT 'pending'`,
);
// 2) project_attachments 폐기 (드라이브 연동으로 대체)
// 제약 이름에 의존하지 않도록 CASCADE 로 테이블·FK 를 한 번에 제거.
await queryRunner.query(
`DROP TABLE IF EXISTS "project_attachments" CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 1) project_attachments 복원 (AddProjectAttachments + ExtractedText 와 동일 스키마)
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "extracted_text" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
// 2) drive_files 추출 캐시 컬럼 제거
await queryRunner.query(
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "extract_status"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "extracted_text"`,
);
}
}
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 드라이브 파일 삭제 감사(소프트 삭제) 컬럼 추가.
// - deleted_at: 삭제 시각(null 이면 미삭제)
// - deleted_by_id: 삭제한 사용자(FK users, 사용자 삭제 시 SET NULL)
// 파일 삭제 시 실제 객체는 즉시 영구 삭제하되, 메타 행은 status='deleted' 묘비로 보존해
// "누가·언제 지웠는지" 추적한다. status 는 기존 varchar 라 값만 추가(스키마 변경 없음).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
export class AddDriveFileSoftDeleteAudit1783100000000 implements MigrationInterface {
name = 'AddDriveFileSoftDeleteAudit1783100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_by_id" uuid`,
);
// FK 는 제약 이름 존재 검사로 중복 추가를 방지(IF NOT EXISTS 미지원 대비)
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_files_deleted_by'
) THEN
ALTER TABLE "drive_files"
ADD CONSTRAINT "FK_drive_files_deleted_by"
FOREIGN KEY ("deleted_by_id") REFERENCES "users"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "drive_files" DROP CONSTRAINT IF EXISTS "FK_drive_files_deleted_by"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_by_id"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_at"`,
);
}
}
@@ -0,0 +1,105 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 전사 공유 드라이브 전환 + 전역 감사 로그.
// - drive_activities: 누가 무엇을(추가/이름변경/삭제) 했는지 전역 감사 로그(프로젝트 무관).
// - drive_files / drive_folders 의 owner_id FK: CASCADE → SET NULL 로 변경
// (공유 드라이브에서 업로더/생성자가 탈퇴해도 공유 파일·폴더는 보존).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// 멱등·견고: IF NOT EXISTS / owner_id 의 기존 FK 를 동적으로 모두 제거 후 재생성(제약 이름 상이 대비).
export class AddDriveSharedAndAudit1783200000000 implements MigrationInterface {
name = 'AddDriveSharedAndAudit1783200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ===== 감사 로그 테이블 =====
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "drive_activities" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"type" character varying NOT NULL,
"payload" jsonb NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"actor_id" uuid,
CONSTRAINT "PK_drive_activities" PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_drive_activities_actor" ON "drive_activities" ("actor_id")`,
);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_activities_actor'
) THEN
ALTER TABLE "drive_activities"
ADD CONSTRAINT "FK_drive_activities_actor"
FOREIGN KEY ("actor_id") REFERENCES "users"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
END IF;
END $$;
`);
// ===== owner_id FK 를 SET NULL 로 교체(drive_files, drive_folders) =====
for (const table of ['drive_files', 'drive_folders']) {
// 혹시 NOT NULL 이면 해제(공유 파일은 업로더 없이도 존재 가능)
await queryRunner.query(
`ALTER TABLE "${table}" ALTER COLUMN "owner_id" DROP NOT NULL`,
);
// owner_id 컬럼에 걸린 기존 외래키를 이름과 무관하게 모두 제거 → SET NULL 로 재생성
await queryRunner.query(`
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT con.conname
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
WHERE rel.relname = '${table}'
AND con.contype = 'f'
AND att.attname = 'owner_id'
LOOP
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
END LOOP;
ALTER TABLE "${table}"
ADD CONSTRAINT "FK_${table}_owner"
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
ON DELETE SET NULL ON UPDATE NO ACTION;
END $$;
`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
// owner_id FK 를 CASCADE 로 되돌린다(이름 무관 제거 후 재생성)
for (const table of ['drive_files', 'drive_folders']) {
await queryRunner.query(`
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT con.conname
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
WHERE rel.relname = '${table}'
AND con.contype = 'f'
AND att.attname = 'owner_id'
LOOP
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
END LOOP;
ALTER TABLE "${table}"
ADD CONSTRAINT "FK_${table}_owner"
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE NO ACTION;
END $$;
`);
}
await queryRunner.query(
`ALTER TABLE "drive_activities" DROP CONSTRAINT IF EXISTS "FK_drive_activities_actor"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "drive_activities"`);
}
}
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 웹 푸시 구독 테이블(push_subscriptions) 추가 — 브라우저 PushSubscription 을 사용자별로 보관.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
export class AddPushSubscriptions1783300000000 implements MigrationInterface {
name = 'AddPushSubscriptions1783300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"user_id" uuid,
CONSTRAINT "PK_push_subscriptions" PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_push_subscriptions_endpoint" ON "push_subscriptions" ("endpoint")`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_push_subscriptions_user" ON "push_subscriptions" ("user_id")`,
);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_push_subscriptions_user'
) THEN
ALTER TABLE "push_subscriptions"
ADD CONSTRAINT "FK_push_subscriptions_user"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE NO ACTION;
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "push_subscriptions" DROP CONSTRAINT IF EXISTS "FK_push_subscriptions_user"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "push_subscriptions"`);
}
}
@@ -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"`,
);
}
}
@@ -30,7 +30,11 @@ export type ActivityType =
| 'task.edited'
| 'task.checkpoint_reported'
| 'task.file_removed'
| 'task.removed';
| 'task.removed'
// 드라이브(프로젝트 연동 폴더 한정 — 누가 올렸/지웠는지 추적)
| 'drive.file_uploaded'
| 'drive.file_deleted'
| 'drive.folder_deleted';
// 활동 이벤트 — 행위 타입 + 행위자 + 대상(payload)만 저장하고
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
+58 -15
View File
@@ -84,6 +84,7 @@ const SUGGEST_SYSTEM_PROMPT = [
'',
'[규칙]',
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
'- 출력은 완결된 단일 JSON 객체여야 합니다. 모든 객체는 중괄호 {}로 감싸고, 배열 []·문자열 "" 을 정확히 닫으세요. 중간에 끊지 마세요.',
'- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 4개를 이 순서대로 사용하세요. (다른 이름·추가/누락 카테고리 금지)',
'- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.',
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
@@ -221,34 +222,46 @@ export class AiService {
const raw = await this.callClaude(
[{ role: 'user', content: userMsg }],
SUGGEST_SYSTEM_PROMPT,
4096,
8000,
);
return { groups: this.parseSuggestions(raw) };
}
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
let text = raw
const text = raw
.trim()
.replace(/^```(?:json)?\s*/i, '')
.replace(/```$/, '')
.replace(/```\s*$/, '')
.trim();
// 1) 표준 파싱 — 첫 { ~ 마지막 } 구간만 잘라 JSON.parse
let groups: unknown[] = [];
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) text = text.slice(start, end + 1);
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
this.logger.warn(
`추천 JSON 파싱 실패(응답 잘림 가능) — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
);
throw this.suggestFailed();
if (start >= 0 && end > start) {
try {
const parsed = JSON.parse(text.slice(start, end + 1)) as {
groups?: unknown;
};
if (Array.isArray(parsed.groups)) groups = parsed.groups;
} catch {
// 표준 파싱 실패 → 아래 견고 파싱으로 폴백
}
}
// 2) 견고 파싱(폴백) — 모델이 깨진/잘린 JSON(누락 중괄호 등)을 내도
// "category"/"items" 패턴을 정규식으로 직접 복구한다.
if (groups.length === 0) {
const recovered = this.recoverGroups(text);
if (recovered.length > 0) {
groups = recovered;
this.logger.warn(
`추천 JSON 표준 파싱 실패 → 견고 파싱으로 복구(${recovered.length}그룹). 원본 ${raw.length}`,
);
}
}
const rawGroups = (parsed as { groups?: unknown }).groups;
const groups: unknown[] = Array.isArray(rawGroups) ? rawGroups : [];
const result: ChecklistSuggestionGroup[] = [];
for (const g of groups.slice(0, 8)) {
const obj = g as { category?: unknown; items?: unknown };
@@ -271,6 +284,36 @@ export class AiService {
return result;
}
// 깨진/잘린 JSON 에서도 카테고리·항목을 정규식으로 복구(중괄호 누락·꼬리 잘림 방어).
// 완결된 "category":"...","items":[ ... ] 블록만 추출하므로, 잘린 마지막 그룹은 버리고 나머지는 살린다.
private recoverGroups(text: string): { category: string; items: string[] }[] {
const out: { category: string; items: string[] }[] = [];
const groupRe =
/"category"\s*:\s*("(?:[^"\\]|\\.)*")\s*,\s*"items"\s*:\s*\[([\s\S]*?)\]/g;
const itemRe = /"(?:[^"\\]|\\.)*"/g;
let gm: RegExpExecArray | null;
while ((gm = groupRe.exec(text)) !== null) {
let category: string;
try {
category = JSON.parse(gm[1]) as string;
} catch {
continue;
}
const items: string[] = [];
let im: RegExpExecArray | null;
while ((im = itemRe.exec(gm[2])) !== null) {
try {
const s = JSON.parse(im[0]) as unknown;
if (typeof s === 'string') items.push(s);
} catch {
// 깨진 토큰은 건너뛴다
}
}
out.push({ category, items });
}
return out;
}
private suggestFailed(): BusinessException {
return new BusinessException(
'BIZ_001',
@@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { UserService, type PublicUser } from '../user/user.service';
import {
DriveActivity,
type DriveActivityType,
} from './entities/drive-activity.entity';
// 감사 로그 응답(원시) — 표시 문구/아이콘은 프론트가 type+payload 로 조립
export interface DriveActivityResponse {
id: string;
type: DriveActivityType;
actor: PublicUser | null;
payload: Record<string, unknown>;
createdAt: string;
}
// 감사 로그 적재 파라미터
export interface RecordDriveActivityParams {
actorId: string | null;
type: DriveActivityType;
payload?: Record<string, unknown>;
}
// 드라이브 감사 로그 비즈니스 로직 — DriveService 가 record() 로 행위를 적재하고,
// 드라이브 화면이 list() 로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
@Injectable()
export class DriveAuditService {
private readonly logger = new Logger(DriveAuditService.name);
constructor(
@InjectRepository(DriveActivity)
private readonly activityRepo: Repository<DriveActivity>,
) {}
// 감사 로그 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
async record(params: RecordDriveActivityParams): Promise<void> {
try {
const activity = this.activityRepo.create({
actor: params.actorId ? { id: params.actorId } : null,
type: params.type,
payload: params.payload ?? {},
});
await this.activityRepo.save(activity);
} catch (err) {
this.logger.error(
`드라이브 감사 로그 적재 실패: ${params.type}`,
err instanceof Error ? err.stack : String(err),
);
}
}
// 전역 감사 로그 — 최신순, 오프셋 페이지네이션. 날짜 그룹핑은 프론트.
async list(
page?: number,
size?: number,
): Promise<PaginatedResult<DriveActivityResponse>> {
const pg = resolvePage(page, size);
const [rows, total] = await this.activityRepo.findAndCount({
relations: ['actor'],
order: { createdAt: 'DESC' },
skip: pg.skip,
take: pg.take,
});
return paginated(
rows.map((a) => this.toResponse(a)),
total,
pg,
);
}
// 엔티티 → 응답 매핑
private toResponse(a: DriveActivity): DriveActivityResponse {
return {
id: a.id,
type: a.type,
actor: a.actor ? UserService.toPublic(a.actor) : null,
payload: a.payload ?? {},
createdAt: a.createdAt.toISOString(),
};
}
}
+22 -3
View File
@@ -1,11 +1,13 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
@@ -13,6 +15,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import type { PaginatedResult } from '../../common/pagination/pagination';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
@@ -23,21 +26,27 @@ import {
type DriveListResponse,
type PresignUploadResponse,
} from './drive.service';
import {
DriveAuditService,
type DriveActivityResponse,
} from './drive-audit.service';
import { CreateFolderDto } from './dto/create-folder.dto';
import { PresignUploadDto } from './dto/presign-upload.dto';
import { RenameDto } from './dto/rename.dto';
// 드라이브 컨트롤러 — 프로젝트와 독립된 개인 드라이브. 모든 엔드포인트 인증 필요.
// 드라이브 컨트롤러 — 전사 공유 드라이브(로그인 사용자 전원 공유). 모든 엔드포인트 인증 필요.
@ApiTags('Drive')
@Controller('drive')
@UseGuards(JwtAuthGuard)
export class DriveController {
constructor(private readonly driveService: DriveService) {}
constructor(
private readonly driveService: DriveService,
private readonly auditService: DriveAuditService,
) {}
@Get()
@ApiOperation({ summary: '드라이브 목록(폴더 미지정 시 루트)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
list(
@CurrentUser() user: PublicUser,
@Query('folderId') folderId?: string,
@@ -45,6 +54,16 @@ export class DriveController {
return this.driveService.list(user.id, folderId);
}
@Get('activity')
@ApiOperation({ summary: '드라이브 감사 로그(누가 무엇을 했는지, 최신순)' })
@ApiResponse({ status: 200, description: '조회 성공' })
activity(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
): Promise<PaginatedResult<DriveActivityResponse>> {
return this.auditService.list(page, size);
}
/* ===================== 폴더 ===================== */
@Post('folders')
+8 -5
View File
@@ -1,28 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { ActivityModule } from '../activity/activity.module';
import { DriveFolder } from './entities/drive-folder.entity';
import { DriveFile } from './entities/drive-file.entity';
import { ProjectFolderLink } from './entities/project-folder-link.entity';
import { ProjectMember } from '../project/entities/project-member.entity';
import { DriveActivity } from './entities/drive-activity.entity';
import { DriveService } from './drive.service';
import { DriveAuditService } from './drive-audit.service';
import { DriveController } from './drive.controller';
// 드라이브 모듈 — 프로젝트와 독립된 파일 저장소.
// 드라이브 모듈 — 전사 공유 파일 저장소(로그인 사용자 전원 공유).
// 저장소(StorageService)는 전역 StorageModule 에서 주입된다.
// ProjectMember 는 폴더-프로젝트 연동 시 멤버십(권한) 판정을 위해 forFeature 로 등록한다.
// ProjectFolderLink 는 프로젝트 연동(AI 검토 등) 부가 기능을 위해 유지한다.
@Module({
imports: [
TypeOrmModule.forFeature([
DriveFolder,
DriveFile,
ProjectFolderLink,
ProjectMember,
DriveActivity,
]),
UserModule,
ActivityModule,
],
controllers: [DriveController],
providers: [DriveService],
providers: [DriveService, DriveAuditService],
exports: [DriveService],
})
export class DriveModule {}
+121 -107
View File
@@ -13,7 +13,9 @@ import { StorageService } from '../../common/storage/storage.service';
import type { PresignedUpload } from '../../common/storage/storage.types';
import { UserService, type PublicUser } from '../user/user.service';
import { User } from '../user/entities/user.entity';
import { ProjectMember } from '../project/entities/project-member.entity';
import { ActivityService } from '../activity/activity.service';
import type { ActivityType } from '../activity/entities/activity.entity';
import { DriveAuditService } from './drive-audit.service';
import { DriveFolder } from './entities/drive-folder.entity';
import { DriveFile } from './entities/drive-file.entity';
import { ProjectFolderLink } from './entities/project-folder-link.entity';
@@ -28,14 +30,6 @@ import {
import { CreateFolderDto } from './dto/create-folder.dto';
import { PresignUploadDto } from './dto/presign-upload.dto';
// 폴더/파일에 대한 접근 수준 — 높을수록 권한이 강하다.
export type DriveAccess = 'owner' | 'write' | 'read';
const ACCESS_RANK: Record<DriveAccess, number> = {
read: 1,
write: 2,
owner: 3,
};
// --- 응답 형태 ---
export interface DriveFolderResponse {
id: string;
@@ -84,26 +78,27 @@ export class DriveService {
private readonly fileRepo: Repository<DriveFile>,
@InjectRepository(ProjectFolderLink)
private readonly linkRepo: Repository<ProjectFolderLink>,
@InjectRepository(ProjectMember)
private readonly memberRepo: Repository<ProjectMember>,
private readonly storage: StorageService,
private readonly userService: UserService,
private readonly activity: ActivityService,
private readonly audit: DriveAuditService,
) {}
/* ===================== 조회 ===================== */
// 드라이브 목록 — folderId 없으면 루트, 있으면 해당 폴더(읽기 권한 필요).
async list(userId: string, folderId?: string): Promise<DriveListResponse> {
// 드라이브 목록 — folderId 없으면 공유 루트, 있으면 해당 폴더.
// 전사 공유 드라이브이므로 로그인 사용자 모두가 동일한 트리를 본다(소유자 필터 없음).
async list(_userId: string, folderId?: string): Promise<DriveListResponse> {
if (!folderId) {
// 루트 — 내 소유의 최상위 폴더/파일
// 루트 — 공유 최상위 폴더/파일 전체
const [folders, files] = await Promise.all([
this.folderRepo.find({
where: { owner: { id: userId }, parent: IsNull() },
where: { parent: IsNull() },
relations: { owner: true },
order: { name: 'ASC' },
}),
this.fileRepo.find({
where: { owner: { id: userId }, folder: IsNull(), status: 'active' },
where: { folder: IsNull(), status: 'active' },
relations: { owner: true },
order: { name: 'ASC' },
}),
@@ -116,7 +111,7 @@ export class DriveService {
};
}
const folder = await this.getFolderForAccess(userId, folderId, 'read');
const folder = await this.loadFolder(folderId);
const [folders, files, breadcrumb] = await Promise.all([
this.folderRepo.find({
where: { parent: { id: folder.id } },
@@ -140,14 +135,14 @@ export class DriveService {
/* ===================== 폴더 CRUD ===================== */
// 폴더 생성 — 루트면 내 드라이브, 하위면 상위 폴더에 쓰기 권한 필요.
// 폴더 생성 — 루트 또는 상위 폴더 하위에 생성(로그인 사용자 전원 가능).
async createFolder(
userId: string,
dto: CreateFolderDto,
): Promise<DriveFolderResponse> {
let parent: DriveFolder | null = null;
if (dto.parentId) {
parent = await this.getFolderForAccess(userId, dto.parentId, 'write');
parent = await this.loadFolder(dto.parentId);
}
const owner = await this.getUserOrThrow(userId);
// 1차 저장으로 id 확보 → materialized path 구성 후 재저장
@@ -161,32 +156,60 @@ export class DriveService {
);
created.path = `${parent ? parent.path : ''}${created.id}/`;
await this.folderRepo.save(created);
await this.audit.record({
actorId: userId,
type: 'folder_created',
payload: { name: created.name },
});
return this.toFolderResponse({ ...created, owner }, parent?.id ?? null);
}
// 폴더 이름 변경 — 쓰기 권한 필요.
// 폴더 이름 변경 — 로그인 사용자 전원 가능.
async renameFolder(
userId: string,
folderId: string,
name: string,
): Promise<DriveFolderResponse> {
const folder = await this.getFolderForAccess(userId, folderId, 'write');
const folder = await this.loadFolder(folderId);
const fromName = folder.name;
folder.name = name;
await this.folderRepo.save(folder);
await this.audit.record({
actorId: userId,
type: 'folder_renamed',
payload: { fromName, toName: name },
});
return this.toFolderResponse(folder, folder.parent?.id ?? null);
}
// 폴더 삭제 — 쓰기 권한 필요. 하위 트리의 객체 스토리지 파일까지 정리.
// 폴더 삭제 — 로그인 사용자 전원 가능. 하위 트리의 객체 스토리지 파일까지 정리.
async deleteFolder(userId: string, folderId: string): Promise<void> {
const folder = await this.getFolderForAccess(userId, folderId, 'write');
const folder = await this.loadFolder(folderId);
// 자기 + 하위 폴더에 속한 모든 파일의 객체 키 수집(행은 FK CASCADE 로 삭제됨)
const descendantFiles = await this.fileRepo
.createQueryBuilder('f')
.innerJoin('f.folder', 'fd')
.where('fd.path LIKE :prefix', { prefix: `${folder.path}%` })
.getMany();
// 활동 기록은 제거 전에 — 연동 프로젝트 해석에 폴더 경로(자기+조상)가 필요하다.
const projectIds = await this.resolveLinkedProjectIds(
this.parseFolderPath(folder.path),
);
await this.folderRepo.remove(folder);
await this.deleteObjects(descendantFiles.map((f) => f.objectKey));
await this.audit.record({
actorId: userId,
type: 'folder_deleted',
payload: { name: folder.name, fileCount: descendantFiles.length },
});
for (const projectId of projectIds) {
await this.activity.record({
projectId,
actorId: userId,
type: 'drive.folder_deleted',
payload: { folderName: folder.name, fileCount: descendantFiles.length },
});
}
}
/* ===================== 파일 업로드(Presigned) ===================== */
@@ -205,7 +228,7 @@ export class DriveService {
}
let folder: DriveFolder | null = null;
if (dto.folderId) {
folder = await this.getFolderForAccess(userId, dto.folderId, 'write');
folder = await this.loadFolder(dto.folderId);
}
const owner = await this.getUserOrThrow(userId);
const objectKey = buildObjectKey(userId, dto.filename);
@@ -245,9 +268,14 @@ export class DriveService {
if (!file) {
throw new NotFoundException();
}
// 발급자 본인만 확정 가능(업로드 2단계 핸드셰이크 무결성 — 접근 권한과 무관)
if (file.owner?.id !== userId) {
throw new ForbiddenException();
}
if (file.status === 'deleted') {
// 이미 삭제된 묘비 행 — 확정 불가
throw new NotFoundException();
}
if (file.status === 'active') {
// 멱등 — 이미 확정된 경우 현재 상태 반환
return this.toFileResponse(file, file.folder?.id ?? null);
@@ -282,36 +310,60 @@ export class DriveService {
file.size = head.size;
file.status = 'active';
await this.fileRepo.save(file);
await this.audit.record({
actorId: userId,
type: 'file_uploaded',
payload: { name: file.name },
});
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 업로드 기록
await this.recordFileActivity(file, userId, 'drive.file_uploaded');
return this.toFileResponse(file, file.folder?.id ?? null);
}
/* ===================== 파일 CRUD ===================== */
// 파일 이름 변경 — 쓰기 권한 필요.
// 파일 이름 변경 — 로그인 사용자 전원 가능.
async renameFile(
userId: string,
fileId: string,
name: string,
): Promise<DriveFileResponse> {
const file = await this.getFileForAccess(userId, fileId, 'write');
const file = await this.loadFile(fileId);
const fromName = file.name;
file.name = name;
await this.fileRepo.save(file);
await this.audit.record({
actorId: userId,
type: 'file_renamed',
payload: { fromName, toName: name },
});
return this.toFileResponse(file, file.folder?.id ?? null);
}
// 파일 삭제 — 쓰기 권한 필요. 객체 스토리지 파일도 정리.
// 파일 삭제 — 로그인 사용자 전원 가능. 객체 스토리지 파일은 즉시 영구 삭제(복구 불가)하고,
// 메타 행은 누가·언제 지웠는지 추적할 수 있도록 묘비(tombstone)로 보존(소프트 삭제)한다.
async deleteFile(userId: string, fileId: string): Promise<void> {
const file = await this.getFileForAccess(userId, fileId, 'write');
await this.fileRepo.remove(file);
const file = await this.loadFile(fileId);
file.status = 'deleted';
file.deletedAt = new Date();
file.deletedBy = { id: userId } as User;
await this.fileRepo.save(file);
await this.deleteObjects([file.objectKey]);
await this.audit.record({
actorId: userId,
type: 'file_deleted',
payload: { name: file.name },
});
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 삭제 기록
await this.recordFileActivity(file, userId, 'drive.file_deleted');
}
// 다운로드용 단기 presigned GET URL 발급 — 읽기 권한 필요.
// 다운로드용 단기 presigned GET URL 발급 — 로그인 사용자 전원 가능.
async getDownloadUrl(
userId: string,
_userId: string,
fileId: string,
): Promise<{ url: string }> {
const file = await this.getFileForAccess(userId, fileId, 'read');
const file = await this.loadFile(fileId);
if (file.status !== 'active') {
// 아직 업로드 확정 전 — 다운로드 불가
throw new NotFoundException();
@@ -324,14 +376,10 @@ export class DriveService {
return { url };
}
/* ===================== 권한 해석 ===================== */
/* ===================== 로딩 ===================== */
// 폴더 로딩하고 요구 접근 수준을 만족하는지 검사(불충분 시 403, 미존재 404).
private async getFolderForAccess(
userId: string,
folderId: string,
required: DriveAccess,
): Promise<DriveFolder> {
// 폴더 로딩(미존재 404). 전사 공유라 권한 검사는 없다.
private async loadFolder(folderId: string): Promise<DriveFolder> {
const folder = await this.folderRepo.findOne({
where: { id: folderId },
relations: { owner: true, parent: true },
@@ -339,86 +387,21 @@ export class DriveService {
if (!folder) {
throw new NotFoundException();
}
const access = await this.resolveFolderAccess(userId, folder);
this.assertAccess(access, required);
return folder;
}
// 파일 로딩하고 요구 접근 수준을 만족하는지 검사.
private async getFileForAccess(
userId: string,
fileId: string,
required: DriveAccess,
): Promise<DriveFile> {
// 파일 로딩(미존재/소프트 삭제 묘비는 404 — 목록·다운로드·재삭제 차단).
private async loadFile(fileId: string): Promise<DriveFile> {
const file = await this.fileRepo.findOne({
where: { id: fileId },
relations: { owner: true, folder: true },
});
if (!file) {
if (!file || file.status === 'deleted') {
throw new NotFoundException();
}
const access = await this.resolveFileAccess(userId, file);
this.assertAccess(access, required);
return file;
}
// 폴더 접근 수준 산출 — 소유자 / 연동(쓰기·읽기) / 없음.
private async resolveFolderAccess(
userId: string,
folder: DriveFolder,
): Promise<DriveAccess | null> {
if (folder.owner?.id === userId) return 'owner';
// 자기 + 조상 폴더 중 하나라도 내가 멤버인 프로젝트에 연동되어 있으면 그 권한을 적용
return this.resolveLinkedAccess(this.parseFolderPath(folder.path), userId);
}
// 파일 접근 수준 산출 — 소유자 / 소속 폴더의 연동 권한 / 없음(루트 파일은 소유자 전용).
private async resolveFileAccess(
userId: string,
file: DriveFile,
): Promise<DriveAccess | null> {
if (file.owner?.id === userId) return 'owner';
if (!file.folder) return null;
return this.resolveLinkedAccess(
this.parseFolderPath(file.folder.path),
userId,
);
}
// 폴더 id 집합(자기+조상)에 대해, 내가 멤버인 프로젝트로의 연동 권한 중 최상위를 반환.
private async resolveLinkedAccess(
folderIds: string[],
userId: string,
): Promise<DriveAccess | null> {
if (folderIds.length === 0) return null;
const links = await this.linkRepo.find({
where: { folder: { id: In(folderIds) } },
relations: { project: true },
});
if (links.length === 0) return null;
const projectIds = [...new Set(links.map((l) => l.project.id))];
const memberships = await this.memberRepo.find({
where: { project: { id: In(projectIds) }, user: { id: userId } },
relations: { project: true },
});
const memberProjectIds = new Set(memberships.map((m) => m.project.id));
const accessible = links.filter((l) => memberProjectIds.has(l.project.id));
if (accessible.length === 0) return null;
return accessible.some((l) => l.permission === 'read-write')
? 'write'
: 'read';
}
// 접근 수준이 요구치 미만이면 403
private assertAccess(
access: DriveAccess | null,
required: DriveAccess,
): void {
if (!access || ACCESS_RANK[access] < ACCESS_RANK[required]) {
throw new ForbiddenException();
}
}
// "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상)
private parseFolderPath(path: string): string[] {
return path.split('/').filter(Boolean);
@@ -442,6 +425,37 @@ export class DriveService {
/* ===================== 내부 유틸 ===================== */
// 파일이 프로젝트 연동 폴더(또는 그 하위)에 속하면 해당 프로젝트(들) 활동 피드에 기록한다.
// (전역 감사 로그와는 별개로, 프로젝트 화면의 활동 피드 표시를 위한 부가 기록)
private async recordFileActivity(
file: DriveFile,
actorId: string,
type: ActivityType,
): Promise<void> {
const folderIds = file.folder ? this.parseFolderPath(file.folder.path) : [];
const projectIds = await this.resolveLinkedProjectIds(folderIds);
for (const projectId of projectIds) {
await this.activity.record({
projectId,
actorId,
type,
payload: { fileName: file.name },
});
}
}
// 폴더 id 집합(자기+조상)이 연동된 프로젝트 id 목록(중복 제거)을 반환.
private async resolveLinkedProjectIds(
folderIds: string[],
): Promise<string[]> {
if (folderIds.length === 0) return [];
const links = await this.linkRepo.find({
where: { folder: { id: In(folderIds) } },
relations: { project: true },
});
return [...new Set(links.map((l) => l.project.id))];
}
private async getUserOrThrow(userId: string): Promise<User> {
const user = await this.userService.findById(userId);
if (!user) {
@@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 드라이브 감사 로그 이벤트 타입 — 추가/이름변경/삭제 행위에서 자동 적재(읽기 전용)
export type DriveActivityType =
| 'file_uploaded'
| 'file_renamed'
| 'file_deleted'
| 'folder_created'
| 'folder_renamed'
| 'folder_deleted';
// 드라이브 감사 로그 — 전사 공유 드라이브에서 "누가 무엇을 했는지" 전역 추적.
// 프로젝트 활동(activities)은 project NOT NULL 이라 전사 로그로 못 쓰므로 별도 테이블로 둔다.
// 표시 문구는 프론트가 type+payload 로 조립한다(파생값 규칙).
@Entity('drive_activities')
export class DriveActivity {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 행위자 (사용자 삭제 시에도 기록은 유지 — SET NULL)
@Index()
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'actor_id' })
actor!: User | null;
// 이벤트 타입
@Column({ type: 'varchar' })
type!: DriveActivityType;
// 대상 정보(자유형) — 예: name, fromName/toName(이름변경), folderName(상위 폴더 컨텍스트), fileCount(폴더 삭제)
@Column({ type: 'jsonb', default: () => "'{}'" })
payload!: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -16,7 +16,12 @@ import type { AttachmentKind } from '../../task/entities/attachment.entity';
// 드라이브 파일 업로드 상태
// pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과
export type DriveFileStatus = 'pending' | 'active';
// deleted: 소프트 삭제(객체는 영구 제거됨, 메타 행은 감사용 묘비로 보존 — 누가·언제 삭제했는지)
export type DriveFileStatus = 'pending' | 'active' | 'deleted';
// AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시)
// pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패
export type DriveExtractStatus = 'pending' | 'done' | 'unsupported' | 'failed';
// 드라이브 파일 — 실제 바이너리는 오브젝트 스토리지에 저장하고 메타데이터만 보관.
// 업로드는 presigned URL 직접 전송 후 complete 로 확정한다.
@@ -25,11 +30,11 @@ export class DriveFile {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소유자 (사용자 삭제 시 파일 메타도 함께 삭제 — 객체 정리는 서비스가 담당)
// 업로더 (전사 공유 드라이브 — 업로더가 탈퇴해도 공유 파일은 보존하므로 SET NULL)
@Index()
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'owner_id' })
owner!: User;
owner!: User | null;
// 소속 폴더 (null = 드라이브 루트). 폴더 삭제 시 파일 메타도 함께 삭제(CASCADE).
@Index()
@@ -67,9 +72,31 @@ export class DriveFile {
@Column({ type: 'varchar', default: 'pending' })
status!: DriveFileStatus;
// AI 검토용 추출 본문(프로젝트 연동 시 1회 추출·캐싱) — 본문은 응답에 노출하지 않음
@Column({
name: 'extracted_text',
type: 'text',
nullable: true,
select: false,
})
extractedText!: string | null;
// AI 추출 상태 — 연동 문서의 AI 반영 배지 산출 + 재추출 방지에 사용
@Column({ name: 'extract_status', type: 'varchar', default: 'pending' })
extractStatus!: DriveExtractStatus;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
// 삭제 시각(소프트 삭제) — null 이면 미삭제. 객체는 즉시 영구 삭제되며 이 행만 묘비로 남는다.
@Column({ name: 'deleted_at', type: 'timestamp', nullable: true })
deletedAt!: Date | null;
// 삭제한 사용자 — 소유자가 아닌 연동 멤버가 지울 수 있으므로 별도 기록(사용자 삭제 시 SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'deleted_by_id' })
deletedBy!: User | null;
}
@@ -11,18 +11,18 @@ import {
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 드라이브 폴더 — 사용자 소유의 폴더 트리(adjacency list).
// 프로젝트와 독립적이며, ProjectFolderLink 로 프로젝트에 연동(공유)할 수 있다.
// 드라이브 폴더 — 전사 공유 폴더 트리(adjacency list). 로그인 사용자 모두가 공유한다.
// ProjectFolderLink 로 프로젝트에 연동(AI 검토 등)할 수 있으나 접근은 전원 공유다.
@Entity('drive_folders')
export class DriveFolder {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소유자 (사용자 삭제 시 폴더도 함께 삭제)
// 생성자(업로더) — 전사 공유라 생성자가 탈퇴해도 폴더는 보존하므로 SET NULL
@Index()
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'owner_id' })
owner!: User;
owner!: User | null;
// 상위 폴더 (null = 드라이브 루트). 상위 삭제 시 하위도 함께 삭제(CASCADE).
// Relation<> 래퍼: 자기참조 순환 회피
@@ -0,0 +1,37 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 브라우저 PushSubscription.keys
class PushKeysDto {
@ApiProperty({ description: '구독 공개키(p256dh)' })
@IsString()
@IsNotEmpty()
p256dh!: string;
@ApiProperty({ description: '인증 시크릿(auth)' })
@IsString()
@IsNotEmpty()
auth!: string;
}
// 푸시 구독 등록 — 브라우저 PushSubscription.toJSON() 형태
export class SubscribePushDto {
@ApiProperty({ description: '푸시 서비스 엔드포인트 URL' })
@IsString()
@IsNotEmpty()
endpoint!: string;
@ApiProperty({ description: '구독 키(p256dh/auth)' })
@ValidateNested()
@Type(() => PushKeysDto)
keys!: PushKeysDto;
}
// 푸시 구독 해제
export class UnsubscribePushDto {
@ApiProperty({ description: '해제할 구독 엔드포인트 URL' })
@IsString()
@IsNotEmpty()
endpoint!: string;
}
@@ -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' // 프로젝트에서 제외됨
@@ -0,0 +1,39 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 웹 푸시 구독 — 브라우저(PushManager)가 발급한 구독 정보를 사용자별로 보관한다.
// 한 사용자가 여러 기기/브라우저를 가질 수 있으므로 userId당 N개. endpoint 가 고유 키.
@Entity('push_subscriptions')
export class PushSubscription {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 구독 소유자 (사용자 삭제 시 구독도 함께 삭제 — Notification.recipient 와 동일 패턴)
@Index()
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User;
// 브라우저 푸시 엔드포인트(푸시 서비스 URL) — 구독의 고유 식별자
@Index({ unique: true })
@Column({ type: 'text' })
endpoint!: string;
// 암호화 키(브라우저 PushSubscription.keys)
@Column({ type: 'text' })
p256dh!: string;
@Column({ type: 'text' })
auth!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -1,4 +1,5 @@
import {
Body,
Controller,
DefaultValuePipe,
Get,
@@ -22,13 +23,18 @@ import {
NotificationService,
type NotificationListResult,
} from './notification.service';
import { WebPushService } from './web-push.service';
import { SubscribePushDto, UnsubscribePushDto } from './dto/subscribe-push.dto';
// 알림 컨트롤러 — 모든 엔드포인트 인증 필요(본인 수신함 전용)
@ApiTags('Notification')
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}
constructor(
private readonly notificationService: NotificationService,
private readonly webPush: WebPushService,
) {}
@Get()
@ApiOperation({ summary: '내 알림 목록 (페이지네이션 + 미읽음 수)' })
@@ -62,6 +68,35 @@ export class NotificationController {
return this.notificationService.markAllRead(user.id);
}
@Post('push/subscribe')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '웹 푸시 구독 등록(브라우저 PushSubscription)' })
@ApiResponse({ status: 200, description: '등록 성공' })
async subscribePush(
@CurrentUser() user: PublicUser,
@Body() dto: SubscribePushDto,
): Promise<null> {
await this.webPush.subscribe(
user.id,
dto.endpoint,
dto.keys.p256dh,
dto.keys.auth,
);
return null;
}
@Post('push/unsubscribe')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '웹 푸시 구독 해제' })
@ApiResponse({ status: 200, description: '해제 성공' })
async unsubscribePush(
@CurrentUser() user: PublicUser,
@Body() dto: UnsubscribePushDto,
): Promise<null> {
await this.webPush.unsubscribe(user.id, dto.endpoint);
return null;
}
// 실시간 스트림(SSE) — 표준 응답 래퍼를 건너뛰고 text/event-stream 으로 흘려보낸다.
// 인증은 access_token 쿠키(EventSource withCredentials)로 연결 시점에 1회 검증된다.
@Sse('stream')
@@ -1,15 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Notification } from './entities/notification.entity';
import { PushSubscription } from './entities/push-subscription.entity';
import { NotificationService } from './notification.service';
import { WebPushService } from './web-push.service';
import { NotificationController } from './notification.controller';
// 알림 모듈 — 다른 도메인 모듈(task/repo)이 import 해 NotificationService 로 알림을 적재한다.
// (Notification 은 User 만 참조하므로 순환 의존 없음)
// 알림 모듈 — 다른 도메인 모듈(task/project/schedule)이 import 해 NotificationService 로 알림을 적재한다.
// 인앱(DB) + 실시간(SSE) + 웹 푸시(WebPushService) 3경로로 전달한다.
@Module({
imports: [TypeOrmModule.forFeature([Notification])],
imports: [TypeOrmModule.forFeature([Notification, PushSubscription])],
controllers: [NotificationController],
providers: [NotificationService],
providers: [NotificationService, WebPushService],
exports: [NotificationService],
})
export class NotificationModule {}
@@ -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';
@@ -18,6 +19,7 @@ import {
Notification,
type NotificationType,
} from './entities/notification.entity';
import { WebPushService } from './web-push.service';
// 알림 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
export interface NotificationResponse {
@@ -64,10 +66,13 @@ 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)
private readonly notificationRepo: Repository<Notification>,
private readonly webPush: WebPushService,
) {}
// 기동 시 Redis pub/sub 연결 — REDIS_HOST 있을 때만. 실패해도 인메모리로 폴백.
@@ -156,12 +161,19 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
);
const saved = await this.notificationRepo.save(entities);
// 저장 성공분을 각 수신자에게 실시간 전달(Redis 발행 또는 로컬)
// 푸시 표시 내용은 type+payload 로 1회만 산출(수신자 공통)
const pushContent = this.webPush.notificationContent(
params.type,
params.payload ?? {},
);
// 저장 성공분을 각 수신자에게 실시간 전달(Redis/로컬 SSE) + 웹 푸시(사이트 닫힘 대비)
for (let i = 0; i < saved.length; i++) {
this.dispatch({
userId: recipients[i],
data: this.toResponse(saved[i]),
});
void this.webPush.sendToUser(recipients[i], pushContent);
}
} catch (err) {
this.logger.error(
@@ -215,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);
});
});
@@ -0,0 +1,245 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as webpush from 'web-push';
import { PushSubscription } from './entities/push-subscription.entity';
import type { NotificationType } from './entities/notification.entity';
// 푸시 표시 내용 — 서비스워커가 showNotification 에 그대로 사용한다.
export interface PushContent {
title: string;
body: string;
url: string | null;
}
// payload(자유형) 안전 추출
function pstr(p: Record<string, unknown>, k: string): string {
const v = p[k];
return typeof v === 'string' ? v : '';
}
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.
@Injectable()
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)
private readonly subRepo: Repository<PushSubscription>,
private readonly config: ConfigService,
) {}
onModuleInit(): void {
const publicKey = this.config.get<string>('VAPID_PUBLIC_KEY');
const privateKey = this.config.get<string>('VAPID_PRIVATE_KEY');
const subject =
this.config.get<string>('VAPID_SUBJECT') || 'mailto:admin@example.com';
if (!publicKey || !privateKey) {
this.logger.log('웹 푸시: 비활성(VAPID 키 미설정) — 푸시 발송 건너뜀');
return;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
this.enabled = true;
this.logger.log('웹 푸시: 활성화됨(VAPID)');
}
// 구독 등록(endpoint 기준 upsert — 같은 브라우저 재구독 시 키 갱신)
async subscribe(
userId: string,
endpoint: string,
p256dh: string,
auth: string,
): Promise<void> {
const existing = await this.subRepo.findOne({ where: { endpoint } });
if (existing) {
existing.user = { id: userId } as PushSubscription['user'];
existing.p256dh = p256dh;
existing.auth = auth;
await this.subRepo.save(existing);
return;
}
await this.subRepo.save(
this.subRepo.create({
user: { id: userId } as PushSubscription['user'],
endpoint,
p256dh,
auth,
}),
);
}
// 구독 해제(본인 구독만)
async unsubscribe(userId: string, endpoint: string): Promise<void> {
await this.subRepo.delete({ endpoint, user: { id: userId } });
}
// 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>,
): PushContent {
const actor = pstr(payload, 'actorName') || '누군가';
const projectName = pstr(payload, 'projectName');
const taskTitle = pstr(payload, 'taskTitle');
const eventTitle = pstr(payload, 'eventTitle');
const projectId = pstr(payload, 'projectId');
const taskSeq = pnum(payload, 'taskSeq');
const taskUrl =
projectId && taskSeq !== null
? `/projects/${projectId}/tasks/${taskSeq}`
: null;
const projectUrl = projectId ? `/projects/${projectId}` : null;
// 기본값 — 대부분 제목=행위자, 본문=행위 문구
let title = actor;
let body = '새 알림이 도착했습니다';
let url: string | null = taskUrl;
switch (type) {
case 'task.assigned':
body = `'${taskTitle}' 업무를 지시했습니다`;
break;
case 'task.unassigned':
body = `'${taskTitle}' 업무 담당에서 회원님을 제외했습니다`;
break;
case 'task.started':
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
body = `'${taskTitle}' 업무를 ${pbool(payload, 'resumed') ? '재개' : '시작'}했습니다`;
break;
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;
case 'task.removed':
body = `'${taskTitle}' 업무를 삭제했습니다`;
url = projectUrl; // 업무가 삭제되므로 프로젝트로 이동
break;
case 'task.approval_requested':
body = `'${taskTitle}' 승인을 요청했습니다`;
break;
case 'task.approved':
title = '업무 승인';
body = `'${taskTitle}' 업무가 승인되었습니다`;
break;
case 'task.changes_requested':
body = `'${taskTitle}' 수정을 요청했습니다`;
break;
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;
break;
case 'member.role_changed':
body = `'${projectName}'에서 회원님의 역할을 ${pstr(payload, 'roleType') === 'admin' ? '관리자' : '멤버'}(으)로 변경했습니다`;
url = projectUrl;
break;
case 'member.removed':
body = `'${projectName}' 프로젝트에서 회원님을 제외했습니다`;
url = projectUrl;
break;
case 'schedule.invited':
body = `'${eventTitle}' 일정에 회원님을 참석자로 지정했습니다`;
url = '/schedule';
break;
case 'schedule.updated':
body = `참석 일정 '${eventTitle}'을(를) 수정했습니다`;
url = '/schedule';
break;
case 'schedule.removed':
body = `참석 일정 '${eventTitle}'을(를) 삭제했습니다`;
url = '/schedule';
break;
}
return { title, body, url };
}
// 한 사용자의 모든 구독(기기)에 발송 — 만료(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);
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)}`,
);
}
}
}
}
@@ -0,0 +1,19 @@
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import type { DriveLinkPermission } from '../../drive/entities/project-folder-link.entity';
// 프로젝트에 드라이브 폴더를 연동하는 요청
export class LinkDriveFolderDto {
@ApiProperty({ description: '연동할 드라이브 폴더 ID', format: 'uuid' })
@IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' })
folderId!: string;
@ApiPropertyOptional({
description: "연동 권한 ('read' 보기/다운로드 | 'read-write' 멤버 업로드 허용)",
default: 'read',
enum: ['read', 'read-write'],
})
@IsOptional()
@IsIn(['read', 'read-write'], { message: '올바른 권한 값이 아닙니다.' })
permission?: DriveLinkPermission;
}
@@ -1,65 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Project } from './project.entity';
import type { AttachmentKind } from '../../task/entities/attachment.entity';
// 프로젝트 문서 첨부 — 실제 바이너리는 서버 업로드 폴더에 저장하고 메타데이터만 보관.
// (업무 첨부 Attachment 와 동일 패턴 / 동일 업로드 설정 재사용)
@Entity('project_attachments')
export class ProjectAttachment {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 프로젝트 (프로젝트 삭제 시 함께 삭제)
@Index()
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 업로더 (사용자 삭제 시에도 첨부는 유지 — SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'uploader_id' })
uploader!: User | null;
// 원본 파일명(다운로드 시 표시)
@Column()
name!: string;
// 디스크 저장명(업로드 폴더 내 고유 파일명)
@Column({ name: 'stored_name' })
storedName!: string;
// 파일 크기(바이트)
@Column({ type: 'int' })
size!: number;
// MIME 타입
@Column()
mime!: string;
// 아이콘 종류(MIME/확장자에서 파생)
@Column({ type: 'varchar' })
kind!: AttachmentKind;
// AI 추천용 추출 본문 — 업로드 시 1회 추출·캐싱(매 호출 재파싱 방지).
// 추출 불가 형식/실패 시 null. 용량이 크므로 기본 조회에서는 제외(select:false).
@Column({
name: 'extracted_text',
type: 'text',
nullable: true,
select: false,
})
extractedText!: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
+54 -11
View File
@@ -1,6 +1,7 @@
import { readFile } from 'fs/promises';
import { PDFParse } from 'pdf-parse';
import { extractRawText } from 'mammoth';
import { Workbook } from 'exceljs';
// 문서 1건당 추출 텍스트 상한(자) — DB 용량과 AI 토큰 사용량을 함께 보호한다.
export const PER_DOC_TEXT_LIMIT = 8000;
@@ -9,7 +10,7 @@ export const PER_DOC_TEXT_LIMIT = 8000;
// ready: 추출 성공(반영 가능) / failed: 지원 형식이나 추출 실패 / unsupported: 미지원 형식
export type AttachmentAiStatus = 'ready' | 'failed' | 'unsupported';
// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx). 미지원/추출실패 배지 구분에 사용한다.
// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx·xlsx). 미지원/추출실패 배지 구분에 사용한다.
export function isExtractableType(mime: string, name: string): boolean {
const lower = name.toLowerCase();
return (
@@ -21,34 +22,49 @@ export function isExtractableType(mime: string, name: string): boolean {
lower.endsWith('.pdf') ||
mime ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
lower.endsWith('.docx')
lower.endsWith('.docx') ||
mime ===
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
lower.endsWith('.xlsx')
);
}
// 첨부 문서에서 평문 텍스트를 추출한다(업로드 시 1회). 추출 불가 형식/실패/빈 결과는 null.
// 지원 형식: txt·csv(직접 읽기), pdf(pdf-parse), docx(mammoth).
// 그 외(이미지·xlsx·ppt·압축 등)는 추출하지 않고 null 을 반환한다.
// 디스크 경로 버전 — 버퍼를 읽어 공용 추출기에 위임한다.
export async function extractAttachmentText(
absPath: string,
mime: string,
name: string,
): Promise<string | null> {
try {
const buf = await readFile(absPath);
return extractTextFromBuffer(buf, mime, name);
} catch {
return null;
}
}
// 버퍼 버전 — 오브젝트 스토리지에서 받은 바이트에서 텍스트를 추출한다(드라이브 연동 문서용).
// 지원 형식: txt·csv(직접 디코드), pdf(pdf-parse), docx(mammoth), xlsx(exceljs). 그 외는 null.
export async function extractTextFromBuffer(
buf: Buffer,
mime: string,
name: string,
): Promise<string | null> {
const lower = name.toLowerCase();
try {
// 1) 평문 텍스트 — 라이브러리 없이 그대로 읽는다
// 1) 평문 텍스트
if (
mime === 'text/plain' ||
mime === 'text/csv' ||
lower.endsWith('.txt') ||
lower.endsWith('.csv')
) {
const buf = await readFile(absPath);
return finalize(buf.toString('utf8'));
}
// 2) PDF — pdf-parse v2(PDFParse 클래스)로 페이지 텍스트 추출
// 2) PDF — pdf-parse v2(PDFParse 클래스)
if (mime === 'application/pdf' || lower.endsWith('.pdf')) {
const buf = await readFile(absPath);
const parser = new PDFParse({ data: buf });
try {
const result = await parser.getText();
@@ -58,19 +74,46 @@ export async function extractAttachmentText(
}
}
// 3) Word(.docx) — mammoth 로 서식 제거한 본문만 추출
// 3) Word(.docx) — mammoth (버퍼 입력)
if (
mime ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
lower.endsWith('.docx')
) {
const result = await extractRawText({ path: absPath });
const result = await extractRawText({ buffer: buf });
return finalize(result.value);
}
// 4) Excel(.xlsx) — exceljs 로 시트별 셀 텍스트 추출(행=탭 구분)
if (
mime ===
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
lower.endsWith('.xlsx')
) {
const wb = new Workbook();
// exceljs 타입(Buffer)과 Node Buffer 제네릭(ArrayBufferLike) 마찰 회피 —
// load 의 실제 파라미터 타입으로 캐스팅한다.
await wb.xlsx.load(buf as unknown as Parameters<typeof wb.xlsx.load>[0]);
const lines: string[] = [];
wb.eachSheet((sheet) => {
const sheetLines: string[] = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
const cells: string[] = [];
row.eachCell({ includeEmpty: false }, (cell) => {
const t = (cell.text ?? '').toString().trim();
if (t) cells.push(t);
});
if (cells.length) sheetLines.push(cells.join('\t'));
});
// 내용 있는 시트만 제목과 함께 포함
if (sheetLines.length) lines.push(`# ${sheet.name}`, ...sheetLines);
});
return finalize(lines.join('\n'));
}
return null;
} catch {
// 추출 실패는 무시한다첨부 자체는 정상 저장되어야 한다.
// 추출 실패는 무시 — 호출부가 상태만 'failed' 로 기록한다.
return null;
}
}
@@ -12,37 +12,22 @@ import {
Patch,
Post,
Query,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ApiConsumes,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { BusinessException } from '../../common/exceptions/business.exception';
import type { PublicUser } from '../user/user.service';
import type { PaginatedResult } from '../../common/pagination/pagination';
import {
ProjectService,
type ProjectResponse,
type ProjectAttachmentResponse,
type ProjectDriveLinkResponse,
type ProjectDocumentResponse,
} from './project.service';
import {
UPLOAD_DIR,
MAX_FILE_SIZE,
fileMimeFilter,
type UploadedDiskFile,
} from '../task/upload.config';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { LinkDriveFolderDto } from './dto/link-drive-folder.dto';
// 프로젝트 컨트롤러 — 모든 엔드포인트 인증 필요
@ApiTags('Project')
@@ -102,7 +87,7 @@ export class ProjectController {
@Delete(':projectId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '프로젝트 삭제 (소유자, 시스템 프로젝트 제외)' })
@ApiOperation({ summary: '프로젝트 삭제 (소유자)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
async remove(
@@ -113,65 +98,50 @@ export class ProjectController {
return null;
}
/* ===================== 문서 첨부 ===================== */
/* ===================== 드라이브 연동 폴더 / AI 검토 문서 ===================== */
@Post(':projectId/files')
@HttpCode(HttpStatus.CREATED)
@UseInterceptors(
FileInterceptor('file', {
dest: UPLOAD_DIR,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: fileMimeFilter,
}),
)
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: '프로젝트 문서 업로드 (admin)' })
@ApiResponse({ status: 201, description: '업로드 성공' })
addFile(
@Get(':projectId/drive-links')
@ApiOperation({ summary: '연동된 드라이브 폴더 목록 (멤버)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listDriveLinks(
@Param('projectId', ParseUUIDPipe) projectId: string,
@UploadedFile() file: UploadedDiskFile,
@CurrentUser() user: PublicUser,
): Promise<ProjectAttachmentResponse> {
if (!file) {
throw new BusinessException(
'BIZ_001',
'업로드할 수 없는 파일 형식이거나 파일이 없습니다.',
HttpStatus.BAD_REQUEST,
);
}
return this.projectService.addFile(projectId, user.id, file);
): Promise<ProjectDriveLinkResponse[]> {
return this.projectService.listDriveLinks(projectId);
}
@Delete(':projectId/files/:fileId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '프로젝트 문서 삭제 (admin)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async removeFile(
@Post(':projectId/drive-links')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '드라이브 폴더 연동 (admin, 본인 소유 폴더만)' })
@ApiResponse({ status: 201, description: '연동 성공' })
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
@ApiResponse({ status: 409, description: '이미 연동된 폴더 (BIZ_001)' })
linkDriveFolder(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('fileId', ParseUUIDPipe) fileId: string,
@Body() dto: LinkDriveFolderDto,
@CurrentUser() user: PublicUser,
): Promise<ProjectDriveLinkResponse> {
return this.projectService.linkDriveFolder(projectId, user.id, dto);
}
@Delete(':projectId/drive-links/:linkId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '드라이브 폴더 연동 해제 (admin)' })
@ApiResponse({ status: 200, description: '해제 성공' })
async unlinkDriveFolder(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('linkId', ParseUUIDPipe) linkId: string,
@CurrentUser() user: PublicUser,
): Promise<null> {
await this.projectService.removeFile(projectId, user.id, fileId);
await this.projectService.unlinkDriveFolder(projectId, user.id, linkId);
return null;
}
@Get(':projectId/files/:fileId/download')
@ApiOperation({ summary: '프로젝트 문서 다운로드 (멤버)' })
@ApiResponse({ status: 200, description: '파일 스트림' })
async downloadFile(
@Get(':projectId/documents')
@ApiOperation({ summary: 'AI 검토 대상 문서 목록 (연동 폴더 내 파일)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listDocuments(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Param('fileId', ParseUUIDPipe) fileId: string,
@CurrentUser() user: PublicUser,
@Res() res: Response,
): Promise<void> {
const { attachment, absolutePath } =
await this.projectService.getFileForDownload(projectId, user.id, fileId);
res.setHeader('Content-Type', attachment.mime);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`,
);
res.sendFile(absolutePath);
): Promise<ProjectDocumentResponse[]> {
return this.projectService.listDocuments(projectId);
}
}
+11 -2
View File
@@ -3,8 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { ProjectAttachment } from './entities/project-attachment.entity';
import { Task } from '../task/entities/task.entity';
import { DriveFolder } from '../drive/entities/drive-folder.entity';
import { DriveFile } from '../drive/entities/drive-file.entity';
import { ProjectFolderLink } from '../drive/entities/project-folder-link.entity';
import { ActivityModule } from '../activity/activity.module';
import { NotificationModule } from '../notification/notification.module';
import { ProjectService } from './project.service';
@@ -16,7 +18,14 @@ import { ProjectMemberController } from './project-member.controller';
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
@Module({
imports: [
TypeOrmModule.forFeature([Project, ProjectMember, ProjectAttachment, Task]),
TypeOrmModule.forFeature([
Project,
ProjectMember,
Task,
DriveFolder,
DriveFile,
ProjectFolderLink,
]),
UserModule,
ActivityModule,
NotificationModule,
+220 -135
View File
@@ -1,48 +1,58 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository, type FindOptionsOrder } from 'typeorm';
import { unlink } from 'fs/promises';
import { join } from 'path';
import { ILike, In, Like, Repository, type FindOptionsOrder } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import {
paginated,
resolvePage,
type PaginatedResult,
} from '../../common/pagination/pagination';
import { BusinessException } from '../../common/exceptions/business.exception';
import { StorageService } from '../../common/storage/storage.service';
import { Project } from './entities/project.entity';
import { ProjectMember } from './entities/project-member.entity';
import { ProjectAttachment } from './entities/project-attachment.entity';
import { Task } from '../task/entities/task.entity';
import type { AttachmentKind } from '../task/entities/attachment.entity';
import { DriveFolder } from '../drive/entities/drive-folder.entity';
import { DriveFile } from '../drive/entities/drive-file.entity';
import {
UPLOAD_DIR,
deriveKind,
decodeOriginalName,
type UploadedDiskFile,
} from '../task/upload.config';
ProjectFolderLink,
type DriveLinkPermission,
} from '../drive/entities/project-folder-link.entity';
import {
extractAttachmentText,
extractTextFromBuffer,
isExtractableType,
type AttachmentAiStatus,
} from './extract-text';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { LinkDriveFolderDto } from './dto/link-drive-folder.dto';
// 프로젝트 첨부 응답
export interface ProjectAttachmentResponse {
id: string;
// 문서의 AI 반영 상태 — 'pending'(추출 대기) 추가
export type DocumentAiStatus = AttachmentAiStatus | 'pending';
// 프로젝트에 연동된 드라이브 폴더 응답
export interface ProjectDriveLinkResponse {
id: string; // 연동(link) ID
folder: { id: string; name: string; path: string };
permission: DriveLinkPermission;
linkedBy: PublicUser | null;
documentCount: number; // 연동 폴더(하위 포함) 내 활성 파일 수
createdAt: string;
}
// AI 검토 대상 문서(연동 폴더 내 드라이브 파일) 응답 — 본문은 노출하지 않음
export interface ProjectDocumentResponse {
id: string; // 드라이브 파일 ID(다운로드는 드라이브 API 사용)
name: string;
size: number;
kind: AttachmentKind;
url: string;
uploader: PublicUser | null;
createdAt: string;
// AI 추천 반영 상태 — 추출 본문은 노출하지 않고 상태 플래그만 제공
aiStatus: AttachmentAiStatus;
aiStatus: DocumentAiStatus;
}
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
@@ -55,10 +65,11 @@ export interface ProjectResponse {
doneCount: number; // 완료 업무 수
totalCount: number; // 전체 업무 수
updatedAt: string;
// 단건(findOne)에서만 — 현재 사용자 권한 + 문서 첨부
// 단건(findOne)에서만 — 현재 사용자 권한 + 연동 폴더/문서
canManage?: boolean; // admin
isOwner?: boolean; // 소유자
attachments?: ProjectAttachmentResponse[];
driveLinks?: ProjectDriveLinkResponse[];
documents?: ProjectDocumentResponse[];
}
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
@@ -71,8 +82,13 @@ export class ProjectService {
private readonly memberRepo: Repository<ProjectMember>,
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
@InjectRepository(ProjectAttachment)
private readonly attachmentRepo: Repository<ProjectAttachment>,
@InjectRepository(ProjectFolderLink)
private readonly linkRepo: Repository<ProjectFolderLink>,
@InjectRepository(DriveFolder)
private readonly folderRepo: Repository<DriveFolder>,
@InjectRepository(DriveFile)
private readonly fileRepo: Repository<DriveFile>,
private readonly storage: StorageService,
private readonly userService: UserService,
) {}
@@ -125,7 +141,8 @@ export class ProjectService {
...base,
canManage: membership?.roleType === 'admin',
isOwner: membership?.isOwner ?? false,
attachments: await this.listFiles(project.id),
driveLinks: await this.listDriveLinks(project.id),
documents: await this.listDocuments(project.id),
};
}
@@ -213,153 +230,221 @@ export class ProjectService {
}
}
// --- 문서 첨부 ---
// --- 드라이브 연동 폴더 / AI 검토 문서 ---
// 첨부 목록(최신순) — 프로젝트 상세에 포함
async listFiles(projectId: string): Promise<ProjectAttachmentResponse[]> {
// extractedText 는 select:false 이지만, AI 반영 상태 배지 산출을 위해 명시적으로 선택한다.
// (본문은 응답에 담지 않고 상태 판별에만 사용)
const rows = await this.attachmentRepo
.createQueryBuilder('a')
.leftJoinAndSelect('a.uploader', 'uploader')
.addSelect('a.extractedText')
.where('a.project_id = :projectId', { projectId })
.orderBy('a.created_at', 'DESC')
.getMany();
return rows.map((a) => this.toAttachmentResponse(a, projectId));
// 프로젝트에 연동된 드라이브 폴더 목록(최신순) — 프로젝트 상세에 포함
async listDriveLinks(projectId: string): Promise<ProjectDriveLinkResponse[]> {
const links = await this.linkRepo.find({
where: { project: { id: projectId } },
relations: { folder: true, linkedBy: true },
order: { createdAt: 'DESC' },
});
const out: ProjectDriveLinkResponse[] = [];
for (const l of links) {
out.push(
this.toDriveLinkResponse(
l,
await this.countFilesInFolderTree(l.folder),
),
);
}
return out;
}
// AI 업무 추천용 — 프로젝트 문서들의 캐싱된 추출 본문을 문서명과 함께 합쳐 반환한다.
// 총량 상한으로 잘라 토큰 사용을 보호하며, 추출 텍스트가 없으면 빈 문자열.
// (extractedText 는 select:false 이므로 명시적으로 선택해 조회한다.)
// 드라이브 폴더를 프로젝트에 연동(admin). 전사 공유 드라이브이므로 모든 폴더를 연동할 수 있다.
async linkDriveFolder(
projectId: string,
userId: string,
dto: LinkDriveFolderDto,
): Promise<ProjectDriveLinkResponse> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectAdmin(project.id, userId);
const folder = await this.folderRepo.findOne({
where: { id: dto.folderId },
relations: { owner: true },
});
if (!folder) {
throw new NotFoundException();
}
const existing = await this.linkRepo.findOne({
where: { project: { id: project.id }, folder: { id: folder.id } },
});
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 연동된 폴더입니다.',
HttpStatus.CONFLICT,
);
}
const user = await this.userService.findById(userId);
const saved = await this.linkRepo.save(
this.linkRepo.create({
project: { id: project.id } as Project,
folder: { id: folder.id } as DriveFolder,
linkedBy: user ?? null,
permission: dto.permission ?? 'read',
}),
);
return this.toDriveLinkResponse(
{ ...saved, folder, linkedBy: user ?? null },
await this.countFilesInFolderTree(folder),
);
}
// 연동 해제(admin)
async unlinkDriveFolder(
projectId: string,
userId: string,
linkId: string,
): Promise<void> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectAdmin(project.id, userId);
const link = await this.linkRepo.findOne({
where: { id: linkId, project: { id: project.id } },
});
if (!link) {
throw new NotFoundException();
}
await this.linkRepo.remove(link);
}
// AI 검토 대상 문서 목록 — 연동 폴더(하위 포함) 내 활성 파일 + 추출 상태
async listDocuments(projectId: string): Promise<ProjectDocumentResponse[]> {
const files = await this.collectLinkedFiles(projectId, false);
return files.map((f) => ({
id: f.id,
name: f.name,
size: f.size,
kind: f.kind,
aiStatus: this.driveAiStatus(f),
}));
}
// AI 업무 추천용 — 연동 폴더 내 문서들의 추출 본문을 문서명과 함께 합쳐 반환한다.
// 미추출(pending) 문서는 이 시점에 NCP 에서 받아 추출·캐싱한다(지연 추출). 총량 상한 적용.
async getAttachmentsTextForAi(projectId: string): Promise<string> {
const TOTAL_LIMIT = 16000;
const rows = await this.attachmentRepo
.createQueryBuilder('a')
.select(['a.name', 'a.extractedText'])
.where('a.project_id = :projectId', { projectId })
.orderBy('a.created_at', 'ASC')
.getMany();
const files = await this.collectLinkedFiles(projectId, true);
const parts: string[] = [];
let used = 0;
for (const r of rows) {
const text = r.extractedText?.trim();
for (const f of files) {
let text = f.extractedText?.trim() ?? null;
if (!text && f.extractStatus === 'pending') {
text = await this.extractDriveFile(f);
}
if (!text) continue;
const remaining = TOTAL_LIMIT - used;
if (remaining <= 0) break;
const slice = text.length > remaining ? text.slice(0, remaining) : text;
parts.push(`### ${r.name}\n${slice}`);
parts.push(`### ${f.name}\n${slice}`);
used += slice.length;
}
return parts.join('\n\n');
}
// 첨부 업로드 — 관리자만. 디스크 저장은 multer 가 처리, 메타데이터만 영속
async addFile(
// 연동 폴더(자기+하위 트리) 내 활성 파일 수집. withText=true 면 추출 본문도 선택.
private async collectLinkedFiles(
projectId: string,
userId: string,
file: UploadedDiskFile,
): Promise<ProjectAttachmentResponse> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectAdmin(project.id, userId);
const uploader = await this.userService.findById(userId);
const name = decodeOriginalName(file.originalname);
// 업로드 시점에 텍스트를 1회 추출해 캐싱 — AI 추천에서 재파싱 없이 꺼내 쓴다.
const extractedText = await extractAttachmentText(
file.path,
file.mimetype,
name,
);
const saved = await this.attachmentRepo.save(
this.attachmentRepo.create({
project: { id: project.id } as Project,
uploader: uploader ?? null,
name,
storedName: file.filename,
size: file.size,
mime: file.mimetype,
kind: deriveKind(file.mimetype, name),
extractedText,
}),
);
return this.toAttachmentResponse(
{ ...saved, uploader: uploader ?? null },
project.id,
);
withText: boolean,
): Promise<DriveFile[]> {
const folderIds = await this.collectLinkedFolderIds(projectId);
if (folderIds.length === 0) return [];
const qb = this.fileRepo
.createQueryBuilder('f')
.leftJoin('f.folder', 'fd')
.where('fd.id IN (:...folderIds)', { folderIds })
.andWhere("f.status = 'active'")
.orderBy('f.name', 'ASC');
if (withText) qb.addSelect('f.extractedText');
return qb.getMany();
}
// 첨부 삭제 — 관리자만. 디스크 파일도 정리
async removeFile(
projectId: string,
userId: string,
fileId: string,
): Promise<void> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectAdmin(project.id, userId);
const attachment = await this.attachmentRepo.findOne({
where: { id: fileId, project: { id: project.id } },
// 프로젝트에 연동된 모든 폴더 + 그 하위 폴더 ID 집합(materialized path 기반)
private async collectLinkedFolderIds(projectId: string): Promise<string[]> {
const links = await this.linkRepo.find({
where: { project: { id: projectId } },
relations: { folder: true },
});
if (!attachment) {
throw new NotFoundException();
if (links.length === 0) return [];
const ids = new Set<string>();
for (const l of links) {
const tree = await this.folderRepo.find({
where: { path: Like(`${l.folder.path}%`) },
select: { id: true },
});
tree.forEach((f) => ids.add(f.id));
ids.add(l.folder.id);
}
await this.attachmentRepo.remove(attachment);
await this.deleteFileFromDisk(attachment.storedName);
return [...ids];
}
// 다운로드용 첨부 메타 + 디스크 절대경로 — 멤버면 조회 가능. 컨트롤러가 스트리밍
async getFileForDownload(
projectId: string,
userId: string,
fileId: string,
): Promise<{ attachment: ProjectAttachment; absolutePath: string }> {
const project = await this.getProjectOrThrow(projectId);
await this.assertProjectMember(project.id, userId);
const attachment = await this.attachmentRepo.findOne({
where: { id: fileId, project: { id: project.id } },
// 폴더(자기+하위) 내 활성 파일 수
private async countFilesInFolderTree(folder: DriveFolder): Promise<number> {
const tree = await this.folderRepo.find({
where: { path: Like(`${folder.path}%`) },
select: { id: true },
});
const ids = tree.map((f) => f.id);
if (folder.id && !ids.includes(folder.id)) ids.push(folder.id);
if (ids.length === 0) return 0;
return this.fileRepo.count({
where: { folder: { id: In(ids) }, status: 'active' },
});
if (!attachment) {
throw new NotFoundException();
}
return {
attachment,
absolutePath: join(UPLOAD_DIR, attachment.storedName),
};
}
// 디스크 파일 삭제(베스트 에포트 — 없어도 무시)
private async deleteFileFromDisk(storedName: string): Promise<void> {
// 드라이브 파일 1건 텍스트 추출(NCP 다운로드 → 추출 → 캐싱). 결과 상태를 함께 기록.
private async extractDriveFile(f: DriveFile): Promise<string | null> {
if (!isExtractableType(f.mime, f.name)) {
await this.fileRepo.update(f.id, { extractStatus: 'unsupported' });
return null;
}
try {
await unlink(join(UPLOAD_DIR, storedName));
const buf = await this.storage.getObjectBuffer(f.objectKey);
if (!buf) {
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
return null;
}
const text = await extractTextFromBuffer(buf, f.mime, f.name);
if (text) {
await this.fileRepo.update(f.id, {
extractedText: text,
extractStatus: 'done',
});
return text;
}
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
return null;
} catch {
// 이미 없거나 권한 문제 — 본 흐름을 막지 않는다
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
return null;
}
}
private toAttachmentResponse(
att: ProjectAttachment,
projectId: string,
): ProjectAttachmentResponse {
private toDriveLinkResponse(
link: ProjectFolderLink,
documentCount: number,
): ProjectDriveLinkResponse {
return {
id: att.id,
name: att.name,
size: att.size,
kind: att.kind,
url: `/api/projects/${projectId}/files/${att.id}/download`,
uploader: att.uploader ? UserService.toPublic(att.uploader) : null,
createdAt: att.createdAt.toISOString(),
aiStatus: this.deriveAiStatus(att),
id: link.id,
folder: {
id: link.folder.id,
name: link.folder.name,
path: link.folder.path,
},
permission: link.permission,
linkedBy: link.linkedBy ? UserService.toPublic(link.linkedBy) : null,
documentCount,
createdAt: link.createdAt.toISOString(),
};
}
// 첨부의 AI 반영 상태 산출 — 추출 본문 유무 + 형식으로 판별
private deriveAiStatus(att: ProjectAttachment): AttachmentAiStatus {
if (att.extractedText && att.extractedText.trim().length > 0) {
return 'ready';
}
// 본문이 비어 있음 — 지원 형식이면 추출 실패, 아니면 미지원 형식
return isExtractableType(att.mime, att.name) ? 'failed' : 'unsupported';
// 드라이브 문서의 AI 반영 상태 — 추출 상태/형식으로 판별
private driveAiStatus(f: DriveFile): DocumentAiStatus {
if (f.extractStatus === 'done') return 'ready';
if (f.extractStatus === 'unsupported') return 'unsupported';
if (f.extractStatus === 'failed') return 'failed';
// pending — 추출 가능 형식이면 '대기', 아니면 미지원
return isExtractableType(f.mime, f.name) ? 'pending' : 'unsupported';
}
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
@@ -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
+2
View File
@@ -13,6 +13,8 @@ services:
# 화상회의 — 브라우저가 접속할 LiveKit URL(개발은 LiveKit Cloud 사용).
# 런타임 env 로 주입해 이미지 재빌드 없이 .env 변경 + 컨테이너 재생성만으로 반영된다.
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
# 웹 푸시(VAPID) 공개키 — dev 는 Vite dev 서버 런타임 env 로 주입
- VITE_VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
volumes:
- ./${FRONTEND_DIR}:/app
- /app/node_modules
+5
View File
@@ -13,6 +13,8 @@ services:
- VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
# 브라우저가 접속할 LiveKit WebSocket URL
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
# 웹 푸시(VAPID) 공개키 — 브라우저 푸시 구독에 사용
- VITE_VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
expose:
- "80"
environment:
@@ -72,6 +74,9 @@ services:
- OBJECT_STORAGE_ENDPOINT=${OBJECT_STORAGE_ENDPOINT}
- OBJECT_STORAGE_REGION=${OBJECT_STORAGE_REGION}
- OBJECT_STORAGE_BUCKET=${OBJECT_STORAGE_BUCKET}
# 웹 푸시(VAPID) — 비밀 아닌 값만 루트에서 주입(PRIVATE_KEY 는 backend env_file)
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT}
restart: unless-stopped
networks:
- app-network
+2
View File
@@ -16,6 +16,8 @@ ARG VITE_AI_TIMEOUT_MS
ENV VITE_AI_TIMEOUT_MS=$VITE_AI_TIMEOUT_MS
ARG VITE_LIVEKIT_URL
ENV VITE_LIVEKIT_URL=$VITE_LIVEKIT_URL
ARG VITE_VAPID_PUBLIC_KEY
ENV VITE_VAPID_PUBLIC_KEY=$VITE_VAPID_PUBLIC_KEY
RUN npm run build-only -- --mode ${BUILD_MODE}
+2
View File
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
readonly VITE_AI_TIMEOUT_MS: string
/** 브라우저가 접속할 LiveKit WebSocket URL (예: ws://localhost:7880) */
readonly VITE_LIVEKIT_URL: string
/** 웹 푸시(VAPID) 공개키 — 푸시 구독에 사용. 미설정 시 푸시 비활성 */
readonly VITE_VAPID_PUBLIC_KEY: string
}
interface ImportMeta {
+114
View File
@@ -0,0 +1,114 @@
/* eslint-disable */
// Relay 서비스워커 — 웹 푸시(Level2): 사이트/탭을 닫아도 OS 토스트를 띄운다.
// 푸시 페이로드는 백엔드 WebPushService 가 { title, body, url } 로 보낸다.
self.addEventListener('install', () => {
// 새 워커를 즉시 활성화
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
if (!event.data) return
let payload = {}
try {
payload = event.data.json()
} catch (e) {
payload = { title: 'Relay', body: '새 알림이 도착했습니다' }
}
const title = payload.title || 'Relay'
const body = payload.body || ''
const url = payload.url || null
event.waitUntil(
(async () => {
// 열린 클라이언트(탭)가 있으면 페이지의 SSE 가 이미 토스트를 처리 → 중복 표시 생략
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
})
if (clients.length > 0) return
await self.registration.showNotification(title, {
body,
icon: '/relay-icon-256.png',
badge: '/relay-icon-256.png',
data: { url },
})
})(),
)
})
// 브라우저가 푸시 구독을 자체 회전(만료·교체)시키면 발생 — 새 구독으로 재등록해
// 사용자가 앱을 다시 열지 않아도 푸시가 끊기지 않게 한다. 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) || '/'
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
})
// 이미 열린 창이 있으면 포커스 후 해당 경로로 이동
for (const client of clients) {
if ('focus' in client) {
await client.focus()
if ('navigate' in client && url) {
try {
await client.navigate(url)
} catch (e) {
/* 이동 실패 무시 */
}
}
return
}
}
// 열린 창이 없으면 새 창
if (self.clients.openWindow) await self.clients.openWindow(url)
})(),
)
})
@@ -0,0 +1,210 @@
<script setup lang="ts">
// 공용 활동 피드 — 날짜 그룹 단위로 "원형 아이콘 + 아바타 + 본문 + 시간" 행을 렌더한다.
// 프로젝트 활동 페이지와 드라이브 기록 패널이 동일한 디자인을 공유하기 위한 컴포넌트.
// 본문(item.html)은 스토어에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다.
import { defineComponent, h } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import type { ActivityIcon, ProjectActivityDay } from '@/mock/relay.mock'
withDefaults(
defineProps<{
days: ProjectActivityDay[]
// 카드 테두리/배경 없이(모달 등 내부에 임베드할 때) 본문만 렌더
bare?: boolean
}>(),
{ bare: false },
)
// 활동 아이콘 — name 으로 SVG path 분기
const ACT_PATHS: Record<ActivityIcon, string> = {
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
'member-add': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>',
'member-check': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/>',
'member-remove': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
repo: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
}
const ActIcon = defineComponent({
props: { name: { type: String, required: true } },
setup(props) {
return () =>
h('svg', {
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
innerHTML: ACT_PATHS[props.name as ActivityIcon] ?? '',
})
},
})
</script>
<template>
<!-- act-text v-html 스토어에서 사용자 입력을 escapeHtml 이스케이프한 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
<!-- eslint-disable vue/no-v-html -->
<div
class="feed"
:class="{ bare }"
>
<div class="feed-inner">
<template
v-for="group in days"
:key="group.day"
>
<div class="day">
{{ group.day }}
</div>
<div
v-for="(item, i) in group.items"
:key="group.day + i"
class="act"
:class="{ 'last-in-group': i === group.items.length - 1 }"
>
<span
class="act-ico"
:class="item.tone"
><ActIcon :name="item.icon" /></span>
<div class="act-main">
<span class="act-av"><UserAvatar :url="item.avatarUrl" /></span>
<span
class="act-text"
v-html="item.html"
/>
</div>
<span class="act-time">{{ item.time }}</span>
</div>
</template>
</div>
</div>
</template>
<style scoped>
/* 활동 피드 */
.feed {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 0.375rem 1.25rem 1.125rem;
}
/* 모달 등 내부 임베드 — 카드 외곽 제거 */
.feed.bare {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
}
.feed-inner {
position: relative;
}
.day {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-3);
letter-spacing: 0.0125rem;
padding: 1.125rem 0 0.375rem;
}
/* bare 모드의 첫 날짜 헤더는 위 여백 축소(모달 본문 padding 과 중복 방지) */
.feed.bare .feed-inner > .day:first-child {
padding-top: 0.25rem;
}
.act {
display: flex;
align-items: center;
gap: 0.8125rem;
padding: 0.5625rem 0;
position: relative;
}
.act::before {
content: '';
position: absolute;
left: 0.969rem;
top: 50%;
bottom: -0.5625rem;
width: 1.5px;
background: var(--border);
z-index: 0;
}
.act.last-in-group::before {
display: none;
}
.act-ico {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: grid;
place-items: center;
flex-shrink: 0;
z-index: 1;
}
.act-ico svg {
width: 1rem;
height: 1rem;
}
.act-ico.task {
background: var(--blue-weak);
color: var(--blue);
}
.act-ico.member {
background: var(--green-weak);
color: var(--green);
}
.act-ico.setting {
background: var(--amber-weak);
color: var(--amber);
}
.act-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.act-av {
width: 1.375rem;
height: 1.375rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.625rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.act-text {
font-size: 0.844rem;
color: var(--text-2);
line-height: 1.5;
}
.act-text :deep(b) {
color: var(--text);
font-weight: 600;
}
.act-text :deep(.tgt) {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.act-text :deep(.tgt:hover) {
text-decoration: underline;
}
.act-text :deep(.st) {
font-weight: 600;
}
.act-text :deep(.st.prog) {
color: var(--blue);
}
.act-text :deep(.st.done) {
color: var(--green);
}
.act-time {
font-size: 0.75rem;
color: var(--text-3);
white-space: nowrap;
flex-shrink: 0;
margin-left: 0.75rem;
}
</style>
@@ -1,10 +1,10 @@
<script setup lang="ts">
// 프로젝트 문서의 AI 추천 반영 상태 배지
// ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식
// ready: 반영됨 / pending: 추출 대기 / failed: 추출 실패 / unsupported: 미지원 형식
import { computed } from 'vue'
interface Props {
status: 'ready' | 'failed' | 'unsupported'
status: 'ready' | 'pending' | 'failed' | 'unsupported'
}
const props = defineProps<Props>()
@@ -15,6 +15,11 @@ const META: Record<Props['status'], { label: string; title: string; cls: string
title: 'AI 업무 추천에 이 문서 내용이 반영됩니다.',
cls: 'ok',
},
pending: {
label: '추출 대기',
title: '다음 AI 업무 추천 시 문서 내용을 추출해 반영합니다.',
cls: 'muted',
},
failed: {
label: '추출 실패',
title: '문서에서 텍스트를 추출하지 못해 AI 추천에 반영되지 않습니다.',
@@ -0,0 +1,208 @@
<script setup lang="ts">
// 드라이브 폴더 선택 모달 — 내 드라이브를 탐색해 프로젝트에 연동할 폴더를 고른다.
import { ref } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useDrive } from '@/composables/useDrive'
import type { DriveCrumb, DriveFolder } from '@/types/drive'
const emit = defineEmits<{
(e: 'select', folder: { id: string; name: string }): void
(e: 'close'): void
}>()
const drive = useDrive()
const loading = ref(false)
const breadcrumb = ref<DriveCrumb[]>([])
const folders = ref<DriveFolder[]>([])
async function load(id: string | null): Promise<void> {
loading.value = true
try {
const res = await drive.list(id ?? undefined)
breadcrumb.value = res.breadcrumb
folders.value = res.folders
} finally {
loading.value = false
}
}
function pick(f: DriveFolder): void {
emit('select', { id: f.id, name: f.name })
}
void load(null)
</script>
<template>
<BaseModal
title="연동할 폴더 선택"
@close="emit('close')"
>
<template #subtitle>
<b> 드라이브</b> 폴더(하위 포함) 프로젝트의 AI 검토 문서로 연동합니다.
</template>
<nav class="dfp-crumb">
<a
v-if="breadcrumb.length"
href="#"
@click.prevent="load(null)"
>드라이브</a>
<b v-else>드라이브</b>
<template
v-for="(c, i) in breadcrumb"
:key="c.id"
>
<span class="dfp-sep"></span>
<a
v-if="i < breadcrumb.length - 1"
href="#"
@click.prevent="load(c.id)"
>{{ c.name }}</a>
<b v-else>{{ c.name }}</b>
</template>
</nav>
<p
v-if="loading"
class="dfp-msg"
>
불러오는
</p>
<EmptyState v-else-if="folders.length === 0">
위치에 하위 폴더가 없습니다. 드라이브에서 폴더를 먼저 만들어 주세요.
</EmptyState>
<div
v-else
class="dfp-list"
>
<div
v-for="f in folders"
:key="f.id"
class="dfp-row"
>
<button
class="dfp-open"
type="button"
title="폴더 열기"
@click="load(f.id)"
>
<span class="dfp-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
</span>
<span class="dfp-name">{{ f.name }}</span>
</button>
<button
class="btn sm"
type="button"
@click="pick(f)"
>
연동
</button>
</div>
</div>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
닫기
</button>
</template>
</BaseModal>
</template>
<style scoped>
.dfp-crumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
font-size: 0.8125rem;
color: var(--text-2);
}
.dfp-crumb a {
color: var(--text-2);
text-decoration: none;
cursor: pointer;
}
.dfp-crumb a:hover {
color: var(--accent);
}
.dfp-sep {
color: var(--text-3);
opacity: 0.5;
}
.dfp-msg {
font-size: 0.844rem;
color: var(--text-3);
padding: 1rem 0;
text-align: center;
}
.dfp-list {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 0.625rem;
overflow: hidden;
max-height: 18rem;
overflow-y: auto;
}
.dfp-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);
}
.dfp-row:first-child {
border-top: none;
}
.dfp-row:hover {
background: #fafbfc;
}
.dfp-open {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 0.625rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
padding: 0.125rem 0;
}
.dfp-ico {
flex: 0 0 auto;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: grid;
place-items: center;
color: var(--accent);
background: var(--accent-weak);
}
.dfp-ico svg {
width: 1.125rem;
height: 1.125rem;
}
.dfp-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -0,0 +1,66 @@
<script setup lang="ts">
// 드라이브 이름 입력 모달 — 폴더 생성 / 폴더·파일 이름 변경 공용
import { computed, ref } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
const props = withDefaults(
defineProps<{
title: string
label?: string
placeholder?: string
init?: string
confirmText?: string
}>(),
{ label: '이름', placeholder: '', init: '', confirmText: '저장' },
)
const emit = defineEmits<{
(e: 'save', name: string): void
(e: 'close'): void
}>()
const name = ref(props.init)
const valid = computed(() => {
const v = name.value.trim()
return v.length >= 1 && v.length <= 255
})
function submit(): void {
if (!valid.value) return
emit('save', name.value.trim())
}
</script>
<template>
<BaseModal
:title="title"
size="sm"
@close="emit('close')"
>
<label class="form-field"><span class="form-label"><span class="req">*</span> {{ label }}</span>
<input
v-model="name"
class="form-input"
:placeholder="placeholder"
maxlength="255"
autofocus
@keydown.enter="submit"
>
</label>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ confirmText }}
</button>
</template>
</BaseModal>
</template>
@@ -168,15 +168,6 @@ async function copyInvite(room: ApiMeetingRoom) {
</div>
</div>
<div class="rr-actions">
<button
type="button"
class="btn rr-invite"
:class="{ copied: copiedCode === r.code }"
:title="copiedCode === r.code ? '복사됨!' : '초대 링크 복사'"
@click="copyInvite(r)"
>
<MeetingIcon :name="copiedCode === r.code ? 'check' : 'link'" />
</button>
<button
v-if="r.canJoin"
type="button"
@@ -194,6 +185,15 @@ async function copyInvite(room: ApiMeetingRoom) {
>
<MeetingIcon name="lock" />권한 없음
</button>
<button
type="button"
class="btn rr-invite"
:class="{ copied: copiedCode === r.code }"
:title="copiedCode === r.code ? '복사됨!' : '초대 링크 복사'"
@click="copyInvite(r)"
>
<MeetingIcon :name="copiedCode === r.code ? 'check' : 'link'" />
</button>
<button
v-if="r.isHost"
type="button"
@@ -0,0 +1,153 @@
<script setup lang="ts">
// 프로젝트 AI 검토 문서 모달 — 연동 드라이브 폴더 내 문서를 리스트로 표시(다운로드 전용)
import BaseModal from '@/components/common/BaseModal.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue'
import { useDrive } from '@/composables/useDrive'
import { fileBadgeOf, formatBytes } from '@/shared/utils/format'
import type { ApiProjectDocument, ApiProjectDriveLink } from '@/types/project'
defineProps<{
documents: ApiProjectDocument[]
driveLinks: ApiProjectDriveLink[]
}>()
defineEmits<{ (e: 'close'): void }>()
const drive = useDrive()
// 드라이브 단기 presigned URL 발급 후 다운로드
async function download(fileId: string): Promise<void> {
const url = await drive.getDownloadUrl(fileId)
const a = document.createElement('a')
a.href = url
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
}
</script>
<template>
<BaseModal
title="AI 검토 문서"
@close="$emit('close')"
>
<template #subtitle>
연동된 드라이브 폴더 <b>{{ driveLinks.length }}</b>개의 문서를 AI 업무 추천에 활용합니다.
</template>
<EmptyState v-if="documents.length === 0">
연동 폴더에 문서가 없습니다. 설정에서 드라이브 폴더를 연동해 보세요.
</EmptyState>
<div
v-else
class="pdm-list"
>
<button
v-for="d in documents"
:key="d.id"
class="pdm-row"
type="button"
title="다운로드"
@click="download(d.id)"
>
<span
class="pdm-ico"
:class="fileBadgeOf(d.name).cls"
>{{ fileBadgeOf(d.name).label }}</span>
<span class="pdm-main">
<span class="pdm-name">{{ d.name }}</span>
<span class="pdm-size">{{ formatBytes(d.size) }}</span>
</span>
<AttachmentAiBadge :status="d.aiStatus" />
</button>
</div>
</BaseModal>
</template>
<style scoped>
.pdm-list {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 0.625rem;
overflow: hidden;
max-height: 24rem;
overflow-y: auto;
}
.pdm-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border: none;
border-top: 1px solid var(--border);
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
}
.pdm-row:first-child {
border-top: none;
}
.pdm-row:hover {
background: #fafbfc;
}
.pdm-ico {
flex: 0 0 auto;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
display: grid;
place-items: center;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.02em;
color: #fff;
}
.pdm-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.pdm-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdm-size {
font-size: 0.75rem;
color: var(--text-3);
}
/* 파일 종류 배지 색상(format.fileBadgeOf 와 동일) */
.fi-img {
background: #2aa775;
}
.fi-pdf {
background: #e25950;
}
.fi-doc {
background: #3d8bf2;
}
.fi-xls {
background: #1f9d57;
}
.fi-ppt {
background: #e0823d;
}
.fi-zip {
background: #8a64d6;
}
.fi-txt {
background: #5b6470;
}
.fi-media {
background: #c0469d;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
import { useApi } from './useApi'
import type {
DriveActivityPage,
DriveFile,
DriveFolder,
DriveList,
PresignUploadPayload,
PresignUploadResult,
} from '@/types/drive'
// 드라이브 도메인 API Composable — 인터셉터가 success/data 를 언래핑
// (바이너리 송수신은 백엔드를 거치지 않고 presigned URL 로 직접 — store 에서 처리)
export function useDrive() {
const api = useApi()
// 목록 (folderId 생략 시 루트)
async function list(folderId?: string): Promise<DriveList> {
return (await api.get('/drive', {
params: folderId ? { folderId } : {},
})) as unknown as DriveList
}
// 폴더
async function createFolder(
name: string,
parentId?: string,
): Promise<DriveFolder> {
return (await api.post('/drive/folders', {
name,
...(parentId ? { parentId } : {}),
})) as unknown as DriveFolder
}
async function renameFolder(id: string, name: string): Promise<DriveFolder> {
return (await api.patch(`/drive/folders/${id}`, { name })) as unknown as DriveFolder
}
async function deleteFolder(id: string): Promise<void> {
await api.delete(`/drive/folders/${id}`)
}
// 파일 — presigned 3단계 업로드(① presign → ② 직접 PUT[store] → ③ complete)
async function presignUpload(
payload: PresignUploadPayload,
): Promise<PresignUploadResult> {
return (await api.post('/drive/files/presign', payload)) as unknown as PresignUploadResult
}
async function completeUpload(fileId: string): Promise<DriveFile> {
return (await api.post(`/drive/files/${fileId}/complete`)) as unknown as DriveFile
}
async function getDownloadUrl(fileId: string): Promise<string> {
const res = (await api.get(`/drive/files/${fileId}/download-url`)) as unknown as {
url: string
}
return res.url
}
async function renameFile(id: string, name: string): Promise<DriveFile> {
return (await api.patch(`/drive/files/${id}`, { name })) as unknown as DriveFile
}
async function deleteFile(id: string): Promise<void> {
await api.delete(`/drive/files/${id}`)
}
// 감사 로그(최신순, 페이지네이션)
async function listActivity(
page = 1,
size = 20,
): Promise<DriveActivityPage> {
return (await api.get('/drive/activity', {
params: { page, size },
})) as unknown as DriveActivityPage
}
return {
list,
createFolder,
renameFolder,
deleteFolder,
presignUpload,
completeUpload,
getDownloadUrl,
renameFile,
deleteFile,
listActivity,
}
}
+14 -1
View File
@@ -31,5 +31,18 @@ export function useNotification() {
)) as unknown as UnreadCountResult
}
return { list, markRead, markAllRead }
// 웹 푸시 구독 등록(브라우저 PushSubscription)
async function subscribePush(body: {
endpoint: string
keys: { p256dh: string; auth: string }
}): Promise<void> {
await api.post('/notifications/push/subscribe', body)
}
// 웹 푸시 구독 해제
async function unsubscribePush(endpoint: string): Promise<void> {
await api.post('/notifications/push/unsubscribe', { endpoint })
}
return { list, markRead, markAllRead, subscribePush, unsubscribePush }
}
+46 -14
View File
@@ -1,7 +1,8 @@
import { useApi } from './useApi'
import type {
ApiProject,
ApiProjectAttachment,
ApiProjectDriveLink,
ApiProjectDocument,
CreateProjectPayload,
ProjectListQuery,
UpdateProjectPayload,
@@ -42,22 +43,53 @@ export function useProject() {
await api.delete(`/projects/${id}`)
}
// 문서 첨부 업로드(관리자) — multipart/form-data
async function uploadFile(
// 연동된 드라이브 폴더 목록
async function listDriveLinks(
projectId: string,
file: File,
): Promise<ApiProjectAttachment> {
const form = new FormData()
form.append('file', file)
return (await api.post(`/projects/${projectId}/files`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})) as unknown as ApiProjectAttachment
): Promise<ApiProjectDriveLink[]> {
return (await api.get(
`/projects/${projectId}/drive-links`,
)) as unknown as ApiProjectDriveLink[]
}
// 문서 첨부 삭제(관리자)
async function removeFile(projectId: string, fileId: string): Promise<void> {
await api.delete(`/projects/${projectId}/files/${fileId}`)
// 드라이브 폴더 연동(관리자, 본인 소유 폴더만)
async function linkDriveFolder(
projectId: string,
folderId: string,
permission?: 'read' | 'read-write',
): Promise<ApiProjectDriveLink> {
return (await api.post(`/projects/${projectId}/drive-links`, {
folderId,
...(permission ? { permission } : {}),
})) as unknown as ApiProjectDriveLink
}
return { list, get, create, update, remove, uploadFile, removeFile }
// 드라이브 폴더 연동 해제(관리자)
async function unlinkDriveFolder(
projectId: string,
linkId: string,
): Promise<void> {
await api.delete(`/projects/${projectId}/drive-links/${linkId}`)
}
// AI 검토 대상 문서 목록(연동 폴더 내 파일)
async function listDocuments(
projectId: string,
): Promise<ApiProjectDocument[]> {
return (await api.get(
`/projects/${projectId}/documents`,
)) as unknown as ApiProjectDocument[]
}
return {
list,
get,
create,
update,
remove,
listDriveLinks,
linkDriveFolder,
unlinkDriveFolder,
listDocuments,
}
}
+114
View File
@@ -0,0 +1,114 @@
import { ref } from 'vue'
import { useNotification } from '@/composables/useNotification'
// VAPID 공개키(base64url) → Uint8Array (applicationServerKey 형식)
// ArrayBuffer 백킹으로 명시(BufferSource 타입 충족)
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(base64)
const arr = new Uint8Array(new ArrayBuffer(raw.length))
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
return arr
}
// 푸시 켜기 결과 — UI 가 사유별 안내를 띄울 수 있도록 상태로 반환
export type PushResult =
| 'subscribed' // 구독 성공
| 'denied' // 브라우저가 알림을 차단함
| 'dismissed' // 사용자가 권한 팝업을 닫음(미결정 유지)
| 'unsupported' // 미지원/비보안 컨텍스트
| 'no-key' // 서버 VAPID 공개키 미설정
| 'error' // 그 외 실패(SW/구독)
// 웹 푸시(Level2) 구독 관리 — 권한 요청 + PushManager 구독 + 백엔드 등록/해제.
export function usePush() {
const api = useNotification()
const supported =
typeof navigator !== 'undefined' &&
'serviceWorker' in navigator &&
typeof window !== 'undefined' &&
'PushManager' in window &&
typeof Notification !== 'undefined'
// 현재 권한 상태(UI 토글용)
const permission = ref<NotificationPermission>(
supported ? Notification.permission : 'denied',
)
// 서비스워커 등록 확보 — 명시적 register 로 .ready 무한대기를 피한다.
// 활성화 전이면 최대 5초만 기다린다(그래도 없으면 그대로 진행 — subscribe 가 처리).
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
if (!supported) return null
try {
const reg = await navigator.serviceWorker.register('/sw.js')
if (!reg.active) {
await Promise.race([
navigator.serviceWorker.ready,
new Promise((resolve) => setTimeout(resolve, 5000)),
])
}
return reg
} catch {
return null
}
}
// 권한 요청(필요 시) + 푸시 구독 + 백엔드 등록. 결과 상태를 반환(UI 안내용).
// silent=true 면 이미 granted 인 경우에만 진행(권한 팝업 띄우지 않음 — 자동 재구독용).
async function enablePush(silent = false): Promise<PushResult> {
if (!supported) return 'unsupported'
const key = import.meta.env.VITE_VAPID_PUBLIC_KEY
if (!key) return 'no-key'
let perm = Notification.permission
if (perm !== 'granted') {
if (silent) return 'denied'
perm = await Notification.requestPermission()
}
permission.value = perm
if (perm === 'denied') return 'denied'
if (perm !== 'granted') return 'dismissed'
const reg = await getRegistration()
if (!reg) return 'error'
try {
let sub = await reg.pushManager.getSubscription()
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
})
}
const json = sub.toJSON()
await api.subscribePush({
endpoint: sub.endpoint,
keys: {
p256dh: json.keys?.p256dh ?? '',
auth: json.keys?.auth ?? '',
},
})
return 'subscribed'
} catch {
return 'error'
}
}
// 구독 해제(로그아웃 등) — 백엔드 삭제 + 브라우저 구독 해지
async function disablePush(): Promise<void> {
if (!supported) return
const reg = await getRegistration()
if (!reg) return
try {
const sub = await reg.pushManager.getSubscription()
if (sub) {
await api.unsubscribePush(sub.endpoint)
await sub.unsubscribe()
}
} catch {
// 해제 실패 무시
}
}
return { supported, permission, enablePush, disablePush }
}
+126 -11
View File
@@ -6,6 +6,8 @@ import { storeToRefs } from 'pinia'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
import { useNotificationStore } from '@/stores/notification.store'
import { usePush } from '@/composables/usePush'
import { useDialog } from '@/composables/useDialog'
import UserAvatar from '@/components/UserAvatar.vue'
import RelayMark from '@/components/RelayMark.vue'
import AgentChatPanel from '@/components/AgentChatPanel.vue'
@@ -29,10 +31,11 @@ function toggleCollapse() {
const mobileOpen = ref(false)
// 현재 경로 기준으로 사이드바 네비 활성 항목을 판별
const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting'>(() => {
const activeNav = computed<'tasks' | 'projects' | 'schedule' | 'meeting' | 'drive'>(() => {
if (route.path.startsWith('/projects')) return 'projects'
if (route.path.startsWith('/schedule')) return 'schedule'
if (route.path.startsWith('/meeting')) return 'meeting'
if (route.path.startsWith('/drive')) return 'drive'
return 'tasks'
})
@@ -51,6 +54,45 @@ const {
const notiOpen = ref(false)
// --- PC 데스크톱 알림(웹 푸시) ---
const dialog = useDialog()
const { supported: pushSupported, permission: pushPermission, enablePush, disablePush } =
usePush()
// 종 드롭다운에서 "PC 알림 켜기" 노출 조건(지원 + 아직 허용 전)
const canEnablePush = computed(
() => pushSupported && pushPermission.value !== 'granted',
)
async function onEnablePush() {
const result = await enablePush()
if (result === 'subscribed') {
notiOpen.value = false
void dialog.alert('이 브라우저에서 PC 알림을 받습니다.', {
title: 'PC 알림 켜짐',
})
} else if (result === 'denied') {
void dialog.alert(
'브라우저에서 이 사이트의 알림이 차단되어 있습니다. 주소창 왼쪽 자물쇠(ⓘ) 아이콘 → 알림을 “허용”으로 바꾼 뒤 다시 시도해 주세요.',
{ title: 'PC 알림 차단됨', variant: 'danger' },
)
} else if (result === 'unsupported') {
void dialog.alert(
'이 브라우저(또는 보안 컨텍스트)에서는 PC 알림을 지원하지 않습니다. localhost 또는 https 환경의 최신 Chrome/Edge 에서 이용해 주세요.',
{ title: 'PC 알림 미지원', variant: 'danger' },
)
} else if (result === 'no-key') {
void dialog.alert(
'서버에 푸시 키가 설정되어 있지 않습니다. 관리자에게 문의해 주세요.',
{ title: 'PC 알림', variant: 'danger' },
)
} else if (result === 'error') {
void dialog.alert(
'PC 알림 설정에 실패했습니다. 잠시 후 다시 시도해 주세요.',
{ title: 'PC 알림', variant: 'danger' },
)
}
// 'dismissed'(권한 팝업 닫음)는 조용히 무시
}
// 화면 전환 시 모바일 드로어 닫기
watch(
() => route.path,
@@ -69,11 +111,16 @@ async function onNotiClick(v: NotificationView) {
// AppShell 은 인증 이후 화면에서만 마운트되므로 여기서 스트림 연결(멱등)
// (store 싱글톤이 연결을 보유 → 화면 전환에도 끊기지 않음. 해제는 로그아웃 시에만)
onMounted(() => {
if (authStore.isLoggedIn) void notiStore.connect()
if (authStore.isLoggedIn) {
void notiStore.connect()
// 이미 권한을 허용한 사용자면 조용히 재구독(권한 팝업 없이) — 구독 최신화
void enablePush(true)
}
})
// 로그아웃 → 알림 스트림 해제 후 로그인 화면으로
// 로그아웃 → 푸시 구독·알림 스트림 해제 후 로그인 화면으로
async function onLogout() {
await disablePush()
notiStore.disconnect()
await authStore.logout()
await router.push('/login')
@@ -227,6 +274,24 @@ async function onLogout() {
</svg>
<span>화상회의</span>
</RouterLink>
<RouterLink
to="/drive"
class="nav-item"
title="드라이브"
:class="{ active: activeNav === 'drive' }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
</svg>
<span>드라이브</span>
</RouterLink>
</nav>
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
@@ -373,14 +438,36 @@ async function onLogout() {
>
<div class="noti-head">
<span class="noti-title">알림</span>
<button
v-if="unreadCount > 0"
type="button"
class="noti-readall"
@click="notiStore.markAllRead()"
>
모두 읽음
</button>
<span class="noti-head-actions">
<button
v-if="canEnablePush"
type="button"
class="noti-pushbtn"
title="이 브라우저에서 PC 알림 받기"
@click="onEnablePush"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
PC 알림 켜기
</button>
<button
v-if="unreadCount > 0"
type="button"
class="noti-readall"
@click="notiStore.markAllRead()"
>
모두 읽음
</button>
</span>
</div>
<div class="noti-list">
<div
@@ -776,6 +863,11 @@ async function onLogout() {
font-weight: 700;
color: var(--text);
}
.noti-head-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.noti-readall {
border: none;
background: transparent;
@@ -790,6 +882,29 @@ async function onLogout() {
.noti-readall:hover {
background: var(--accent-weak);
}
/* PC 알림 켜기 — 권한 허용 전에만 노출 */
.noti-pushbtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: none;
background: transparent;
color: var(--text-2);
font-family: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
padding: 0.125rem 0.3125rem;
border-radius: var(--radius-sm);
}
.noti-pushbtn svg {
width: 0.8125rem;
height: 0.8125rem;
}
.noti-pushbtn:hover {
background: var(--accent-weak);
color: var(--accent);
}
.noti-list {
max-height: 24rem;
overflow-y: auto;
+9
View File
@@ -13,3 +13,12 @@ app.use(createPinia())
app.use(router)
app.mount('#app')
// 웹 푸시용 서비스워커 등록 — 푸시 수신/클릭 처리(Level2). 실패해도 앱에 영향 없음.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('/sw.js').catch(() => {
// 등록 실패(미지원/비보안 컨텍스트) 무시 — 인앱/SSE 알림은 정상 동작
})
})
}
+9 -3
View File
@@ -3,7 +3,7 @@
* (구 목업 정적 데이터는 백엔드 연동 완료로 제거됨 — store/composable 가 실제 응답을 사용)
*/
import type { ApiProjectAttachment } from '@/types/project'
import type { ApiProjectDriveLink, ApiProjectDocument } from '@/types/project'
/** 업무 상태 */
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes'
@@ -97,8 +97,10 @@ export interface Project {
canManage?: boolean
/** 소유자 여부 — 프로젝트 삭제 권한 */
isOwner?: boolean
/** 문서 첨부 — 단건 조회에서만 채워짐 */
attachments?: ApiProjectAttachment[]
/** 연동된 드라이브 폴더 — 단건 조회에서만 채워짐 */
driveLinks?: ApiProjectDriveLink[]
/** AI 검토 대상 문서(연동 폴더 내 파일) — 단건 조회에서만 채워짐 */
documents?: ApiProjectDocument[]
}
/** 프로젝트의 업무 목록 행 */
@@ -169,6 +171,8 @@ export interface TaskDetail {
activities: Activity[]
assignees: User[]
issuer: User
/** 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다 */
coIssuers: User[]
dueLabel: string
dday: string
/** 원본 마감 ISO(중간 점검 보고 가능 구간 계산용) — 없으면 null */
@@ -182,6 +186,8 @@ export interface TaskDetail {
}
/** 수정 요청 메시지(수정 요청 상태일 때) */
changesNote?: string
/** 최근 검토(승인/수정요청)를 수행한 사용자 — 없으면(레거시) 원 지시자로 대체 표기 */
reviewer: User | null
issuedLabel: string
/** 중간 점검일 목록 */
checkpoints: TaskCheckpointView[]
+842
View File
@@ -0,0 +1,842 @@
<script setup lang="ts">
// 드라이브(파일 저장소) — 폴더 탐색 + presigned 업/다운로드 + 이름변경/삭제
import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { watch } from 'vue'
import { storeToRefs } from 'pinia'
import AppShell from '@/layouts/AppShell.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import ActivityFeed from '@/components/common/ActivityFeed.vue'
import DriveNameModal from '@/components/drive/DriveNameModal.vue'
import { useDriveStore } from '@/stores/drive.store'
import { useDialog } from '@/composables/useDialog'
import { fileBadgeOf, formatBytes, relativeTimeKo } from '@/shared/utils/format'
import {
DRIVE_ACCEPT,
DRIVE_MAX_FILE_SIZE,
isAllowedDriveMime,
resolveMimeType,
} from '@/shared/utils/drive'
import type { DriveFile, DriveFolder } from '@/types/drive'
const route = useRoute()
const router = useRouter()
const store = useDriveStore()
const dialog = useDialog()
const {
current,
breadcrumb,
folders,
files,
loading,
uploads,
itemCount,
isEmpty,
activityDays,
activityLoading,
hasMoreActivity,
} = storeToRefs(store)
// 로컬 상태 — 검색(현재 폴더 내), 모달, 드래그, 파일 입력
const keyword = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
type ModalState =
| { kind: 'create-folder' }
| { kind: 'rename-folder'; id: string; name: string }
| { kind: 'rename-file'; id: string; name: string }
| null
const modal = ref<ModalState>(null)
// 드래그앤드롭(중첩 요소 깜빡임 방지용 depth 카운터)
const dragDepth = ref(0)
const isDragging = computed(() => dragDepth.value > 0)
// --- 검색 필터(클라이언트, 현재 폴더 한정) ---
const kw = computed(() => keyword.value.trim().toLowerCase())
const shownFolders = computed(() =>
kw.value ? folders.value.filter((f) => f.name.toLowerCase().includes(kw.value)) : folders.value,
)
const shownFiles = computed(() =>
kw.value ? files.value.filter((f) => f.name.toLowerCase().includes(kw.value)) : files.value,
)
const noResult = computed(
() => shownFolders.value.length === 0 && shownFiles.value.length === 0,
)
// 모달 표시 속성
const modalProps = computed(() => {
const m = modal.value
if (!m) return null
if (m.kind === 'create-folder') {
return { title: '새 폴더', label: '폴더 이름', placeholder: '폴더 이름', init: '', confirmText: '만들기' }
}
if (m.kind === 'rename-folder') {
return { title: '폴더 이름 변경', label: '폴더 이름', placeholder: '', init: m.name, confirmText: '저장' }
}
return { title: '파일 이름 변경', label: '파일 이름', placeholder: '', init: m.name, confirmText: '저장' }
})
// --- 탐색 ---
function openFolder(f: DriveFolder): void {
router.push(`/drive/${f.id}`)
}
// --- 모달 열기 ---
function openCreateFolder(): void {
modal.value = { kind: 'create-folder' }
}
function openRenameFolder(f: DriveFolder): void {
modal.value = { kind: 'rename-folder', id: f.id, name: f.name }
}
function openRenameFile(f: DriveFile): void {
modal.value = { kind: 'rename-file', id: f.id, name: f.name }
}
async function onModalSave(name: string): Promise<void> {
const m = modal.value
if (!m) return
try {
if (m.kind === 'create-folder') await store.createFolder(name)
else if (m.kind === 'rename-folder') await store.renameFolder(m.id, name)
else await store.renameFile(m.id, name)
modal.value = null
} catch {
// 에러는 API 인터셉터가 다이얼로그로 표시 — 모달은 열어 둔다
}
}
// --- 삭제 ---
async function delFolder(f: DriveFolder): Promise<void> {
const ok = await dialog.confirm(
`'${f.name}' 폴더를 삭제할까요? 하위 폴더·파일이 모두 삭제되며 되돌릴 수 없습니다.`,
{ title: '폴더 삭제', confirmText: '삭제', variant: 'danger' },
)
if (!ok) return
await store.removeFolder(f.id)
}
async function delFile(f: DriveFile): Promise<void> {
const ok = await dialog.confirm(
`'${f.name}' 파일을 삭제할까요? 이 작업은 되돌릴 수 없습니다.`,
{ title: '파일 삭제', confirmText: '삭제', variant: 'danger' },
)
if (!ok) return
await store.removeFile(f.id)
}
// --- 업로드 ---
function pickFiles(): void {
fileInput.value?.click()
}
function onFileInput(e: Event): void {
const input = e.target as HTMLInputElement
if (input.files) handleFiles(Array.from(input.files))
input.value = '' // 같은 파일 재선택 허용
}
// 검증 후 허용 파일만 업로드 시작(거부 항목은 모아서 안내)
function handleFiles(list: File[]): void {
const rejected: string[] = []
const accepted: { file: File; mime: string }[] = []
for (const f of list) {
const mime = resolveMimeType(f)
if (f.size === 0) rejected.push(`${f.name} (빈 파일)`)
else if (f.size > DRIVE_MAX_FILE_SIZE) rejected.push(`${f.name} (용량 초과 · 최대 ${formatBytes(DRIVE_MAX_FILE_SIZE)})`)
else if (!isAllowedDriveMime(mime)) rejected.push(`${f.name} (지원하지 않는 형식)`)
else accepted.push({ file: f, mime })
}
if (rejected.length) {
dialog.alert(`다음 파일은 업로드할 수 없습니다.\n\n${rejected.join('\n')}`, {
title: '업로드 불가',
variant: 'danger',
})
}
for (const a of accepted) void store.uploadOne(a.file, a.mime)
}
// 드래그앤드롭
function onDragEnter(): void {
dragDepth.value++
}
function onDragLeave(): void {
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
function onDrop(e: DragEvent): void {
dragDepth.value = 0
const dropped = e.dataTransfer?.files
if (dropped && dropped.length) handleFiles(Array.from(dropped))
}
// 라우트(folderId) 변경 시 목록 로드 — 진입/뒤로가기 포함
watch(
() => route.params.folderId,
(v) => {
const id = typeof v === 'string' && v ? v : null
keyword.value = ''
void store.fetchList(id)
},
{ immediate: true },
)
// --- 기록(감사 로그) 패널 ---
const showActivity = ref(false)
function openActivity(): void {
showActivity.value = true
void store.fetchActivity() // 열 때마다 최신으로 갱신
}
</script>
<template>
<AppShell>
<div
class="page"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<!-- 브레드크럼 전역 .crumb/.lnk/.sep 시스템(프로젝트 화면과 동일) -->
<nav class="crumb">
<template v-if="breadcrumb.length === 0">
<b>드라이브</b>
</template>
<template v-else>
<RouterLink
to="/drive"
class="lnk"
>
드라이브
</RouterLink>
<template
v-for="(c, i) in breadcrumb"
:key="c.id"
>
<span class="sep">/</span>
<RouterLink
v-if="i < breadcrumb.length - 1"
:to="`/drive/${c.id}`"
class="lnk"
>
{{ c.name }}
</RouterLink>
<b v-else>{{ c.name }}</b>
</template>
</template>
</nav>
<div class="pagehead">
<h1>{{ current ? current.name : '드라이브' }}</h1>
<span class="count-pill">{{ itemCount }}</span>
</div>
<p class="lede">
파일을 올리고 폴더로 정리해 모든 구성원과 함께 관리하세요.
</p>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="11"
cy="11"
r="7"
/>
<path d="m21 21-4.3-4.3" />
</svg>
<input
v-model="keyword"
type="search"
placeholder="이 폴더에서 검색"
aria-label="현재 폴더 검색"
>
</div>
<button
class="btn"
type="button"
@click="openActivity"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="12"
cy="12"
r="9"
/>
<path d="M12 7v5l3 2" />
</svg>
기록
</button>
<button
class="btn"
type="button"
@click="openCreateFolder"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
<path d="M12 11v4M10 13h4" />
</svg>
폴더
</button>
<button
class="btn primary"
type="button"
@click="pickFiles"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 16V4M7 9l5-5 5 5" />
<path d="M5 20h14" />
</svg>
업로드
</button>
<input
ref="fileInput"
type="file"
multiple
:accept="DRIVE_ACCEPT"
class="dr-file-input"
@change="onFileInput"
>
</div>
<!-- 업로드 진행 패널 -->
<div
v-if="uploads.length"
class="dr-uploads"
>
<div
v-for="u in uploads"
:key="u.id"
class="dr-up"
:class="{ error: u.status === 'error' }"
>
<span class="dr-up-name">{{ u.name }}</span>
<span class="dr-up-bar">
<span
class="dr-up-fill"
:class="{ error: u.status === 'error' }"
:style="{ width: (u.status === 'error' ? 100 : u.progress) + '%' }"
/>
</span>
<span class="dr-up-status">
<template v-if="u.status === 'uploading'">{{ u.progress }}%</template>
<template v-else-if="u.status === 'completing'">확정 </template>
<template v-else>{{ u.error || '실패' }}</template>
</span>
<button
v-if="u.status === 'error'"
class="dr-up-x"
type="button"
aria-label="닫기"
@click="store.dismissUpload(u.id)"
>
</button>
</div>
</div>
<!-- 목록 -->
<div class="dr-listwrap">
<EmptyState v-if="loading && isEmpty">
불러오는
</EmptyState>
<EmptyState v-else-if="isEmpty">
폴더가 비어 있습니다. 파일을 끌어다 놓거나 업로드 파일을 추가해 보세요.
</EmptyState>
<EmptyState v-else-if="noResult">
검색 결과가 없습니다.
</EmptyState>
<div
v-else
class="list"
>
<!-- 폴더 -->
<div
v-for="f in shownFolders"
:key="'fd-' + f.id"
class="dr-row"
>
<button
class="dr-hit"
type="button"
@click="openFolder(f)"
>
<span class="dr-ico dr-folder">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
</span>
<span class="dr-main">
<span class="dr-name">{{ f.name }}</span>
<span class="dr-meta">폴더<template v-if="f.owner"> · {{ f.owner.name }}</template> · {{ relativeTimeKo(f.updatedAt) }}</span>
</span>
</button>
<span class="dr-actions">
<button
class="btn dr-act"
type="button"
title="이름 변경"
@click="openRenameFolder(f)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
</button>
<button
class="btn danger dr-act"
type="button"
title="삭제"
@click="delFolder(f)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" /></svg>
</button>
</span>
</div>
<!-- 파일 -->
<div
v-for="f in shownFiles"
:key="'fl-' + f.id"
class="dr-row"
>
<button
class="dr-hit"
type="button"
title="다운로드"
@click="store.download(f.id)"
>
<span
class="dr-ico dr-badge"
:class="fileBadgeOf(f.name).cls"
>{{ fileBadgeOf(f.name).label }}</span>
<span class="dr-main">
<span class="dr-name">{{ f.name }}</span>
<span class="dr-meta">{{ formatBytes(f.size) }}<template v-if="f.owner"> · {{ f.owner.name }}</template> · {{ relativeTimeKo(f.updatedAt) }}</span>
</span>
</button>
<span class="dr-actions">
<button
class="btn dr-act"
type="button"
title="다운로드"
@click="store.download(f.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 4v12M7 11l5 5 5-5" /><path d="M5 20h14" /></svg>
</button>
<button
class="btn dr-act"
type="button"
title="이름 변경"
@click="openRenameFile(f)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
</button>
<button
class="btn danger dr-act"
type="button"
title="삭제"
@click="delFile(f)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" /></svg>
</button>
</span>
</div>
</div>
<!-- 드래그 오버레이 -->
<div
v-if="isDragging"
class="dr-drop"
>
<div class="dr-drop-card">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 16V4M7 9l5-5 5 5" />
<path d="M5 20h14" />
</svg>
<span>여기에 놓아 업로드</span>
</div>
</div>
</div>
</div>
<!-- 폴더 생성 / 이름 변경 모달 -->
<DriveNameModal
v-if="modal && modalProps"
:title="modalProps.title"
:label="modalProps.label"
:placeholder="modalProps.placeholder"
:init="modalProps.init"
:confirm-text="modalProps.confirmText"
@save="onModalSave"
@close="modal = null"
/>
<!-- 기록(감사 로그) 패널 -->
<BaseModal
v-if="showActivity"
title="드라이브 기록"
@close="showActivity = false"
>
<template #subtitle>
누가 무엇을 추가·변경·삭제했는지 전체 기록입니다.
</template>
<EmptyState v-if="activityLoading && activityDays.length === 0">
불러오는
</EmptyState>
<EmptyState v-else-if="activityDays.length === 0">
아직 기록이 없습니다.
</EmptyState>
<ActivityFeed
v-else
:days="activityDays"
bare
/>
<LoadMore
v-if="hasMoreActivity"
:loading="activityLoading"
@load="store.loadMoreActivity()"
/>
</BaseModal>
</AppShell>
</template>
<style scoped>
/* 페이지 헤더 — 다른 목록 페이지(ProjectListPage)와 동일 스페이싱 */
.pagehead {
display: flex;
align-items: center;
gap: 0.6875rem;
padding: 0.5625rem 0 0.25rem;
}
.pagehead h1 {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.025rem;
white-space: nowrap;
}
.lede {
color: var(--text-2);
font-size: 0.844rem;
margin: 0.25rem 0 1.125rem;
}
/* 툴바 — 검색은 전역 고정폭(17.5rem) 사용(프로젝트·회의 화면과 통일), 버튼 그룹은 우측 정렬 */
.toolbar > .btn:first-of-type {
margin-left: auto;
}
.dr-file-input {
display: none;
}
/* 업로드 진행 패널 */
.dr-uploads {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.dr-up {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
}
.dr-up.error {
border-color: var(--red-border);
background: var(--red-weak);
}
.dr-up-name {
flex: 0 0 14rem;
max-width: 14rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
}
.dr-up-bar {
flex: 1 1 auto;
height: 0.5rem;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
}
.dr-up-fill {
display: block;
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.15s ease;
}
.dr-up-fill.error {
background: var(--red);
}
.dr-up-status {
flex: 0 0 auto;
min-width: 3rem;
text-align: right;
font-size: 0.8125rem;
color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.dr-up.error .dr-up-status {
color: var(--red);
}
.dr-up-x {
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
font-size: 0.875rem;
padding: 0 0.25rem;
}
.dr-up-x:hover {
color: var(--text);
}
/* 목록 — 전역 .list 카드(radius 0.625rem + shadow-sm) 사용 */
/* 페이지를 세로 플렉스로 만들어 목록 영역이 남는 높이를 모두 채우게 한다(드롭 영역 확장). */
.page {
display: flex;
flex-direction: column;
min-height: calc(100vh - 3.25rem); /* 100vh - .topbar 높이 */
}
.dr-listwrap {
position: relative;
flex: 1 1 auto; /* 파일 영역이 페이지의 남는 공간 전체를 차지 → 드래그 드롭 영역도 그만큼 */
min-height: 16rem; /* 짧은 뷰포트에서도 최소 드롭 영역 보장 */
}
/* 비어있음/검색없음일 때도 안내문 카드는 상단에 폭을 꽉 채워 배치(프로젝트·회의 화면과 통일).
남는 아래 공간은 그대로 드래그 드롭 영역으로 유지된다. */
.dr-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
}
.dr-row:first-child {
border-top: none;
}
.dr-row:hover {
background: #fafbfc;
}
.dr-hit {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 1rem;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
}
.dr-ico {
flex: 0 0 auto;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5625rem;
display: grid;
place-items: center;
}
.dr-ico svg {
width: 1.3125rem;
height: 1.3125rem;
}
.dr-folder {
color: var(--accent);
background: var(--accent-weak);
}
/* 파일 종류 배지(.fi-* 색상 — TaskDetail 첨부와 동일 팔레트) */
.dr-badge {
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.02em;
color: #fff;
}
.dr-badge.fi-img {
background: #2aa775;
}
.dr-badge.fi-pdf {
background: #e25950;
}
.dr-badge.fi-zip {
background: #8a64d6;
}
.dr-badge.fi-doc {
background: #3d8bf2;
}
.dr-badge.fi-xls {
background: #1f9d57;
}
.dr-badge.fi-ppt {
background: #e0823d;
}
.dr-badge.fi-txt {
background: #5b6470;
}
.dr-badge.fi-media {
background: #c0469d;
}
.dr-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.dr-name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dr-row:hover .dr-name {
color: var(--accent);
}
.dr-meta {
font-size: 0.8125rem;
color: var(--text-3);
}
/* 행 액션 — 전역 .btn 아이콘 버튼(회의 로비와 동일 패턴) */
.dr-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.dr-act {
width: 2.25rem;
padding: 0;
justify-content: center;
}
.dr-act:hover {
border-color: var(--border-strong);
color: var(--text);
}
/* danger 변형(전역 .btn 보강 — 회의 로비와 동일) */
.btn.danger {
background: #fff;
border-color: var(--red-border);
color: var(--red);
}
.btn.danger:hover {
background: var(--red-weak);
border-color: var(--red);
color: var(--red);
}
@media (max-width: 40rem) {
.dr-up-name {
flex-basis: 8rem;
}
}
/* 드래그 오버레이 */
.dr-drop {
position: absolute;
inset: 0;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--accent-weak) 85%, transparent);
border: 2px dashed var(--accent-border);
display: grid;
place-items: center;
pointer-events: none;
z-index: 5;
}
.dr-drop-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--accent);
font-weight: 700;
}
.dr-drop-card svg {
width: 2rem;
height: 2rem;
}
</style>
@@ -1,18 +1,18 @@
<script setup lang="ts">
// 프로젝트 활동 — 날짜 그룹 단위 활동 피드 (API 연동)
import { computed, defineComponent, h, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import ActivityFeed from '@/components/common/ActivityFeed.vue'
import { useProjectStore } from '@/stores/project.store'
import { useActivityStore } from '@/stores/activity.store'
import type { Project, ActivityIcon } from '@/mock/relay.mock'
import type { Project } from '@/mock/relay.mock'
type Filter = 'all' | 'task' | 'member' | 'setting'
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
@@ -82,32 +82,6 @@ const memberCount = computed(() =>
)
const loading = computed(() => activityStore.loading)
const isEmpty = computed(() => !loading.value && days.value.length === 0)
// 활동 아이콘 — name 으로 SVG path 분기
const ACT_PATHS: Record<ActivityIcon, string> = {
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
'member-add': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>',
'member-check': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/>',
'member-remove': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
repo: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
}
const ActIcon = defineComponent({
props: { name: { type: String, required: true } },
setup(props) {
return () =>
h('svg', {
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
innerHTML: ACT_PATHS[props.name as ActivityIcon] ?? '',
})
},
})
</script>
<template>
@@ -187,42 +161,10 @@ const ActIcon = defineComponent({
</EmptyState>
<!-- 활동 피드 -->
<!-- act-text v-html store 에서 사용자 입력을 escapeHtml 이스케이프한 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
<!-- eslint-disable vue/no-v-html -->
<div
<ActivityFeed
v-else
class="feed"
>
<div class="feed-inner">
<template
v-for="group in days"
:key="group.day"
>
<div class="day">
{{ group.day }}
</div>
<div
v-for="(item, i) in group.items"
:key="group.day + i"
class="act"
:class="{ 'last-in-group': i === group.items.length - 1 }"
>
<span
class="act-ico"
:class="item.tone"
><ActIcon :name="item.icon" /></span>
<div class="act-main">
<span class="act-av"><UserAvatar :url="item.avatarUrl" /></span>
<span
class="act-text"
v-html="item.html"
/>
</div>
<span class="act-time">{{ item.time }}</span>
</div>
</template>
</div>
</div>
:days="days"
/>
<!-- 더보기 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
<LoadMore
@@ -245,120 +187,4 @@ const ActIcon = defineComponent({
gap: 0.625rem;
margin-bottom: 0.5rem;
}
/* 활동 피드 */
.feed {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 0.375rem 1.25rem 1.125rem;
}
.feed-inner {
position: relative;
}
.day {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-3);
letter-spacing: 0.0125rem;
padding: 1.125rem 0 0.375rem;
}
.act {
display: flex;
align-items: center;
gap: 0.8125rem;
padding: 0.5625rem 0;
position: relative;
}
.act::before {
content: '';
position: absolute;
left: 0.969rem;
top: 50%;
bottom: -0.5625rem;
width: 1.5px;
background: var(--border);
z-index: 0;
}
.act.last-in-group::before {
display: none;
}
.act-ico {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: grid;
place-items: center;
flex-shrink: 0;
z-index: 1;
}
.act-ico svg {
width: 1rem;
height: 1rem;
}
.act-ico.task {
background: var(--blue-weak);
color: var(--blue);
}
.act-ico.member {
background: var(--green-weak);
color: var(--green);
}
.act-ico.setting {
background: var(--amber-weak);
color: var(--amber);
}
.act-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.act-av {
width: 1.375rem;
height: 1.375rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.625rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.act-text {
font-size: 0.844rem;
color: var(--text-2);
line-height: 1.5;
}
.act-text :deep(b) {
color: var(--text);
font-weight: 600;
}
.act-text :deep(.tgt) {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.act-text :deep(.tgt:hover) {
text-decoration: underline;
}
.act-text :deep(.st) {
font-weight: 600;
}
.act-text :deep(.st.prog) {
color: var(--blue);
}
.act-text :deep(.st.done) {
color: var(--green);
}
.act-time {
font-size: 0.75rem;
color: var(--text-3);
white-space: nowrap;
flex-shrink: 0;
margin-left: 0.75rem;
}
</style>
+5 -72
View File
@@ -4,46 +4,17 @@ import { ref } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { useProjectStore } from '@/stores/project.store'
import { useProject } from '@/composables/useProject'
import { useDialog } from '@/composables/useDialog'
import { formatBytes } from '@/shared/utils/format'
const router = useRouter()
const projectStore = useProjectStore()
const projectApi = useProject()
const dialog = useDialog()
const name = ref('')
const description = ref('')
const submitting = ref(false)
// 첨부 문서 — 생성 전엔 메모리에 보관, 생성 직후 업로드
const pendingFiles = ref<File[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
function triggerFilePicker() {
fileInput.value?.click()
}
function onPickFiles(e: Event) {
const input = e.target as HTMLInputElement
pendingFiles.value.push(...(input.files ? Array.from(input.files) : []))
input.value = ''
}
function removePending(i: number) {
pendingFiles.value.splice(i, 1)
}
async function uploadPending(projectId: string): Promise<number> {
let failed = 0
for (const f of pendingFiles.value) {
try {
await projectApi.uploadFile(projectId, f)
} catch {
failed++
}
}
return failed
}
// 생성 — 성공 시 첨부 업로드 후 새 프로젝트 상세로 이동
// 생성 — 성공 시 새 프로젝트 상세로 이동(문서는 설정에서 드라이브 폴더 연동으로 추가)
async function createProject() {
if (submitting.value) return
if (!name.value.trim()) {
@@ -56,13 +27,6 @@ async function createProject() {
name: name.value.trim(),
description: description.value.trim() || undefined,
})
const failed = await uploadPending(project.id)
if (failed > 0) {
void dialog.alert(
`문서 ${failed}개 업로드에 실패했습니다. 프로젝트 설정에서 다시 첨부해 주세요.`,
{ variant: 'warning' },
)
}
await router.push(`/projects/${project.id}`)
} catch {
// 에러는 API 인터셉터에서 전역 처리
@@ -129,45 +93,14 @@ async function createProject() {
/>
</div>
<!-- 문서 첨부 -->
<!-- 문서 안내 -->
<div class="fblock">
<div class="flabel">
프로젝트 문서
AI 검토 문서
</div>
<div class="fhint">
기획서·가이드 관련 문서를 첨부하세요. (1개당 최대 25MB)
</div>
<div class="files">
<div
v-for="(f, i) in pendingFiles"
:key="i"
class="file-row"
>
<span class="fr-name">{{ f.name }}</span>
<span class="fr-size">{{ formatBytes(f.size) }}</span>
<button
type="button"
class="fr-x"
title="제거"
@click="removePending(i)"
>
×
</button>
</div>
<button
type="button"
class="file-add"
@click="triggerFilePicker"
>
+ 파일 추가
</button>
<input
ref="fileInput"
type="file"
multiple
hidden
@change="onPickFiles"
>
프로젝트 생성 <b>설정 AI 검토 문서</b>에서 드라이브 폴더를 연동하면,
폴더의 문서가 AI 업무 추천에 활용됩니다.
</div>
</div>
+27 -76
View File
@@ -12,11 +12,11 @@ import DropdownSelect from '@/components/DropdownSelect.vue'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import ProjectDocumentsModal from '@/components/project/ProjectDocumentsModal.vue'
import { useProjectStore } from '@/stores/project.store'
import { useTaskStore } from '@/stores/task.store'
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
import { taskStatusLabel, formatBytes } from '@/shared/utils/format'
import { toAbsoluteApiUrl } from '@/composables/useApi'
import { taskStatusLabel } from '@/shared/utils/format'
import type { Project } from '@/mock/relay.mock'
const route = useRoute()
@@ -35,6 +35,9 @@ async function loadProject() {
}
}
// AI 검토 문서 모달 표시 여부
const docsModalOpen = ref(false)
const keyword = ref('')
const segment = ref<TaskSegment>('all')
// 세그먼트 탭 옵션(수량 포함) — counts 갱신에 반응
@@ -174,12 +177,16 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
:can-manage="repo.canManage"
/>
<!-- 프로젝트 문서(있을 때만, 다운로드 전용) -->
<!-- AI 검토 문서 버튼으로 모달 열기(연동 폴더가 있을 때만 노출) -->
<div
v-if="repo.attachments && repo.attachments.length"
class="proj-docs"
v-if="(repo.documents && repo.documents.length) || (repo.driveLinks && repo.driveLinks.length)"
class="docs-bar"
>
<div class="pd-head">
<button
type="button"
class="btn docs-btn"
@click="docsModalOpen = true"
>
<svg
viewBox="0 0 24 24"
fill="none"
@@ -188,22 +195,18 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
stroke-linecap="round"
stroke-linejoin="round"
><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
프로젝트 문서 <span class="pd-ct">{{ repo.attachments.length }}</span>
</div>
<div class="pd-list">
<a
v-for="a in repo.attachments"
:key="a.id"
class="pd-item"
:href="toAbsoluteApiUrl(a.url)"
download
>
<span class="pd-name">{{ a.name }}</span>
<span class="pd-size">{{ formatBytes(a.size) }}</span>
</a>
</div>
AI 검토 문서
<span class="count-pill">{{ repo.documents?.length ?? 0 }}</span>
</button>
</div>
<ProjectDocumentsModal
v-if="docsModalOpen"
:documents="repo.documents ?? []"
:drive-links="repo.driveLinks ?? []"
@close="docsModalOpen = false"
/>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
@@ -488,64 +491,12 @@ const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
}
/* 프로젝트 문서 */
.proj-docs {
/* AI 검토 문서 — 모달 열기 버튼 */
.docs-bar {
margin: 0.25rem 0 1rem;
padding: 0.875rem 1.125rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
}
.pd-head {
display: flex;
align-items: center;
gap: 0.4375rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-2);
margin-bottom: 0.625rem;
}
.pd-head svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
.pd-head .pd-ct {
font-size: 0.719rem;
color: var(--text-3);
}
.pd-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pd-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
max-width: 100%;
padding: 0.375rem 0.6875rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
text-decoration: none;
color: inherit;
}
.pd-item:hover {
background: #fafbfc;
border-color: var(--border-strong);
}
.pd-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pd-size {
font-size: 0.719rem;
color: var(--text-3);
flex-shrink: 0;
.docs-btn .count-pill {
margin-left: 0.125rem;
}
.toolbar .search {
width: 15rem;
+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);
@@ -5,14 +5,12 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue'
import DriveFolderPicker from '@/components/drive/DriveFolderPicker.vue'
import { useProjectStore } from '@/stores/project.store'
import { useProject } from '@/composables/useProject'
import { useDialog } from '@/composables/useDialog'
import { formatBytes } from '@/shared/utils/format'
import { toAbsoluteApiUrl } from '@/composables/useApi'
import { type Project } from '@/mock/relay.mock'
import type { ApiProjectAttachment } from '@/types/project'
import type { ApiProjectDriveLink } from '@/types/project'
const route = useRoute()
const router = useRouter()
@@ -22,10 +20,10 @@ const projectApi = useProject()
const dialog = useDialog()
const repo = ref<Project | null>(null)
// 첨부는 폼 미저장 입력 보존하기 위해 별도 ref 로 관리(업/삭제 시 부분 갱신)
const attachments = ref<ApiProjectAttachment[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
const fileBusy = ref(false)
// 연동 폴더는 폼 미저장 입력 보존 위해 별도 ref 로 관리(연동/해제 시 부분 갱신)
const driveLinks = ref<ApiProjectDriveLink[]>([])
const pickerOpen = ref(false)
const linkBusy = ref(false)
// 현재 사용자 권한 — 단건 응답에 포함된 값(백엔드와 동일 정책: 설정=admin, 삭제=소유자)
const canManage = computed(() => repo.value?.canManage === true)
@@ -56,48 +54,39 @@ async function loadProject() {
}
displayTitle.value = repo.value.name
description.value = repo.value.desc
attachments.value = repo.value.attachments ?? []
driveLinks.value = repo.value.driveLinks ?? []
}
watch(projectId, loadProject, { immediate: true })
// --- 문서 첨부 (관리자) ---
function triggerFilePicker() {
fileInput.value?.click()
}
async function onPickFiles(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
input.value = ''
if (!files.length || fileBusy.value) return
fileBusy.value = true
let failed = 0
for (const f of files) {
try {
attachments.value = [
await projectApi.uploadFile(projectId.value, f),
...attachments.value,
]
} catch {
failed++
}
// --- 드라이브 폴더 연동 (관리자) ---
// 폴더 선택 → 연동 생성. 이미 연동/권한오류 등은 인터셉터가 알림 처리.
async function onPickFolder(folder: { id: string; name: string }) {
pickerOpen.value = false
if (linkBusy.value) return
// 중복 연동 방지(클라이언트 단)
if (driveLinks.value.some((l) => l.folder.id === folder.id)) {
void dialog.alert('이미 연동된 폴더입니다.', { variant: 'warning' })
return
}
fileBusy.value = false
if (failed > 0) {
void dialog.alert(`문서 ${failed}개 업로드에 실패했습니다.`, {
variant: 'warning',
})
linkBusy.value = true
try {
const link = await projectApi.linkDriveFolder(projectId.value, folder.id)
driveLinks.value = [link, ...driveLinks.value]
} catch {
// 인터셉터 전역 처리
} finally {
linkBusy.value = false
}
}
async function removeAttachment(id: string) {
const ok = await dialog.confirm('이 문서를 삭제할까요?', {
title: '문서 삭제',
variant: 'danger',
confirmText: '삭제',
})
async function removeLink(link: ApiProjectDriveLink) {
const ok = await dialog.confirm(
`'${link.folder.name}' 폴더 연동을 해제할까요? (드라이브의 폴더·파일은 삭제되지 않습니다)`,
{ title: '연동 해제', variant: 'danger', confirmText: '해제' },
)
if (!ok) return
try {
await projectApi.removeFile(projectId.value, id)
attachments.value = attachments.value.filter((a) => a.id !== id)
await projectApi.unlinkDriveFolder(projectId.value, link.id)
driveLinks.value = driveLinks.value.filter((l) => l.id !== link.id)
} catch {
// 인터셉터 전역 처리
}
@@ -242,57 +231,60 @@ async function removeProject() {
</div>
</div>
<!-- 문서 -->
<!-- AI 검토 문서 (드라이브 폴더 연동) -->
<h2 class="sec-title">
문서
AI 검토 문서
</h2>
<div class="card">
<div class="fblock">
<div class="flabel">
프로젝트 문서
드라이브 폴더 연동
</div>
<div class="fhint">
기획서·가이드 관련 문서를 첨부하세요. (1개당 최대 25MB)
드라이브의 폴더를 연동하면, 폴더(하위 포함) 문서가 AI 업무 추천에 활용됩니다.
연동된 폴더의 문서는 프로젝트 멤버가 열람·다운로드할 있습니다.
</div>
<div class="files">
<a
v-for="a in attachments"
:key="a.id"
<div
v-for="l in driveLinks"
:key="l.id"
class="file-row"
:href="toAbsoluteApiUrl(a.url)"
download
>
<span class="fr-name">{{ a.name }}</span>
<AttachmentAiBadge :status="a.aiStatus" />
<span class="fr-size">{{ formatBytes(a.size) }}</span>
<span class="fr-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
</span>
<span class="fr-name">{{ l.folder.name }}</span>
<span class="fr-size">문서 {{ l.documentCount }}</span>
<button
type="button"
class="fr-x"
title="제"
@click.prevent="removeAttachment(a.id)"
>×</button>
</a>
title="연동 해제"
@click="removeLink(l)"
>
×
</button>
</div>
<div
v-if="attachments.length === 0"
v-if="driveLinks.length === 0"
class="files-empty"
>
첨부 문서 없습니다.
연동 폴더 없습니다.
</div>
<button
type="button"
class="file-add"
:disabled="fileBusy"
@click="triggerFilePicker"
:disabled="linkBusy"
@click="pickerOpen = true"
>
{{ fileBusy ? '업로드 중…' : '+ 파일 추가' }}
{{ linkBusy ? '연동 중…' : '+ 폴더 연동' }}
</button>
<input
ref="fileInput"
type="file"
multiple
hidden
@change="onPickFiles"
>
</div>
</div>
</div>
@@ -327,6 +319,13 @@ async function removeProject() {
</div>
</div>
</div>
<!-- 드라이브 폴더 선택 모달 -->
<DriveFolderPicker
v-if="pickerOpen"
@select="onPickFolder"
@close="pickerOpen = false"
/>
</AppShell>
</template>
@@ -536,6 +535,20 @@ textarea.tarea {
.file-row:hover {
background: #fafbfc;
}
.fr-ico {
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.4375rem;
display: grid;
place-items: center;
color: var(--accent);
background: var(--accent-weak);
}
.fr-ico svg {
width: 1rem;
height: 1rem;
}
.fr-name {
flex: 1;
min-width: 0;
+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;
+7
View File
@@ -115,6 +115,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/relay/MeetingPage.vue'),
meta: { requiresAuth: true },
},
{
// 드라이브 — 개인 파일 저장소. :folderId 로 폴더 직접 진입(/drive/<폴더ID>)
path: '/drive/:folderId?',
name: 'drive',
component: () => import('@/pages/relay/DrivePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
+83
View File
@@ -0,0 +1,83 @@
// 드라이브 클라이언트 측 상수/순수 유틸 — 업로드 사전 검증·MIME 추론
// (백엔드 drive.config.ts 의 화이트리스트/최대크기와 동기화 유지)
// 파일 1건 최대 크기(기본 200MB) — 백엔드 DRIVE_MAX_FILE_MB 기본값과 일치
export const DRIVE_MAX_FILE_SIZE = 200 * 1024 * 1024
// 허용 MIME 화이트리스트(백엔드 DRIVE_ALLOWED_MIME_TYPES 미러)
export const DRIVE_ALLOWED_MIME: ReadonlySet<string> = new Set<string>([
// 이미지
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
// 문서
'application/pdf',
'text/plain',
'text/csv',
'text/markdown',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/json',
// 압축
'application/zip',
'application/x-zip-compressed',
'application/x-7z-compressed',
'application/x-rar-compressed',
// 미디어
'video/mp4',
'video/webm',
'video/quicktime',
'audio/mpeg',
'audio/wav',
'audio/ogg',
])
// <input type="file" accept="..."> 용 확장자 목록
export const DRIVE_ACCEPT =
'.png,.jpg,.jpeg,.gif,.webp,.pdf,.txt,.csv,.md,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.json,.zip,.7z,.rar,.mp4,.webm,.mov,.mp3,.wav,.ogg'
// 확장자 → MIME 추론(브라우저가 file.type 을 비워줄 때 보완)
const EXT_TO_MIME: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
md: 'text/markdown',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
json: 'application/json',
zip: 'application/zip',
'7z': 'application/x-7z-compressed',
rar: 'application/x-rar-compressed',
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
}
// 파일의 MIME 결정 — file.type 우선, 없으면 확장자로 추론
export function resolveMimeType(file: File): string {
if (file.type) return file.type
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
return EXT_TO_MIME[ext] ?? ''
}
// 업로드 허용 여부
export function isAllowedDriveMime(mime: string): boolean {
return DRIVE_ALLOWED_MIME.has(mime)
}
+25
View File
@@ -82,6 +82,31 @@ export function fileBadge(kind: FileKind): { label: string; cls: string } {
return map[kind]
}
/**
* 확장자 기준 세분화 배지(라벨/색상) — 4종 kind 보다 정확히 표기.
* 예: 엑셀 → XLS, 파워포인트 → PPT, 워드 → DOC. (드라이브 파일 행 등)
*/
export function fileBadgeOf(name: string): { label: string; cls: string } {
const ext = name.split('.').pop()?.toLowerCase() ?? ''
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].includes(ext))
return { label: 'IMG', cls: 'fi-img' }
if (ext === 'pdf') return { label: 'PDF', cls: 'fi-pdf' }
if (['xls', 'xlsx'].includes(ext)) return { label: 'XLS', cls: 'fi-xls' }
if (['doc', 'docx'].includes(ext)) return { label: 'DOC', cls: 'fi-doc' }
if (['ppt', 'pptx'].includes(ext)) return { label: 'PPT', cls: 'fi-ppt' }
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext))
return { label: 'ZIP', cls: 'fi-zip' }
if (ext === 'csv') return { label: 'CSV', cls: 'fi-txt' }
if (ext === 'json') return { label: 'JSON', cls: 'fi-txt' }
if (['txt', 'md'].includes(ext))
return { label: ext.toUpperCase(), cls: 'fi-txt' }
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext))
return { label: 'VID', cls: 'fi-media' }
if (['mp3', 'wav', 'ogg', 'flac', 'm4a'].includes(ext))
return { label: 'AUD', cls: 'fi-media' }
return { label: 'FILE', cls: 'fi-doc' }
}
/** 바이트 수를 사람이 읽기 쉬운 단위로 (예: 19293798 → "18.4 MB") */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
+24
View File
@@ -112,6 +112,30 @@ function toProjectActivityItem(api: ApiActivity): ProjectActivityItem {
}
case 'task.removed':
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 삭제했습니다` }
case 'drive.file_uploaded':
return {
...base,
tone: 'setting',
icon: 'repo',
html: `<b>${actor}</b>님이 파일 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 업로드했습니다`,
}
case 'drive.file_deleted':
return {
...base,
tone: 'setting',
icon: 'pencil',
html: `<b>${actor}</b>님이 파일 <b>${escapeHtml(pstr(p, 'fileName'))}</b>을(를) 삭제했습니다`,
}
case 'drive.folder_deleted': {
const cnt = typeof p.fileCount === 'number' ? p.fileCount : 0
const suffix = cnt > 0 ? ` (파일 ${cnt}개 포함)` : ''
return {
...base,
tone: 'setting',
icon: 'pencil',
html: `<b>${actor}</b>님이 폴더 <b>${escapeHtml(pstr(p, 'folderName'))}</b>을(를) 삭제했습니다${suffix}`,
}
}
default:
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님의 활동` }
}
+296
View File
@@ -0,0 +1,296 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useDrive } from '@/composables/useDrive'
import {
dayGroupKo,
escapeHtml,
relativeTimeKo,
} from '@/shared/utils/format'
import type {
DriveActivity,
DriveCrumb,
DriveFile,
DriveFolder,
} from '@/types/drive'
import type { PresignedUpload } from '@/types/drive'
import type { ProjectActivityDay, ProjectActivityItem } from '@/mock/relay.mock'
// 감사 로그 페이지 크기
const ACTIVITY_PAGE_SIZE = 20
// payload(자유형)에서 문자열 안전 추출
function pstr(payload: Record<string, unknown>, key: string): string {
const v = payload[key]
return typeof v === 'string' ? v : ''
}
// 드라이브 감사 로그 1건 → 공용 활동 아이템(프로젝트 활동과 동일 디자인 토큰).
// 드라이브 행위는 모두 tone 'setting'(앰버), 추가/생성=repo·변경/삭제=pencil 아이콘.
// 사용자 입력(파일/폴더명)은 escapeHtml 로 감싸 XSS 를 방지한다.
function toDriveActivityItem(a: DriveActivity): ProjectActivityItem {
const base: Pick<
ProjectActivityItem,
'avatarUrl' | 'actorId' | 'actorName' | 'time'
> = {
avatarUrl: a.actor?.avatarUrl ?? null,
actorId: a.actor?.id ?? null,
actorName: a.actor?.name ?? null,
time: relativeTimeKo(a.createdAt),
}
const actor = escapeHtml(a.actor?.name ?? '알 수 없음')
const p = a.payload
const name = `<b>${escapeHtml(pstr(p, 'name'))}</b>`
const renamePair = `<b>${escapeHtml(pstr(p, 'fromName'))}</b> → <b>${escapeHtml(pstr(p, 'toName'))}</b>`
switch (a.type) {
case 'file_uploaded':
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 파일 ${name}을(를) 업로드했습니다` }
case 'file_renamed':
return { ...base, tone: 'setting', icon: 'pencil', html: `<b>${actor}</b>님이 파일 이름을 ${renamePair}(으)로 변경했습니다` }
case 'file_deleted':
return { ...base, tone: 'setting', icon: 'pencil', html: `<b>${actor}</b>님이 파일 ${name}을(를) 삭제했습니다` }
case 'folder_created':
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 폴더 ${name}을(를) 만들었습니다` }
case 'folder_renamed':
return { ...base, tone: 'setting', icon: 'pencil', html: `<b>${actor}</b>님이 폴더 이름을 ${renamePair}(으)로 변경했습니다` }
case 'folder_deleted': {
const cnt = typeof p.fileCount === 'number' ? p.fileCount : 0
const suffix = cnt > 0 ? ` (파일 ${cnt}개 포함)` : ''
return { ...base, tone: 'setting', icon: 'pencil', html: `<b>${actor}</b>님이 폴더 ${name}을(를) 삭제했습니다${suffix}` }
}
default:
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님의 활동` }
}
}
// 진행 중 업로드 1건(UI 표시용)
export interface UploadItem {
id: number // 로컬 임시 id
name: string
size: number
progress: number // 0~100
status: 'uploading' | 'completing' | 'error'
error?: string
}
// 이름 오름차순 정렬(백엔드 정렬과 동일 기준 유지)
function byName<T extends { name: string }>(a: T, b: T): number {
return a.name.localeCompare(b.name, 'ko')
}
// presigned URL 로 객체 스토리지에 직접 PUT — 진행률을 위해 XHR 사용(fetch 는 업로드 진행률 미지원)
function putToStorage(
upload: PresignedUpload,
file: File,
onProgress: (pct: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(upload.method, upload.url)
// 서명 대상 헤더(Content-Type 등)를 반드시 그대로 전송
for (const [k, v] of Object.entries(upload.headers)) {
xhr.setRequestHeader(k, v)
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100))
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error(`업로드에 실패했습니다. (${xhr.status})`))
}
xhr.onerror = () => reject(new Error('네트워크 오류로 업로드에 실패했습니다.'))
xhr.send(file)
})
}
let uploadSeq = 0
export const useDriveStore = defineStore('drive', () => {
const drive = useDrive()
// --- 상태 ---
const folderId = ref<string | null>(null) // 현재 폴더(루트면 null)
const current = ref<DriveFolder | null>(null)
const breadcrumb = ref<DriveCrumb[]>([])
const folders = ref<DriveFolder[]>([])
const files = ref<DriveFile[]>([])
const loading = ref(false)
const uploads = ref<UploadItem[]>([])
// 감사 로그(기록 패널)
const activities = ref<DriveActivity[]>([])
const activityLoading = ref(false)
const activityPage = ref(0) // 마지막으로 불러온 페이지(0=미로드)
const activityTotal = ref(0)
const hasMoreActivity = computed(
() => activities.value.length < activityTotal.value,
)
// 누적 감사 로그 → 날짜 그룹(최신순이라 같은 날은 연속) — 공용 ActivityFeed 입력
const activityDays = computed<ProjectActivityDay[]>(() => {
const groups: ProjectActivityDay[] = []
let current: ProjectActivityDay | null = null
for (const a of activities.value) {
const day = dayGroupKo(a.createdAt)
if (!current || current.day !== day) {
current = { day, items: [] }
groups.push(current)
}
current.items.push(toDriveActivityItem(a))
}
return groups
})
const isEmpty = computed(
() => folders.value.length === 0 && files.value.length === 0,
)
const itemCount = computed(() => folders.value.length + files.value.length)
// --- 조회 ---
async function fetchList(id: string | null): Promise<void> {
folderId.value = id
loading.value = true
try {
const res = await drive.list(id ?? undefined)
current.value = res.current
breadcrumb.value = res.breadcrumb
folders.value = [...res.folders].sort(byName)
files.value = [...res.files].sort(byName)
} finally {
loading.value = false
}
}
// --- 폴더 ---
async function createFolder(name: string): Promise<void> {
const created = await drive.createFolder(name, folderId.value ?? undefined)
folders.value = [...folders.value, created].sort(byName)
}
async function renameFolder(id: string, name: string): Promise<void> {
const updated = await drive.renameFolder(id, name)
folders.value = folders.value
.map((f) => (f.id === id ? updated : f))
.sort(byName)
}
async function removeFolder(id: string): Promise<void> {
await drive.deleteFolder(id)
folders.value = folders.value.filter((f) => f.id !== id)
}
// --- 파일 ---
async function renameFile(id: string, name: string): Promise<void> {
const updated = await drive.renameFile(id, name)
files.value = files.value.map((f) => (f.id === id ? updated : f)).sort(byName)
}
async function removeFile(id: string): Promise<void> {
await drive.deleteFile(id)
files.value = files.value.filter((f) => f.id !== id)
}
// 다운로드 — 단기 presigned URL 발급 후 브라우저 다운로드 트리거
async function download(id: string): Promise<void> {
const url = await drive.getDownloadUrl(id)
const a = document.createElement('a')
a.href = url
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
a.remove()
}
// 업로드 1건 — presign → 직접 PUT(진행률) → complete. 검증은 호출 전(page)에서 끝낸 상태로 가정.
async function uploadOne(file: File, contentType: string): Promise<void> {
const targetFolder = folderId.value // 시작 시점의 폴더(완료 시 위치 비교용)
const item: UploadItem = {
id: ++uploadSeq,
name: file.name,
size: file.size,
progress: 0,
status: 'uploading',
}
uploads.value = [...uploads.value, item]
try {
const res = await drive.presignUpload({
filename: file.name,
contentType,
size: file.size,
folderId: targetFolder ?? undefined,
})
await putToStorage(res.upload, file, (p) => {
item.progress = p
})
item.status = 'completing'
const done = await drive.completeUpload(res.fileId)
// 업로드 중 사용자가 폴더를 옮기지 않았다면 현재 목록에 즉시 반영
if ((done.folderId ?? null) === (folderId.value ?? null)) {
files.value = [...files.value, done].sort(byName)
}
uploads.value = uploads.value.filter((u) => u.id !== item.id)
} catch (e: unknown) {
item.status = 'error'
item.error = e instanceof Error ? e.message : '업로드에 실패했습니다.'
}
}
// 완료/실패 업로드 항목 닫기
function dismissUpload(id: number): void {
uploads.value = uploads.value.filter((u) => u.id !== id)
}
// --- 감사 로그(기록 패널) ---
// 첫 페이지 로드(패널을 열 때마다 최신 상태로 갱신)
async function fetchActivity(): Promise<void> {
activityLoading.value = true
try {
const res = await drive.listActivity(1, ACTIVITY_PAGE_SIZE)
activities.value = res.items
activityTotal.value = res.total
activityPage.value = res.page
} finally {
activityLoading.value = false
}
}
// 다음 페이지 누적
async function loadMoreActivity(): Promise<void> {
if (activityLoading.value || !hasMoreActivity.value) return
activityLoading.value = true
try {
const res = await drive.listActivity(
activityPage.value + 1,
ACTIVITY_PAGE_SIZE,
)
activities.value = [...activities.value, ...res.items]
activityTotal.value = res.total
activityPage.value = res.page
} finally {
activityLoading.value = false
}
}
return {
folderId,
current,
breadcrumb,
folders,
files,
loading,
uploads,
activities,
activityDays,
activityLoading,
hasMoreActivity,
isEmpty,
itemCount,
fetchList,
createFolder,
renameFolder,
removeFolder,
renameFile,
removeFile,
download,
uploadOne,
dismissUpload,
fetchActivity,
loadMoreActivity,
}
})
+103 -9
View File
@@ -1,10 +1,43 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import router from '@/router'
import { useNotification } from '@/composables/useNotification'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import { escapeHtml, relativeTimeKo, taskDueInfo } from '@/shared/utils/format'
import type { ApiNotification, NotificationView } from '@/types/notification'
// Level1 — 페이지가 열려 있으면(포커스 여부 무관) 항상 OS 토스트를 띄운다.
// 페이지가 닫혀 있을 때는 서비스워커 푸시가 담당(SW 는 열린 탭이 있으면 표시 생략 → 중복 방지).
function showLocalNotification(view: NotificationView): void {
if (typeof Notification === 'undefined') return
if (Notification.permission !== 'granted') return
try {
const notif = new Notification('Relay', {
body: view.text,
tag: view.id, // 동일 알림 중복 토스트 방지
icon: '/relay-icon-256.png',
})
notif.onclick = () => {
window.focus()
if (view.to) void router.push(view.to)
notif.close()
}
} catch {
// 알림 생성 실패(권한/환경) 무시
}
}
// 조립된 html → 태그/엔티티 제거 평문(OS 토스트 본문용)
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}
// payload(자유형)에서 문자열/숫자 값 안전 추출
function pstr(p: Record<string, unknown>, k: string): string {
const v = p[k]
@@ -16,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') || '누군가')
@@ -44,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')
@@ -55,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>의 중간 점검 상태를 보고했습니다`
@@ -80,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
@@ -113,7 +177,15 @@ function toView(n: ApiNotification): NotificationView {
html = `<b>${actor}</b>님이 참석 일정 <b>${eventTitle}</b>을(를) 삭제했습니다`
break
}
return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to }
return {
id: n.id,
read: n.read,
tone,
html,
text: stripHtml(html),
time: relativeTimeKo(n.createdAt),
to,
}
}
// SSE 재연결 지연(ms) — 토큰 만료/네트워크 끊김 시 정리 후 재시도
@@ -151,6 +223,8 @@ export const useNotificationStore = defineStore('notification', () => {
items.value.unshift(n)
total.value += 1
if (!n.read) unreadCount.value += 1
// OS 토스트(Level1) — 페이지가 열려 있으면 포커스 여부와 무관하게 표시
showLocalNotification(toView(n))
} catch {
// 파싱 실패(하트비트 등 비정상 페이로드) 무시
}
@@ -161,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 -1
View File
@@ -42,7 +42,8 @@ function toProjectView(api: ApiProject): ProjectView {
progressLabel: prog.label,
canManage: api.canManage,
isOwner: api.isOwner,
attachments: api.attachments,
driveLinks: api.driveLinks,
documents: api.documents,
}
}
+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
@@ -19,6 +19,9 @@ export type ApiActivityType =
| 'task.checkpoint_reported'
| 'task.file_removed'
| 'task.removed'
| 'drive.file_uploaded'
| 'drive.file_deleted'
| 'drive.folder_deleted'
// 활동(API 원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립한다
export interface ApiActivity {
+102
View File
@@ -0,0 +1,102 @@
// 드라이브(파일 저장소) 타입 — 백엔드 drive 모듈 응답과 1:1 미러
// (SSOT: backend/src/modules/drive/drive.service.ts 의 *Response 인터페이스)
import type { FileKind } from '@/shared/utils/format'
// 업로더/소유자(개인 드라이브에선 보통 본인) — PublicUser 부분 미러
export interface DriveOwner {
id: string
name: string
email?: string
avatarUrl?: string | null
}
// 파일 업로드 상태: pending(presign 후 확정 전) | active(확정 완료)
export type DriveFileStatus = 'pending' | 'active'
// 폴더
export interface DriveFolder {
id: string
name: string
parentId: string | null
owner: DriveOwner | null
createdAt: string
updatedAt: string
}
// 파일
export interface DriveFile {
id: string
name: string
size: number // 바이트
mime: string
kind: FileKind // 'pdf' | 'zip' | 'doc' | 'img'
status: DriveFileStatus
folderId: string | null
owner: DriveOwner | null
createdAt: string
updatedAt: string
}
// 브레드크럼 한 칸(루트→현재 경로)
export interface DriveCrumb {
id: string
name: string
}
// 목록 응답
export interface DriveList {
current: DriveFolder | null
breadcrumb: DriveCrumb[]
folders: DriveFolder[]
files: DriveFile[]
}
// presigned 업로드 발급 결과(storage.types.PresignedUpload 미러)
export interface PresignedUpload {
url: string
method: 'PUT'
headers: Record<string, string>
expiresAt: string
}
// presign 발급 응답
export interface PresignUploadResult {
fileId: string
file: DriveFile
upload: PresignedUpload
}
// presign 요청 본문
export interface PresignUploadPayload {
filename: string
contentType: string
size: number
folderId?: string
}
// --- 감사 로그(전사 공유 드라이브 — 누가 무엇을 했는지) ---
// (SSOT: backend/src/modules/drive/drive-audit.service.ts 의 DriveActivityResponse)
export type DriveActivityType =
| 'file_uploaded'
| 'file_renamed'
| 'file_deleted'
| 'folder_created'
| 'folder_renamed'
| 'folder_deleted'
// 감사 로그 1건(표시 문구는 프론트가 type+payload 로 조립)
export interface DriveActivity {
id: string
type: DriveActivityType
actor: DriveOwner | null
payload: Record<string, unknown>
createdAt: string
}
// 페이지네이션 응답(backend common/pagination 미러)
export interface DriveActivityPage {
items: DriveActivity[]
total: number
page: number
size: number
}
+4
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'
@@ -46,6 +49,7 @@ export interface NotificationView {
read: boolean
tone: string // 좌측 점 색상(blue/amber/green/red/accent/'')
html: string // 조립된 문구(사용자 입력은 escapeHtml 적용)
text: string // 태그 없는 평문(OS 토스트 본문용)
time: string // 상대시간
to: string | null // 클릭 시 이동 경로
}
+20 -10
View File
@@ -2,17 +2,26 @@
import type { ApiMember } from '@/types/repo'
// 프로젝트 문서 첨부(API 원시) — 백엔드 ProjectAttachmentResponse 와 1:1
export interface ApiProjectAttachment {
id: string
// 문서 AI 반영 상태 — ready: 반영됨 / failed: 추출 실패 / unsupported: 미지원 형식 / pending: 추출 대기
export type ProjectDocAiStatus = 'ready' | 'failed' | 'unsupported' | 'pending'
// 프로젝트에 연동된 드라이브 폴더(API 원시) — 백엔드 ProjectDriveLinkResponse 와 1:1
export interface ApiProjectDriveLink {
id: string // 연동(link) ID
folder: { id: string; name: string; path: string }
permission: 'read' | 'read-write'
linkedBy: { id: string; name: string; avatarUrl: string | null } | null
documentCount: number // 연동 폴더(하위 포함) 내 파일 수
createdAt: string
}
// AI 검토 대상 문서(연동 폴더 내 드라이브 파일, API 원시) — 백엔드 ProjectDocumentResponse 와 1:1
export interface ApiProjectDocument {
id: string // 드라이브 파일 ID(다운로드는 드라이브 API 사용)
name: string
size: number
kind: 'pdf' | 'zip' | 'doc' | 'img'
url: string // 다운로드 경로(/api/...)
uploader: { id: string; name: string; avatarUrl: string | null } | null
createdAt: string
// AI 추천 반영 상태 — ready: 반영 가능 / failed: 추출 실패 / unsupported: 미지원 형식
aiStatus: 'ready' | 'failed' | 'unsupported'
aiStatus: ProjectDocAiStatus
}
// 프로젝트(API 원시) — 백엔드 ProjectResponse 와 1:1
@@ -25,10 +34,11 @@ export interface ApiProject {
doneCount: number
totalCount: number
updatedAt: string
// 단건 조회에서만 — 현재 사용자 권한 + 문서 첨부
// 단건 조회에서만 — 현재 사용자 권한 + 연동 폴더/문서
canManage?: boolean // admin (설정 변경/탭 노출)
isOwner?: boolean // 소유자 (프로젝트 삭제)
attachments?: ApiProjectAttachment[]
driveLinks?: ApiProjectDriveLink[]
documents?: ApiProjectDocument[]
}
// 프로젝트 목록 정렬 — 최근 업데이트순/이름순/최근 생성순 (백엔드 sort 쿼리와 1:1)
+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 }[]
+138
View File
@@ -0,0 +1,138 @@
<#
.SYNOPSIS
화상회의 "화면 띄우기" 시각 테스트 러너 (Windows 네이티브 lk)
.DESCRIPTION
livekit-cli(lk)로 가상 video publisher 를 대량 접속시켜, 운영(또는 지정) LiveKit
서버의 특정 방(code)에 합성 영상을 발행한다. 사용자가 같은 방에 브라우저로 입장하면
그 가상 참가자들이 실제 영상 타일로 화면에 표시된다(=50명 화면 띄우기 검증).
⚠️ 왜 Docker(load-test.sh) 가 아니라 네이티브 lk 인가:
Docker Desktop(Windows)은 컨테이너 UDP/ICE NAT 한계로 WebRTC 미디어 연결이
타임아웃된다. 브라우저와 동일한 네트워크 경로를 쓰려면 호스트 네이티브 lk 가 필요하다.
설치: winget install --id LiveKit.LiveKitCLI
자격증명/URL 은 루트 .env.{APP_ENV} 에서 읽는다(기본 .env.production).
- LIVEKIT_API_KEY / LIVEKIT_API_SECRET : lk 가 방 토큰 서명에 사용(브라우저 백엔드와 동일 키)
- LIVEKIT_WS_URL : 브라우저가 접속하는 공개 wss URL (백엔드용 LIVEKIT_URL=내부주소 가 아님!)
(.env 의 "==" 오타나 CRLF 는 자동 보정한다)
.PARAMETER Room
대상 방의 입장 code. 앱(relay)에서 방을 먼저 생성한 뒤 /meeting/<code> 의 code 를 넣는다.
생략 시 임의 이름으로 들어가지만, 그 경우 브라우저는 해당 code 의 DB 방이 없어 입장 불가하다.
.EXAMPLE
# 운영 서버, 특정 방에 50명 저해상도 10분
.\livekit\visual-test.ps1 -Room a1b2c3d4e5 -Publishers 50 -Duration 10m
.EXAMPLE
# 화질을 올리되 업로드 대역폭 절약 위해 simulcast 끄기
.\livekit\visual-test.ps1 -Room a1b2c3d4e5 -Publishers 30 -Resolution medium -NoSimulcast
#>
[CmdletBinding()]
param(
[string] $Room,
[int] $Publishers = 50,
[int] $Subscribers = 0,
[string] $Duration = '10m',
[ValidateSet('low', 'medium', 'high')] [string] $Resolution = 'low',
[int] $NumPerSecond = 5,
[switch] $NoSimulcast,
[switch] $WithAudio, # 각 가상 유저가 마이크(오디오)도 발행 (영상 인원만큼)
[int] $AudioPublishers = 0, # 오디오 발행 인원 직접 지정(고급). -WithAudio 보다 우선
[string] $IdentityPrefix = 'load',
[string] $EnvFile
)
$ErrorActionPreference = 'Stop'
# 인원/방 코드를 인자로 안 넘기면 대화형으로 입력받는다(매번 플래그 외울 필요 없음).
if (-not $PSBoundParameters.ContainsKey('Room') -or [string]::IsNullOrWhiteSpace($Room)) {
$Room = (Read-Host '입장할 방 코드(code) 입력 (앱 /meeting/<code> 의 code)').Trim()
}
if ([string]::IsNullOrWhiteSpace($Room)) { throw '방 코드(code)가 필요합니다.' }
$promptedCount = $false
if (-not $PSBoundParameters.ContainsKey('Publishers')) {
$promptedCount = $true
$ans = (Read-Host "입장시킬 가상 인원 수 [기본 $Publishers]").Trim()
if ($ans) {
if ($ans -as [int]) { $Publishers = [int]$ans }
else { throw "인원 수는 숫자여야 합니다: $ans" }
}
}
if ($Publishers -lt 1) { throw "인원 수는 1명 이상이어야 합니다: $Publishers" }
# 마이크(오디오) 처리.
# - -AudioPublishers N : 그 값 그대로 사용(고급).
# - -WithAudio : 영상 인원만큼 오디오도 발행(각 가상 유저가 캠+마이크).
# - 둘 다 없고 대화형이면 켤지 물어본다(스크립트 인자모드에서는 안 물어봐 멈춤 없음).
if (-not $PSBoundParameters.ContainsKey('AudioPublishers')) {
if ($WithAudio) {
$AudioPublishers = $Publishers
}
elseif ($promptedCount) {
$a = (Read-Host '마이크(오디오)도 켤까요? 각 가상 유저가 마이크 발행 [y/N]').Trim()
if ($a -match '^(y|yes)$') { $AudioPublishers = $Publishers }
}
}
if ($AudioPublishers -lt 0) { $AudioPublishers = 0 }
# 루트 경로 / env 파일 결정
$root = Split-Path -Parent $PSScriptRoot
if (-not $EnvFile) {
$appEnv = if ($env:APP_ENV) { $env:APP_ENV } else { 'production' }
$EnvFile = Join-Path $root ".env.$appEnv"
}
if (-not (Test-Path $EnvFile)) { throw "env 파일을 찾을 수 없습니다: $EnvFile" }
# .env 에서 필요한 값만 읽기(주석/CRLF 무시, 마지막 정의 우선)
function Read-EnvValue([string]$key) {
$line = Get-Content $EnvFile | Where-Object { $_ -match "^\s*$([regex]::Escape($key))=" } | Select-Object -Last 1
if (-not $line) { return $null }
$val = ($line -split '=', 2)[1]
return ($val -replace "`r", '').Trim()
}
$apiKey = Read-EnvValue 'LIVEKIT_API_KEY'
$apiSecret = Read-EnvValue 'LIVEKIT_API_SECRET'
# 브라우저 접속용 공개 WS URL. .env 의 "==" 오타(앞 등호) 자동 보정.
$wsUrl = Read-EnvValue 'LIVEKIT_WS_URL'
if ($wsUrl) { $wsUrl = $wsUrl.TrimStart('=').Trim() }
if (-not $apiKey -or -not $apiSecret -or -not $wsUrl) {
throw "LIVEKIT_API_KEY / LIVEKIT_API_SECRET / LIVEKIT_WS_URL 를 $EnvFile 에서 찾을 수 없습니다."
}
# lk 실행 파일 경로 해석 (PATH 우선, 없으면 winget Links 폴백)
$lk = (Get-Command lk -ErrorAction SilentlyContinue).Source
if (-not $lk) { $lk = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\lk.exe' }
if (-not (Test-Path $lk)) {
throw "lk(livekit-cli) 를 찾을 수 없습니다. 설치: winget install --id LiveKit.LiveKitCLI"
}
# 자격증명은 환경변수로만 전달(명령줄 노출 금지)
$env:LIVEKIT_URL = $wsUrl
$env:LIVEKIT_API_KEY = $apiKey
$env:LIVEKIT_API_SECRET = $apiSecret
Write-Host "▶ LiveKit 시각 테스트 (네이티브 lk)" -ForegroundColor Cyan
Write-Host " URL=$wsUrl ROOM=$Room"
Write-Host " publishers=$Publishers audio=$AudioPublishers subscribers=$Subscribers duration=$Duration resolution=$Resolution simulcast=$([bool](-not $NoSimulcast))"
Write-Host " 브라우저에서 같은 방(code=$Room)에 입장하면 가상 참가자 영상이 타일로 보입니다. 중단: Ctrl+C" -ForegroundColor DarkGray
Write-Host ""
$lkArgs = @(
'load-test',
'--room', $Room,
'--duration', $Duration,
'--video-publishers', $Publishers,
'--audio-publishers', $AudioPublishers,
'--subscribers', $Subscribers,
'--video-resolution', $Resolution,
'--num-per-second', $NumPerSecond,
'--identity-prefix', $IdentityPrefix
)
if ($NoSimulcast) { $lkArgs += '--no-simulcast' }
& $lk @lkArgs