Compare commits

..

41 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
ttipo 83d0cda163 Merge feature/drive: 드라이브(파일 저장소/폴더 관리) 기능 추가 2026-06-29 21:18:08 +09:00
ttipo 46f1875ca3 style: 화상회의 방 수정 삼항 연산자 포맷 정리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:11:59 +09:00
ttipo 612c4fec4c docs: 프론트 env 에 VITE_LIVEKIT_URL 매핑 주석 추가
docker-compose 가 LIVEKIT_WS_URL → VITE_LIVEKIT_URL 로 주입함을 명시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:11:59 +09:00
ttipo eebdbc34f5 chore: 메모리 디렉터리 git 추적 제외
Claude Code 작업 메모(/memory)는 저장소 산출물이 아니므로 .gitignore 에 추가한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:11:59 +09:00
ttipo 22a02be937 feat: 드라이브(파일 저장소/폴더 관리) 기능 추가
NCP Object Storage(S3 호환) 기반 파일 업로드/폴더 관리 기능을 백엔드에 추가한다.

- 스토리지 계층(common/storage): presign 기반 업로드/다운로드 URL 발급 래퍼
- 드라이브 모듈: 폴더 CRUD, 파일 presign 업로드→complete→다운로드, 이름변경/삭제
- 엔티티 3종: drive_folders(폴더 트리), drive_files(객체 메타), project_folder_links(프로젝트 공유)
- 마이그레이션 AddDriveTables: 3개 테이블 + 인덱스/외래키 생성
- stale pending 파일 정리 @Cron(매시) 추가
- env/docker-compose: OBJECT_STORAGE_* 주입(비시크릿은 루트, ACCESS/SECRET 키는 backend env)
- 의존성: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:11:48 +09:00
ttipo 101b1553b0 Merge feature/meeting-room-code: 화상회의 방 고유 입장 코드 도입 2026-06-26 17:46:58 +09:00
ttipo 2d1026466f feat: 화상회의 방에 고유 입장 코드 도입(이름과 식별자 분리)
방 식별을 이름이 아닌 불변 코드로 전환 — 이름을 바꿔도 링크·세션이 유지되고
초대 링크의 안정적 기준이 된다.

- 백엔드: meeting_rooms.code(고유·불변) 추가, name 고유 해제(표시용 라벨, 중복 허용).
  마이그레이션 AddMeetingRoomCode(코드 백필+name 고유 인덱스 제거). 토큰 발급은
  code 로 방 조회(자동 생성 제거 — 명시 생성된 방만 입장, 없으면 404). LiveKit 방=code.
  createRoom 이 고유 코드 발급. MeetingRoomResponse 에 code 추가.
- 프론트: 입장/라우팅을 code 기준으로(/meeting/:code), 로비 입장은 방 객체 전달,
  '초대 링크 복사' 버튼(코드 기반 URL) 추가. 생성 폼 이름 중복 검사 제거.

검증: 백엔드 build, 프론트 type-check·lint 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:46:57 +09:00
ttipo 5ecc1d7be4 Merge feature/meeting-room-edit: 화상회의 방 편집 기능 2026-06-26 16:15:31 +09:00
ttipo bde4b561f3 feat: 화상회의 내가 만든 방 편집 기능
진행자(생성자)가 방의 이름·예약 시각·참여 제한(allow-list)을 수정할 수 있다.

- 백엔드: PATCH /meetings/rooms/:roomId (updateRoom, 진행자만, 이름 중복 검사).
  UpdateMeetingRoomDto(생성과 동일 필드). MeetingRoomResponse 에 allowedUsers 추가
  (진행자에게만 노출 — 편집 폼 사전 채움용). User repo 주입해 ID→PublicUser 해석.
- 프론트: useMeeting.updateRoom, 로비 각 방에 편집 버튼(진행자), MeetingCreateForm
  를 편집 모드로 재사용(현재 값 프리필·중복검사 자기제외·저장 분기), MeetingIcon edit 추가.

검증: 백엔드 build, 프론트 type-check·lint 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:15:31 +09:00
ttipo 5866d6ad55 Merge feature/meeting-stage-quality: 스테이지 기반 수신 화질 중앙 제어 2026-06-26 15:15:41 +09:00
ttipo 2015b794fc feat: 화상회의 수신 화질을 스테이지 역할 기반으로 중앙 제어
스테이지(featured) 유저만 HIGH, 나머지 원격 참여자는 LOW 로 구독한다.
스테이지가 바뀔 때마다 강등된 참여자가 LOW 로 내려가도록 보장한다.

- adaptiveStream 비활성화: 화질이 "엘리먼트 크기"가 아니라 "스테이지 역할"로만
  결정되도록(스테이지=HIGH 보장, 강등 반영 결정적). dynacast 는 유지.
- 화질 제어를 useLiveKitRoom 로 중앙화: featuredIdentity 변경·참여자/트랙 변동
  시마다 전원 재적용. 기존 타일 리마운트 의존(레이스로 강등자가 HIGH 유지되던 버그) 제거.
- ParticipantTile 의 개별 setVideoQuality 제거(중복·레이스 원인), 영상 연결만 담당.

검증: type-check, lint 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:15:40 +09:00
ttipo 04995ca91a chore: 루트 임시 스크린샷(image.png) gitignore 추가
작업 중 공유한 스크린샷이 반복적으로 스테이징되는 것을 방지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:57:28 +09:00
ttipo 2740205ed5 Merge feature/livekit-upgrade-1.13.1: LiveKit 서버 1.8.4→1.13.1 업그레이드(클라이언트 호환) 2026-06-25 23:57:14 +09:00
ttipo 4ad762881c Merge commit '6bd7fac875d9e9009915053d8a590abb372c5679' into feature/livekit-upgrade-1.13.1 2026-06-25 23:54:33 +09:00
ttipo 6bd7fac875 Squashed 'livekit-server/' changes from 154b4d26..46c43095
46c43095 fix goreleaser workflow, version 1.13.1 (#4577)
e0815be2 chore: improve docker test shutdown reliability (#4576)
bfd9deff expose TCPFallbackRTTThreshold and AllowUDPUnstableFallback via config (#4556)
b93c1e16 Release v1.13.0. (#4573)
fd452212 Update mediatransportutil to get ICE candidate timeout config (#4572)
8be8c74a Update github workflows (#4463)
c4e41872 Update go deps to v1.17.2 (#4462)
dc8e0310 Update go deps to v4 (#4482)
20fd1ad2 turn: allow for providing secret via file (#4564)
6590570d Pin pion/dtls to v3.1.2 (#4570)
cdbbee1f deps: bump protocol + psrpc to latest tips (#4565)
d290de81 Correct config comment (#4563)
77ecf920 rtc: report participant session end time on room move (#4561)
63be96f6 Prevent panic from nil(illegal) syncState.Subscriptions message (#4560)
835ef1b3 Metrics for participant active, i. e. fully established. (#4557)
5bd42534 Document of advertise_internal_ip and external_ip_only (#4554)
356ae211 Config documentation for advertise_internal_ip and skip_external_ip_validation (#4552)
7c319a67 rtc: prevent duration reporting for inactive participants (#4550)
2dd5e632 telemetry: split webhook-processed hook out of NewTelemetryService (#4548)
222177a9 service: prevent nil deref in validate with wrapped join request (#4547)
dd7580b4 Protect against nil clientInfo (#4546)
145689e6 Start tracking Twirp method request latency in prometheus too, not just in logs (#4545)
cde89627 rtc: emit per-data-track bytes via BytesTrackStats (#4540)
2e22911d Remove backwards compatibility support for TURN auth. (#4539)
062d1219 Use NACKQuueInterface type. (#4538)
7f08b04c Add IsIntentionalDisconnect helper (#4537)
1ab2bf04 Clean up packet size logging (#4536)
8ab92a80 Don't require media sections when joining (#4535)
019a6640 rtc: report participant kind code and details (#4534)
77595d38 TEL-336: fix sip error categorization (#4528)
f303f499 Always enable rtx codec (#4533)
e4a8a55c Check Less and LessEq in version compare. (#4532)
37eb7a32 Release v1.12.0 (#4529)
4a7b1e85 Create NACK tracker only once. (#4527)
89faaeba Apply ttl check only when authenticate allocation creating (#4526)
b32933b0 Log details of RTCP packets. (#4525)
8b79ec9e Support SIP auth realm for inbound. (#4522)
4b8db3cf Add integration test for TURN auth failures (#4524)
ef2e5efe Log large packets receive/send. (#4521)
d1236750 feat: auto create rooms for tokens with the RoomCreate grant (#4320)
7a3e595b apply room tags from JWT grant room configuration (#4518)
ab7fdeab add AssignmentHook to AssignJob; propagate websocket write errors (#4516)
cf20c9cd Add expiry to TURN password. (#4515)
20d4a3a1 Populate data track loggers with context (#4514)
12fff29a allow setting agent job assignment url (#4512)
ba366fc7 Fix SIP media config upgrade. (#4511)
8fbc5adf update protocol for protojson (#4510)
3de6f517 Add TURN permission handler. (#4505)
8ffcef93 Update protocol to support SIP media config. (#4509)
1ab1e072 test: verify upstream and downstream connection stats end-to-end (#4508)
c4fd71a5 Fix sense check in DeltaInfo gathering (#4507)
803999ef rename agent environment to deployment (#4506)
bacc21e6 add helper to check for agent worker endpoint (#4503)
253f977d add duration seconds reporting (#4500)
ffab3bd3 add agent environment (#4498)
ccdf23c8 Use mediatransportutil/codec package, no functional change (#4497)
680703f2 Include reception reoprts in receiver report callback. (#4496)
f51798bc Fix publish-only limitations being incorrectly applied to receivers (#4495)
a002337d Legacy TrackInfo.Simulcast flag. (#4493)
af1dcc88 Add CloseWithReason to agent SignalConn interface (#4492)
d7c2daf1 report all simulcast layers (#4491)
c1ad2b22 Misc optimisations. (#4490)
19b9e8c0 Additional data tracks logging (#4489)
743d9c8b add support for client capabilities (#4461)
fc47e478 Close peer connection unconditionally to unblock set local/remote (#4485)
639406eb Update module github.com/pion/ice/v4 to v4.2.3 (#4481)
dc6b7505 reduce some heap use in packet path (#4478)
f3b80b28 fix: wrap IPv6 addresses in brackets in UDP TURN URLs (RFC 3986) (#4476)
3a7f2628 Turn off transceiver re-use on Safari. (#4474)
d84f3d7a add more types to signum (#4473)
701a37c2 Convert sort.Slice -> slices.SortFunc (#4472)
85be9d70 Avoid stream allocator event data cast to interface and back. (#4471)
b43685e8 Keep a shadow copy of tracks for use by different stream allocator state (#4470)
27c2b149 Consolidate RTCP packets and do RTCP callback outside lock. (#4469)
31083307 do not log data track stats if not started (#4468)
9ee06635 feat(pion/ice): replace deprecated NAT1To1 with SetAddressRewriteRules (#4466)
8ccad68d Release v1.11.0 (#4459)
dbf5cf61 Store concrete ICE candidate for remote candidates. (#4458)
2a04bc3c fix publisher frame count reporting for simulcast streams (#4457)
1d804737 fix: limit join request and WHIP request body to http.DefaultMaxHeaderBytes (#4450)
3cfb71e7 Use Muted in TrackInfo to propagated published track muted. (#4453)
69aa9479 Some drive-by clean up (#4452)
6c81f678 Add subscriber stream start event notification (#4449)
ce1bf47b Revert "fix: ensure num_participants is accurate in webhook events (#4265) (#…" (#4448)
cdb0769c fix: ensure num_participants is accurate in webhook events (#4265) (#4422)
c91e79af Switch to stdlib maps, slices (#4445)
ea7b9c6f Update module github.com/livekit/protocol to v1.45.3 (#4435)
97378368 Update go deps (major) (#3179)
d6aef547 Update go deps (#3862)
afc9feae Update github workflows (#4331)
4b385612 chore: pin GH commits and switch to golangci-lint (#4444)
2974ba87 Unsubscribe from data track on close (#4443)
5dc4e90d Apply IPFilter when get local ip (#4440)
88c77dc6 compute agent dispatch affinity from target load (#4442)
8fe99377 Log join duration. (#4433)
0a503a57 Add `Close` method for UpDataTrackManager and call it on participant (#4432)
55912dff Add some simple data track stats (#4431)
050909e6 Enable data tracks by default. (#4429)
72c7e65c chore: log API key during worker registration (#4428)
8a67dd1b Do not close publisher peer connection to aid migration. (#4427)
91e90c10 Add some more logging around migration. (#4426)
c6ddc879 isExpectedToResume is based on whether flushing or not. (#4425)
7d06cfca Keep subscription synchronous when publisher is expected to resume. (#4424)
934f8598 Clean up data track observers on unsubscribe. (#4421)
9674ac48 Cleaning up some logs and standardising log frequency. (#4420)
7b925304 Drop time inverted packets in RED -> Opus conversion. (#4418)
4d8d232a ensure participant init is correctly serialized for logging (#4417)
4fe80877 Log time inversion between incoming packets (#4415)
248d7394 Guard against timestamp inversion in RED -> Opus conversion. (#4414)
9ab8c1d5 clear track notifier observers on subscription teardown (#4413)
397cd09a Embedded turn test (#4412)
56326654 Prepare release v1.10.1 (#4408)
e9b113c8 Make the TURN bind address configurable and allow for multiple addresses. (#4315)
4bc5e6bb Address malformed H264/H265 parsing issues. (#4407)
77a0a4fc AV1 parser overflow fix. (#4405)
ff7fd7ed feat(agent-dispatch): add job restart policy (#4401)
34bd1e08 do not log roll over for padding only packets (#4396)
13d02ee9 add deadline to dtls connect context (#4395)
9055a349 Path check helpers (#4392)
1f1eeb68 Fallback to servicestore if rpc is unavailable (#4391)
59e9bb41 Fix TURN server URL (#4389)
9e0a7e54 Close both peer connections to aid migration. (#4382)
9474c807 route participant reads through PSRPC instead of Redis (#4387)
a5333a86 add packet trailer stripping support (#4361)
0e3d765d Release v1.10.0 (#4380)
bc3aeaf3 Update grpc to address CVE-2026-33186 (#4381)
8cdd6f4c Replace deprecated io/ioutil with io in whipservice (#4375)
89410df7 handle AGENT_ERROR disconnect reason (#4339)
8f984c77 Fix repair stream ID reporting for RTX pairing. (#4369)
cdfaacfc Restart nacker on OOB sequence number restart. (#4368)
4a9e0045 Update protocol. (#4367)
750d5904 Add API to restart lite stats. (#4366)
c8bb2578 Rename log field "pID" to "participantID" for consistency (#4365)
77fc74a7 Do not block all ext ID determination on stream allocator listener (#4364)
90a46fab Do not kick off migration of closed participant (#4363)
5dc2e7b1 Switch data track extension to 1-byte ID/length. (#4362)
7323ad02 Sample data send error logging. (#4358)
0d34e455 Add option to not re-use transceiver in e2ee. (#4356)
95225ff2 don't require media section for dual peerconnection mode (#4354)
e9639538 Refine ipv6 support (#4352)
b34b0472 Add StopEgress function to the EgressLauncher interface (#4353)
69235ed2 update readme (#4340)
db1a8046 defensive check for peer connection instance (#4350)
cb7dc2d0 TEL-405: support originating calls from custom domains (#4349)
7eaaaada Mark last run of grow bucket outside goroutine. (#4348)
caa47522 Add option to require media sections when participant joining (#4347)
087050d1 Release v1.9.12 (#4346)
493e87df Fix SIP client timeout. (#4345)
52c28a93 Log a bit more details of out-of-order TWCC feedback report. (#4343)
516aeabf Use ParticipantTelemetryListener of LocalParticipant. (#4342)
b3510565 Exclude ice restart case from offer answer id mismatch warning (#4341)
303657bc feat: make INSTALL_PATH overridable in install script (#3954)
9d418689 Send participant left event after track unpublished for moved (#4334)
bab91868 do not discount packets lost on duplicate packets (#4333)
939794cf mark + restart (#4329)
75f9c462 Add debug for receiver restart. (#4328)
74891f30 Protect against incorrect temporal layer. (#4327)
f51b2732 Update pion/webrtc and deps to update dtls (#4326)
b81bac0e Key telemetry stats worker using combination of roomID, participantID  (#4323)
2d40449f Update self-hosting deployment documentation link (#4312)
03e90dd7 Fix for some CodeQL reported issues (#4314)
77c858f0 Log stats worker when it not closable. (#4313)
a6035212 ESP32 client info (#4267)
478e486a Copilot suggested improvement to Github Actions permissions (#4310)
cbd2f82d Copilot suggested improvement to Github Actions permissions (#4311)
a9b8d40d Publish is always on publisher peer connection. (#4307)
8ae56406 generate & log egressID for start egress request (#4303)
35d7ef88 Avoid alloc in RTPStatsReceiver.Update (#4302)
bb744916 More optimisation in RTPStats module. (#4298)
cefd5da9 Optimise some bits in rtpstats_receiver (#4297)
52a4b848 Read client protocol from query param (#4294)
195b17f6 Populate client_protocol field in ParticipantInfo (#4293)
370e0a4d Set up audio config in audio level module when config is updated. (#4290)
f3e9b688 Do not increase max expected layer on track info update. (#4285)
a9849340 Avoid logger data race. (#4284)
97016627 Do not hold lock when creating buffer (#4283)
6b68e3d5 Create buffer if needed when a PLI is requested. (#4282)
3cca7180 use separate allocation for signal stats telemetry guard (#4281)
d2bae34d refresh telemetry guard on participant move (#4280)
333f0349 clear reference guard when resetting signal stats (#4279)
d1bab17b Add session duration and participant kind to closing log. (#4277)
1e689e1a Reducing some info level logs. (#4274)
88facc02 adds a test to ensure agent worker errors cause disconnection (#4273)
76a41a7a Generate config flags (#4268)
700e1788 require participant broadcast when metadata/attributes are set in token (#4266)
b61799ec Ignore parse addr error when add remote candidate (#4264)
01bd966f Add silent frame for pcmu/a (#4258)
0c33b8c6 chore: move codecs/mime stuff to protocol (#4255)
165c1735 Update livekit protocol to v1.44.0 (#4254)
343f12b8 Wrapping SIP errors for invalid argument and not found (#4253)
aea044c5 Defer setting clock rate in RTPStats module till codec is bound. (#4250)
d9f716c1 FIx receiver restart race (#4248)
0508ee9f remove copy/paste left over line (#4246)
40408407 Release v1.9.11 (#4245)
f8675507 Do not remove participant from cache on disconnect. (#4241)
a35a6ae7 Add participant option for data track auto-subscribe. (#4240)
07572511 Wrapping the invalid request errors for CreateSipParticipant (#4239)
843d8c3e Update Pion transport package. (#4237)
a05690d2 Changing field naming of data track packet (#4235)
c4015008 Clear participant version cache on disconnect (#4234)
18db4ec1 Log modified timeout of API context. (#4232)
ac20ccda Log timeout in API. (#4231)
541a7d6c Change some logs to debugw (#4229)
52ab3374 Return on SDP fragment read error. (#4228)
7fae5ac9 Do not restart receiver on codec change mid-session. (#4225)
dafdc36e Add option to force simuclast codec. (#4226)
0a7dd40b Use only layer 0 for SVC codecs. (#4224)
4ec0f8f4 Support OpenTelemetry tracing. Add Jaeger support. (#4222)
80ba93fa Do NACK updates as soon as flow state is processed. (#4221)
4405afe2 Use atomic pointer and return interface from RED transformer constructors (#4220)
b649c2fe Remove method not needed from REDTransformer. (#4219)
f0080f35 Remove enable arrival time forwarding method. (#4217)
335f4c33 Swap result sink atomically rather than closing and setting. (#4216)
46651c19 Release v1.9.10 (#4214)
08ac4ecd Support preserving external supplied time. (#4212)
f6efccce report video size from media data for whip (#4211)
d92f6a79 return iceservers for whip (#4210)
1a4758ed Skip restart callback if external. (#4208)
dde4fb49 configurable dependency descriptor restart (#4207)
08793bea Use active at time to check for track not bound timeout. (#4206)
3606ce54 Do not warn about track not bound if participant is not ready. (#4205)
b8ddd0f9 Taking interface{} -> any modernize bits (#4204)
b91cd2e4 Rework receiver restart. (#4202)
bb00c864 Restart API on receiver. (#4200)
25ece1e9 Minor refactor in buffer base and audio level (#4198)
599002f8 ignore PLI requests for non-video (#4196)
2510b946 Taking a bunch of go modernize suggestions. (#4194)
ed8e6afc Handle repair SSRC of simulcast tracks during migration. (#4193)
c6bf7a27 Fix logging key and other clean up around stream restart. (#4192)
3cb9abb6 Update pion/webrtc to v4.2.1 (#4191)
8b0efb8c Resolve RTX pair via OnTrack also. (#4190)
381bce03 Return extended sequence number only and not packet. (#4189)
6bcbf54e Always instantiate nacker when using out-of-band sequence numbers. (#4187)
e71184de Store buffer after creating it.  (#4186)
7c8ea115 Refactor receiver and buffer into Base and higher layer.  (#4185)
4104b827 update protocol (#4183)
cd99fec2 Make new path for signalling v1.5 support. (#4180)
32cd0370 Flush the ext packets on restart/close and release packets. (#4179)
1df1316b Move OnDataTrackMessage to participant listener to support replay. (#4178)
e7601251 Make data message naming a bit more consistent. (#4177)
a04e566d Use published track for model access in data down track. (#4176)
47c86be1 Add support for TURN static auth secret credentials (#3796)
24559a28 chore(deps): update github workflows to v6 (#3866)
0a824386 add explicit room exists servicestore op (#4175)
39bd077d Release v1.9.9 (#4174)
cbb2c617 Publish/Unpublish counter match. (#4173)
fb849edc Minor clean up (#4172)
c28e5e45 fix(kindToProto): update protocol (#4171)
47324abd Drop run away receiver reports. (#4170)
462ec324 prevent uint overflow setting packet not found count (#4169)
5c841b8e Some logging changes. (#4168)
2f2d0a57 skip lost sequence number ranges in getIntervalStats (#4166)
898ebe05 clean up manual roomservice log redaction (#4165)
3e417253 move delete to oss service store (#4164)
5964efbb Ensure subscribe data track handles are unique (#4162)
a26c4830 Add support for RTP stream restart. (#4161)
386f0b38 fix typo in clearing index when removing track from room track manager (#4158)
0abfb251 deregister observability function when participant is closed (#4157)
97aba5e7 Consistently undo update to sequence number and timestamp when the (#4156)
2317c295 Fix panic while removing track from room track manager. (#4153)
a0a28ac5 Avoid duplicate track add to room track manager.  (#4152)
f01008f8 Revert telemetry stats worker wait configuration. (#4151)
ca4b56d2 Handle case of sequence number jump just after start. (#4150)
97099cae Configurable telemetry stats worker clean up wait. (#4148)
d7db7cb3 chore: fix a large number of spelling issues (#4147)
cadf2ad9 Release v1.9.8 (#4145)
498304cd defensive nil check (#4144)
20f6a497 Store ddParser in atomic.Pointer (#4143)
037cb906 release ext packet if patching fails (#4142)
dd598ef2 Release ExtPacket if dependency descriptor or other parsing fails (#4141)
35b0e2bc update webrtc to 4.1.8 to pick up DTLS fingerprint check during handshake (#4140)
1c1a836c Mark RTCP buffer Write as noinline. (#4138)
ea9b2177 protocol deps to get inactive file adjusted memory usage. (#4137)
64f3d1e9 switch participant callbacks to room to listener interface (#4136)
c6e6c021 add debug metric for tracking references (#4134)
a30c79fa Use isEnding to indicate if down track could be resumed. (#4132)
8c241ecf Fix RTCP reader leak in DownTrack. (#4131)
3eef869a Do not pause rid in SDP (#4129)
8e01e595 Release 1.9.7 (#4128)
7c1a0fab Fix concurrent map access. (#4127)
14446b1c Let participant close remove the published tracks. (#4125)
fa0633aa move utils.WrapAround to mediatransportutil (#4124)
f8706cd4 Update pion/ice to stop gather first on close (#4123)
7954748d Data tracks (#4089)
7158d983 log bucket growth (#4122)
04b35eb6 Release v1.9.6 (#4121)
ebdcead5 Update mediatransportutil to get bucket packet size limit. (#4120)
411b09f6 Release v1.9.5 (#4119)
8dcf235a Update pion/ice - attempt to address tcp packet conn close hang (#4116)
64c65143 Update mediatransportutil (#4115)
0a2943bb Clean up bits added to debug peer connection close hang. (#4114)
9c483a69 Use released version v1.8.41 of pion/sctp (#4113)
35c79a57 Update SCTP hash, had the wrong one in previous PR (#4111)
e0fbbef1 Update pion/sctp with RFC9260 revert (#4110)
f3c80917 Try SCTP with read deadline to unblock abort. (#4109)
bd5382da Splitting transport close timeout logs. (#4108)
6d4154b8 Update pion/ice. (#4107)
a6418ae2 Log more peer conenction state on close timeout. (#4105)
06d99974 Check for cancel on unsubscription/source track going away. (#4104)
7f10e18b Record join/publish/subscribe cancellations. (#4102)
40293632 Clear stereo=1 if stereo is not enabled. (#4101)
70f6def3 Add checks for participant and sub-components close. (#4100)
ffbabcc7 Switch forwarding latency log to Debugw (#4098)
27d82a72 Fix "address" typo in transport logs (addddress → address) (#4097)
37a06821 logger proto redaction. (#4090)
54cf7d46 Control latency of lossy data channel (#4088)
5175c1af Lock x/tools at 0.37.0 (#4085)
d510fff1 Downgrade x/tools to be able to make a release (#4084)
c3ea5890 Prepare release v1.9.4. (#4083)
3a128e61 protocol bump for SIP error mapping and validation (#4081)
c3964ba2 Use sync.Pool for objects in packet path. (#4066)
f8b994d4 Forwarding latency measurement tweaks. (#4080)
f4929f09 Revert "Revert pion/transpor to v3.0.8 (#4073)" (#4074)
a04d9c48 Revert pion/transpor to v3.0.8 (#4073)
2d5054ad kind details for connector (#4072)
a272e28a Log raeson for subscriber not being to determine codec. (#4071)
b9b4eec9 Update pion/transport to v3.1.1 (#4070)
b23d093c update protocol (#4069)
4ce07bed Higher resolution forwarding latency histogram. (#4067)
858db7ab fix(deps): update module github.com/livekit/protocol to v1.43.0 (#4015)
1dc9b8fc Use buffered indicator to exclude from forwarding latency.  (#4062)
f117ee51 Track start up delay.  (#4061)
4872f205 Return write count from WriteRTP. (#4059)
d0ba46b4 Log write count atomic. (#4057)
ae5fb7e8 Add packet to forwarding stats only if packet is forwarded. (#4056)
f6909192 Update PsRPC to get redis pipeliner implementation. (#4055)
ca3c507b Prevent invalid track access while peer connection is shutting down. (#4054)
9ca6ee00 Use replace so that x/tools does not get overridden (#4048)
b9323eab chore(deps): downgrade x/tools for counterfeiter (#4047)
2f1e6c36 Prep release v1.9.3 (#4046)
9d5c351d Fix prom units for forwarding latency/jitter. (#4045)
e183657c Add prom histogram for forwarding latency and jitter. (#4044)
1eefeb30 Enable AbsCaptureTimeURI in RTC configuration (#4043)
075a7576 Use simulcast codec as default policy for audio track (#4040)
c264b504 Don't warn 0 payload type for PCMU (#4039)
32fc3525 Broadcast cond var on RTX write. (#4038)
061eb8b4 AddDownTrack to regressed codec after restarting forwarder. (#4037)
c87eb8ed fix: add missing Unlock() in AddReceiver (#4036)
70444924 if RingingTimeout is provided, deadline should be set to that timeout. (#4018)
ab906d71 Prevent leakage of previous codec after codec regression. (#4035)
79b03f97 Log queueing latency when encountering high forwarding latency (#4034)
29117b14 set max layer in allocation (#4033)
15b19ccd Remove ~ from rid which indicates disabled layer to get the actual rid (#4032)
34e16a87 Check more conditions for opportunistic alloc. (#4031)
81fbd355 Use the optimal allocation function for opportunistic allocation. (#4030)
a2ce73e0 Do not bind buffer if codec is invalid. (#4028)
cef6fdb7 Correct direction for request/response for prom counters. (#4027)
5042c06c Use rtp converter from protocol/utils/rtputil (#4020)
5a426d15 Use rtp converter from protocol/utils (#4019)
35fb8877 feat: use env var for GOARCH (#4012)
c0397696 Issue #1 only: Fix spatial layer initialization in Forwarder (#4003)
2afbf0e8 Some golang modernisation bits. (#4016)
484f784a Prepare release v1.9.2 (#4011)
ad074ed2 counterfeiter needs an older version of x/tools (#4009)
e63e8b6f Include mid -> trackID in both SDP offer and answer. (#4007)
781dfede Do not call receiver methods under settings lock. (#4006)
69ff25a0 Use answer with mid -> trackID mapping when in single peer connection (#4005)
fe912acf Update pion/webrtc to prevent GetStats panic. (#4004)
7930dcde Do not try to read stats from peer connection after close. (#4002)
ca0d5ee9 Count request/response packets on both client and server side. (#4001)
dd62eb00 Resort to full search for requested quality is not available. (#4000)
f6ca82d1 Revert to using silence packets for audio dummy start. (#3999)
0e2c59c8 Sort codec layers when adding track (#3998)
100bb46a Adding ProviderInfo to GetSIPTrunkAuthenticationResponse (#3993)
a8d4df66 "Power of Two Random Choices" option for node selection  (#3785)
a20bbe34 Log RPC details. (#3991)
158496bc Increment RTP timestamp on padding when using dummy start. (#3989)
4f6ed65d Limit check to red + opus when looking for primary codec match. (#3988)
a87f6c4b Allow passing inline trunk for outbound calls. (#3987)
bf06596f Support Opus mixed with RED when encrypted. (#3986)
ea208a1c Add encryption datapacket type (#3869)
2a6adbe8 Use padding only packets for dummy start of audio. (#3984)
be018f97 Provide the InputVideo/AudioState to Ingress in WHIPRTCConnectionNotify (#3982)
146bd969 Do not panic of redis is not configured (#3981)
01337ba7 Do not start forawarding on out-of-order packet. (#3985)
c7f625d6 Do not force codec regression between opus and red. (#3980)
3bd20ddb Revert unintentional change to not handle transport fallback on (#3970)
89a2f46c Update deps to fix redis issue when 1 cluster address is provided (#3969)
060719d1 add config for user data recording (#3966)
b3ee219c fix stats worker closed condition (#3965)
3d737031 add idempotent reference count to telemetry stats worker (#3964)
735c663a Update protocol for EventKey helper. (#3963)
646b9de8 Add node_ip to config-sample.yaml (#3960)
0bf7b178 avoid logging on small values (#3958)
00ff2ab9 Adjust for hold time when fowarding RTCP report. (#3956)
e180be06 short circuit participant broadcast filter in livestream mode (#3955)
bfba6fee Adjust stream allocator ping interval based on state. (#3951)
3837006b Revert "Switch ops queue a singly linked list. (#3949)" (#3950)
990c5faf feat: server rpc apis (#3904)
80b11662 Switch ops queue a singly linked list. (#3949)
56ee2328 handle terminated job requests (#3948)
49f9b9c8 Flush stats when there are no packets. (#3947)
e6a3df1e ForwarStats.GetStats needs to be public (#3946)
824d116b Tweaks tresholds for logging high forwarding latency/jitter. (#3945)
408492e0 Log some information around high forwarding latency. (#3944)
6a41fae5 Use microseconds for forwarding stats. (#3943)
856e0871 mediatransportutil to log local address when validating external IP (#3942)
40101cf7 Update protocol for SipCreateParticipant (#3939)
b07e7a38 Use difference in key frame counter to stop seeder. (#3936)
d7f92878 Avoid matching on empty track id. (#3937)
56fb2885 Do DD restart only if DD structure is present. (#3935)
86facce9 More debugging of DD jump (#3934)
6058a3f6 Add debugging from DD frame number wrap around. (#3933)
dc6825c0 mediatransportutil crash fix for logging local address (#3930)
d6f0588f Update mediatransportutil to log external IP found via STUN. (#3929)
2c30a064 Fix dynacast subscriber node clearing on move participant. (#3926)
6489237e Simulcast audio fixes (#3925)
9f0ab870 Wait for `SetRemoteDescription` before configuring senders. (#3924)
df6c26db Subscrbed audio codecs - update from remote nodes. (#3921)
798fa761 Support simulcasting of audio (#3920)
f4a06cf0 Clean code as there is no oss sweeper for ingress (#3918)
5f561b4f Include agent_name as a participant attribute (#3914)
782a35e8 update protocol for psrpc (#3915)
eee2001a Set publisher codec preferences after setting remote description (#3913)
fc995533 add incoming request id to request response message (#3912)
76645fad Rpcs for ingress proxy WHIP (#3911)
991a4a4f Refactor subscribedTrack + mediaTrackSubscriptions. (#3908)
e16b3ba9 Use gzip reader pool (#3903)
17c34921 update protocol for sip api change (#3902)
2f43a575 Release candidate for v1.9.1 (#3899)
07c40cf3 Use `RequestResponse` to report protocol handling errors (#3895)
98352fd0 Prevent race in determining BWE type. (#3891)
f7291fda Do not send both asb-send-time and twcc. (#3890)
21b42fa6 Do not advertise NACK for RED. (#3889)
6633bf93 Use departure timeout from room preset. (#3888)
38f7906e Handle migration better in single peer connection case. (#3886)
5026de2b handle frame number wrap back in svc (#3885)
091e3c13 Revert to using answer for migration case. (#3884)
2aeadf14 init ua parser once (#3883)
998a9f94 Switch known rids from 012 -> 210, used by OBS. (#3882)
890fd942 Single peer connection mode (#3873)
bfe98eaa fix: ensure the participant kind is set on refresh tokens (#3881)
8d270e2a chunk room updates (#3880)
b4e146c5 update mediatransport util for ice port 3478 (#3877)
dc3a7753 Fix timeout handing in StopEgress (#3876)
d62336e1 Remove unnecessary check (#3870)
c58e5d23 Update golang Docker tag to v1.25 (#3864)
98d577ee Update module github.com/livekit/protocol to v1.40.0 (#3865)
afbf541e Update pion deps (#3863)
b660c3b5 Extract video size from media stream (#3856)
456b8709 Fix missed unlock (#3861)
d500806e Handle no codecs in track info. (#3859)
11b240d6 Log track settings more. (#3853)
1aa0f963 Log signal messages on media node. (#3852)
b182d07b Log signal messages as debug. (#3851)
a370bb20 Support G.711 A-law and U-law (#3849)
fa5f4ef3 Populate SDP cid in track info when available. (#3845)
eed27885 Send `participant_connection_aborted` when participant session is closed (#3848)
61e59346 Update go deps (#3439)
1b228913 Support video layer mode from client and make most of the code mime aware (#3843)
f2da4444 Support per simulcast codec layers. (#3840)
f275f592 handle SyncState in join request (#3839)
5d44cf6d Use wrapped join request to be able to support compressed and uncompressed. (#3838)
5ca16264 Support join request as proto + base64 encoded query param (#3836)
7dea1012 Clean up missed v2 pieces (#3837)
34a49130 Delete v2 signalling (#3835)
1fe33716 Fix: RingingTimeout was being skipped for transferParticipant (#3831)
5751692a deps (#3829)
db4bc127 Get to the point of connecting publisher PC and using it for async signalling (#3822)
5e483e75 update readme (#3809)
e3155b14 Get to the point of establishing subscriber peer connection. (#3821)
a7ce1382 HTTP DELETE of participant session (#3819)
01de0e36 Do not send leave if nil (to older clients) (#3817)
10103449 Add country label to edge prom stats. (#3816)
68387b41 Minor tweak to keep RPC type at service level. (#3815)
a75295fc More v2 signalling changes (#3814)
b20db94d Validation end point for v2 signalling. (#3811)
f2f595f4 update readme (#3808)
fffc2ac0 Use signalling utils from protocol (#3807)
f5fc82d3 Filling out messages unlikely to change in v2. (#3806)
1c99b9ad Split signal segmenter and reassembler. (#3805)
0a1bfd30 Signal handling interfaces and participant specific HTTP PATCH. (#3804)
7837c8e5 starting signaller interface (#3802)
18ce5244 Handle Metadata field from RoomConfig (#3798)
2a6a9b8a Grouping all signal messages into participant_signal. (#3801)
078c01fa Signal v2: envelope and fragments as wire message format. (#3800)
b9a44c3f Signalling V2 protocol implementation start (#3794)
ba702a53 forward agent id to job state (#3786)
1f31d430 Map ErrNoResponse to ErrRequestTimedOut in StopEgress to avoid returning 503 (#3788)
51bbe8c5 Set participant active when peerconnection connected (#3790)
40028dc3 Normalize known rids. (#3779)
ddd92329 Return default layer for invalid rid + track info combination. (#3778)
8c033ce9 Enable H265 by default (#3773)
7678e087 Set rids for all codecs. (#3772)
5d636acf Limit taking rids from SDP only in WHIP path. (#3771)
4d09e5b5 Log SDP rids to understand the mapping better. (#3770)
c69f1aae Revert "Temporary change: use pre-defined rids" (#3769)
8197438e bounds check layer index (#3768)
d11da5f5 Temporary change: use pre-defined rids (#3767)
cb4da533 fix(deps): update module github.com/livekit/protocol to v1.39.3 (#3733)
d6d2b6d8 feat(cli-flags): add option for cpu profiling (#3765)
9fc4ddbe ClearAllReceivers interface is used to pause relay tracks. (#3761)
1216113b Do not need to just clean up receivers. Remove that interface. (#3760)
ef6c38ce Log previous allocation to see changes. (#3759)
01bf9685 SVC with RID -> spatial layer mapping (#3754)
c481396f offer could be nil when migrating. (#3752)
8c2fc0bc Fix svc encoding for chrome mobile on iOS (#3751)
e467daa0 move egress roomID load to launcher (#3748)
3783ebb3 feat(cli): update to urfave/cli/v3 (#3745)
03d3fcab Fix data packet ParticipantIdentity override logic in participant.go (#3735)
068b4366 reuse compiled client config scripts (#3743)
e754a860 return error when moving egree/agent participant (#3741)
7542cf07 remove unused code (#3740)
9d569e2f Take ClientInfo from request. (#3738)
80774263 chore: set workerid on job creation (#3737)
5549ab55 Revert clearing RIDs. (#3732)
ae967313 Clear rids if not present in SDP. (#3731)
0e033907 Return highest available layer if requested quality is higher than (#3729)
9ce737db Add log for dropping out of order reliable message (#3728)
1b95e818 Don't check bindState on downtrack.Bind (#3726)
670f927f Set and use rid/spatial layer in TrackInfo. (#3724)
a9e29116 Add Id to SDP signalling messages. (#3722)
4ec828ce Fix bug with SDP rid, clear only overflow. (#3723)
8f6c3a9b Clear rids from default for layers not published. (#3721)
ce07740e Add simulcast support for WHIP. (#3719)
e98fb94f Create client config manager in room manager constructor. (#3718)
fdf9b852 e2e reliability for data channel (#3716)
35dda8ea swap pub/sub track metrics (#3717)
1d9a4366 Do not require create permission for WHIP participant. (#3715)
e0aea17a Flush stats on close (#3713)
630aa7d9 implement observability for room metrics (#3712)
e7f0294e remove unused ws signal read loop (#3709)
77f70b18 for real, pick up protocol change for webhooks queue length bucnkets (#3707)
7b180646 protocol dep for webhook stats buckets (#3706)
b0ab95ba warn about credentials when used in tokens (#3705)
a72ce30f Small changes to add/use helper functions for length checks. (#3704)
425f6bb3 Allow passing extra attributes to RTC endpoint. (#3693)
758e1762 Add a trend check before declaring joint queuing region. (#3701)
fe81e411 Adds Devin to readme so it auto updates DeepWiki weekly (#3699)
09dede35 version bump v1.9.0 (#3698)
fc867c5b Webhook prom stats (#3697)
0e17916f Do not use Redis pipeline for SIP delete. Fixes Redis clustering support. (#3694)
1b760393 WHIP support. (#3692)
e4f7d81b add client ip to agent worker registration (#3675)
6b849a87 update mediatransportutil for sctp congestion control (#3673)
83b189b0 Add ServerInfo to ReconnectResponse (#3671)
5f87a35b Prevent operating on swapped out map. (#3670)
13b55a80 move agent token (#3669)
c9385edd handle agent worker jwt (#3668)
3b359d8b Use logger resolver reset to reset contexts. (#3665)
dbb70e0f Fix dynacast quality for moving out tracks (#3664)
0a5f3c2a resolve new room name logger earlier when moving participant (#3662)
2df05517 Revert unbound transceiver stop. (#3661)
5172af15 ~Send initial participant update only after a participant becomes active.~ - General clean up (#3655)
5d4d86f8 protocol to pick resolver values replace (#3659)
7f8e6323 Send self participant update immediately. (#3656)
11630878 Use unordered for lossy data channel. (#3653)
aee34ffe log request for agent dispatch api (#3650)
793b383a Add Moving participant to another room (#3648)
2fff36cb Stub MoveParticipant so that cloud can include the latest protocol. (#3646)
d4ab1142 Redact address (#3643)
b83190a3 protocol update to fix memory stats path (#3642)
a1f4e88e Update protocol to latest, got bit by tag (#3641)
8d3902af Protocol to pick up cgroups v2 memory path fix (#3640)
2f002388 Use participant close reason in remove. (#3639)
58822c26 Include clientInfo in connectivity logs. (#3638)
6d6393a6 Take AudioFeatures from AddTrack. (#3635)
08670412 Limit buffer queue before Bind. (#3634)
9f5bc9b9 Avoid synthesising duplicate feature. (#3632)
847239c3 Disable vp9 for safari 18.4 (#3631)
f69ab680 Populate the sender identity when translating to user packet. (#3628)
e1490558 Forward data between WHIP client and non-WHIP client (#3627)
6739e7bc Broadcast inside lock (#3626)
f24152b4 Call Broadcast in lock scope. (#3625)
b760918a Use logger from request context. (#3623)
34a2e2c1 Check for multiple layers for managed track. (#3622)
4955ebe4 Forward transfer headers to internal request (#3615)
d9ee9214 Set up RTX for WHIP publish (#3619)
d8cf5439 Determine TURN connection type and no fallback for TURN/TLS. (#3612)
d0d212fd Fix WHIP ICE restart. (#3616)
5e7f8a12 Update mediatransportutil for max sctp message (65K) (#3611)
28dfac14 Use exported GetEgressNotifyOptions (#3604)
75236bef protocol update to fix IPv6 SDP fragment parsing (#3603)
2130980d Add basic video support to WHIP. (#3602)
e5cbb227 Allow specifying extra webhooks with egress requests (#3597)
7e16106a Add OnSubscirberReady callback on LocalParticipant. (#3600)
5c2d96b9 Check DestinationRoom of VideoGrant for participant forwarding (#3599)
2e236a19 Revert participant state ACTIVE change. (#3598)
35ac5f56 Add support for WHIP ICE Trickle/Restart. (#3596)
ec2dff96 Fix SIP updates when replacing slices. (#3592)
e24fe77b map PEER_CONNECTION_DISCONNECTED -> CONNECTION_TIMEOUT (#3591)
6ee6eb43 Do not drop audio codecs (#3590)
68357ba6 List audio codecs after video codecs. (#3589)
05a891ff Fix rule (had an extra bracket) (#3588)
d7c41091 Exclude RED from enabled codecs for Flutter + 2.4.2 + Android. (#3587)
ee08aede skip out of order participant state updates (#3583)
15a8d9a2 Break track published fuse when there are no tracks (#3581)
35f83c51 Replace Promise with Fuse. (#3580)
07fe9b72 Prevent migration race. (#3579)
ac8082ff Use older SDP module to accommodate bad SDP. (#3578)
1c8307c7 Use cgroup for memstats. (#3573)
e9be0fca log SDP offer on error (#3577)
3238ab8d Calculate rates for memory used and total. (#3570)
d08487bf Unlabeled (pass through) data channels. (#3567)
52ce18d5 fix: revert recent changes to determine simulcast from sdp (#3565)
cdfbb106 Audio uses signal SignalCid and SdpCid. (#3564)
ed5e2f16 Keep simulcast information tied to receiver. (#3563)
ad010cfc chore(logs): log VLS type for VP9/AV1 (#3561)
8cc17f8f Rework node stats a bit. (#3555)
15f56551 fix(video): determine svc/simulcast from SDP for advanced codecs (#3549)
2b6a46f4 Handle `prefer_regression` for backup codec (#3554)
b0abb0ae Add option to use different pacer with send side bwe. (#3552)
26822b6b ParseUsername utility for TURN user name. (#3547)
55909ed7 log the initial join response (#3546)
97fcb82a Fix: Return NotFoundErr instead of Unavailable when the participant does not exist in UpdateParticipant. (#3543)
75d0e18e Implement SIP update API. (#3141)
e118aff1 Fire track subscribed when the subscriber connected (#3540)
13417c01 Send mute event only on change (#3537)
7f4c4597 Stubs for SIP update API. (#3533)
fe673bb2 Send regressed codec upstream stats to analytics. (#3532)
8eb81388 Use a generation to counter to stop key frame seeder on codec change (#3531)
188470a2 Do not accept unsupported track type in AddTrack (#3530)
507fc9cf Do not instantiate 0 sized sequencer. (#3529)
20bddfea Clean up published track on participant removal. (#3527)
65d8aa28 Handle subscribe race with track close better. (#3526)
a6cb00b3 Reduce seeder duration to 30s and also do not force send PLI. (#3525)
c8233205 Add a key frame seeder in up track. (#3524)
0f61ff3a Remove redundant log (#3523)
7685cd25 Log ParticipantInit on signal start to get a picture of join params (#3522)
ac9e62ef add server agent load threshold config (#3520)
cd5d32f0 Add pID and connID to log context to make it easier to search using pID. (#3518)
2d9aa6dd Update api call info method (#3515)
b3779a90 WebHookConfig (#3517)
6121b9af Check ForwardParticipant room name (#3514)
9a7c9442 mediatransportutil update (#3511)
50ab47c1 Log packet drops/forward. (#3510)
139d1b13 Add ForwardParticipant method to room service (#3507)
6c04909f Use atomic to store codec. (#3505)
7f6afe05 Prevent bind lock deadlock on muted. (#3504)
48063df5 load mime type before calling writeBlankFrameRTP (#3502)
d2e6cd15 Do not bind lock across flush which could take time (#3501)
47896f50 Update protocol and IO service. (#3499)
1dc42eef Bump github.com/go-jose/go-jose/v3 from 3.0.3 to 3.0.4 (#3497)
3a35cbc4 Log migration complete only when coming from sync (#3496)
c2f17a10 refactor: using slices.Contains to simplify the code (#3495)
01e51dbd fix: fix the wrong error return value (#3493)
ff9115b2 Disable dd parser for vp8 if extension is not found (#3492)
c3e06f05 Do not attempt to create objects for URL ingresses as the ingress service will do so (#3491)
f0edfbba Fix receiver rtt/jitter. (#3487)
05dfd30d Take RTT and jitter from receiver view while reporting track stats for (#3483)
04ed5683 Don't issue TrackPublished/Unpublished event on migrated track (#3482)
1cffe30c Use a RED transformer to consolidate both RED -> Opus OR Opus -> RED (#3481)
591888f7 Fix missing RTCP sender report when forwarding RED as Opus. (#3480)

git-subtree-dir: livekit-server
git-subtree-split: 46c4309554d37d23ee8da88a8a7e02a68fba09c1
2026-06-25 23:54:33 +09:00
ttipo 7f6baff425 Merge fix/meeting-connect-error-log: 화상회의 연결 실패 콘솔 로깅 2026-06-25 23:35:57 +09:00
ttipo 8d78ba25a2 fix: 화상회의 연결 실패 원인을 콘솔에 로깅
connect 실패가 errorMsg(미렌더링 ref)로만 삼켜져 화면·콘솔 어디에도
드러나지 않아 디버깅이 불가능했다. catch 에서 console.error 로 원인을 남긴다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:35:57 +09:00
402 changed files with 45862 additions and 16372 deletions
+21
View File
@@ -68,3 +68,24 @@ LIVEKIT_API_KEY=APIxxxxxxxxxxxx
LIVEKIT_API_SECRET=your-livekit-cloud-api-secret
LIVEKIT_WS_URL=wss://your-project.livekit.cloud
LIVEKIT_URL=wss://your-project.livekit.cloud
# ==========================================
# 드라이브 (NCP Object Storage / S3 호환)
# ==========================================
# 파일 업로드/저장용 오브젝트 스토리지. NCP 콘솔에서 버킷 생성 후 값을 채운다.
# 비시크릿(endpoint/region/bucket)만 루트에 두고, ACCESS/SECRET 키는 backend/.env 에 둔다.
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
# OBJECT_STORAGE_BUCKET : 버킷명(환경별로 분리 권장, 예: comrelay-dev)
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
+21
View File
@@ -71,3 +71,24 @@ LIVEKIT_API_KEY=change-me-livekit-key
LIVEKIT_API_SECRET=change-me-livekit-secret-min-32-characters
LIVEKIT_WS_URL=wss://livekit.example.com
LIVEKIT_URL=http://livekit:7880
# ==========================================
# 드라이브 (NCP Object Storage / S3 호환)
# ==========================================
# 운영 버킷은 개발과 분리한다(예: comrelay-prod). 비시크릿만 루트에 두고
# ACCESS/SECRET 키는 backend/.env.production 에 둔다.
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
# OBJECT_STORAGE_BUCKET : 운영 버킷명
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
@@ -57,3 +57,9 @@ frontend/*단일 파일*.html
# LiveKit 클러스터 설정 사본(redis 비밀번호 포함) — 커밋 제외
livekit/*.local.yaml
# 작업 중 공유용 임시 스크린샷(루트) 커밋 방지
/image.png
# Claude Code 메모리 디렉터리(작업 메모) — 커밋 제외
/memory/
+15
View File
@@ -42,3 +42,18 @@ GOOGLE_CALLBACK_URL=http://localhost:3100/api/auth/google/callback
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
CLAUDE_API_KEY=change-me-claude-api-key
CLAUDE_MODEL=claude-sonnet-4-6
# ==========================================
# 드라이브 (NCP Object Storage) — 시크릿
# ==========================================
# NCP 콘솔 → 마이페이지 → 인증키 관리(또는 서브계정)에서 발급한 키.
# endpoint/region/bucket 은 루트 .env 에서 docker-compose 가 주입한다.
# 미설정 시 드라이브 기능만 비활성화되고 앱은 정상 부팅된다.
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=
+14
View File
@@ -42,3 +42,17 @@ GOOGLE_CALLBACK_URL=https://your-domain.com/api/auth/google/callback
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
CLAUDE_API_KEY=change-me-claude-api-key
CLAUDE_MODEL=claude-sonnet-4-6
# ==========================================
# 드라이브 (NCP Object Storage) — 시크릿
# ==========================================
# NCP 콘솔에서 발급한 운영용 키. endpoint/region/bucket 은 루트 .env 가 주입.
# 운영은 권한 최소화를 위해 해당 버킷에만 접근 가능한 서브계정 키 사용 권장.
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=
+1366 -11
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -25,6 +25,8 @@
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1075.0",
"@keyv/redis": "^5.1.6",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
@@ -39,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",
@@ -59,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": {
+4
View File
@@ -10,6 +10,7 @@ import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { StorageModule } from './common/storage/storage.module';
import { HealthModule } from './modules/health/health.module';
import { UserModule } from './modules/user/user.module';
import { AuthModule } from './modules/auth/auth.module';
@@ -20,6 +21,7 @@ import { AiModule } from './modules/ai/ai.module';
import { ProjectModule } from './modules/project/project.module';
import { ScheduleModule } from './modules/schedule/schedule.module';
import { MeetingModule } from './modules/meeting/meeting.module';
import { DriveModule } from './modules/drive/drive.module';
@Module({
imports: [
@@ -92,6 +94,7 @@ import { MeetingModule } from './modules/meeting/meeting.module';
};
},
}),
StorageModule,
HealthModule,
UserModule,
AuthModule,
@@ -102,6 +105,7 @@ import { MeetingModule } from './modules/meeting/meeting.module';
ProjectModule,
ScheduleModule,
MeetingModule,
DriveModule,
],
controllers: [AppController],
providers: [
@@ -0,0 +1,14 @@
import type { ValueTransformer } from 'typeorm';
// bigint 컬럼 변환기 — TypeORM 은 bigint 를 문자열로 반환하므로 number 로 복원한다.
// (파일 크기 등 Number.MAX_SAFE_INTEGER 이내 값에 사용)
export const numericColumnTransformer: ValueTransformer = {
// 저장: number → DB(bigint). null/undefined 는 그대로.
to(value: number | null | undefined): number | null | undefined {
return value;
},
// 조회: DB 문자열 → number. null 은 그대로.
from(value: string | null): number | null {
return value === null ? null : Number(value);
},
};
@@ -0,0 +1,187 @@
import { Injectable, Logger } from '@nestjs/common';
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { StorageService } from './storage.service';
import type {
ObjectHead,
PresignDownloadInput,
PresignUploadInput,
PresignedUpload,
} from './storage.types';
// presigned URL 기본 유효 시간(초)
const DEFAULT_UPLOAD_EXPIRES = 300; // 업로드: 5분
const DEFAULT_DOWNLOAD_EXPIRES = 60; // 다운로드: 1분(단기 노출)
// NCP Object Storage 구현체 — S3 호환 API 를 그대로 사용한다.
// 환경변수(SSOT: 루트 .env → docker-compose 주입 / 시크릿은 backend 자체 env):
// OBJECT_STORAGE_ENDPOINT 예) https://kr.object.ncloudstorage.com
// OBJECT_STORAGE_REGION 예) kr-standard
// OBJECT_STORAGE_BUCKET 버킷명
// OBJECT_STORAGE_ACCESS_KEY 서브계정 Access Key (시크릿)
// OBJECT_STORAGE_SECRET_KEY 서브계정 Secret Key (시크릿)
@Injectable()
export class NcpObjectStorageService extends StorageService {
private readonly logger = new Logger(NcpObjectStorageService.name);
// 설정 누락 시에도 앱은 부팅되며(드라이브 외 기능 정상), 실제 사용 시점에 실패한다.
private readonly client: S3Client | null;
private readonly bucketName: string;
private readonly missing: string[];
constructor() {
super();
const endpoint = process.env.OBJECT_STORAGE_ENDPOINT;
const region = process.env.OBJECT_STORAGE_REGION || 'kr-standard';
const bucket = process.env.OBJECT_STORAGE_BUCKET;
const accessKeyId = process.env.OBJECT_STORAGE_ACCESS_KEY;
const secretAccessKey = process.env.OBJECT_STORAGE_SECRET_KEY;
this.bucketName = bucket || '';
this.missing = [
['OBJECT_STORAGE_ENDPOINT', endpoint],
['OBJECT_STORAGE_BUCKET', bucket],
['OBJECT_STORAGE_ACCESS_KEY', accessKeyId],
['OBJECT_STORAGE_SECRET_KEY', secretAccessKey],
]
.filter(([, v]) => !v)
.map(([k]) => k as string);
if (this.missing.length > 0) {
// 부팅은 통과시키되 경고를 남긴다 — 드라이브 API 호출 시 ensureConfigured() 가 막는다.
this.client = null;
this.logger.warn(
`오브젝트 스토리지 미설정(드라이브 비활성): ${this.missing.join(', ')} 누락`,
);
return;
}
this.client = new S3Client({
endpoint,
region,
// NCP/MinIO 등 S3 호환 스토리지는 path-style 접근이 필요(가상호스트 도메인 미지원)
forcePathStyle: true,
credentials: {
accessKeyId: accessKeyId as string,
secretAccessKey: secretAccessKey as string,
},
});
this.logger.log(
`오브젝트 스토리지 초기화: endpoint=${endpoint}, bucket=${this.bucketName}`,
);
}
get bucket(): string {
return this.bucketName;
}
// 설정이 완료되어 실제 호출 가능한지 보장 — 미설정 시 명확한 에러로 막는다.
private ensureConfigured(): S3Client {
if (!this.client) {
throw new Error(
`오브젝트 스토리지 설정 누락: ${this.missing.join(', ')} 를 환경변수로 지정해 주세요.`,
);
}
return this.client;
}
async presignUpload(input: PresignUploadInput): Promise<PresignedUpload> {
const client = this.ensureConfigured();
const expiresIn = input.expiresInSeconds ?? DEFAULT_UPLOAD_EXPIRES;
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: input.key,
// ContentType 을 서명에 포함 → 클라이언트는 동일 Content-Type 헤더로만 업로드 가능
ContentType: input.contentType,
});
const url = await getSignedUrl(client, command, { expiresIn });
return {
url,
method: 'PUT',
headers: { 'Content-Type': input.contentType },
expiresAt: new Date(Date.now() + expiresIn * 1000),
};
}
async presignDownload(input: PresignDownloadInput): Promise<string> {
const client = this.ensureConfigured();
const expiresIn = input.expiresInSeconds ?? DEFAULT_DOWNLOAD_EXPIRES;
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: input.key,
// 다운로드 강제 + 원본 파일명 복원(비ASCII 안전) + 타입 고정
ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(
input.filename,
)}`,
ResponseContentType: input.contentType,
});
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 {
const res = await client.send(
new HeadObjectCommand({ Bucket: this.bucketName, Key: key }),
);
return {
size: res.ContentLength ?? 0,
contentType: res.ContentType ?? null,
};
} catch (err: unknown) {
// 미존재(404/NotFound)는 정상 흐름 → null. 그 외 오류는 상위로 전파.
if (this.isNotFound(err)) return null;
throw err;
}
}
async deleteObject(key: string): Promise<void> {
const client = this.ensureConfigured();
try {
await client.send(
new DeleteObjectCommand({ Bucket: this.bucketName, Key: key }),
);
} catch (err: unknown) {
// 이미 없으면 멱등 처리(삭제 목적은 달성). 그 외 오류는 전파.
if (this.isNotFound(err)) return;
throw err;
}
}
// AWS SDK v3 의 미존재 응답 판별(상태코드 404 또는 NotFound 계열 name)
private isNotFound(err: unknown): boolean {
const e = err as {
name?: string;
$metadata?: { httpStatusCode?: number };
};
return (
e?.$metadata?.httpStatusCode === 404 ||
e?.name === 'NotFound' ||
e?.name === 'NoSuchKey'
);
}
}
@@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { NcpObjectStorageService } from './ncp-object-storage.service';
// 스토리지 모듈 — StorageService 토큰을 NCP Object Storage 구현체에 바인딩한다.
// @Global 로 등록해 어느 모듈에서든 StorageService 를 주입받을 수 있다.
// (구현체 교체 시 useClass 만 변경하면 됨 — 예: 로컬 디스크/MinIO)
@Global()
@Module({
providers: [
{
provide: StorageService,
useClass: NcpObjectStorageService,
},
],
exports: [StorageService],
})
export class StorageModule {}
@@ -0,0 +1,29 @@
import type {
ObjectHead,
PresignDownloadInput,
PresignUploadInput,
PresignedUpload,
} from './storage.types';
// 오브젝트 스토리지 추상 인터페이스.
// abstract class 를 DI 토큰으로 사용한다(NestJS 권장 패턴) — 구현체는 storage.module 에서 주입.
// 바이너리는 백엔드를 거치지 않고 클라이언트가 presigned URL 로 직접 송수신한다.
export abstract class StorageService {
// 현재 사용 중인 버킷 이름(메타데이터에 함께 저장해 추후 버킷 이전 대비)
abstract get bucket(): string;
// 업로드용 presigned URL 발급(PUT). contentType 이 서명에 포함된다.
abstract presignUpload(input: PresignUploadInput): Promise<PresignedUpload>;
// 다운로드용 단기 presigned URL 발급(GET). 파일명/타입을 응답 헤더로 강제한다.
abstract presignDownload(input: PresignDownloadInput): Promise<string>;
// 객체 메타 조회 — 미존재 시 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,42 @@
// 스토리지 추상화 공용 타입 — 구현체(NCP Object Storage 등)와 호출부가 공유한다.
// Presigned 업로드 발급 입력
export interface PresignUploadInput {
// 버킷 내 객체 키 (예: development/drive/{userId}/{uuid}-name.pdf)
key: string;
// 업로드 시 강제할 MIME 타입 — 서명에 포함되어 클라이언트가 다른 타입으로 못 올림
contentType: string;
// URL 유효 시간(초). 미지정 시 구현체 기본값
expiresInSeconds?: number;
}
// Presigned 업로드 발급 결과
export interface PresignedUpload {
// 클라이언트가 직접 PUT 할 URL
url: string;
// HTTP 메서드 (PUT 고정)
method: 'PUT';
// 업로드 요청 시 반드시 포함해야 하는 헤더(서명 대상) — 예: { 'Content-Type': '...' }
headers: Record<string, string>;
// 만료 시각(클라이언트 표시/검증용)
expiresAt: Date;
}
// Presigned 다운로드 발급 입력
export interface PresignDownloadInput {
key: string;
// 다운로드 시 표시할 원본 파일명(Content-Disposition 에 반영)
filename: string;
// 응답 Content-Type
contentType: string;
// URL 유효 시간(초). 미지정 시 구현체 기본값
expiresInSeconds?: number;
}
// HEAD 조회 결과 — 업로드 완료 검증(실제 size/type 재확인)에 사용
export interface ObjectHead {
// 실제 저장된 바이트 크기
size: number;
// 실제 저장된 MIME 타입(없을 수 있음)
contentType: string | null;
}
@@ -0,0 +1,109 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 드라이브 기능 — drive_folders / drive_files / project_folder_links 테이블 추가.
// - drive_folders: 사용자 소유 폴더 트리(adjacency list + materialized path)
// - drive_files: 오브젝트 스토리지 객체 메타(presign 업로드, status 로 완료 관리)
// - project_folder_links: 폴더를 프로젝트에 연동(공유), 권한(read/read-write)
export class AddDriveTables1782600000000 implements MigrationInterface {
name = 'AddDriveTables1782600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ===== drive_folders =====
await queryRunner.query(
`CREATE TABLE "drive_folders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "path" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "parent_id" uuid, CONSTRAINT "PK_drive_folders" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_owner" ON "drive_folders" ("owner_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_parent" ON "drive_folders" ("parent_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_path" ON "drive_folders" ("path")`,
);
// ===== drive_files =====
await queryRunner.query(
`CREATE TABLE "drive_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "bucket" character varying NOT NULL, "object_key" character varying NOT NULL, "size" bigint NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "folder_id" uuid, CONSTRAINT "PK_drive_files" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_drive_files_object_key" ON "drive_files" ("object_key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_owner" ON "drive_files" ("owner_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_folder" ON "drive_files" ("folder_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_status" ON "drive_files" ("status")`,
);
// ===== project_folder_links =====
await queryRunner.query(
`CREATE TABLE "project_folder_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "permission" character varying NOT NULL DEFAULT 'read', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "folder_id" uuid, "linked_by_id" uuid, CONSTRAINT "PK_project_folder_links" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_project_folder_links_project" ON "project_folder_links" ("project_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_project_folder_links_folder" ON "project_folder_links" ("folder_id")`,
);
// 한 폴더는 한 프로젝트에 한 번만 연동
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "UQ_project_folder_link" UNIQUE ("project_id", "folder_id")`,
);
// ===== 외래키 =====
await queryRunner.query(
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_parent" FOREIGN KEY ("parent_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_linked_by" FOREIGN KEY ("linked_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 외래키 → 테이블 순서 역순 정리
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_linked_by"`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_folder"`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_project"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_folder"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_owner"`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_parent"`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_owner"`,
);
await queryRunner.query(`DROP TABLE "project_folder_links"`);
await queryRunner.query(`DROP TABLE "drive_files"`);
await queryRunner.query(`DROP TABLE "drive_folders"`);
}
}
@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방에 입장 코드(code) 추가 — 이름과 식별자를 분리한다.
// code = 입장 URL·LiveKit 방·초대 링크의 안정적 키(불변). 이름은 표시용 라벨로 강등(고유 해제).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRoomCode1782900000000 implements MigrationInterface {
name = 'AddMeetingRoomCode1782900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1) 코드 컬럼 추가(우선 nullable) → 기존 행 백필 → NOT NULL 승격
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "code" character varying`,
);
// 기존 방에 고유 코드 백필(md5(random) 12자리 16진수 — 충돌 사실상 없음)
await queryRunner.query(
`UPDATE "meeting_rooms" SET "code" = substr(md5(random()::text || "id"::text), 1, 12) WHERE "code" IS NULL`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ALTER COLUMN "code" SET NOT NULL`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_meeting_rooms_code" ON "meeting_rooms" ("code")`,
);
// 2) 이름은 더 이상 고유가 아니다(표시용 라벨) → 고유 인덱스 제거
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 주의: 이름 중복 데이터가 있으면 고유 인덱스 복원이 실패할 수 있다.
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
);
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_code"`);
await queryRunner.query(`ALTER TABLE "meeting_rooms" DROP COLUMN "code"`);
}
}
@@ -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(),
};
}
}
+72
View File
@@ -0,0 +1,72 @@
import { randomUUID } from 'crypto';
import { extname } from 'path';
import type { AttachmentKind } from '../task/entities/attachment.entity';
// 드라이브 파일 1건 최대 크기(기본 200MB) — env 로 조정 가능.
// presigned PUT 은 크기를 서명으로 강제하지 못하므로, 최종 검증은 complete 단계의 HEAD 로 한다.
export const DRIVE_MAX_FILE_SIZE = (() => {
const mb = Number(process.env.DRIVE_MAX_FILE_MB);
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 200 * 1024 * 1024;
})();
// 허용 MIME 화이트리스트 — 실행/스크립트(text/html, image/svg+xml 등)는 차단(심층 방어).
// 문서 첨부(upload.config) 기준에 드라이브 용도의 미디어 타입을 추가했다.
export const DRIVE_ALLOWED_MIME_TYPES: 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',
]);
// 허용 MIME 여부
export function isAllowedMime(mime: string): boolean {
return DRIVE_ALLOWED_MIME_TYPES.has(mime);
}
// MIME/확장자로 아이콘 종류 파생(첨부와 동일 분류 재사용)
export function deriveKind(mime: string, name: string): AttachmentKind {
const lower = name.toLowerCase();
if (mime.startsWith('image/')) return 'img';
if (mime === 'application/pdf' || lower.endsWith('.pdf')) return 'pdf';
if (
mime.includes('zip') ||
mime.includes('compressed') ||
lower.endsWith('.zip')
) {
return 'zip';
}
return 'doc';
}
// 버킷 객체 키 생성 — {APP_ENV}/drive/{userId}/{uuid}{ext}
// 원본 파일명은 DB(name)에만 보관하고, 키는 충돌/인코딩 안전을 위해 uuid 기반으로 만든다.
export function buildObjectKey(userId: string, filename: string): string {
const env = process.env.APP_ENV || process.env.NODE_ENV || 'development';
const ext = extname(filename).toLowerCase().slice(0, 16); // 비정상적으로 긴 확장자 방어
return `${env}/drive/${userId}/${randomUUID()}${ext}`;
}
@@ -0,0 +1,165 @@
import {
Body,
Controller,
DefaultValuePipe,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
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';
import {
DriveService,
type DriveFileResponse,
type DriveFolderResponse,
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,
private readonly auditService: DriveAuditService,
) {}
@Get()
@ApiOperation({ summary: '드라이브 목록(폴더 미지정 시 루트)' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
list(
@CurrentUser() user: PublicUser,
@Query('folderId') folderId?: string,
): Promise<DriveListResponse> {
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')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '폴더 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createFolder(
@CurrentUser() user: PublicUser,
@Body() dto: CreateFolderDto,
): Promise<DriveFolderResponse> {
return this.driveService.createFolder(user.id, dto);
}
@Patch('folders/:folderId')
@ApiOperation({ summary: '폴더 이름 변경' })
@ApiResponse({ status: 200, description: '변경 성공' })
renameFolder(
@CurrentUser() user: PublicUser,
@Param('folderId', ParseUUIDPipe) folderId: string,
@Body() dto: RenameDto,
): Promise<DriveFolderResponse> {
return this.driveService.renameFolder(user.id, folderId, dto.name);
}
@Delete('folders/:folderId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '폴더 삭제(하위 폴더·파일 포함)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteFolder(
@CurrentUser() user: PublicUser,
@Param('folderId', ParseUUIDPipe) folderId: string,
): Promise<null> {
await this.driveService.deleteFolder(user.id, folderId);
return null;
}
/* ===================== 파일 ===================== */
@Post('files/presign')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '업로드용 presigned URL 발급(+pending 메타 생성)' })
@ApiResponse({ status: 201, description: '발급 성공' })
@ApiResponse({
status: 400,
description: '허용되지 않는 형식/크기 (VAL_001)',
})
presignUpload(
@CurrentUser() user: PublicUser,
@Body() dto: PresignUploadDto,
): Promise<PresignUploadResponse> {
return this.driveService.presignUpload(user.id, dto);
}
@Post('files/:fileId/complete')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '업로드 확정(HEAD 검증 후 active 전환)' })
@ApiResponse({ status: 200, description: '확정 성공' })
@ApiResponse({ status: 400, description: '업로드 미완료/검증 실패' })
completeUpload(
@CurrentUser() user: PublicUser,
@Param('fileId', ParseUUIDPipe) fileId: string,
): Promise<DriveFileResponse> {
return this.driveService.completeUpload(user.id, fileId);
}
@Get('files/:fileId/download-url')
@ApiOperation({ summary: '다운로드용 단기 presigned URL 발급' })
@ApiResponse({ status: 200, description: '발급 성공' })
@ApiResponse({ status: 404, description: '파일 없음/미확정 (RES_001)' })
getDownloadUrl(
@CurrentUser() user: PublicUser,
@Param('fileId', ParseUUIDPipe) fileId: string,
): Promise<{ url: string }> {
return this.driveService.getDownloadUrl(user.id, fileId);
}
@Patch('files/:fileId')
@ApiOperation({ summary: '파일 이름 변경' })
@ApiResponse({ status: 200, description: '변경 성공' })
renameFile(
@CurrentUser() user: PublicUser,
@Param('fileId', ParseUUIDPipe) fileId: string,
@Body() dto: RenameDto,
): Promise<DriveFileResponse> {
return this.driveService.renameFile(user.id, fileId, dto.name);
}
@Delete('files/:fileId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '파일 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteFile(
@CurrentUser() user: PublicUser,
@Param('fileId', ParseUUIDPipe) fileId: string,
): Promise<null> {
await this.driveService.deleteFile(user.id, fileId);
return null;
}
}
+31
View File
@@ -0,0 +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 { DriveActivity } from './entities/drive-activity.entity';
import { DriveService } from './drive.service';
import { DriveAuditService } from './drive-audit.service';
import { DriveController } from './drive.controller';
// 드라이브 모듈 — 전사 공유 파일 저장소(로그인 사용자 전원 공유).
// 저장소(StorageService)는 전역 StorageModule 에서 주입된다.
// ProjectFolderLink 는 프로젝트 연동(AI 검토 등) 부가 기능을 위해 유지한다.
@Module({
imports: [
TypeOrmModule.forFeature([
DriveFolder,
DriveFile,
ProjectFolderLink,
DriveActivity,
]),
UserModule,
ActivityModule,
],
controllers: [DriveController],
providers: [DriveService, DriveAuditService],
exports: [DriveService],
})
export class DriveModule {}
+538
View File
@@ -0,0 +1,538 @@
import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { HttpStatus } from '@nestjs/common';
import { In, IsNull, LessThan, Repository } from 'typeorm';
import { BusinessException } from '../../common/exceptions/business.exception';
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 { 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';
import type { AttachmentKind } from '../task/entities/attachment.entity';
import type { DriveFileStatus } from './entities/drive-file.entity';
import {
DRIVE_MAX_FILE_SIZE,
buildObjectKey,
deriveKind,
isAllowedMime,
} from './drive.config';
import { CreateFolderDto } from './dto/create-folder.dto';
import { PresignUploadDto } from './dto/presign-upload.dto';
// --- 응답 형태 ---
export interface DriveFolderResponse {
id: string;
name: string;
parentId: string | null;
owner: PublicUser | null;
createdAt: string;
updatedAt: string;
}
export interface DriveFileResponse {
id: string;
name: string;
size: number;
mime: string;
kind: AttachmentKind;
status: DriveFileStatus;
folderId: string | null;
owner: PublicUser | null;
createdAt: string;
updatedAt: string;
}
export interface DriveListResponse {
// 현재 폴더(루트면 null) + 루트→현재 경로(브레드크럼)
current: DriveFolderResponse | null;
breadcrumb: { id: string; name: string }[];
folders: DriveFolderResponse[];
files: DriveFileResponse[];
}
export interface PresignUploadResponse {
fileId: string;
file: DriveFileResponse;
upload: PresignedUpload;
}
@Injectable()
export class DriveService {
private readonly logger = new Logger(DriveService.name);
constructor(
@InjectRepository(DriveFolder)
private readonly folderRepo: Repository<DriveFolder>,
@InjectRepository(DriveFile)
private readonly fileRepo: Repository<DriveFile>,
@InjectRepository(ProjectFolderLink)
private readonly linkRepo: Repository<ProjectFolderLink>,
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> {
if (!folderId) {
// 루트 — 공유 최상위 폴더/파일 전체
const [folders, files] = await Promise.all([
this.folderRepo.find({
where: { parent: IsNull() },
relations: { owner: true },
order: { name: 'ASC' },
}),
this.fileRepo.find({
where: { folder: IsNull(), status: 'active' },
relations: { owner: true },
order: { name: 'ASC' },
}),
]);
return {
current: null,
breadcrumb: [],
folders: folders.map((f) => this.toFolderResponse(f, null)),
files: files.map((f) => this.toFileResponse(f, null)),
};
}
const folder = await this.loadFolder(folderId);
const [folders, files, breadcrumb] = await Promise.all([
this.folderRepo.find({
where: { parent: { id: folder.id } },
relations: { owner: true },
order: { name: 'ASC' },
}),
this.fileRepo.find({
where: { folder: { id: folder.id }, status: 'active' },
relations: { owner: true },
order: { name: 'ASC' },
}),
this.buildBreadcrumb(folder),
]);
return {
current: this.toFolderResponse(folder, folder.parent?.id ?? null),
breadcrumb,
folders: folders.map((f) => this.toFolderResponse(f, folder.id)),
files: files.map((f) => this.toFileResponse(f, folder.id)),
};
}
/* ===================== 폴더 CRUD ===================== */
// 폴더 생성 — 루트 또는 상위 폴더 하위에 생성(로그인 사용자 전원 가능).
async createFolder(
userId: string,
dto: CreateFolderDto,
): Promise<DriveFolderResponse> {
let parent: DriveFolder | null = null;
if (dto.parentId) {
parent = await this.loadFolder(dto.parentId);
}
const owner = await this.getUserOrThrow(userId);
// 1차 저장으로 id 확보 → materialized path 구성 후 재저장
const created = await this.folderRepo.save(
this.folderRepo.create({
owner,
parent: parent ? { id: parent.id } : null,
name: dto.name,
path: '',
}),
);
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.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.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) ===================== */
// ① 업로드용 presigned URL 발급 + pending 메타 생성.
async presignUpload(
userId: string,
dto: PresignUploadDto,
): Promise<PresignUploadResponse> {
if (!isAllowedMime(dto.contentType)) {
throw new BusinessException(
'VAL_001',
'업로드할 수 없는 파일 형식입니다.',
HttpStatus.BAD_REQUEST,
);
}
let folder: DriveFolder | null = null;
if (dto.folderId) {
folder = await this.loadFolder(dto.folderId);
}
const owner = await this.getUserOrThrow(userId);
const objectKey = buildObjectKey(userId, dto.filename);
const saved = await this.fileRepo.save(
this.fileRepo.create({
owner,
folder: folder ? { id: folder.id } : null,
name: dto.filename,
bucket: this.storage.bucket,
objectKey,
size: dto.size,
mime: dto.contentType,
kind: deriveKind(dto.contentType, dto.filename),
status: 'pending',
}),
);
const upload = await this.storage.presignUpload({
key: objectKey,
contentType: dto.contentType,
});
return {
fileId: saved.id,
file: this.toFileResponse({ ...saved, owner }, folder?.id ?? null),
upload,
};
}
// ③ 업로드 확정 — HEAD 로 실제 크기/타입 재검증 후 active 전환. 발급자 본인만 호출.
async completeUpload(
userId: string,
fileId: string,
): Promise<DriveFileResponse> {
const file = await this.fileRepo.findOne({
where: { id: fileId },
relations: { owner: true, folder: true },
});
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);
}
const head = await this.storage.headObject(file.objectKey);
if (!head) {
throw new BusinessException(
'BIZ_001',
'업로드가 완료되지 않았습니다. 파일 전송 후 다시 시도해 주세요.',
HttpStatus.BAD_REQUEST,
);
}
// 실제 저장된 객체 기준으로 검증(클라이언트 신고값을 신뢰하지 않음)
if (head.size > DRIVE_MAX_FILE_SIZE) {
await this.discardFile(file);
throw new BusinessException(
'VAL_001',
'허용 최대 파일 크기를 초과했습니다.',
HttpStatus.BAD_REQUEST,
);
}
if (head.contentType && !isAllowedMime(head.contentType)) {
await this.discardFile(file);
throw new BusinessException(
'VAL_001',
'업로드할 수 없는 파일 형식입니다.',
HttpStatus.BAD_REQUEST,
);
}
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.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.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 발급 — 로그인 사용자 전원 가능.
async getDownloadUrl(
_userId: string,
fileId: string,
): Promise<{ url: string }> {
const file = await this.loadFile(fileId);
if (file.status !== 'active') {
// 아직 업로드 확정 전 — 다운로드 불가
throw new NotFoundException();
}
const url = await this.storage.presignDownload({
key: file.objectKey,
filename: file.name,
contentType: file.mime,
});
return { url };
}
/* ===================== 로딩 ===================== */
// 폴더 로딩(미존재 404). 전사 공유라 권한 검사는 없다.
private async loadFolder(folderId: string): Promise<DriveFolder> {
const folder = await this.folderRepo.findOne({
where: { id: folderId },
relations: { owner: true, parent: true },
});
if (!folder) {
throw new NotFoundException();
}
return folder;
}
// 파일 로딩(미존재/소프트 삭제 묘비는 404 — 목록·다운로드·재삭제 차단).
private async loadFile(fileId: string): Promise<DriveFile> {
const file = await this.fileRepo.findOne({
where: { id: fileId },
relations: { owner: true, folder: true },
});
if (!file || file.status === 'deleted') {
throw new NotFoundException();
}
return file;
}
// "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상)
private parseFolderPath(path: string): string[] {
return path.split('/').filter(Boolean);
}
// 브레드크럼(루트→현재) — path 의 폴더들을 이름과 함께 순서대로 반환
private async buildBreadcrumb(
folder: DriveFolder,
): Promise<{ id: string; name: string }[]> {
const ids = this.parseFolderPath(folder.path);
if (ids.length === 0) return [];
const rows = await this.folderRepo.find({
where: { id: In(ids) },
select: { id: true, name: true },
});
const byId = new Map(rows.map((r) => [r.id, r.name]));
return ids
.filter((id) => byId.has(id))
.map((id) => ({ id, name: byId.get(id) as string }));
}
/* ===================== 내부 유틸 ===================== */
// 파일이 프로젝트 연동 폴더(또는 그 하위)에 속하면 해당 프로젝트(들) 활동 피드에 기록한다.
// (전역 감사 로그와는 별개로, 프로젝트 화면의 활동 피드 표시를 위한 부가 기록)
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) {
throw new NotFoundException();
}
return user;
}
// 검증 실패한 업로드 폐기 — 객체 + 메타 행 제거
private async discardFile(file: DriveFile): Promise<void> {
await this.fileRepo.remove(file);
await this.deleteObjects([file.objectKey]);
}
// 객체 스토리지 파일 삭제(베스트 에포트 — 실패해도 본 흐름을 막지 않음)
private async deleteObjects(keys: string[]): Promise<void> {
for (const key of keys) {
try {
await this.storage.deleteObject(key);
} catch (err: unknown) {
this.logger.warn(
`객체 삭제 실패(무시): ${key}${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// 미완료(pending) 업로드 정리 — 1시간 이상 확정되지 않은 행/객체 제거
@Cron(CronExpression.EVERY_HOUR)
async cleanupStalePending(): Promise<void> {
try {
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
const stale = await this.fileRepo.find({
where: { status: 'pending', createdAt: LessThan(cutoff) },
});
if (stale.length === 0) return;
await this.deleteObjects(stale.map((f) => f.objectKey));
await this.fileRepo.remove(stale);
this.logger.log(`미완료 업로드 ${stale.length}건 정리`);
} catch (err: unknown) {
this.logger.warn(
`미완료 업로드 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/* ===================== 매핑 ===================== */
private toFolderResponse(
folder: DriveFolder,
parentId: string | null,
): DriveFolderResponse {
return {
id: folder.id,
name: folder.name,
parentId,
owner: folder.owner ? UserService.toPublic(folder.owner) : null,
createdAt: folder.createdAt.toISOString(),
updatedAt: folder.updatedAt.toISOString(),
};
}
private toFileResponse(
file: DriveFile,
folderId: string | null,
): DriveFileResponse {
return {
id: file.id,
name: file.name,
size: file.size,
mime: file.mime,
kind: file.kind,
status: file.status,
folderId,
owner: file.owner ? UserService.toPublic(file.owner) : null,
createdAt: file.createdAt.toISOString(),
updatedAt: file.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,18 @@
import { IsOptional, IsString, IsUUID, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
// 폴더 생성 요청
export class CreateFolderDto {
@ApiProperty({ description: '폴더명', example: '디자인 시안' })
@IsString()
@Length(1, 255, { message: '폴더명은 1~255자로 입력해 주세요.' })
name!: string;
@ApiPropertyOptional({
description: '상위 폴더 ID (생략 시 드라이브 루트)',
format: 'uuid',
})
@IsOptional()
@IsUUID('4', { message: '올바른 상위 폴더 ID가 아닙니다.' })
parentId?: string;
}
@@ -0,0 +1,45 @@
import {
IsInt,
IsOptional,
IsString,
IsUUID,
Length,
Max,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DRIVE_MAX_FILE_SIZE } from '../drive.config';
// 업로드용 presigned URL 발급 요청
export class PresignUploadDto {
@ApiProperty({ description: '원본 파일명', example: '기획서.pdf' })
@IsString()
@Length(1, 255, { message: '파일명은 1~255자로 입력해 주세요.' })
filename!: string;
@ApiProperty({
description: 'MIME 타입(업로드 시 동일 Content-Type 으로 전송해야 함)',
example: 'application/pdf',
})
@IsString()
@Length(1, 255)
contentType!: string;
@ApiProperty({
description: '파일 크기(바이트)',
example: 1048576,
maximum: DRIVE_MAX_FILE_SIZE,
})
@IsInt({ message: '파일 크기가 올바르지 않습니다.' })
@Min(1, { message: '빈 파일은 업로드할 수 없습니다.' })
@Max(DRIVE_MAX_FILE_SIZE, { message: '허용 최대 파일 크기를 초과했습니다.' })
size!: number;
@ApiPropertyOptional({
description: '업로드 대상 폴더 ID (생략 시 드라이브 루트)',
format: 'uuid',
})
@IsOptional()
@IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' })
folderId?: string;
}
@@ -0,0 +1,10 @@
import { IsString, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 폴더/파일 이름 변경 요청 (공용)
export class RenameDto {
@ApiProperty({ description: '변경할 이름', example: '최종_시안.pdf' })
@IsString()
@Length(1, 255, { message: '이름은 1~255자로 입력해 주세요.' })
name!: string;
}
@@ -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;
}
@@ -0,0 +1,102 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { DriveFolder } from './drive-folder.entity';
import { numericColumnTransformer } from '../../../common/database/numeric-column.transformer';
import type { AttachmentKind } from '../../task/entities/attachment.entity';
// 드라이브 파일 업로드 상태
// pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과
// deleted: 소프트 삭제(객체는 영구 제거됨, 메타 행은 감사용 묘비로 보존 — 누가·언제 삭제했는지)
export type DriveFileStatus = 'pending' | 'active' | 'deleted';
// AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시)
// pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패
export type DriveExtractStatus = 'pending' | 'done' | 'unsupported' | 'failed';
// 드라이브 파일 — 실제 바이너리는 오브젝트 스토리지에 저장하고 메타데이터만 보관.
// 업로드는 presigned URL 직접 전송 후 complete 로 확정한다.
@Entity('drive_files')
export class DriveFile {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 업로더 (전사 공유 드라이브 — 업로더가 탈퇴해도 공유 파일은 보존하므로 SET NULL)
@Index()
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'owner_id' })
owner!: User | null;
// 소속 폴더 (null = 드라이브 루트). 폴더 삭제 시 파일 메타도 함께 삭제(CASCADE).
@Index()
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'folder_id' })
folder!: Relation<DriveFolder> | null;
// 원본 파일명(다운로드 시 표시)
@Column()
name!: string;
// 저장 버킷 — 추후 버킷 이전 대비해 메타에 함께 보관
@Column()
bucket!: string;
// 버킷 내 객체 키(고유). 예: development/drive/{userId}/{uuid}-name.pdf
@Index({ unique: true })
@Column({ name: 'object_key' })
objectKey!: string;
// 파일 크기(바이트) — 대용량 대비 bigint(문자열) → number 변환
@Column({ type: 'bigint', transformer: numericColumnTransformer })
size!: number;
// MIME 타입
@Column()
mime!: string;
// 아이콘 종류(MIME/확장자에서 파생) — 업무/프로젝트 첨부와 동일 분류 재사용
@Column({ type: 'varchar' })
kind!: AttachmentKind;
// 업로드 상태 — pending 은 미완료(목록/다운로드 비노출), 정리 크론 대상
@Index()
@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;
}
@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
// 드라이브 폴더 — 전사 공유 폴더 트리(adjacency list). 로그인 사용자 모두가 공유한다.
// ProjectFolderLink 로 프로젝트에 연동(AI 검토 등)할 수 있으나 접근은 전원 공유다.
@Entity('drive_folders')
export class DriveFolder {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 생성자(업로더) — 전사 공유라 생성자가 탈퇴해도 폴더는 보존하므로 SET NULL
@Index()
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'owner_id' })
owner!: User | null;
// 상위 폴더 (null = 드라이브 루트). 상위 삭제 시 하위도 함께 삭제(CASCADE).
// Relation<> 래퍼: 자기참조 순환 회피
@Index()
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'parent_id' })
parent!: Relation<DriveFolder> | null;
// 폴더명
@Column()
name!: string;
// 머터리얼라이즈드 패스 — 루트부터 자기 자신까지의 폴더 id 를 '/' 로 이어 저장(자기 포함, 끝에 '/').
// 예: "rootId/childId/" . 조상 연동 여부 판정과 하위 트리 조회(LIKE)에 사용한다.
// (parent_id 가 트리의 SSOT 이고, path 는 빠른 조회를 위한 파생값)
@Index()
@Column({ type: 'text' })
path!: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Project } from '../../project/entities/project.entity';
import { DriveFolder } from './drive-folder.entity';
// 연동 권한 — read: 보기/다운로드만, read-write: 프로젝트 멤버도 업로드 가능
export type DriveLinkPermission = 'read' | 'read-write';
// 프로젝트-폴더 연동 — 드라이브 폴더를 프로젝트에 공유한다.
// 연동된 폴더와 그 하위 트리 전체가 해당 프로젝트 멤버에게 노출된다.
@Entity('project_folder_links')
@Unique('UQ_project_folder_link', ['project', 'folder'])
export class ProjectFolderLink {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 대상 프로젝트 (프로젝트 삭제 시 연동 해제)
@Index()
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project!: Relation<Project>;
// 연동된 드라이브 폴더 (폴더 삭제 시 연동도 함께 삭제)
@Index()
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'folder_id' })
folder!: Relation<DriveFolder>;
// 연동을 수행한 사용자(폴더 소유자) — 사용자 삭제 시에도 연동은 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'linked_by_id' })
linkedBy!: User | null;
// 연동 권한 — 기본 read(안전)
@Column({ type: 'varchar', default: 'read' })
permission!: DriveLinkPermission;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -1,17 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Length, Matches } from 'class-validator';
// 화상회의 입장 토큰 발급 요청 — 참여할 방 이름을 전달한다.
// 화상회의 입장 토큰 발급 요청 — 참여할 방의 입장 코드를 전달한다.
export class CreateMeetingTokenDto {
@ApiProperty({
description: '참여할 회의 방 이름(한글/영문/숫자/-/_ 허용, 공백 불가)',
example: '주간-회의',
description: '참여할 회의 방 입장 코드(영문/숫자)',
example: 'a1b2c3d4e5',
})
@IsString()
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
@Matches(/^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]+$/, {
message:
'방 이름은 한글, 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다. (공백 불가)',
@Length(6, 32, { message: '입장 코드 형식이 올바르지 않습니다.' })
@Matches(/^[A-Za-z0-9]+$/, {
message: '입장 코드 형식이 올바르지 않습니다.',
})
roomName!: string;
code!: string;
}
@@ -0,0 +1,5 @@
import { CreateMeetingRoomDto } from './create-meeting-room.dto';
// 화상회의 방 수정 요청 — 생성과 동일한 필드(진행자가 이름·예약 시각·참여 제한을 수정).
// 폼이 현재 값을 그대로 채워 전체 상태를 보내므로 별도 부분 갱신(Partial) 의미는 두지 않는다.
export class UpdateMeetingRoomDto extends CreateMeetingRoomDto {}
@@ -18,9 +18,14 @@ export class MeetingRoom {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 방 이름(고유) — 같은 이름으로 들어온 사람끼리 연결된다
// 방 입장 코드(고유·불변) — 입장 URL·LiveKit 방 식별자·초대 링크의 기준.
// 이름과 분리해, 이름을 바꿔도 링크/세션이 유지된다.
@Index({ unique: true })
@Column()
code!: string;
// 방 이름(표시용 라벨) — 고유하지 않으며 진행자가 자유롭게 변경할 수 있다.
@Column()
name!: string;
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
@@ -7,6 +7,7 @@ import {
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
@@ -26,6 +27,7 @@ import {
} from './meeting.service';
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
import { UpdateMeetingRoomDto } from './dto/update-meeting-room.dto';
// 화상회의 컨트롤러 — 방 관리 + LiveKit 입장 토큰 발급(인증 필요).
@ApiTags('화상회의')
@@ -52,6 +54,17 @@ export class MeetingController {
return this.meetingService.createRoom(user.id, dto);
}
@Patch('rooms/:roomId')
@ApiOperation({ summary: '회의 방 수정(진행자만)' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateRoom(
@CurrentUser() user: PublicUser,
@Param('roomId', ParseUUIDPipe) roomId: string,
@Body() dto: UpdateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
return this.meetingService.updateRoom(user.id, roomId, dto);
}
@Delete('rooms/:roomId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: '회의 방 삭제(진행자만)' })
@@ -70,6 +83,6 @@ export class MeetingController {
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingTokenDto,
): Promise<MeetingTokenResult> {
return this.meetingService.issueToken(user, dto.roomName);
return this.meetingService.issueToken(user, dto.code);
}
}
@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { MeetingController } from './meeting.controller';
import { MeetingService } from './meeting.service';
import { MeetingRoom } from './entities/meeting-room.entity';
import { User } from '../user/entities/user.entity';
// 화상회의 모듈 — LiveKit 입장 토큰 발급 + 방/진행자(생성자) 보관.
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
// User repo 는 참여 제한(allow-list) 멤버를 진행자 편집 폼용으로 조회하는 데 사용한다.
@Module({
imports: [TypeOrmModule.forFeature([MeetingRoom])],
imports: [TypeOrmModule.forFeature([MeetingRoom, User])],
controllers: [MeetingController],
providers: [MeetingService],
exports: [MeetingService],
+104 -45
View File
@@ -1,13 +1,15 @@
import { randomBytes } from 'crypto';
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { AccessToken } from 'livekit-server-sdk';
import { BusinessException } from '../../common/exceptions/business.exception';
import { UserService, type PublicUser } from '../user/user.service';
import { User } from '../user/entities/user.entity';
import { MeetingRoom } from './entities/meeting-room.entity';
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
import { UpdateMeetingRoomDto } from './dto/update-meeting-room.dto';
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
export type MeetingRole = 'host' | 'participant';
@@ -15,12 +17,15 @@ export type MeetingRole = 'host' | 'participant';
// 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함
export interface MeetingRoomResponse {
id: string;
code: string; // 입장 코드(불변) — 입장 URL·초대 링크의 기준
name: string;
host: PublicUser | null;
scheduledAt: string | null;
restricted: boolean; // 참여 제한(allow-list) 적용 여부
isHost: boolean; // 현재 사용자가 진행자인지
canJoin: boolean; // 현재 사용자가 입장 가능한지(공개 OR 진행자 OR 허용목록 포함)
// 참여 허용 멤버 — 진행자에게만 노출(편집 폼 사전 채움용). 공개 방/비진행자는 null.
allowedUsers: PublicUser[] | null;
createdAt: string;
}
@@ -45,6 +50,8 @@ export class MeetingService {
config: ConfigService,
@InjectRepository(MeetingRoom)
private readonly roomRepo: Repository<MeetingRoom>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {
this.apiKey = (config.get<string>('LIVEKIT_API_KEY') ?? '').trim();
this.apiSecret = (config.get<string>('LIVEKIT_API_SECRET') ?? '').trim();
@@ -65,7 +72,7 @@ export class MeetingService {
// 역할은 토큰 metadata 에도 실어 다른 참여자가 진행자를 식별할 수 있게 한다.
async issueToken(
user: PublicUser,
roomName: string,
code: string,
): Promise<MeetingTokenResult> {
if (!this.enabled) {
throw new BusinessException(
@@ -75,7 +82,7 @@ export class MeetingService {
);
}
const role = await this.resolveAccess(user.id, roomName);
const { room, role } = await this.resolveRoomAccess(user.id, code);
const isHost = role === 'host';
const at = new AccessToken(this.apiKey, this.apiSecret, {
@@ -88,7 +95,8 @@ export class MeetingService {
});
at.addGrant({
roomJoin: true,
room: roomName,
// LiveKit 방 식별자 = 코드(이름이 아니라 불변 코드라 이름을 바꿔도 같은 방으로 연결).
room: room.code,
canPublish: true,
canSubscribe: true,
canPublishData: true,
@@ -96,41 +104,26 @@ export class MeetingService {
});
const token = await at.toJwt();
return { token, roomName, identity: user.id, role };
return { token, roomName: room.name, identity: user.id, role };
}
// 입장 권한 판정 + 역할 결정.
// · 방이 없으면 이 사용자를 진행자로 생성(공개 방) — 직접 링크 입장 호환.
// · 방이 있으면 참여 제한(allow-list)을 검사하고, 생성자 일치로 역할을 판정.
private async resolveAccess(
// 코드로 방을 찾아 입장 권한 판정한다(자동 생성 없음 — 명시적으로 생성된 방만 입장 가능).
// · 방이 없으면 404. · 참여 제한(allow-list) 검사 후 생성자 일치로 역할을 판정.
private async resolveRoomAccess(
userId: string,
roomName: string,
): Promise<MeetingRole> {
let room = await this.roomRepo.findOne({
where: { name: roomName },
code: string,
): Promise<{ room: MeetingRoom; role: MeetingRole }> {
const room = await this.roomRepo.findOne({
where: { code },
relations: ['host'],
});
if (!room) {
// 방이 없으면 생성 시도 — 이 사용자가 진행자가 된다.
try {
await this.roomRepo.save(
this.roomRepo.create({
name: roomName,
host: { id: userId } as User,
}),
);
return 'host';
} catch {
// 동시 생성 경쟁(name unique 충돌) — 재조회로 판정.
room = await this.roomRepo.findOne({
where: { name: roomName },
relations: ['host'],
});
}
throw new BusinessException(
'RES_001',
'회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.',
HttpStatus.NOT_FOUND,
);
}
if (!room) return 'participant';
const isHost = room.host?.id === userId;
if (
!isHost &&
@@ -143,24 +136,39 @@ export class MeetingService {
HttpStatus.FORBIDDEN,
);
}
return isHost ? 'host' : 'participant';
return { room, role: isHost ? 'host' : 'participant' };
}
// 명시적 방 생성 — 생성자가 진행자. 예약 시각/참여 제한을 함께 설정.
// URL-safe 입장 코드(base62, 10자) 생성.
private generateCode(len = 10): string {
const ALPHA =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const bytes = randomBytes(len);
let out = '';
for (let i = 0; i < len; i++) out += ALPHA[bytes[i] % 62];
return out;
}
// 충돌하지 않는 고유 입장 코드 — 드문 충돌 시 재시도, 그래도 충돌하면 길이를 늘린다.
private async generateUniqueCode(): Promise<string> {
for (let i = 0; i < 5; i++) {
const code = this.generateCode();
const taken = await this.roomRepo.count({ where: { code } });
if (!taken) return code;
}
return this.generateCode(14);
}
// 명시적 방 생성 — 생성자가 진행자. 고유 코드 발급 + 예약 시각/참여 제한 설정.
// 이름은 표시용 라벨이라 중복을 허용한다(식별은 코드로 한다).
async createRoom(
userId: string,
dto: CreateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
const existing = await this.roomRepo.findOne({ where: { name: dto.name } });
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 존재하는 방 이름입니다. 다른 이름을 사용해 주세요.',
HttpStatus.CONFLICT,
);
}
const code = await this.generateUniqueCode();
const saved = await this.roomRepo.save(
this.roomRepo.create({
code,
name: dto.name,
host: { id: userId } as User,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
@@ -180,7 +188,41 @@ export class MeetingService {
relations: ['host'],
order: { createdAt: 'DESC' },
});
return rows.map((r) => this.toRoomResponse(r, userId));
return Promise.all(rows.map((r) => this.toRoomResponse(r, userId)));
}
// 방 수정 — 진행자(생성자)만 가능. 이름/예약 시각/참여 제한을 변경한다.
async updateRoom(
userId: string,
roomId: string,
dto: UpdateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
const room = await this.roomRepo.findOne({
where: { id: roomId },
relations: ['host'],
});
if (!room) {
throw new BusinessException(
'RES_001',
'방을 찾을 수 없습니다.',
HttpStatus.NOT_FOUND,
);
}
if (room.host?.id !== userId) {
throw new BusinessException(
'AUTH_003',
'방 수정 권한이 없습니다.',
HttpStatus.FORBIDDEN,
);
}
// 이름은 표시용 라벨이라 중복 검사 없이 변경한다(코드는 불변).
if (dto.name) room.name = dto.name;
room.scheduledAt = dto.scheduledAt ? new Date(dto.scheduledAt) : null;
room.allowedUserIds = dto.allowedUserIds?.length
? dto.allowedUserIds
: null;
await this.roomRepo.save(room);
return this.toRoomResponse(room, userId);
}
// 방 삭제 — 진행자(생성자)만 가능.
@@ -210,23 +252,40 @@ export class MeetingService {
return !!(room.allowedUserIds && room.allowedUserIds.length > 0);
}
private toRoomResponse(
private async toRoomResponse(
room: MeetingRoom,
userId: string,
): MeetingRoomResponse {
): Promise<MeetingRoomResponse> {
const isHost = room.host?.id === userId;
const restricted = this.isRestricted(room);
const canJoin =
isHost || !restricted || room.allowedUserIds!.includes(userId);
return {
id: room.id,
code: room.code,
name: room.name,
host: room.host ? UserService.toPublic(room.host) : null,
scheduledAt: room.scheduledAt ? room.scheduledAt.toISOString() : null,
restricted,
isHost,
canJoin,
// 편집 폼 사전 채움을 위해 진행자에게만 허용 멤버를 풀어서 내려준다(저장 순서 보존).
allowedUsers:
isHost && restricted
? await this.resolveAllowedUsers(room.allowedUserIds!)
: null,
createdAt: room.createdAt.toISOString(),
};
}
// 허용 사용자 ID 목록을 PublicUser 로 해석(저장된 순서 유지, 삭제된 사용자는 제외).
private async resolveAllowedUsers(ids: string[]): Promise<PublicUser[]> {
if (!ids.length) return [];
const users = await this.userRepo.findBy({ id: In(ids) });
const byId = new Map(users.map((u) => [u.id, u]));
return ids
.map((id) => byId.get(id))
.filter((u): u is User => !!u)
.map((u) => UserService.toPublic(u));
}
}
@@ -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
+9
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:
@@ -68,6 +70,13 @@ services:
- LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
- LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
- LIVEKIT_URL=${LIVEKIT_URL}
# 드라이브(NCP Object Storage) — 비시크릿만 루트에서 주입(ACCESS/SECRET 키는 backend env_file)
- 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
+1
View File
@@ -3,5 +3,6 @@
# VITE_ 변수는 docker-compose 가 루트 .env.development 값을 build arg 로 주입한다:
# VITE_API_BASE_URL ← BACKEND_API_URL
# VITE_AI_TIMEOUT_MS ← AI_TIMEOUT_MS
# VITE_LIVEKIT_URL ← LIVEKIT_WS_URL
# 따라서 이 파일에 별도로 작성할 값은 없다.
# ==========================================
+1
View File
@@ -3,5 +3,6 @@
# VITE_ 변수는 docker-compose 가 루트 .env.production 값을 build arg 로 주입한다:
# VITE_API_BASE_URL ← BACKEND_API_URL
# VITE_AI_TIMEOUT_MS ← AI_TIMEOUT_MS
# VITE_LIVEKIT_URL ← LIVEKIT_WS_URL
# 따라서 이 파일에 별도로 작성할 값은 없다.
# ==========================================
+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>
@@ -12,12 +12,12 @@ import type { ApiMember } from '@/types/repo'
import type { ApiMeetingRoom } from '@/types/meeting'
interface Props {
// 이름 중복 즉시 검사용 현재 방 목록
rooms: ApiMeetingRoom[]
// 편집 모드 — 전달되면 해당 방의 현재 값으로 채우고 수정한다(없으면 생성 모드).
editRoom?: ApiMeetingRoom | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
// 생성 성공 → 부모가 목록 갱신 + 모달 닫기
// 생성/수정 성공 → 부모가 목록 갱신 + 모달 닫기
(e: 'created'): void
(e: 'cancel'): void
}>()
@@ -27,14 +27,26 @@ const NAME_RE = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]{1,64}$/
const userApi = useUser()
const meetingApi = useMeeting()
const name = ref('')
const scheduledAt = ref('')
const restrict = ref(false)
const isEdit = computed(() => !!props.editRoom)
// ISO(UTC) → datetime-local 입력값('YYYY-MM-DDTHH:mm', 로컬 시간)
function toLocalInput(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const name = ref(props.editRoom?.name ?? '')
const scheduledAt = ref(
props.editRoom?.scheduledAt ? toLocalInput(props.editRoom.scheduledAt) : '',
)
const restrict = ref(props.editRoom?.restricted ?? false)
const error = ref('')
const submitting = ref(false)
// --- 참여 허용 멤버 선택 (업무 담당자 할당과 동일한 형태) ---
const selected = ref<ApiMember[]>([]) // 선택된 멤버(입력칸 아래 칩)
// 편집 모드면 현재 허용 멤버로 칩을 미리 채운다(백엔드가 진행자에게 allowedUsers 제공).
const selected = ref<ApiMember[]>([...(props.editRoom?.allowedUsers ?? [])]) // 선택된 멤버(입력칸 아래 칩)
const query = ref('')
const found = ref<ApiMember[]>([]) // 검색 결과
const showDropdown = ref(false)
@@ -75,26 +87,30 @@ async function submit() {
error.value = '방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다.'
return
}
if (props.rooms.some((r) => r.name === n)) {
error.value = '이미 같은 이름의 방이 있습니다.'
return
}
// 이름은 표시용 라벨이라 중복을 허용한다(식별은 코드로). 별도 중복 검사 없음.
error.value = ''
submitting.value = true
try {
await meetingApi.createRoom({
name: n,
scheduledAt: scheduledAt.value
? new Date(scheduledAt.value).toISOString()
const payload = {
name: n,
scheduledAt: scheduledAt.value
? new Date(scheduledAt.value).toISOString()
: undefined,
allowedUserIds:
restrict.value && selected.value.length
? selected.value.map((u) => u.id)
: undefined,
allowedUserIds:
restrict.value && selected.value.length
? selected.value.map((u) => u.id)
: undefined,
})
}
try {
if (props.editRoom) {
await meetingApi.updateRoom(props.editRoom.id, payload)
} else {
await meetingApi.createRoom(payload)
}
emit('created')
} catch {
error.value = '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
error.value = isEdit.value
? '방 수정에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
: '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
submitting.value = false
}
}
@@ -102,11 +118,11 @@ async function submit() {
<template>
<BaseModal
title="새 회의 만들기"
:title="isEdit ? '회의 편집' : '새 회의 만들기'"
@close="emit('cancel')"
>
<template #subtitle>
방을 만들면 내가 진행자가 됩니다.
{{ isEdit ? '내가 만든 방의 이름·예약·참여 제한을 수정합니다.' : '방을 만들면 내가 진행자가 됩니다.' }}
</template>
<form
@@ -243,7 +259,15 @@ async function submit() {
:disabled="submitting"
@click="submit"
>
<MeetingIcon name="plus" />{{ submitting ? '생성 중…' : '방 만들기' }}
<MeetingIcon :name="isEdit ? 'edit' : 'plus'" />{{
submitting
? isEdit
? '저장 중…'
: '생성 중…'
: isEdit
? '저장'
: '방 만들기'
}}
</button>
</template>
</BaseModal>
@@ -23,6 +23,9 @@ export type MeetingIconName =
| 'warn'
| 'lock'
| 'trash'
| 'edit'
| 'link'
| 'check'
</script>
<script setup lang="ts">
@@ -173,5 +176,14 @@ defineProps<{ name: MeetingIconName }>()
<template v-else-if="name === 'trash'">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" />
</template>
<template v-else-if="name === 'edit'">
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</template>
<template v-else-if="name === 'link'">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</template>
<template v-else-if="name === 'check'">
<path d="M20 6 9 17l-5-5" />
</template>
</svg>
</template>
@@ -14,13 +14,17 @@ interface Props {
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'join', name: string): void
(e: 'join', room: ApiMeetingRoom): void
(e: 'created'): void
(e: 'delete', room: ApiMeetingRoom): void
(e: 'clear-error'): void
}>()
const showForm = ref(false)
// 방금 초대 링크를 복사한 방 코드(잠깐 "복사됨" 표시)
const copiedCode = ref<string | null>(null)
// 편집 대상 방 — 설정되면 생성 폼이 편집 모드로 열린다(null 이면 생성 모드).
const editRoom = ref<ApiMeetingRoom | null>(null)
const keyword = ref('')
// 방 이름·진행자 이름으로 목록 검색(클라이언트 필터)
@@ -37,8 +41,23 @@ const filteredRooms = computed(() => {
function onSearchInput() {
if (props.joinError) emit('clear-error')
}
function onCreated() {
// 새 회의 만들기(생성 모드)
function openCreate() {
editRoom.value = null
showForm.value = true
}
// 방 편집(편집 모드) — 진행자만 노출됨
function openEdit(room: ApiMeetingRoom) {
showForm.value = false
editRoom.value = room
}
function closeForm() {
showForm.value = false
editRoom.value = null
}
// 생성/수정 완료 → 폼 닫고 부모에 목록 갱신 요청
function onFormDone() {
closeForm()
emit('created')
}
@@ -49,6 +68,20 @@ function fmtSchedule(iso: string): string {
timeStyle: 'short',
})
}
// 초대 링크 복사 — 입장 코드 기반 URL(/meeting/<code>). 이름이 바뀌어도 링크는 유지된다.
async function copyInvite(room: ApiMeetingRoom) {
const url = `${window.location.origin}/meeting/${room.code}`
try {
await navigator.clipboard.writeText(url)
copiedCode.value = room.code
window.setTimeout(() => {
if (copiedCode.value === room.code) copiedCode.value = null
}, 1500)
} catch {
// 클립보드 권한이 없으면 무시한다(사용자가 직접 복사)
}
}
</script>
<template>
@@ -78,7 +111,7 @@ function fmtSchedule(iso: string): string {
<button
type="button"
class="btn primary new-room"
@click="showForm = true"
@click="openCreate"
>
<MeetingIcon name="plus" /> 회의 만들기
</button>
@@ -92,10 +125,10 @@ function fmtSchedule(iso: string): string {
</div>
<MeetingCreateForm
v-if="showForm"
:rooms="rooms"
@created="onCreated"
@cancel="showForm = false"
v-if="showForm || editRoom"
:edit-room="editRoom"
@created="onFormDone"
@cancel="closeForm"
/>
<!-- 목록 -->
@@ -139,7 +172,7 @@ function fmtSchedule(iso: string): string {
v-if="r.canJoin"
type="button"
class="btn primary rr-join"
@click="emit('join', r.name)"
@click="emit('join', r)"
>
입장
</button>
@@ -152,6 +185,24 @@ function fmtSchedule(iso: string): string {
>
<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"
class="btn rr-edit"
title="방 편집"
@click="openEdit(r)"
>
<MeetingIcon name="edit" />
</button>
<button
v-if="r.isHost"
type="button"
@@ -332,15 +383,29 @@ function fmtSchedule(iso: string): string {
.rr-noaccess {
color: var(--text-3);
}
.rr-invite,
.rr-edit,
.rr-del {
width: 2.25rem;
padding: 0;
justify-content: center;
}
.rr-invite svg,
.rr-edit svg,
.rr-del svg {
width: 0.9375rem;
height: 0.9375rem;
}
.rr-invite:hover,
.rr-edit:hover {
border-color: var(--border-strong);
color: var(--text);
}
/* 초대 링크 복사 완료 — 잠깐 초록 강조 */
.rr-invite.copied {
color: var(--green);
border-color: var(--green);
}
/* 버튼 기본/primary 는 전역 .btn 사용. 여기선 danger·비활성·사이즈 변형만 보강. */
.btn.danger {
@@ -6,8 +6,6 @@ import {
Track,
ParticipantEvent,
ConnectionQuality,
RemoteTrackPublication,
VideoQuality,
type Participant,
} from 'livekit-client'
import QualityBars from './QualityBars.vue'
@@ -63,15 +61,12 @@ function sync() {
isHost.value = parseHost(p.metadata)
// 오디오는 룸 레벨(useLiveKitRoom)에서 전원 재생하므로 여기서는 영상만 다룬다.
// 영상 트랙 연결만 담당한다. 수신 화질(스테이지=HIGH/나머지=LOW)은 타일이 아니라
// useLiveKitRoom 가 스테이지 변경에 맞춰 중앙에서 제어한다(강등 시 누락·레이스 방지).
const camPub = p.getTrackPublication(Track.Source.Camera)
if (camPub?.track && videoEl.value) {
camPub.track.attach(videoEl.value)
hasVideo.value = !camPub.isMuted
// 원격 참여자: 스테이지(featured)는 고화질, 썸네일은 저화질로 구독한다.
// (adaptiveStream 의 타일 크기 기반 선택과도 일치 — 대역폭 절약)
if (!props.isLocal && camPub instanceof RemoteTrackPublication) {
camPub.setVideoQuality(props.featured ? VideoQuality.HIGH : VideoQuality.LOW)
}
} else {
hasVideo.value = false
}
@@ -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,
}
}
+60 -6
View File
@@ -1,13 +1,14 @@
import { ref, shallowRef } from 'vue'
import { ref, shallowRef, watch } from 'vue'
import {
Room,
RoomEvent,
Track,
ConnectionQuality,
RemoteTrackPublication,
VideoQuality,
type Participant,
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
} from 'livekit-client'
import type { ChatMessage, MeetingQuality } from '@/types/meeting'
@@ -92,6 +93,51 @@ export function useLiveKitRoom() {
]
}
// 스테이지(featured)로 보일 참여자 식별자 해석 — 명시 승격 > 진행자 > 첫 참여자.
// (MeetingRoom 의 레이아웃 featured 해석과 동일 규칙이라 화질=스테이지가 일치한다)
function effectiveFeaturedId(): string | null {
const r = room.value
if (!r) return null
const all = [
r.localParticipant,
...Array.from(r.remoteParticipants.values()),
]
if (
featuredIdentity.value &&
all.some((p) => p.identity === featuredIdentity.value)
) {
return featuredIdentity.value
}
return (all.find((p) => isHostMeta(p.metadata)) ?? all[0])?.identity ?? null
}
// 수신 화질 적용 — 스테이지 유저만 HIGH, 나머지 원격 참여자는 모두 LOW 로 구독한다.
// 각 클라이언트가 자기 시점에서 적용하므로 "남들이 보기에" 스테이지=고화질이 된다.
// 스테이지 변경·참여자/트랙 변동 시마다 호출해 강등된 참여자도 확실히 LOW 로 내린다.
// (adaptiveStream 을 끈 상태라 setVideoQuality 가 곧 실효 화질 — 크기에 덮어써지지 않음)
function applyVideoQuality() {
const r = room.value
if (!r) return
const fid = effectiveFeaturedId()
r.remoteParticipants.forEach((p) => {
const pub = p.getTrackPublication(Track.Source.Camera)
if (pub instanceof RemoteTrackPublication && pub.isSubscribed) {
pub.setVideoQuality(
p.identity === fid ? VideoQuality.HIGH : VideoQuality.LOW,
)
}
})
}
// 참여자 변동(입장/퇴장/메타변경) 시 목록 갱신 + 화질 재적용(스테이지 폴백이 바뀔 수 있음)
function onParticipantsChanged() {
refresh()
applyVideoQuality()
}
// 스테이지(featuredIdentity)가 바뀌면 즉시 화질 재적용 — 강등된 참여자를 LOW 로 내린다.
watch(featuredIdentity, () => applyVideoQuality())
// --- 원격 오디오는 화면 타일(페이지네이션)과 무관하게 항상 재생되어야 한다 ---
// 타일에 attach 하면 현재 페이지에 없는 참여자(>50번째)는 무음이 되므로,
// 숨은 컨테이너에 트랙별 audio 엘리먼트를 붙여 룸 레벨에서 전원 오디오를 재생한다.
@@ -129,6 +175,8 @@ export function useLiveKitRoom() {
function onTrackSubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
refresh()
attachRemoteAudio(track, pub)
// 새로 구독된 영상에도 스테이지 여부에 맞는 화질을 적용한다(늦게 켠 카메라 등).
applyVideoQuality()
}
function onTrackUnsubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
refresh()
@@ -221,20 +269,24 @@ export function useLiveKitRoom() {
connecting.value = true
errorMsg.value = null
try {
const r = new Room({ adaptiveStream: true, dynacast: true })
// adaptiveStream 은 끈다 — 화질을 "엘리먼트 크기"가 아니라 "스테이지 역할"로
// 명시 제어하기 위함(스테이지=HIGH, 나머지=LOW 보장). 켜져 있으면 setVideoQuality 가
// 타일 크기에 덮어써져(min) 스테이지가 HD 가 안 되거나 강등 반영이 비결정적이 된다.
// dynacast 는 유지 — 아무도 구독 안 하는 레이어는 발행자가 송출을 멈춘다.
const r = new Room({ dynacast: true })
// 참여자 목록(배열) 재생성이 필요한 이벤트만 구독한다.
// 발화(ActiveSpeakers)·연결품질(ConnectionQuality)은 매우 잦게 발생하는데
// 각 타일이 ParticipantEvent 로 자체 갱신하므로, 여기서 전체 배열을 재생성하면
// 대규모(수십 명) 화면에서 불필요한 리렌더로 버벅임을 유발한다 → 제외.
r.on(RoomEvent.ParticipantConnected, refresh)
.on(RoomEvent.ParticipantDisconnected, refresh)
r.on(RoomEvent.ParticipantConnected, onParticipantsChanged)
.on(RoomEvent.ParticipantDisconnected, onParticipantsChanged)
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
.on(RoomEvent.TrackMuted, refresh)
.on(RoomEvent.TrackUnmuted, refresh)
.on(RoomEvent.LocalTrackPublished, refresh)
.on(RoomEvent.LocalTrackUnpublished, refresh)
.on(RoomEvent.ParticipantMetadataChanged, refresh)
.on(RoomEvent.ParticipantMetadataChanged, onParticipantsChanged)
.on(RoomEvent.DataReceived, onData)
.on(RoomEvent.Disconnected, onDisconnected)
@@ -264,6 +316,8 @@ export function useLiveKitRoom() {
}
refresh()
} catch (e) {
// 연결 실패 원인을 콘솔에 남긴다(이전엔 errorMsg 로만 삼켜져 화면·콘솔 어디에도 안 보였다).
console.error('[meeting] LiveKit 연결 실패:', e)
errorMsg.value = e instanceof Error ? e.message : '연결에 실패했습니다.'
await disconnect()
} finally {
+16 -4
View File
@@ -2,6 +2,7 @@ import { useApi } from './useApi'
import type {
ApiMeetingRoom,
CreateMeetingRoomPayload,
UpdateMeetingRoomPayload,
} from '@/types/meeting'
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
@@ -19,10 +20,10 @@ export interface MeetingToken {
export function useMeeting() {
const api = useApi()
// LiveKit 입장 토큰 발급
async function getToken(roomName: string): Promise<MeetingToken> {
// LiveKit 입장 토큰 발급 — 방 입장 코드로 요청한다.
async function getToken(code: string): Promise<MeetingToken> {
return (await api.post('/meetings/token', {
roomName,
code,
})) as unknown as MeetingToken
}
@@ -41,10 +42,21 @@ export function useMeeting() {
)) as unknown as ApiMeetingRoom
}
// 방 수정(진행자만) — 이름·예약·참여 제한 변경
async function updateRoom(
roomId: string,
payload: UpdateMeetingRoomPayload,
): Promise<ApiMeetingRoom> {
return (await api.patch(
`/meetings/rooms/${roomId}`,
payload,
)) as unknown as ApiMeetingRoom
}
// 방 삭제(진행자만)
async function removeRoom(roomId: string): Promise<void> {
await api.delete(`/meetings/rooms/${roomId}`)
}
return { getToken, listRooms, createRoom, removeRoom }
return { getToken, listRooms, createRoom, updateRoom, removeRoom }
}
+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>
+25 -38
View File
@@ -35,10 +35,9 @@ const {
sendChat,
} = useLiveKitRoom()
const ROOM_PATTERN = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]{1,64}$/
// 프리조인 대상 방 정보(표시용)
// 프리조인 대상 방 정보(표시용 + 입장 코드)
interface PrejoinTarget {
code: string
name: string
hostName: string | null
restricted: boolean
@@ -74,51 +73,34 @@ async function loadRooms() {
}
}
// 로비에서 방 선택/입력 → 프리조인으로
function onJoin(name: string) {
const n = (name || '').trim()
if (!n) {
joinError.value = '방 이름을 입력해 주세요.'
return
}
if (!ROOM_PATTERN.test(n)) {
joinError.value =
'방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다. (공백 불가)'
return
}
const room = rooms.value.find((r) => r.name === n)
if (room && room.restricted && !room.canJoin) {
joinError.value = `'${n}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
// 로비에서 방 선택 → 프리조인으로 (방은 명시적으로 생성된 것만 입장 가능)
function onJoin(room: ApiMeetingRoom) {
if (room.restricted && !room.canJoin) {
joinError.value = `'${room.name}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
return
}
joinError.value = null
target.value = room
? {
name: room.name,
hostName: room.host?.name ?? null,
restricted: room.restricted,
isHost: room.isHost,
}
: {
name: n,
hostName: myName.value, // 새 방은 입장자가 진행자가 된다
restricted: false,
isHost: true,
}
target.value = {
code: room.code,
name: room.name,
hostName: room.host?.name ?? null,
restricted: room.restricted,
isHost: room.isHost,
}
phase.value = 'prejoin'
}
// 프리조인 → 입장(토큰 발급 + 연결)
async function onEnter(opts: { mic: boolean; cam: boolean }) {
const name = target.value?.name
if (!name) return
const t = target.value
if (!t) return
entering.value = true
try {
const { token, role } = await meetingApi.getToken(name)
const { token, role } = await meetingApi.getToken(t.code)
myRole.value = role
await connect(token, opts)
if (route.params.room !== name) {
void router.replace(`/meeting/${name}`)
if (route.params.code !== t.code) {
void router.replace(`/meeting/${t.code}`)
}
} catch {
joinError.value = '입장에 실패했습니다. 잠시 후 다시 시도해 주세요.'
@@ -168,8 +150,13 @@ function onCreated() {
// 진입 시 방 목록 로드. 공유 링크(/meeting/:room)면 그 방 프리조인으로 바로 이동.
onMounted(async () => {
await loadRooms()
const param = route.params.room
if (typeof param === 'string' && param) onJoin(param)
// 공유/초대 링크(/meeting/:code) — 코드로 방을 찾아 프리조인. 없으면 안내한다.
const param = route.params.code
if (typeof param === 'string' && param) {
const room = rooms.value.find((r) => r.code === param)
if (room) onJoin(room)
else joinError.value = '회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.'
}
})
// 화면 이탈 시 연결 정리
@@ -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;
+9 -2
View File
@@ -109,12 +109,19 @@ const routes: RouteRecordRaw[] = [
meta: { requiresAuth: true },
},
{
// 화상회의 — LiveKit 기반(단계 1: 방 입장/연결). :room 으로 공유 링크 가능
path: '/meeting/:room?',
// 화상회의 — LiveKit 기반. :code 로 공유/초대 링크 가능(/meeting/<입장코드>)
path: '/meeting/:code?',
name: 'meeting',
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>님의 활동` }
}

Some files were not shown because too many files have changed in this diff Show More